Add save_to_disk plus hand-rolled JSON serializer
Reverses the internal resolved-atlas representation back to the v2 on-disk shape (atlas_aliases as strings, only whitelisted layers emitted, roof key omitted when nil). Hand-rolled JSON stringifier because the engine exposes no cjson Lua binding and load_json has no symmetric save_json counterpart. Output is byte-deterministic (sorted object keys) and pretty-printed with 2-space indent.
This commit is contained in:
127
init.lua
127
init.lua
@@ -699,6 +699,133 @@ function M.set_roof(x, y, value, map_id)
|
||||
map._dirty = true
|
||||
end
|
||||
|
||||
-- =====================================================================
|
||||
-- Internal JSON pretty-printer (hand-rolled, 2-space indent).
|
||||
-- Supports: nil/null, bool, number (int + float), string, array, object.
|
||||
-- Deterministic key ordering (sorted) so saves are byte-stable.
|
||||
-- =====================================================================
|
||||
local json_value -- forward declaration
|
||||
local json_table -- forward declaration
|
||||
|
||||
local function json_escape_string(s)
|
||||
local replacements = {
|
||||
['"'] = '\\"',
|
||||
['\\'] = '\\\\',
|
||||
['\n'] = '\\n',
|
||||
['\r'] = '\\r',
|
||||
['\t'] = '\\t',
|
||||
['\b'] = '\\b',
|
||||
['\f'] = '\\f',
|
||||
}
|
||||
return '"' .. s:gsub('[%z\1-\31"\\]', function(c)
|
||||
return replacements[c] or string.format('\\u%04x', string.byte(c))
|
||||
end) .. '"'
|
||||
end
|
||||
|
||||
json_value = function(v, indent)
|
||||
if v == nil then return "null" end
|
||||
local t = type(v)
|
||||
if t == "boolean" then return v and "true" or "false" end
|
||||
if t == "number" then
|
||||
if v ~= v then return "null" end -- NaN
|
||||
if v == math.huge or v == -math.huge then return "null" end
|
||||
if math.type(v) == "integer" or v == math.floor(v) then
|
||||
return string.format("%d", v)
|
||||
end
|
||||
return string.format("%.17g", v)
|
||||
end
|
||||
if t == "string" then return json_escape_string(v) end
|
||||
if t == "table" then
|
||||
return json_table(v, indent)
|
||||
end
|
||||
error("json: cannot serialize value of type " .. t)
|
||||
end
|
||||
|
||||
json_table = function(t, indent)
|
||||
-- Detect array vs object by checking integer keys 1..n
|
||||
local n = #t
|
||||
local is_array = n > 0
|
||||
if is_array then
|
||||
for k, _ in pairs(t) do
|
||||
if type(k) ~= "number" then is_array = false; break end
|
||||
end
|
||||
end
|
||||
local indent_next = indent .. " "
|
||||
|
||||
if is_array then
|
||||
if n == 0 then return "[]" end
|
||||
local parts = {}
|
||||
for i = 1, n do
|
||||
parts[i] = indent_next .. json_value(t[i], indent_next)
|
||||
end
|
||||
return "[\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "]"
|
||||
end
|
||||
|
||||
-- Object: collect & sort keys for determinism
|
||||
local keys = {}
|
||||
for k, _ in pairs(t) do
|
||||
table.insert(keys, tostring(k))
|
||||
end
|
||||
table.sort(keys)
|
||||
if #keys == 0 then return "{}" end
|
||||
local parts = {}
|
||||
for _, k in ipairs(keys) do
|
||||
parts[#parts + 1] = indent_next .. json_escape_string(k) ..
|
||||
": " .. json_value(t[k], indent_next)
|
||||
end
|
||||
return "{\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "}"
|
||||
end
|
||||
|
||||
-- =====================================================================
|
||||
-- v2-Map serialization: reverse the internal resolved shape back to the
|
||||
-- on-disk JSON schema (atlases as ID strings, layers in canonical order).
|
||||
-- =====================================================================
|
||||
local LAYER_DISK_ORDER = {
|
||||
"foundation", "subsurface", "surface", "topsurface",
|
||||
"lower_wall", "wall", "upper_wall", "canopy",
|
||||
}
|
||||
|
||||
local function serialize_map_v2(map)
|
||||
local out = {
|
||||
schema_version = 2,
|
||||
id = map.id,
|
||||
size = { w = map.size.w, h = map.size.h },
|
||||
atlases = {},
|
||||
layers = {},
|
||||
}
|
||||
-- Use atlas_aliases (raw string IDs from disk) for round-trip fidelity.
|
||||
for i, alias in ipairs(map.atlas_aliases) do
|
||||
out.atlases[i] = alias
|
||||
end
|
||||
for _, layer_name in ipairs(LAYER_DISK_ORDER) do
|
||||
local layer = map.layers[layer_name]
|
||||
if layer then
|
||||
local tiles_copy = {}
|
||||
for i = 1, #layer.tiles do tiles_copy[i] = layer.tiles[i] end
|
||||
out.layers[layer_name] = { tiles = tiles_copy }
|
||||
end
|
||||
end
|
||||
if map.roof then
|
||||
local roof_copy = {}
|
||||
for i = 1, #map.roof do roof_copy[i] = map.roof[i] end
|
||||
out.roof = roof_copy
|
||||
end
|
||||
return json_value(out, "") .. "\n"
|
||||
end
|
||||
|
||||
function M.save_to_disk(map_id, path)
|
||||
local map = require_map(map_id)
|
||||
local json_str = serialize_map_v2(map)
|
||||
local f, err = io.open(path, "w")
|
||||
if not f then
|
||||
error(string.format("maps.save_to_disk: cannot open '%s' (%s)",
|
||||
path, err or "?"))
|
||||
end
|
||||
f:write(json_str)
|
||||
f:close()
|
||||
map._dirty = false
|
||||
end
|
||||
|
||||
function M.is_indoor(x, y, map_id)
|
||||
local id = map_id or current_map_id
|
||||
if not id then error("maps.is_indoor: no current map") end
|
||||
|
||||
Reference in New Issue
Block a user