From 695981dec0125633df6ac9d66d39fc922fb0c39c Mon Sep 17 00:00:00 2001 From: Insality Date: Sun, 28 Sep 2025 14:29:38 +0300 Subject: [PATCH 1/4] Add splash for the inspector --- game.project | 3 +++ 1 file changed, 3 insertions(+) diff --git a/game.project b/game.project index 8830408..ee0cdc9 100644 --- a/game.project +++ b/game.project @@ -22,3 +22,6 @@ dependencies#0 = https://github.com/britzl/deftest/archive/master.zip [library] include_dirs = lang +[html5] +splash_image = /media/logo_thumb.png + From 59f6a9b9bb7587e09815b71aa7746818c49412cf Mon Sep 17 00:00:00 2001 From: Insality Date: Fri, 17 Oct 2025 21:53:27 +0300 Subject: [PATCH 2/4] Add async loading --- lang/lang.lua | 87 ++++++++++++++++++++++++++++++------------ lang/lang_internal.lua | 30 +++++++++------ 2 files changed, 82 insertions(+), 35 deletions(-) diff --git a/lang/lang.lua b/lang/lang.lua index cc7499c..0f79d60 100644 --- a/lang/lang.lua +++ b/lang/lang.lua @@ -23,6 +23,7 @@ local M = {} ---@class lang.data ---@field path string|table Lua table, json or csv path, ex: "/resources/lang/en.json", "/resources/lang/en.csv" ---@field id string Language code, ex: "en". If csv file, it's a header name +---@field loader function|nil Optional async loader function with signature: loader(path, on_success, on_error) ---Current language translations ---@type table Contains all current language translations. Key - lang id, Value - translation @@ -91,10 +92,8 @@ function M.init(available_langs, lang_on_start) end -- Get system language if no specific language is requested - local system_lang = lang_internal.SYSTEM_LANG - if not is_lang_available(system_lang) then - system_lang = nil - end + local system_lang_raw = lang_internal.SYSTEM_LANG + local system_lang = is_lang_available(system_lang_raw) and system_lang_raw or nil -- Determine target language with validation local target_lang = lang_on_start or M.state.lang or system_lang or default_lang @@ -119,37 +118,75 @@ function M.set_logger(logger_instance) end +---Parse and apply language content +---@private +---@param content string File content +---@param lang_id string Language code +---@param is_csv boolean Is CSV format +---@param is_json boolean Is JSON format +---@return boolean success True if successfully applied +local function parse_and_apply_lang(content, lang_id, is_csv, is_json) + if is_csv then + local parsed = lang_internal.parse_csv_content(content) + if not parsed or not parsed[lang_id] then + return false + end + M.set_lang_table(parsed[lang_id]) + elseif is_json then + local success, result = pcall(json.decode, content) + if not success then + return false + end + M.set_lang_table(result) + else + return false + end + + M.state.lang = lang_id + return true +end + + ---Set current language ---@param lang_id string current language code (en, jp, ru, etc.) ----@return boolean is language changed -function M.set_lang(lang_id) +---@param on_lang_changed function? +function M.set_lang(lang_id, on_lang_changed) if not lang_id then lang_internal.logger:error("Language id cannot be nil") - return false + return end local previous_lang = M.state.lang - local previous_loaded_lang = previous_lang or nil - - -- Check if language is available using fast lookup - if not is_lang_available(lang_id) then - lang_internal.logger:error("Lang not found", lang_id) - return false - end - - -- Get language data using fast lookup local lang_data = get_lang_data(lang_id) + if not lang_data then - lang_internal.logger:error("Lang data not found", lang_id) - return false + lang_internal.logger:error("Lang not found", lang_id) + return end local is_lua = type(lang_data.path) == "table" - ---@type string|nil local path_str = type(lang_data.path) == "string" and lang_data.path --[[@as string]] or nil - local is_csv = not is_lua and path_str and string.find(path_str, ".csv") - local is_json = not is_lua and path_str and string.find(path_str, ".json") + local is_csv = not is_lua and path_str and string.find(path_str, ".csv") ~= nil + local is_json = not is_lua and path_str and string.find(path_str, ".json") ~= nil + + -- Async loading with loader + if lang_data.loader and path_str then + lang_data.loader(path_str, function(content) + if parse_and_apply_lang(content, lang_id, is_csv, is_json) then + if on_lang_changed then + on_lang_changed() + end + lang_internal.logger:info("Lang changed", { previous_lang = previous_lang, lang = lang_id }) + else + lang_internal.logger:error("Failed to parse lang content", path_str) + end + end, function(err) + lang_internal.logger:error("Failed to load lang file", err) + end) + return + end + -- Synchronous loading (backward compatibility) if is_lua then M.set_lang_table(lang_data.path) M.state.lang = lang_id @@ -159,11 +196,13 @@ function M.set_lang(lang_id) M.load_from_json(path_str, lang_id) else lang_internal.logger:error("Lang format not supported", lang_data.path or "unknown") - return false + return end - lang_internal.logger:info("Lang changed", { previous_lang = previous_loaded_lang, lang = lang_id }) - return true + lang_internal.logger:info("Lang changed", { previous_lang = previous_lang, lang = lang_id }) + if on_lang_changed then + on_lang_changed() + end end diff --git a/lang/lang_internal.lua b/lang/lang_internal.lua index f908192..fcef955 100644 --- a/lang/lang_internal.lua +++ b/lang/lang_internal.lua @@ -65,18 +65,12 @@ function M.load_json(json_path) end ----Load CSV file from game resources folder (by relative path to game.project) ----Return nil if file not found or error ----@param csv_path string ----@return table|nil -function M.load_csv(csv_path) - local resource, is_error = sys.load_resource(csv_path) - if is_error or not resource then - return nil - end - +---Parse CSV content string into language tables +---@param csv_content string CSV content as string +---@return table|nil data Table with language data or nil if error +function M.parse_csv_content(csv_content) local data = {} - local f = csv.openstring(resource) + local f = csv.openstring(csv_content) local headers = nil -- Parse headers, first id is a lang_id to table > @@ -108,6 +102,20 @@ function M.load_csv(csv_path) end +---Load CSV file from game resources folder (by relative path to game.project) +---Return nil if file not found or error +---@param csv_path string +---@return table|nil +function M.load_csv(csv_path) + local resource, is_error = sys.load_resource(csv_path) + if is_error or not resource then + return nil + end + + return M.parse_csv_content(resource) +end + + ---Check if a table contains a value ---@param t table ---@param value any From 0df0d7593ff35110f22a9db656d2df93ebf1e44d Mon Sep 17 00:00:00 2001 From: Insality Date: Fri, 17 Oct 2025 21:59:22 +0300 Subject: [PATCH 3/4] Update README --- README.md | 95 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b3fa701..c831c94 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Initialize the **Lang** module by calling `lang.init()` with your language confi ```lua local lang = require("lang.lang") --- Initialize with language files +-- Basic initialization with language files lang.init({ { id = "en", path = "/resources/lang/en.json" }, { id = "ru", path = "/resources/lang/ru.json" }, @@ -57,19 +57,20 @@ lang.init({ }) ``` -You can also force a specific language on initialization: +#### Force Language on Start + +You can force a specific language on initialization: ```lua --- Force a specific language on start +-- Force Spanish language on start lang.init({ { id = "en", path = "/resources/lang/en.json" }, { id = "ru", path = "/resources/lang/ru.json" }, { id = "es", path = "/resources/lang/es.json" }, -}, "es") -- Force Spanish language +}, "es") ``` - -### Default Language +#### Language Selection Priority **Defold Lang** selects the language to use in the following priority order: @@ -80,16 +81,14 @@ lang.init({ The first language in the configuration array serves as the ultimate fallback. Defold uses the two-character [ISO-639 format](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes) for language codes ("en", "ru", "es", etc). -The module uses `sys.load_resource` to load the files. Place your files inside your [custom resources folder](https://defold.com/manuals/project-settings/#custom-resources) to ensure they are included in the build. +> **Note:** Place your language files inside your [custom resources folder](https://defold.com/manuals/project-settings/#custom-resources) to ensure they are included in the build. ### Localization Files -**Defold Lang** supports three file formats: **JSON**, **Lua**, and **CSV**. Each format has its own advantages: +**Defold Lang** supports three file formats: **JSON**, **Lua**, and **CSV**. #### JSON Files -JSON files use a simple key-value structure: - ```json { "ui_hello_world": "Hello, World!", @@ -99,18 +98,7 @@ JSON files use a simple key-value structure: } ``` -Initialize with JSON files: -```lua -lang.init({ - { id = "en", path = "/locales/en.json" }, - { id = "ru", path = "/locales/ru.json" }, - { id = "es", path = "/locales/es.json" }, -}) -``` - #### Lua Files -Lua files return a table with translations: - ```lua -- en.lua return { @@ -121,18 +109,7 @@ return { } ``` -Initialize with Lua files: -```lua -lang.init({ - { id = "en", path = require("locales.en") }, - { id = "ru", path = require("locales.ru") }, - { id = "es", path = require("locales.es") }, -}) -``` - #### CSV Files -CSV files allow multiple languages in a single file. The first column contains keys, and subsequent columns contain translations: - ```csv key,en,ru,es ui_hello_world,"Hello, World!","Привет, мир!","¡Hola, mundo!" @@ -141,26 +118,60 @@ ui_settings,Settings,Настройки,Configuración ui_exit,Exit,Выход,Salir ``` -Initialize with CSV files (specify column names as language IDs): +#### Mixed Format Example +You can mix different file formats in a single configuration: + ```lua lang.init({ - { id = "en", path = "/locales/translations.csv" }, - { id = "ru", path = "/locales/translations.csv" }, - { id = "es", path = "/locales/translations.csv" }, + { id = "en", path = "/resources/lang/en.json" }, + { id = "ru", path = require("resources.lang.ru") }, + { id = "es", path = "/resources/lang/translations.csv" }, }) ``` -#### Mixed Format Example -You can even mix different file formats: +### Load from Bundle Resources + +#### Async Loading with Custom Loaders + +For loading from bundle resources (file I/O or HTTP), you can provide a custom loader function: ```lua +-- Custom async loader for bundle resources, HTTP loading, etc. lang.init({ - { id = "en", path = "/resources/lang/en.json" }, - { id = "ru", path = "/resources/lang/ru.lua" }, - { id = "es", path = "/resources/lang/translations.csv" }, + { + id = "en", + path = "/bundle/lang/en.json", + loader = function(path, on_success, on_error) + -- Your custom loading logic here + -- Call on_success(content) with file content string + -- Or on_error(error_message) on failure + + -- Example: Bundle resource loading + local app_path = sys.get_application_path() + local full_path = app_path .. path + local f = io.open(full_path, "rb") + if f then + local content = f:read("*a") + f:close() + on_success(content) + else + on_error("File not found: " .. full_path) + end + end + }, }) + +-- Language switching with callback +lang.set_lang("en", function() + print("Language loaded!") + print(lang.txt("ui_hello")) + druid.on_language_change() +end) ``` +**Use cases for bundle resource loading:** +- Bundle resources loading (file I/O or HTTP) +- Platform-specific resource access ## API Reference @@ -168,7 +179,7 @@ lang.init({ ```lua lang.init(available_langs, [lang_on_start]) -lang.set_lang(lang_id) +lang.set_lang(lang_id, [on_lang_changed]) lang.get_lang() lang.get_langs() lang.set_next_lang() From b86f5bd3b3f3b045af91d4bec7ac89cd7c6bdb47 Mon Sep 17 00:00:00 2001 From: Insality Date: Sat, 18 Oct 2025 11:47:13 +0300 Subject: [PATCH 4/4] Update logger --- lang/internal/lang_logger.lua | 77 +++++++++++++++++++++++++++++++++++ lang/lang.lua | 29 ++++++------- lang/lang_internal.lua | 30 -------------- 3 files changed, 92 insertions(+), 44 deletions(-) create mode 100644 lang/internal/lang_logger.lua diff --git a/lang/internal/lang_logger.lua b/lang/internal/lang_logger.lua new file mode 100644 index 0000000..a3869bc --- /dev/null +++ b/lang/internal/lang_logger.lua @@ -0,0 +1,77 @@ +---@class lang.logger +---@field trace fun(_, msg: string, data: any) +---@field debug fun(_, msg: string, data: any) +---@field info fun(_, msg: string, data: any) +---@field warn fun(_, msg: string, data: any) +---@field error fun(_, msg: string, data: any) +local M = {} + +local EMPTY_FUNCTION = function(_, message, context) end + +---@type lang.logger +local empty_logger = { + trace = EMPTY_FUNCTION, + debug = EMPTY_FUNCTION, + info = EMPTY_FUNCTION, + warn = EMPTY_FUNCTION, + error = EMPTY_FUNCTION, +} + +---@type lang.logger +local default_logger = { + trace = function(_, msg, data) print("TRACE: " .. msg, M.table_to_string(data)) end, + debug = function(_, msg, data) print("DEBUG: " .. msg, M.table_to_string(data)) end, + info = function(_, msg, data) print("INFO: " .. msg, M.table_to_string(data)) end, + warn = function(_, msg, data) print("WARN: " .. msg, M.table_to_string(data)) end, + error = function(_, msg, data) print("ERROR: " .. msg, M.table_to_string(data)) end +} + +local METATABLE = { __index = default_logger } + +function M.set_logger(logger) + METATABLE.__index = logger or empty_logger +end + + +---Converts table to one-line string +---@param t table +---@param depth number? +---@param result string|nil Internal parameter +---@return string, boolean result String representation of table, Is max string length reached +function M.table_to_string(t, depth, result) + if type(t) ~= "table" then + return tostring(t) or "", false + end + + depth = depth or 0 + result = result or "{" + + for key, value in pairs(t) do + if #result > 1 then + result = result .. ", " + end + + if type(value) == "table" then + if depth == 0 then + local table_len = 0 + for _ in pairs(value) do + table_len = table_len + 1 + end + result = result .. key .. ": {... #" .. table_len .. "}" + else + local convert_result, is_limit = M.table_to_string(value, depth - 1, "") + result = result .. key .. ": {" .. convert_result + if is_limit then + break + end + end + else + result = result .. key .. ": " .. tostring(value) + end + end + + return result .. "}", false +end + + +return setmetatable(M, METATABLE) diff --git a/lang/lang.lua b/lang/lang.lua index 0f79d60..94abbe5 100644 --- a/lang/lang.lua +++ b/lang/lang.lua @@ -12,6 +12,7 @@ local lang_internal = require("lang.lang_internal") local lang_debug_page = require("lang.lang_debug_page") +local logger = require("lang.internal.lang_logger") ---@class lang local M = {} @@ -74,7 +75,7 @@ end ---@param lang_on_start string? Language code to set on start, override saved language function M.init(available_langs, lang_on_start) if not available_langs or #available_langs == 0 then - lang_internal.logger:error("No available languages provided to init") + logger:error("No available languages provided to init") return end @@ -100,7 +101,7 @@ function M.init(available_langs, lang_on_start) -- Validate the target language exists, fallback to default if not if not is_lang_available(target_lang) then - lang_internal.logger:warn("Target language not available, falling back to default", { + logger:warn("Target language not available, falling back to default", { target_lang = target_lang, default_lang = default_lang }) @@ -114,7 +115,7 @@ end ---Set logger for lang module. Pass nil to use empty logger ---@param logger_instance lang.logger|table|nil function M.set_logger(logger_instance) - lang_internal.logger = logger_instance or lang_internal.empty_logger + logger.set_logger(logger_instance) end @@ -152,7 +153,7 @@ end ---@param on_lang_changed function? function M.set_lang(lang_id, on_lang_changed) if not lang_id then - lang_internal.logger:error("Language id cannot be nil") + logger:error("Language id cannot be nil") return end @@ -160,7 +161,7 @@ function M.set_lang(lang_id, on_lang_changed) local lang_data = get_lang_data(lang_id) if not lang_data then - lang_internal.logger:error("Lang not found", lang_id) + logger:error("Lang not found", lang_id) return end @@ -176,12 +177,12 @@ function M.set_lang(lang_id, on_lang_changed) if on_lang_changed then on_lang_changed() end - lang_internal.logger:info("Lang changed", { previous_lang = previous_lang, lang = lang_id }) + logger:info("Lang changed", { previous_lang = previous_lang, lang = lang_id }) else - lang_internal.logger:error("Failed to parse lang content", path_str) + logger:error("Failed to parse lang content", path_str) end end, function(err) - lang_internal.logger:error("Failed to load lang file", err) + logger:error("Failed to load lang file", err) end) return end @@ -195,11 +196,11 @@ function M.set_lang(lang_id, on_lang_changed) elseif is_json and path_str then M.load_from_json(path_str, lang_id) else - lang_internal.logger:error("Lang format not supported", lang_data.path or "unknown") + logger:error("Lang format not supported", lang_data.path or "unknown") return end - lang_internal.logger:info("Lang changed", { previous_lang = previous_lang, lang = lang_id }) + logger:info("Lang changed", { previous_lang = previous_lang, lang = lang_id }) if on_lang_changed then on_lang_changed() end @@ -216,11 +217,11 @@ function M.load_from_json(lang_path, locale_id) local is_parsed, lang_data = pcall(lang_internal.load_json, lang_path) if not is_parsed then - lang_internal.logger:error("Can't load or parse lang file. Check the JSON file is valid", lang_path) + logger:error("Can't load or parse lang file. Check the JSON file is valid", lang_path) return nil end if not lang_data then - lang_internal.logger:error("Lang file not found", lang_path) + logger:error("Lang file not found", lang_path) return nil end @@ -241,12 +242,12 @@ function M.load_from_csv(csv_path, locale_id) local langs_data = lang_internal.load_csv(csv_path) if not langs_data then - lang_internal.logger:error("Can't load or parse lang file. Check the CSV file is valid", csv_path) + logger:error("Can't load or parse lang file. Check the CSV file is valid", csv_path) return nil end if not langs_data[locale_id] then - lang_internal.logger:error("Lang code not found", locale_id) + logger:error("Lang code not found", locale_id) return nil end diff --git a/lang/lang_internal.lua b/lang/lang_internal.lua index fcef955..a8e87bb 100644 --- a/lang/lang_internal.lua +++ b/lang/lang_internal.lua @@ -1,13 +1,5 @@ local csv = require("lang.csv") ----@class lang.logger ----@field trace fun(logger: lang.logger, message: string, data: any|nil) ----@field debug fun(logger: lang.logger, message: string, data: any|nil) ----@field info fun(logger: lang.logger, message: string, data: any|nil) ----@field warn fun(logger: lang.logger, message: string, data: any|nil) ----@field error fun(logger: lang.logger, message: string, data: any|nil) - - local M = {} M.SYSTEM_LANG = sys.get_sys_info().language @@ -29,28 +21,6 @@ function M.split(s, sep) end ---- Use empty function to save a bit of memory -local EMPTY_FUNCTION = function(_, message, context) end - ----@type lang.logger -M.empty_logger = { - trace = EMPTY_FUNCTION, - debug = EMPTY_FUNCTION, - info = EMPTY_FUNCTION, - warn = EMPTY_FUNCTION, - error = EMPTY_FUNCTION, -} - ----@type lang.logger -M.logger = { - trace = function(_, msg, data) print("TRACE:", msg, data) end, - debug = function(_, msg, data) print("DEBUG:", msg, data) end, - info = function(_, msg, data) print("INFO:", msg, data) end, - warn = function(_, msg, data) print("WARN:", msg, data) end, - error = function(_, msg, data) print("ERROR:", msg, data) end -} - - ---Load JSON file from game resources folder (by relative path to game.project) ---Return nil if file not found or error ---@param json_path string