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.
This commit is contained in:
Eevee (Evelyn Woods) 2024-04-25 05:22:18 -06:00
parent 5a17b9022d
commit 9763ceaa1c
4 changed files with 618 additions and 173 deletions

View File

@ -12,7 +12,7 @@ import { PrimaryView, DialogOverlay, ConfirmOverlay, flash_button, svg_icon, loa
import { Editor } from './editor/main.js'; import { Editor } from './editor/main.js';
import CanvasRenderer from './renderer-canvas.js'; import CanvasRenderer from './renderer-canvas.js';
import SOUNDTRACK from './soundtrack.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 TILE_TYPES from './tiletypes.js';
import { random_choice, mk, mk_svg } from './util.js'; import { random_choice, mk, mk_svg } from './util.js';
import * as util 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: // TODO:
// - level password, if any // - level password, if any
const OBITUARIES = { const OBITUARIES = {
@ -1005,12 +1012,6 @@ class Player extends PrimaryView {
time_secs_el: this.root.querySelector('#player-debug-time-secs'), 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 -- // -- Time --
// Hook up back/forward buttons // Hook up back/forward buttons
debug_el.querySelector('.-time-controls').addEventListener('click', ev => { debug_el.querySelector('.-time-controls').addEventListener('click', ev => {
@ -2946,7 +2947,7 @@ const TILESET_SLOTS = [{
name: "CC2", name: "CC2",
}, { }, {
ident: 'll', ident: 'll',
name: "LL/editor", name: "LL",
}]; }];
const CUSTOM_TILESET_BUCKETS = ['Custom 1', 'Custom 2', 'Custom 3']; const CUSTOM_TILESET_BUCKETS = ['Custom 1', 'Custom 2', 'Custom 3'];
const CUSTOM_TILESET_PREFIX = "Lexy's Labyrinth custom tileset: "; const CUSTOM_TILESET_PREFIX = "Lexy's Labyrinth custom tileset: ";
@ -3020,8 +3021,9 @@ class OptionsOverlay extends DialogOverlay {
} }
// Tileset options // Tileset options
this.main.append(mk('h2', "Tilesets"));
this.tileset_els = {}; this.tileset_els = {};
this.renderers = {}; //this.renderer = new CanvasRenderer(conductor.tilesets[slot.ident], 1);
this.available_tilesets = {}; this.available_tilesets = {};
for (let [ident, def] of Object.entries(BUILTIN_TILESETS)) { for (let [ident, def] of Object.entries(BUILTIN_TILESETS)) {
let newdef = { ...def, is_builtin: true }; 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) { for (let slot of TILESET_SLOTS) {
let renderer = new CanvasRenderer(conductor.tilesets[slot.ident], 1); thead.append(mk('th.-slot', slot.name));
this.renderers[slot.ident] = renderer; }
for (let [ident, def] of Object.entries(this.available_tilesets)) {
let select = mk('select', {name: `tileset-${slot.ident}`}); this._add_tileset_row(ident, def);
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,
);
} }
this.custom_tileset_counter = 1; this.custom_tileset_counter = 1;
dl.append(mk('dd', // FIXME allow drag-drop into... this window? area? idk
mk('p', "You can also load a custom tileset, which will be saved in browser storage."), let custom_tileset_button = mk('button', {type: 'button'}, "Load custom tileset");
mk('p', "MSCC, Tile World, and Steam layouts are all supported."), custom_tileset_button.addEventListener('click', () => this.root.elements['custom-tileset'].click());
mk('p', "(Steam tilesets can be found in ", mk('code', "data/bmp"), " within the game's local files)."), this.main.append(
mk('p', mk('input', {type: 'file', name: 'custom-tileset'})), 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'), mk('div.option-load-tileset'),
)); );
this.root.elements['custom-tileset'].addEventListener('change', ev => {
this._load_custom_tileset(ev.target.files[0]);
});
// Load current values // Load current values
this.root.elements['music-volume'].value = this.conductor.options.music_volume ?? 1.0; 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['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['use-cc2-anim-speed'].checked = this.conductor.options.use_cc2_anim_speed ?? false;
this.root.elements['custom-tileset'].addEventListener('change', ev => { for (let slot of TILESET_SLOTS) {
this._load_custom_tileset(ev.target.files[0]); let radioset = this.root.elements[`tileset-${slot.ident}`];
}); let value = conductor.options.tilesets[slot.ident] ?? 'lexy';
if (! conductor._loaded_tilesets[value]) {
this.add_button("save", () => { value = 'lexy';
let options = this.conductor.options; }
options.music_volume = parseFloat(this.root.elements['music-volume'].value); if (radioset instanceof Element) {
options.music_enabled = this.root.elements['music-enabled'].checked; // There's only one radio button so we just got that back
options.sound_volume = parseFloat(this.root.elements['sound-volume'].value); if (radioset.value === value) {
options.sound_enabled = this.root.elements['sound-enabled'].checked; radioset.checked = true;
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 else {
// up deleting // This should be an actual radioset
this.conductor._loaded_tilesets = {}; radioset.value = value;
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.add_button("save", () => this.save(), true);
this.conductor.reload_all_options();
this.close();
}, true);
this.add_button("forget it", () => { this.add_button("forget it", () => {
// Restore the player's music volume just in case // Restore the player's music volume just in case
if (this.original_music_volume !== undefined) { if (this.original_music_volume !== undefined) {
@ -3197,6 +3135,64 @@ class OptionsOverlay extends DialogOverlay {
sfx.enabled = was_enabled; 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) { async _load_custom_tileset(file) {
// This is dumb and roundabout, but such is the web // This is dumb and roundabout, but such is the web
let reader = new FileReader; 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 // 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 // really detect that, but there can't really be alternatives to it either
let result_el = this.root.querySelector('.option-load-tileset'); let result_el = this.root.querySelector('.option-load-tileset');
result_el.textContent = '';
let tileset; let tileset;
try { try {
tileset = infer_tileset_from_image(img, (w, h) => mk('canvas', {width: w, height: h})); tileset = infer_tileset_from_image(img, (w, h) => mk('canvas', {width: w, height: h}));
} }
catch (e) { catch (e) {
console.error(e); console.error(e);
result_el.textContent = '';
result_el.append(mk('p', "This doesn't look like a tileset layout I understand, sorry!")); result_el.append(mk('p', "This doesn't look like a tileset layout I understand, sorry!"));
return; 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_ident = `new-custom-${this.custom_tileset_counter}`;
let tileset_name = `New custom ${this.custom_tileset_counter}`; let tileset_name = `New custom ${this.custom_tileset_counter}`;
this.custom_tileset_counter += 1; let tilesetdef = {
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] = {
ident: tileset_ident, ident: tileset_ident,
name: tileset_name, name: tileset_name,
canvas: tileset.image, canvas: tileset.image,
@ -3263,6 +3230,10 @@ class OptionsOverlay extends DialogOverlay {
tile_width: tileset.size_x, tile_width: tileset.size_x,
tile_height: tileset.size_y, 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) { 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() { close() {
// Ensure the player's music is set back how we left it // Ensure the player's music is set back how we left it
this.conductor.player.update_music_playback_state(); this.conductor.player.update_music_playback_state();

View File

@ -3,10 +3,10 @@ import { mk } from './util.js';
import { DrawPacket } from './tileset.js'; import { DrawPacket } from './tileset.js';
import TILE_TYPES from './tiletypes.js'; import TILE_TYPES from './tiletypes.js';
class CanvasRendererDrawPacket extends DrawPacket { export class CanvasDrawPacket extends DrawPacket {
constructor(renderer, ctx, perception, clock, update_progress, update_rate) { constructor(tileset, ctx, perception, hide_logic, clock, update_progress, update_rate) {
super(perception, renderer.hide_logic, clock, update_progress, update_rate); super(perception, hide_logic, clock, update_progress, update_rate);
this.renderer = renderer; this.tileset = tileset;
this.ctx = ctx; this.ctx = ctx;
// Canvas position of the cell being drawn // Canvas position of the cell being drawn
this.x = 0; this.x = 0;
@ -14,20 +14,17 @@ class CanvasRendererDrawPacket extends DrawPacket {
// Offset within the cell, for actors in motion // Offset within the cell, for actors in motion
this.offsetx = 0; this.offsetx = 0;
this.offsety = 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) { 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, tx + mx, ty + my,
this.x + this.offsetx + mdx, this.y + this.offsety + mdy, this.x + this.offsetx + mdx, this.y + this.offsety + mdy,
mw, mh); mw, mh);
} }
blit_aligned(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) { 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, tx + mx, ty + my,
this.x + mdx, this.y + mdy, this.x + mdx, this.y + mdy,
mw, mh); mw, mh);
@ -79,6 +76,41 @@ export class CanvasRenderer {
return mk('canvas', {width: w, height: h}); 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) { set_level(level) {
this.level = level; this.level = level;
// TODO update viewport size... or maybe Game should do that since you might be cheating // 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]; 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() { _adjust_viewport_if_dirty() {
if (! this.viewport_dirty) if (! this.viewport_dirty)
return; return;
@ -185,8 +207,11 @@ export class CanvasRenderer {
// game starts, because we're trying to interpolate backwards from 0, hence the Math.max() // game starts, because we're trying to interpolate backwards from 0, hence the Math.max()
let clock = (this.level.tic_counter ?? 0) + ( let clock = (this.level.tic_counter ?? 0) + (
(this.level.frame_offset ?? 0) + (update_progress - 1) * this.update_rate) / 3; (this.level.frame_offset ?? 0) + (update_progress - 1) * this.update_rate) / 3;
let packet = new CanvasRendererDrawPacket( let packet = new CanvasDrawPacket(
this, this.ctx, this.perception, Math.max(0, clock), update_progress, this.update_rate); 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 tw = this.tileset.size_x;
let th = this.tileset.size_y; let th = this.tileset.size_y;
@ -386,7 +411,8 @@ export class CanvasRenderer {
width = width ?? this.level.size_x; width = width ?? this.level.size_x;
cells = cells ?? this.level.linear_cells; 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 x = x0; x <= x1; x++) {
for (let y = y0; y <= y1; y++) { for (let y = y0; y <= y1; y++) {
let cell = cells[y * width + x]; let cell = cells[y * width + x];
@ -441,7 +467,8 @@ export class CanvasRenderer {
let ctx = canvas.getContext('2d'); let ctx = canvas.getContext('2d');
// Individual tile types always reveal what they are // 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.x = x;
packet.y = y; packet.y = y;
this.tileset.draw_type(name, tile, packet); this.tileset.draw_type(name, tile, packet);

View File

@ -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 are built piecemeal from two tiles; the first is N/S, the second is E/W
thin_walls: { thin_walls: {
__special__: 'thin_walls', __special__: 'thin-walls',
thin_walls_ns: [1, 10], thin_walls_ns: [1, 10],
thin_walls_ew: [2, 10], thin_walls_ew: [2, 10],
}, },
@ -847,7 +847,7 @@ export const TILE_WORLD_TILESET_LAYOUT = {
wall_invisible_revealed: [0, 1], wall_invisible_revealed: [0, 1],
// FIXME in cc1 tilesets these are opaque so they should draw at the terrain layer // FIXME in cc1 tilesets these are opaque so they should draw at the terrain layer
thin_walls: { thin_walls: {
__special__: 'thin_walls_cc1', __special__: 'thin-walls-cc1',
north: [0, 6], north: [0, 6],
west: [0, 7], west: [0, 7],
south: [0, 8], south: [0, 8],
@ -1166,12 +1166,12 @@ export const LL_TILESET_LAYOUT = {
grass: [2, 7], grass: [2, 7],
thin_walls: { thin_walls: {
__special__: 'thin_walls', __special__: 'thin-walls',
thin_walls_ns: [8, 4], thin_walls_ns: [8, 4],
thin_walls_ew: [8, 5], thin_walls_ew: [8, 5],
}, },
one_way_walls: { one_way_walls: {
__special__: 'thin_walls', __special__: 'thin-walls',
thin_walls_ns: [9, 4], thin_walls_ns: [9, 4],
thin_walls_ew: [9, 5], thin_walls_ew: [9, 5],
}, },
@ -1246,6 +1246,7 @@ export const LL_TILESET_LAYOUT = {
all: new Array(252).fill([12, 8]).concat([ all: new Array(252).fill([12, 8]).concat([
[8, 9], [9, 9], [10, 9], [11, 9], [8, 9], [9, 9], [10, 9], [11, 9],
]), ]),
_distinct: [[12, 8], [8, 9], [9, 9], [10, 9], [11, 9]],
}, },
cracked_ice: [12, 9], cracked_ice: [12, 9],
ice_se: [13, 8], ice_se: [13, 8],
@ -2089,13 +2090,22 @@ export class DrawPacket {
export class Tileset { export class Tileset {
constructor(image, layout, size_x, size_y) { constructor(image, layout, size_x, size_y) {
// XXX curiously, i note that .image is never used within this class
this.image = image; this.image = image;
this.layout = layout; this.layout = layout;
this.size_x = size_x; this.size_x = size_x;
this.size_y = size_y; 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) { draw(tile, packet) {
this.draw_type(tile.type.name, tile, packet); this.draw_type(tile.type.name, tile, packet);
} }
@ -2698,10 +2708,10 @@ export class Tileset {
else if (drawspec.__special__ === 'letter') { else if (drawspec.__special__ === 'letter') {
this._draw_letter(drawspec, name, tile, packet); 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); 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); this._draw_thin_walls_cc1(drawspec, name, tile, packet);
} }
else if (drawspec.__special__ === 'bomb-fuse') { else if (drawspec.__special__ === 'bomb-fuse') {
@ -2815,7 +2825,7 @@ export function parse_tile_world_large_tileset(canvas) {
// fell: n/a // fell: n/a
}, },
thin_walls: { thin_walls: {
__special__: 'thin_walls_cc1', __special__: 'thin-walls-cc1',
}, },
}; };
let image_data = ctx.getImageData(0, 0, canvas.width, canvas.height); 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 // Anything else could be Tile World's "large" layout, which has no fixed dimensions
return parse_tile_world_large_tileset(canvas); 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;
}

View File

@ -600,7 +600,26 @@ img.compat-icon,
.option-volume > input[type=range] { .option-volume > input[type=range] {
flex: auto; 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; vertical-align: middle;
} }
label.option { label.option {
@ -623,6 +642,9 @@ label.option .option-label {
.option-help.--visible { .option-help.--visible {
/* TODO */ /* TODO */
} }
.dialog-options input[type=file][name=custom-tileset] {
display: none;
}
@media (max-width: 800px) { @media (max-width: 800px) {
.dialog { .dialog {