From 9763ceaa1c14f689e8984d4c525fe1992403b3ff Mon Sep 17 00:00:00 2001 From: "Eevee (Evelyn Woods)" Date: Thu, 25 Apr 2024 05:22:18 -0600 Subject: [PATCH] Revamp tileset options; refactor drawing a bit; work on tileset conversion Tileset options now identify the tilesets by their appearance, rather than the fairly useless "custom 1" or whatever. At last you can draw a tile without creating a renderer. Truly this is the future. Tileset conversion is still incredibly jank, but it does a fairly decent job (at least at LL -> CC2) without too much custom fiddling yet. --- js/main.js | 324 ++++++++++++++++++++---------------- js/renderer-canvas.js | 73 ++++++--- js/tileset.js | 370 +++++++++++++++++++++++++++++++++++++++++- style.css | 24 ++- 4 files changed, 618 insertions(+), 173 deletions(-) diff --git a/js/main.js b/js/main.js index 9b4dbc0..fe9dad7 100644 --- a/js/main.js +++ b/js/main.js @@ -12,7 +12,7 @@ import { PrimaryView, DialogOverlay, ConfirmOverlay, flash_button, svg_icon, loa import { Editor } from './editor/main.js'; import CanvasRenderer from './renderer-canvas.js'; import SOUNDTRACK from './soundtrack.js'; -import { Tileset, TILESET_LAYOUTS, parse_tile_world_large_tileset, infer_tileset_from_image } from './tileset.js'; +import { Tileset, TILESET_LAYOUTS, convert_tileset_to_layout, parse_tile_world_large_tileset, infer_tileset_from_image } from './tileset.js'; import TILE_TYPES from './tiletypes.js'; import { random_choice, mk, mk_svg } from './util.js'; import * as util from './util.js'; @@ -54,6 +54,13 @@ function simplify_number(number) { } } +function make_button(label, onclick) { + let button = mk('button', {type: 'button'}, label); + button.addEventListener('click', onclick); + return button; +} + + // TODO: // - level password, if any const OBITUARIES = { @@ -1005,12 +1012,6 @@ class Player extends PrimaryView { time_secs_el: this.root.querySelector('#player-debug-time-secs'), }; - let make_button = (label, onclick) => { - let button = mk('button', {type: 'button'}, label); - button.addEventListener('click', onclick); - return button; - }; - // -- Time -- // Hook up back/forward buttons debug_el.querySelector('.-time-controls').addEventListener('click', ev => { @@ -2946,7 +2947,7 @@ const TILESET_SLOTS = [{ name: "CC2", }, { ident: 'll', - name: "LL/editor", + name: "LL", }]; const CUSTOM_TILESET_BUCKETS = ['Custom 1', 'Custom 2', 'Custom 3']; const CUSTOM_TILESET_PREFIX = "Lexy's Labyrinth custom tileset: "; @@ -3020,8 +3021,9 @@ class OptionsOverlay extends DialogOverlay { } // Tileset options + this.main.append(mk('h2', "Tilesets")); this.tileset_els = {}; - this.renderers = {}; + //this.renderer = new CanvasRenderer(conductor.tilesets[slot.ident], 1); this.available_tilesets = {}; for (let [ident, def] of Object.entries(BUILTIN_TILESETS)) { let newdef = { ...def, is_builtin: true }; @@ -3047,41 +3049,32 @@ class OptionsOverlay extends DialogOverlay { }; } } + + let thead = mk('tr', mk('th', "Preview"), mk('th', "Format")); + this.tileset_table = mk('table.option-tilesets', thead); + this.main.append(this.tileset_table); for (let slot of TILESET_SLOTS) { - let renderer = new CanvasRenderer(conductor.tilesets[slot.ident], 1); - this.renderers[slot.ident] = renderer; - - let select = mk('select', {name: `tileset-${slot.ident}`}); - for (let [ident, def] of Object.entries(this.available_tilesets)) { - if (def.tileset.layout['#supported-versions'].has(slot.ident)) { - select.append(mk('option', {value: ident}, def.name)); - } - } - select.value = conductor.options.tilesets[slot.ident] ?? 'lexy'; - if (! conductor._loaded_tilesets[select.value]) { - select.value = 'lexy'; - } - select.addEventListener('change', () => { - this.update_selected_tileset(slot.ident); - }); - - let el = mk('dd.option-tileset', select, " "); - this.tileset_els[slot.ident] = el; - this.update_selected_tileset(slot.ident); - - dl.append( - mk('dt', `${slot.name} tileset`), - el, - ); + thead.append(mk('th.-slot', slot.name)); + } + for (let [ident, def] of Object.entries(this.available_tilesets)) { + this._add_tileset_row(ident, def); } this.custom_tileset_counter = 1; - dl.append(mk('dd', - mk('p', "You can also load a custom tileset, which will be saved in browser storage."), - mk('p', "MSCC, Tile World, and Steam layouts are all supported."), - mk('p', "(Steam tilesets can be found in ", mk('code', "data/bmp"), " within the game's local files)."), - mk('p', mk('input', {type: 'file', name: 'custom-tileset'})), + // FIXME allow drag-drop into... this window? area? idk + let custom_tileset_button = mk('button', {type: 'button'}, "Load custom tileset"); + custom_tileset_button.addEventListener('click', () => this.root.elements['custom-tileset'].click()); + this.main.append( + mk('p', + mk('input', {type: 'file', name: 'custom-tileset'}), + custom_tileset_button, + " — Any format: MSCC, Tile World, or Steam.", + ), + mk('p', "(Steam CC tilesets are in the game files under ", mk('code', "data/bmp"), ".)"), mk('div.option-load-tileset'), - )); + ); + this.root.elements['custom-tileset'].addEventListener('change', ev => { + this._load_custom_tileset(ev.target.files[0]); + }); // Load current values this.root.elements['music-volume'].value = this.conductor.options.music_volume ?? 1.0; @@ -3092,80 +3085,25 @@ class OptionsOverlay extends DialogOverlay { this.root.elements['show-captions'].checked = this.conductor.options.show_captions ?? false; this.root.elements['use-cc2-anim-speed'].checked = this.conductor.options.use_cc2_anim_speed ?? false; - this.root.elements['custom-tileset'].addEventListener('change', ev => { - this._load_custom_tileset(ev.target.files[0]); - }); - - this.add_button("save", () => { - let options = this.conductor.options; - options.music_volume = parseFloat(this.root.elements['music-volume'].value); - options.music_enabled = this.root.elements['music-enabled'].checked; - options.sound_volume = parseFloat(this.root.elements['sound-volume'].value); - options.sound_enabled = this.root.elements['sound-enabled'].checked; - options.spatial_mode = parseInt(this.root.elements['spatial-mode'].value, 10); - options.show_captions = this.root.elements['show-captions'].checked; - options.use_cc2_anim_speed = this.root.elements['use-cc2-anim-speed'].checked; - - // Tileset stuff: slightly more complicated. Save custom ones to localStorage as data - // URIs, and /delete/ any custom ones we're not using any more, both of which require - // knowing which slots we're already using first - let buckets_in_use = new Set; - let chosen_tilesets = {}; - for (let slot of TILESET_SLOTS) { - let tileset_ident = this.root.elements[`tileset-${slot.ident}`].value; - let tilesetdef = this.available_tilesets[tileset_ident]; - if (! tilesetdef) { - tilesetdef = this.available_tilesets['lexy']; - } - - chosen_tilesets[slot.ident] = tilesetdef; - if (tilesetdef.is_already_stored) { - buckets_in_use.add(tilesetdef.ident); + for (let slot of TILESET_SLOTS) { + let radioset = this.root.elements[`tileset-${slot.ident}`]; + let value = conductor.options.tilesets[slot.ident] ?? 'lexy'; + if (! conductor._loaded_tilesets[value]) { + value = 'lexy'; + } + if (radioset instanceof Element) { + // There's only one radio button so we just got that back + if (radioset.value === value) { + radioset.checked = true; } } - // Clear out _loaded_tilesets first so it no longer refers to any custom tilesets we end - // up deleting - this.conductor._loaded_tilesets = {}; - for (let [slot_ident, tilesetdef] of Object.entries(chosen_tilesets)) { - if (tilesetdef.is_builtin || tilesetdef.is_already_stored) { - options.tilesets[slot_ident] = tilesetdef.ident; - } - else { - // This is a newly uploaded one - let data_uri = tilesetdef.data_uri ?? tilesetdef.canvas.toDataURL('image/png'); - let storage_bucket = CUSTOM_TILESET_BUCKETS.find( - bucket => ! buckets_in_use.has(bucket)); - if (! storage_bucket) { - console.error("Somehow ran out of storage buckets, this should be impossible??"); - continue; - } - buckets_in_use.add(storage_bucket); - save_json_to_storage(CUSTOM_TILESET_PREFIX + storage_bucket, { - src: data_uri, - name: storage_bucket, - layout: tilesetdef.layout, - tile_width: tilesetdef.tile_width, - tile_height: tilesetdef.tile_height, - }); - options.tilesets[slot_ident] = storage_bucket; - } - - // Update the conductor's loaded tilesets - this.conductor.tilesets[slot_ident] = tilesetdef.tileset; - this.conductor._loaded_tilesets[options.tilesets[slot_ident]] = tilesetdef.tileset; - } - // Delete old custom set URIs - for (let bucket of CUSTOM_TILESET_BUCKETS) { - if (! buckets_in_use.has(bucket)) { - window.localStorage.removeItem(CUSTOM_TILESET_PREFIX + bucket); - } + else { + // This should be an actual radioset + radioset.value = value; } + } - this.conductor.save_stash(); - this.conductor.reload_all_options(); - - this.close(); - }, true); + this.add_button("save", () => this.save(), true); this.add_button("forget it", () => { // Restore the player's music volume just in case if (this.original_music_volume !== undefined) { @@ -3197,6 +3135,64 @@ class OptionsOverlay extends DialogOverlay { sfx.enabled = was_enabled; } + _add_tileset_row(ident, def) { + let tr = mk('tr'); + this.tileset_table.append(tr); + + tr.append(mk('td', + // TODO maybe draw these all to a single canvas + CanvasRenderer.draw_single_tile(def.tileset, 'player'), + CanvasRenderer.draw_single_tile(def.tileset, 'chip'), + CanvasRenderer.draw_single_tile(def.tileset, 'exit'), + )); + + tr.append(mk('td.-format', + def.tileset.layout['#name'], + mk('br'), + `${def.tileset.size_x}×${def.tileset.size_y}px`, + )); + + for (let slot of TILESET_SLOTS) { + let td = mk('td.-slot'); + tr.append(td); + if (def.tileset.layout['#supported-versions'].has(slot.ident)) { + td.append(mk('label', mk('input', { + type: 'radio', + name: `tileset-${slot.ident}`, + value: ident, + }))); + } + } + + // FIXME make buttons work + return; + + if (def.is_builtin) { + tr.append(mk('td')); + } + else { + // TODO this doesn't do anything yet. currently we just delete any tilesets not + // assigned to a slot + tr.append(mk('td', mk('button', {type: 'button'}, "Forget"))); + } + + tr.append(mk('td', + make_button("LL", () => { + convert_tileset_to_layout(def.tileset, 'lexy'); + }), + make_button("CC2", () => { + let canvas = convert_tileset_to_layout(def.tileset, 'cc2'); + mk('a', {href: canvas.toDataURL(), target: '_new'}).click(); + }), + make_button("MSCC", () => { + convert_tileset_to_layout(def.tileset, 'tw-static'); + }), + make_button("TW", () => { + convert_tileset_to_layout(def.tileset, 'tw-animated'); + }), + )); + } + async _load_custom_tileset(file) { // This is dumb and roundabout, but such is the web let reader = new FileReader; @@ -3212,49 +3208,20 @@ class OptionsOverlay extends DialogOverlay { // ratio, hopefully. Note that the LL layout is currently in progress so we can't // really detect that, but there can't really be alternatives to it either let result_el = this.root.querySelector('.option-load-tileset'); + result_el.textContent = ''; let tileset; try { tileset = infer_tileset_from_image(img, (w, h) => mk('canvas', {width: w, height: h})); } catch (e) { console.error(e); - result_el.textContent = ''; result_el.append(mk('p', "This doesn't look like a tileset layout I understand, sorry!")); return; } - let renderer = new CanvasRenderer(tileset, 1); - result_el.textContent = ''; - let buttons = mk('p'); - result_el.append( - mk('p', `This looks like a ${tileset.layout['#name']} tileset with ${tileset.size_x}×${tileset.size_y} tiles.`), - mk('p', - renderer.draw_single_tile_type('player'), - renderer.draw_single_tile_type('chip'), - renderer.draw_single_tile_type('exit'), - ), - buttons, - ); - let tileset_ident = `new-custom-${this.custom_tileset_counter}`; let tileset_name = `New custom ${this.custom_tileset_counter}`; - this.custom_tileset_counter += 1; - for (let slot of TILESET_SLOTS) { - if (! tileset.layout['#supported-versions'].has(slot.ident)) - continue; - - let dd = this.tileset_els[slot.ident]; - let select = dd.querySelector('select'); - select.append(mk('option', {value: tileset_ident}, tileset_name)); - - let button = util.mk_button(`Use for ${slot.name}`, () => { - select.value = tileset_ident; - this.update_selected_tileset(slot.ident); - }); - buttons.append(button); - } - - this.available_tilesets[tileset_ident] = { + let tilesetdef = { ident: tileset_ident, name: tileset_name, canvas: tileset.image, @@ -3263,6 +3230,10 @@ class OptionsOverlay extends DialogOverlay { tile_width: tileset.size_x, tile_height: tileset.size_y, }; + this.available_tilesets[tileset_ident] = tilesetdef; + + this.custom_tileset_counter += 1; + this._add_tileset_row(tileset_ident, tilesetdef); } update_selected_tileset(slot_ident) { @@ -3283,6 +3254,77 @@ class OptionsOverlay extends DialogOverlay { ); } + save() { + let options = this.conductor.options; + options.music_volume = parseFloat(this.root.elements['music-volume'].value); + options.music_enabled = this.root.elements['music-enabled'].checked; + options.sound_volume = parseFloat(this.root.elements['sound-volume'].value); + options.sound_enabled = this.root.elements['sound-enabled'].checked; + options.spatial_mode = parseInt(this.root.elements['spatial-mode'].value, 10); + options.show_captions = this.root.elements['show-captions'].checked; + options.use_cc2_anim_speed = this.root.elements['use-cc2-anim-speed'].checked; + + // Tileset stuff: slightly more complicated. Save custom ones to localStorage as data URIs, + // and /delete/ any custom ones we're not using any more, both of which require knowing + // which slots we're already using first + let buckets_in_use = new Set; + let chosen_tilesets = {}; + for (let slot of TILESET_SLOTS) { + let tileset_ident = this.root.elements[`tileset-${slot.ident}`].value; + let tilesetdef = this.available_tilesets[tileset_ident]; + if (! tilesetdef) { + tilesetdef = this.available_tilesets['lexy']; + } + + chosen_tilesets[slot.ident] = tilesetdef; + if (tilesetdef.is_already_stored) { + buckets_in_use.add(tilesetdef.ident); + } + } + // Clear out _loaded_tilesets first so it no longer refers to any custom tilesets we end + // up deleting + this.conductor._loaded_tilesets = {}; + for (let [slot_ident, tilesetdef] of Object.entries(chosen_tilesets)) { + if (tilesetdef.is_builtin || tilesetdef.is_already_stored) { + options.tilesets[slot_ident] = tilesetdef.ident; + } + else { + // This is a newly uploaded one + let data_uri = tilesetdef.data_uri ?? tilesetdef.canvas.toDataURL('image/png'); + let storage_bucket = CUSTOM_TILESET_BUCKETS.find( + bucket => ! buckets_in_use.has(bucket)); + if (! storage_bucket) { + console.error("Somehow ran out of storage buckets, this should be impossible??"); + continue; + } + buckets_in_use.add(storage_bucket); + save_json_to_storage(CUSTOM_TILESET_PREFIX + storage_bucket, { + src: data_uri, + name: storage_bucket, + layout: tilesetdef.layout, + tile_width: tilesetdef.tile_width, + tile_height: tilesetdef.tile_height, + }); + options.tilesets[slot_ident] = storage_bucket; + } + + // Update the conductor's loaded tilesets + this.conductor.tilesets[slot_ident] = tilesetdef.tileset; + this.conductor._loaded_tilesets[options.tilesets[slot_ident]] = tilesetdef.tileset; + } + // Delete old custom set URIs + for (let bucket of CUSTOM_TILESET_BUCKETS) { + if (! buckets_in_use.has(bucket)) { + window.localStorage.removeItem(CUSTOM_TILESET_PREFIX + bucket); + } + } + + this.conductor.save_stash(); + this.conductor.reload_all_options(); + + this.close(); + } + close() { // Ensure the player's music is set back how we left it this.conductor.player.update_music_playback_state(); diff --git a/js/renderer-canvas.js b/js/renderer-canvas.js index e9ec7c8..b3171d4 100644 --- a/js/renderer-canvas.js +++ b/js/renderer-canvas.js @@ -3,10 +3,10 @@ import { mk } from './util.js'; import { DrawPacket } from './tileset.js'; import TILE_TYPES from './tiletypes.js'; -class CanvasRendererDrawPacket extends DrawPacket { - constructor(renderer, ctx, perception, clock, update_progress, update_rate) { - super(perception, renderer.hide_logic, clock, update_progress, update_rate); - this.renderer = renderer; +export class CanvasDrawPacket extends DrawPacket { + constructor(tileset, ctx, perception, hide_logic, clock, update_progress, update_rate) { + super(perception, hide_logic, clock, update_progress, update_rate); + this.tileset = tileset; this.ctx = ctx; // Canvas position of the cell being drawn this.x = 0; @@ -14,20 +14,17 @@ class CanvasRendererDrawPacket extends DrawPacket { // Offset within the cell, for actors in motion this.offsetx = 0; this.offsety = 0; - // Compatibility settings - this.use_cc2_anim_speed = renderer.use_cc2_anim_speed; - this.show_facing = renderer.show_facing; } blit(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) { - this.renderer.blit(this.ctx, + this.tileset.blit_to_canvas(this.ctx, tx + mx, ty + my, this.x + this.offsetx + mdx, this.y + this.offsety + mdy, mw, mh); } blit_aligned(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) { - this.renderer.blit(this.ctx, + this.tileset.blit_to_canvas(this.ctx, tx + mx, ty + my, this.x + mdx, this.y + mdy, mw, mh); @@ -79,6 +76,41 @@ export class CanvasRenderer { return mk('canvas', {width: w, height: h}); } + // Draw a single tile, or even the name of a tile type. Either a canvas or a context may be given. + // If neither is given, a new canvas is returned. + static draw_single_tile(tileset, name_or_tile, canvas = null, x = 0, y = 0) { + let ctx; + if (! canvas) { + canvas = this.make_canvas(tileset.size_x, tileset.size_y); + ctx = canvas.getContext('2d'); + } + else if (canvas instanceof CanvasRenderingContext2D) { + ctx = canvas; + canvas = ctx.canvas; + } + else { + ctx = canvas.getContext('2d'); + } + + let name, tile; + if (typeof name_or_tile === 'string' || name_or_tile instanceof String) { + name = name_or_tile; + tile = null; + } + else { + tile = name_or_tile; + name = tile.type.name; + } + + // Individual tile types always reveal what they are + let packet = new CanvasDrawPacket(tileset, ctx, 'palette'); + packet.x = x; + packet.y = y; + tileset.draw_type(name, tile, packet); + + return canvas; + } + set_level(level) { this.level = level; // TODO update viewport size... or maybe Game should do that since you might be cheating @@ -148,16 +180,6 @@ export class CanvasRenderer { return [x, y]; } - // Draw to a canvas using tile coordinates - blit(ctx, sx, sy, dx, dy, w = 1, h = w) { - let tw = this.tileset.size_x; - let th = this.tileset.size_y; - ctx.drawImage( - this.tileset.image, - sx * tw, sy * th, w * tw, h * th, - dx * tw, dy * th, w * tw, h * th); - } - _adjust_viewport_if_dirty() { if (! this.viewport_dirty) return; @@ -185,8 +207,11 @@ export class CanvasRenderer { // game starts, because we're trying to interpolate backwards from 0, hence the Math.max() let clock = (this.level.tic_counter ?? 0) + ( (this.level.frame_offset ?? 0) + (update_progress - 1) * this.update_rate) / 3; - let packet = new CanvasRendererDrawPacket( - this, this.ctx, this.perception, Math.max(0, clock), update_progress, this.update_rate); + let packet = new CanvasDrawPacket( + this.tileset, this.ctx, this.perception, this.hide_logic, + Math.max(0, clock), update_progress, this.update_rate); + packet.use_cc2_anim_speed = this.use_cc2_anim_speed; + packet.show_facing = this.show_facing; let tw = this.tileset.size_x; let th = this.tileset.size_y; @@ -386,7 +411,8 @@ export class CanvasRenderer { width = width ?? this.level.size_x; cells = cells ?? this.level.linear_cells; - let packet = new CanvasRendererDrawPacket(this, ctx, perception); + let packet = new CanvasDrawPacket(this.tileset, ctx, perception); + packet.show_facing = show_facing; for (let x = x0; x <= x1; x++) { for (let y = y0; y <= y1; y++) { let cell = cells[y * width + x]; @@ -441,7 +467,8 @@ export class CanvasRenderer { let ctx = canvas.getContext('2d'); // Individual tile types always reveal what they are - let packet = new CanvasRendererDrawPacket(this, ctx, 'palette'); + let packet = new CanvasDrawPacket(this.tileset, ctx, 'palette'); + packet.show_facing = this.show_facing; packet.x = x; packet.y = y; this.tileset.draw_type(name, tile, packet); diff --git a/js/tileset.js b/js/tileset.js index c8caf0d..aa1fcd6 100644 --- a/js/tileset.js +++ b/js/tileset.js @@ -294,7 +294,7 @@ export const CC2_TILESET_LAYOUT = { }, // Thin walls are built piecemeal from two tiles; the first is N/S, the second is E/W thin_walls: { - __special__: 'thin_walls', + __special__: 'thin-walls', thin_walls_ns: [1, 10], thin_walls_ew: [2, 10], }, @@ -847,7 +847,7 @@ export const TILE_WORLD_TILESET_LAYOUT = { wall_invisible_revealed: [0, 1], // FIXME in cc1 tilesets these are opaque so they should draw at the terrain layer thin_walls: { - __special__: 'thin_walls_cc1', + __special__: 'thin-walls-cc1', north: [0, 6], west: [0, 7], south: [0, 8], @@ -1166,12 +1166,12 @@ export const LL_TILESET_LAYOUT = { grass: [2, 7], thin_walls: { - __special__: 'thin_walls', + __special__: 'thin-walls', thin_walls_ns: [8, 4], thin_walls_ew: [8, 5], }, one_way_walls: { - __special__: 'thin_walls', + __special__: 'thin-walls', thin_walls_ns: [9, 4], thin_walls_ew: [9, 5], }, @@ -1246,6 +1246,7 @@ export const LL_TILESET_LAYOUT = { all: new Array(252).fill([12, 8]).concat([ [8, 9], [9, 9], [10, 9], [11, 9], ]), + _distinct: [[12, 8], [8, 9], [9, 9], [10, 9], [11, 9]], }, cracked_ice: [12, 9], ice_se: [13, 8], @@ -2089,13 +2090,22 @@ export class DrawPacket { export class Tileset { constructor(image, layout, size_x, size_y) { - // XXX curiously, i note that .image is never used within this class this.image = image; this.layout = layout; this.size_x = size_x; this.size_y = size_y; } + // Draw to a canvas using tile coordinates + blit_to_canvas(ctx, sx, sy, dx, dy, w = 1, h = w) { + ctx.drawImage( + this.image, + sx * this.size_x, sy * this.size_y, w * this.size_x, h * this.size_y, + dx * this.size_x, dy * this.size_y, w * this.size_x, h * this.size_y); + } + + // Everything from here on uses the DrawPacket API + draw(tile, packet) { this.draw_type(tile.type.name, tile, packet); } @@ -2698,10 +2708,10 @@ export class Tileset { else if (drawspec.__special__ === 'letter') { this._draw_letter(drawspec, name, tile, packet); } - else if (drawspec.__special__ === 'thin_walls') { + else if (drawspec.__special__ === 'thin-walls') { this._draw_thin_walls(drawspec, name, tile, packet); } - else if (drawspec.__special__ === 'thin_walls_cc1') { + else if (drawspec.__special__ === 'thin-walls-cc1') { this._draw_thin_walls_cc1(drawspec, name, tile, packet); } else if (drawspec.__special__ === 'bomb-fuse') { @@ -2815,7 +2825,7 @@ export function parse_tile_world_large_tileset(canvas) { // fell: n/a }, thin_walls: { - __special__: 'thin_walls_cc1', + __special__: 'thin-walls-cc1', }, }; let image_data = ctx.getImageData(0, 0, canvas.width, canvas.height); @@ -3194,3 +3204,347 @@ export function infer_tileset_from_image(img, make_canvas) { // Anything else could be Tile World's "large" layout, which has no fixed dimensions return parse_tile_world_large_tileset(canvas); } + + +// ------------------------------------------------------------------------------------------------- +// Tileset conversion + +// Copy tiles from a source image/canvas to a context. The specs should be either a flat list +// (static coordinates) or a list of lists (animation). +function blit_tile_between_layouts(tileset, old_spec, new_spec, ctx) { + // First fix the nesting to be consistent both ways + if (! (old_spec[0] instanceof Array)) { + old_spec = [old_spec]; + } + if (! (new_spec[0] instanceof Array)) { + new_spec = [new_spec]; + } + + // Now blit each frame of the new spec's animation, picking the closest frame from the original + for (let [i, dest] of new_spec.entries()) { + let src = old_spec[Math.floor(i * old_spec.length / new_spec.length)]; + tileset.blit_to_canvas(ctx, ...src, ...dest); + } +} + +const DOUBLE_SIZE_FALLBACK = { + horizontal: 'east', + vertical: 'south', + north: 'vertical', + south: 'vertical', + west: 'horizontal', + east: 'horizontal', +}; + +// TODO: +// bombs +// vfx collision +// no enemy underlay +// no green/purple block overlay +// wrong double-recessed wall, somehow?? +// no turtles +// missing base for double-size +// missing editor cursors +// timid teeth uses different frames, huh. +// inactive red tele + trans are here and shouldn't be +// sliding player is drawn over walking player +// no wire icon +// no clone arrows +function convert_drawspec(tileset, old_spec, new_spec, ctx) { + // If the new spec is null, the tile doesn't exist there, which is fine + if (! new_spec) + return; + + let recurse = (...keys) => { + for (let key of keys) { + convert_drawspec(tileset, old_spec[key], new_spec[key], ctx); + } + }; + + if (new_spec instanceof Array && old_spec instanceof Array) { + // Simple frames + // TODO what if old_spec is *not* an array?? + blit_tile_between_layouts(tileset, old_spec, new_spec, ctx); + } + else if ((new_spec.__special__ ?? 'animated') === (old_spec.__special__ ?? 'animated')) { + if (! new_spec.__special__ || new_spec.__special__ === 'animated') { + // Actor facings + if (old_spec instanceof Array) { + old_spec = {all: old_spec}; + } + if (new_spec instanceof Array) { + new_spec = {all: new_spec}; + } + + if (old_spec.all && new_spec.all) { + recurse('all'); + } + else if (! old_spec.all && ! new_spec.all) { + recurse('north', 'south', 'east', 'west'); + } + else if (old_spec.all && ! new_spec.all) { + convert_drawspec(tileset, old_spec.all, new_spec.north, ctx); + convert_drawspec(tileset, old_spec.all, new_spec.south, ctx); + convert_drawspec(tileset, old_spec.all, new_spec.east, ctx); + convert_drawspec(tileset, old_spec.all, new_spec.west, ctx); + } + else { // ! old_spec.all && new_spec.all + convert_drawspec(tileset, old_spec.south, new_spec.all, ctx); + } + } + else if (new_spec.__special__ === 'arrows') { + recurse('base', 'arrows'); + } + else if (new_spec.__special__ === 'double-size-monster') { + convert_drawspec(tileset, old_spec.base, new_spec.base, ctx); + for (let [direction, fallback] of Object.entries(DOUBLE_SIZE_FALLBACK)) { + convert_drawspec( + tileset, old_spec[direction] ?? old_spec[fallback], new_spec[direction], ctx); + } + } + else if (new_spec.__special__ === 'letter') { + recurse('base'); + // Technically this doesn't work for two layouts with letters laid out differently, but + // no two such layouts exist, so, whatever + for (let [glyph, new_coords] of Object.entries(new_spec.letter_glyphs)) { + let old_coords = old_spec.letter_glyphs[glyph]; + tileset.blit_to_canvas(ctx, ...old_coords, ...new_coords, 0.5, 0.5); + } + for (let [i, new_range] of new_spec.letter_ranges.entries()) { + let old_range = old_spec.letter_ranges[i]; + tileset.blit_to_canvas(ctx, + old_range.x0, old_range.y0, new_range.x0, new_range.y0, + new_range.columns * new_range.w, + Math.ceil((new_range.range[1] - new_range.range[0]) / new_range.columns) * new_range.h); + } + } + else if (new_spec.__special__ === 'logic-gate') { + tileset.blit_to_canvas(ctx, + old_spec.counter_numbers.x, old_spec.counter_numbers.y, + new_spec.counter_numbers.x, new_spec.counter_numbers.y, + old_spec.counter_numbers.width * 12, old_spec.counter_numbers.height); + for (let gate_type of ['not', 'and', 'or', 'xor', 'nand', 'latch-ccw', 'latch-cw', 'counter']) { + convert_drawspec( + tileset, old_spec.logic_gate_tiles[gate_type], new_spec.logic_gate_tiles[gate_type], ctx); + } + } + else if (new_spec.__special__ === 'perception') { + recurse('hidden', 'revealed'); + } + else if (new_spec.__special__ === 'railroad') { + recurse('base', 'railroad_switch'); + for (let key of ['railroad_ties', 'railroad_inactive', 'railroad_active']) { + for (let dir of ['ne', 'se', 'sw', 'nw', 'ew', 'ns']) { + convert_drawspec(tileset, old_spec[key][dir], new_spec[key][dir], ctx); + } + } + } + else if (new_spec.__special__ === 'rover') { + // No one is ever gonna come up with an alternate rover so just copy enough to hit all + // of CC2's frames + recurse('glider', 'walker', 'direction'); + } + else if (new_spec.__special__ === 'scroll') { + let sx = old_spec.base[0] + Math.min(0, old_spec.scroll_region[0]); + let sy = old_spec.base[1] + Math.min(0, old_spec.scroll_region[1]); + let dx = new_spec.base[0] + Math.min(0, new_spec.scroll_region[0]); + let dy = new_spec.base[1] + Math.min(0, new_spec.scroll_region[1]); + tileset.blit_to_canvas( + ctx, sx, sy, dx, dy, + Math.abs(old_spec.scroll_region[0]) + 1, + Math.abs(old_spec.scroll_region[1]) + 1); + } + else if (new_spec.__special__ === 'thin-walls') { + recurse('thin_walls_ns', 'thin_walls_ew'); + } + else if (new_spec.__special__ === 'thin-walls-cc1') { + recurse('north', 'south', 'east', 'west', 'southeast'); + } + else if (new_spec.__special__ === 'visual-state') { + for (let key of Object.keys(new_spec)) { + if (key === '__special__') + continue; + + let old_state = old_spec[key]; + let new_state = new_spec[key]; + // These might be strings, meaning aliases... + if (typeof new_state === 'string') { + // New tileset doesn't have dedicated space for this, so nothing to do + continue; + } + else if (typeof old_state === 'string') { + // New tileset wants it, but old tileset aliases it, so deref + old_state = old_spec[old_state]; + } + convert_drawspec(tileset, old_state, new_state, ctx); + } + } + else if (new_spec.__special__ === 'wires') { + recurse('base', 'wired', 'wired_cross'); + } + /* + else if (drawspec.__special__ === 'overlay') { + this._draw_overlay(drawspec, name, tile, packet); + } + else if (drawspec.__special__ === 'scroll') { + this._draw_scroll(drawspec, name, tile, packet); + } + else if (drawspec.__special__ === 'bomb-fuse') { + this._draw_bomb_fuse(drawspec, name, tile, packet); + } + else if (drawspec.__special__ === 'double-size-monster') { + this._draw_double_size_monster(drawspec, name, tile, packet); + } + else if (drawspec.__special__ === 'rover') { + this._draw_rover(drawspec, name, tile, packet); + } + else if (drawspec.__special__ === 'railroad') { + this._draw_railroad(drawspec, name, tile, packet); + } + else if (drawspec.__special__ === 'encased_item') { + this._draw_encased_item(drawspec, name, tile, packet); + } + else { + console.error(`No such special ${drawspec.__special__} for ${name}`); + } + */ + else { + return false; + } + } + else if (new_spec.__special__ === 'double-size-monster') { + // Converting an old single-size monster to a new double-size one is relatively easy; we can + // just draw the small one offset within the double-size space. Unfortunately for layouts + // like CC2 we can only show one vertical and one horizontal direction... + + for (let [direction, fallback] of Object.entries(DOUBLE_SIZE_FALLBACK)) { + let new_frames = new_spec[direction]; + if (! new_frames) + continue; + let old_frames = old_spec[direction] ?? old_spec[fallback]; + for (let [i, dest] of new_frames.entries()) { + if (dest === null) + // This means "use the base sprite" + continue; + let src = old_frames[Math.floor(i * old_frames.length / new_frames.length)]; + let [dx, dy] = dest; + if (direction === 'horizontal' || fallback === 'horizontal') { + dx += i / new_frames.length; + } + else { + dy += i / new_frames.length; + } + tileset.blit_to_canvas(ctx, ...src, dx, dy); + } + } + } + // TODO the other way, yikes + // Convert buttons to/from LL, which adds depressed states + else if (! old_spec.__special__ && new_spec.__special__ === 'visual-state') { + // Draw the static tile to every state in the new tileset + for (let [key, subspec] of Object.entries(new_spec)) { + if (key === '__special__' || typeof subspect === 'string') + continue; + convert_drawspec(tileset, old_spec, subspec, ctx); + } + } + else if (! new_spec.__special__ && old_spec.__special__ === 'visual-state') { + // Draw the most fundamental state as the static tile + let representative_spec = ( + old_spec.open // trap + || old_spec.released // button + || old_spec.normal // player i guess?? + ); + convert_drawspec(tileset, representative_spec, new_spec, ctx); + } + else { + return false; + } +} + +export function convert_tileset_to_tile_world_animated(tileset) { +} + +export function convert_tileset_to_layout(tileset, layout_ident) { + if (layout_ident === tileset.layout['#ident']) { + return tileset.image; + } + if (layout_ident === 'tw-animated') { + return convert_tileset_to_tile_world_animated(tileset); + } + + let layout = TILESET_LAYOUTS[layout_ident]; + let canvas = document.createElement('canvas'); + canvas.width = layout['#dimensions'][0] * tileset.size_x; + canvas.height = layout['#dimensions'][1] * tileset.size_y; + let ctx = canvas.getContext('2d'); + + let comparison = {}; + let summarize = spec => { + if (! spec) { + return null; + } + else if (spec instanceof Array) { + return '-'; + } + else { + return spec.__special__; + } + }; + + for (let [name, spec] of Object.entries(layout)) { + // These things aren't tiles + if (name === '#ident' || name === '#name' || name === '#dimensions' || + name === '#supported-versions' || name === '#wire-width') + { + continue; + } + + // These sequences only really exist in LL and were faked with other tiles in other tilesets + // TODO so include the fake in LL, right? + if (name === 'player1_exit' || name === 'player2_exit') { + continue; + } + + let old_spec = tileset.layout[name]; + if (! old_spec) + // Guess we can't, uh, do much about this? + // TODO warn? dummy tiles? + continue; + + // Manually adjust some incompatible tilesets + // LL's tileset adds animation for ice, which others don't have, and it's difficult to + // convert directly because it repeats its tiles a lot + if (name === 'ice' && layout_ident === 'lexy') { + // To convert TO LL, copy a lone ice tile to every position + for (let coords of spec._distinct) { + tileset.blit_to_canvas(ctx, ...old_spec, ...coords); + } + continue; + } + else if (name === 'ice' && tileset.layout['#ident'] === 'lexy') { + // To convert FROM LL, pretend it only has its one tile + tileset.blit_to_canvas(ctx, ...old_spec._distinct[0], ...spec); + continue; + } + + // OK, do the automatic thing + if (convert_drawspec(tileset, old_spec, spec, ctx) === false) { + comparison[name] = { + old: summarize(old_spec), + 'new': summarize(spec), + }; + } + } + console.table(comparison); + + console.log(canvas); + console.log('%c ', ` + display: inline-block; + font-size: 1px; + padding: ${canvas.width}px ${canvas.height}px; + background: url(${canvas.toDataURL()}); + `); + console.log(canvas.toDataURL()); + return canvas; +} diff --git a/style.css b/style.css index 0a5fff2..dcad60f 100644 --- a/style.css +++ b/style.css @@ -600,7 +600,26 @@ img.compat-icon, .option-volume > input[type=range] { flex: auto; } -.option-tileset canvas { +table.option-tilesets th, +table.option-tilesets td { + padding: 0.25rem 0.5rem; +} +table.option-tilesets > tr > .-format { + font-size: 0.75em; + text-align: center; +} +table.option-tilesets > tr > .-slot { + padding: 0; +} +table.option-tilesets > tr > .-slot > label { + display: grid; + box-sizing: border-box; + min-width: 100%; + min-height: 100%; + padding: 0.5em 1em; + place-items: center; +} +table.option-tilesets canvas { vertical-align: middle; } label.option { @@ -623,6 +642,9 @@ label.option .option-label { .option-help.--visible { /* TODO */ } +.dialog-options input[type=file][name=custom-tileset] { + display: none; +} @media (max-width: 800px) { .dialog {