1 local plugin_key = "bts/puzzlescript"
3 local sprite = app.sprite
4 local pc = app.pixelColor
9 return string.format("#%02x%02x%02x", c.red, c.green, c.blue)
12 function findOrInsertIdx(table, value)
14 if table[i] == value then
18 table[#table + 1] = value
22 function findOrAdd(table, idx, default)
23 if table[idx] == nil then
24 table[idx] = default or {}
29 function get_layers_flat(output, layer_container)
30 for _, layer in ipairs(layer_container.layers) do
31 table.insert(output, layer)
33 get_layers_flat(output, layer)
42 local PuzzleScriptSection = {}
44 function PuzzleScriptSection:new(o)
52 function PuzzleScriptSection:add_item(item)
53 self.items[#self.items + 1] = item
58 local PuzzleScriptRawItem = {}
60 function PuzzleScriptRawItem:new(o)
68 function PuzzleScriptRawItem:add_line(line)
69 self.lines[#self.lines + 1] = line
72 function PuzzleScriptRawItem:export(script_file)
73 for _, section_item_line in pairs(self.lines) do
74 script_file:write(section_item_line)
75 script_file:write("\n")
81 local PuzzleScriptObjectItem = {}
83 function PuzzleScriptObjectItem:new(o)
92 function PuzzleScriptObjectItem:set_color(color)
93 if color.alpha == 255 then
94 self.colors = { hexColor(color) }
101 function PuzzleScriptObjectItem:set_pixel(x, y, color)
102 local pixel_idx = y * 5 + x + 1
103 local pixel_value = "."
104 if color.alpha == 255 then
105 pixel_value = findOrInsertIdx(self.colors, hexColor(color)) - 1
108 self.pixels[pixel_idx] = pixel_value
111 function PuzzleScriptObjectItem:export(script_file)
112 script_file:write(self.id)
113 script_file:write("\n")
115 if #self.colors > 0 then
116 script_file:write(table.concat(self.colors, " "))
117 script_file:write("\n")
122 script_file:write(self.pixels[y * 5 + x + 1])
124 script_file:write("\n")
128 script_file:write("transparent\n")
134 local PuzzleScriptLayerItem = {}
136 function PuzzleScriptLayerItem:new(o)
138 setmetatable(o, self)
144 function PuzzleScriptLayerItem:add_object(object)
145 self.objects[#self.objects + 1] = object
148 function PuzzleScriptLayerItem:export(script_file)
149 local object_ids = { self.id }
150 for i, obj in ipairs(self.objects) do
151 object_ids[i] = obj.id
153 script_file:write(table.concat(object_ids, ", "))
154 script_file:write("\n")
159 local PuzzleScriptLegendItem = {}
161 function PuzzleScriptLegendItem:new(o)
163 setmetatable(o, self)
169 function PuzzleScriptLegendItem:obj_match(objects)
170 if objects == nil and self.objects == nil then return true end
171 if objects == nil or self.objects == nil then return false end
172 if #objects ~= #self.objects then return false end
173 for i = 1, #self.objects do
174 if objects[i] ~= self.objects[i] then return false end
179 function PuzzleScriptLegendItem:set_objects(objects)
181 for _, obj in pairs(objects) do
182 table.insert(self.objects, obj)
186 function PuzzleScriptLegendItem:export(script_file)
187 local object_ids = {}
190 for i, obj in ipairs(self.objects) do
191 object_ids[i] = obj.id
194 object_ids = { "Background" } -- FIXME
197 script_file:write(self.id)
198 script_file:write(" = ")
199 script_file:write(table.concat(object_ids, " and "))
201 script_file:write("\n")
206 local PuzzleScriptLegendMapping = {}
208 function PuzzleScriptLegendMapping:new(o)
210 setmetatable(o, self)
215 function PuzzleScriptLegendMapping:add_objects(objects)
216 self.objects = self.objects or {}
217 for _, obj in pairs(objects) do
218 table.insert(self.objects, obj)
222 function PuzzleScriptLegendMapping:export(script_file)
223 local object_ids = {}
226 for i, obj in ipairs(self.objects) do
227 object_ids[i] = obj.id
231 -- Failsafe for matching name and single item
232 if #object_ids == 1 and object_ids[1] == self.id then return end
234 script_file:write(self.id)
235 script_file:write(" = ")
236 script_file:write(table.concat(object_ids, " or "))
238 script_file:write("\n")
244 local PuzzleScriptLevelItem = {}
246 function PuzzleScriptLevelItem:new(o)
248 setmetatable(o, self)
254 function PuzzleScriptLevelItem:set_tile(tile_x, tile_y, value)
255 local tile_idx = tile_y * self.width + tile_x
256 if self.tiles[tile_idx] == nil then
257 self.tiles[tile_idx] = {}
260 table.insert(self.tiles[tile_idx], value)
263 function PuzzleScriptLevelItem:map_to_legend(legend_section, legend_icons)
264 self.tile_legends = {}
266 for tile_y = 0, self.height - 1 do
267 for tile_x = 0, self.width - 1 do
268 local tile_idx = tile_y * self.width + tile_x
270 local legend_item = nil
271 for _, test_legend_item in pairs(legend_section.items) do
272 if test_legend_item:obj_match(self.tiles[tile_idx]) then
273 legend_item = test_legend_item
278 if legend_item == nil then
279 legend_item = PuzzleScriptLegendItem:new()
280 assert(#legend_icons > 0)
281 legend_item.id = legend_icons[1]
282 table.remove(legend_icons, 1)
283 legend_item:set_objects(self.tiles[tile_idx])
284 legend_section:add_item(legend_item)
287 self.tile_legends[tile_idx] = legend_item
292 function PuzzleScriptLevelItem:export(script_file)
293 for tile_y = 0, self.height - 1 do
294 for tile_x = 0, self.width - 1 do
295 local tile_idx = tile_y * self.width + tile_x
296 script_file:write(self.tile_legends[tile_idx].id)
298 script_file:write("\n")
304 local PuzzleScriptFile = {}
306 function PuzzleScriptFile:new(o)
308 setmetatable(o, self)
314 function PuzzleScriptFile:get_section(id)
315 id = string.upper(id)
316 return findOrAdd(self.sections, id, PuzzleScriptSection:new{})
319 function PuzzleScriptFile:read(filename)
320 local script_file = io.open(filename, "r")
323 local current_section = self:get_section("PRELUDE")
324 local current_item = nil
326 local line = script_file:read()
327 if line == nil then break end
328 if string.match(line, "^=+$") then
329 local current_section_name = script_file:read()
330 current_section = self:get_section(current_section_name)
331 script_file:read() -- Skip the next line as it's just the closing ===
334 elseif string.match(line, "^[ \t\r\n]*$") then
337 if current_item == nil then
338 current_item = PuzzleScriptRawItem:new{}
339 current_section:add_item(current_item)
342 current_item:add_line(line)
346 io.close(script_file)
350 function PuzzleScriptFile:export(filename)
351 local script_file = io.open(filename, "w")
353 local ordered_section_names = {
364 for _, section_name in pairs(ordered_section_names) do
365 local section = self:get_section(section_name)
366 if section ~= nil then
367 if section_name ~= "PRELUDE" then
368 local separator = string.rep("=", string.len(section_name))
369 script_file:write(separator)
370 script_file:write("\n")
371 script_file:write(section_name)
372 script_file:write("\n")
373 script_file:write(separator)
374 script_file:write("\n\n")
377 for _, section_item in pairs(section.items) do
378 section_item:export(script_file)
380 if section_name ~= "COLLISIONLAYERS" and section_name ~= "LEGEND" then -- fixme, table??
381 script_file:write("\n")
385 if section_name == "COLLISIONLAYERS" or section_name == "LEGEND" then -- fixme, table??
386 script_file:write("\n")
391 io.close(script_file)
397 local name = app.fs.fileTitle(sprite.filename)
399 local previous_export_filename = sprite.properties(plugin_key).previous_export_filename
400 local previous_merge_filename = sprite.properties(plugin_key).previous_merge_filename
401 local previous_export_selected_only = sprite.properties(plugin_key).previous_export_selected_only
402 local previous_export_lofi = sprite.properties(plugin_key).previous_export_lofi
404 if previous_export_filename == nil then
405 local default_path = app.fs.filePath(sprite.filename)
406 local default_filename = name..".txt"
407 previous_export_filename = app.fs.joinPath(default_path, default_filename)
411 dlg:file{ id="script_filename", label="Output:", filename=previous_export_filename, filetypes=".txt", save=true }
412 dlg:file{ id="merge_filename", label="Merge script (optional):", filename=previous_merge_filename, filetypes=".txt" }
413 dlg:check{ id="selected_only", label="Export selected level(s) only", selected=previous_export_selected_only }
414 dlg:check{ id="lofi", label="Prototype export (layer colours)", selected=previous_export_lofi }
415 dlg:button{ id="confirm", text="Confirm" }
416 dlg:button{ id="cancel", text="Cancel" }
419 local data = dlg.data
420 if data.confirm == false then
424 local layers = get_layers_flat({}, sprite)
426 local pz_file = PuzzleScriptFile:new{}
429 local object_section = pz_file:get_section("OBJECTS")
431 local layer_objects = {}
433 for _, layer in ipairs(layers) do
434 if layer.isVisible then
435 if layer.isImage == true and (layer.isTilemap == false or data.lofi) then
436 local object_item = PuzzleScriptObjectItem:new()
437 object_item.id = layer.name
438 object_section:add_item(object_item)
439 object_item:set_color(layer.color)
440 elseif layer.isTilemap == true then
441 local tileset = layer.tileset
443 local used_tiles = {}
444 for _, frame in ipairs(sprite.frames) do
445 local cel = layer:cel(frame)
447 for pixel in cel.image:pixels() do
448 p = pc.tileI(pixel())
456 local object_names = {}
457 for object_name in string.gmatch(layer.data, "[^,]+") do
458 if object_name ~= "" then
459 object_names[#object_names + 1] = object_name
460 used_tiles[#object_names] = true -- Always export named tiles
464 for tile_idx, _ in pairs(used_tiles) do
465 local object_item = PuzzleScriptObjectItem:new()
466 object_section:add_item(object_item)
468 if object_names[tile_idx] then
469 object_item.id = object_names[tile_idx]
471 object_item.id = layer.name..tile_idx
474 object_item.layer = layer
476 local tile = tileset:tile(tile_idx)
479 local layer_tiles = findOrAdd(layer_objects, layer)
480 layer_tiles[tile_idx] = object_item
484 for pixel in tile.image:pixels() do
485 object_item:set_pixel(pixel.x, pixel.y, Color(pixel()))
488 assert(false, "Missing tile: " .. layer.name .. " " .. tile_idx)
495 local levels_section = pz_file:get_section("LEVELS")
497 local tiles_w = math.floor(sprite.width / 5)
498 local tiles_h = math.floor(sprite.height / 5)
500 local exported_frames = sprite.frames
502 if data.selected_only then
503 if app.range.isEmpty == false then
504 exported_frames = app.range.frames
506 exported_frames = { app.frame }
510 for _, frame in ipairs(exported_frames) do
511 local level_item = PuzzleScriptLevelItem:new()
512 levels_section:add_item(level_item)
513 level_item.width = tiles_w
514 level_item.height = tiles_h
516 for tile_y = 0, tiles_h - 1 do
517 for tile_x = 0, tiles_w - 1 do
518 for _, layer in ipairs(layers) do
519 if layer.isVisible and layer.isTilemap == true then
520 local cel = layer:cel(frame)
522 local image = cel.image
524 local offset_x = math.floor(cel.bounds.x / 5)
525 local offset_y = math.floor(cel.bounds.y / 5)
526 local cel_width = math.floor(cel.bounds.width / 5)
527 local cel_height = math.floor(cel.bounds.height / 5)
529 local actual_tile_x = tile_x - offset_x
530 local actual_tile_y = tile_y - offset_y
531 if actual_tile_x >= 0 and actual_tile_y >= 0 and actual_tile_x < cel_width and actual_tile_y < cel_height then
532 local pixel_value = image:getPixel(actual_tile_x, actual_tile_y)
533 local tile_idx = pc.tileI(pixel_value)
535 assert(layer_objects[layer][tile_idx], "Looking for: " .. layer.name)
536 level_item:set_tile(tile_x, tile_y, layer_objects[layer][tile_idx])
546 local legend_section = pz_file:get_section("LEGEND")
548 local background_legend = PuzzleScriptLegendItem:new()
549 background_legend.id = "."
550 legend_section:add_item(background_legend)
552 local legend_icons = {}
553 for icon in string.gmatch("!\"#$%&'*+,-/\\0123456789:;?@_`abcdefghijklmnopqrstuvwzyz~", ".") do
554 legend_icons[#legend_icons + 1] = icon
557 for _, level in pairs(levels_section.items) do
558 level:map_to_legend(legend_section, legend_icons)
561 local layers_section = pz_file:get_section("COLLISIONLAYERS")
562 for _, layer in ipairs(sprite.layers) do
563 local layer_item = PuzzleScriptLayerItem:new()
564 layer_item.id = layer.name
565 layers_section:add_item(layer_item)
570 for layer, objects in pairs(layer_objects) do
571 local legend_map = PuzzleScriptLegendMapping:new()
572 legend_map.id = layer.name
573 legend_map:add_objects(objects)
574 legend_section:add_item(legend_map)
577 for _, layer in ipairs(layers) do
578 if layer.isGroup and #layer.layers > 0 then
579 local legend_map = PuzzleScriptLegendMapping:new()
580 legend_map.id = layer.name
581 for _, sub_layer in ipairs(layer.layers) do
582 legend_map:add_objects({ {id=sub_layer.name} })
584 legend_section:add_item(legend_map)
588 -- Merge after making everything else
589 pz_file:read(data.merge_filename)
591 pz_file:export(data.script_filename)
593 local export_filename_changed = sprite.properties(plugin_key).previous_export_filename ~= data.script_filename
594 local merge_filename_changed = sprite.properties(plugin_key).previous_merge_filename ~= data.merge_filename
595 local selected_only_changed = sprite.properties(plugin_key).previous_export_selected_only ~= data.selected_only
596 local lofi_changed = sprite.properties(plugin_key).previous_export_lofi ~= data.lofi
598 if export_filename_changed or merge_filename_changed or selected_only_changed or lofi_changed then
599 sprite.properties(plugin_key).previous_export_filename = data.script_filename
600 sprite.properties(plugin_key).previous_merge_filename = data.merge_filename
601 sprite.properties(plugin_key).previous_export_selected_only = data.selected_only
602 sprite.properties(plugin_key).previous_export_lofi = data.lofi