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:
parent
5a17b9022d
commit
9763ceaa1c
366
js/main.js
366
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}`});
|
||||
thead.append(mk('th.-slot', slot.name));
|
||||
}
|
||||
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._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,11 +3085,176 @@ 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]);
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// This should be an actual radioset
|
||||
radioset.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
this.add_button("save", () => {
|
||||
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) {
|
||||
this.conductor.player.music_audio_el.volume = this.original_music_volume;
|
||||
this.conductor.player.sfx_player.volume = this.original_sound_volume;
|
||||
}
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
super.open();
|
||||
|
||||
// Forcibly start the music player, since opening this dialog auto-pauses the game, and
|
||||
// anyway it's hard to gauge music volume if it's not playing
|
||||
if (this.resume_music_on_open && this.conductor.player.music_enabled) {
|
||||
this.conductor.player.music_audio_el.play();
|
||||
}
|
||||
}
|
||||
|
||||
_play_random_sfx() {
|
||||
let sfx = this.conductor.player.sfx_player;
|
||||
// Temporarily force enable it
|
||||
let was_enabled = sfx.enabled;
|
||||
sfx.enabled = true;
|
||||
sfx.play_once(util.random_choice([
|
||||
'blocked', 'door', 'get-chip', 'get-key', 'get-tool', 'socket', 'splash',
|
||||
]));
|
||||
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;
|
||||
let reader_loaded = util.promise_event(reader, 'load', 'error');
|
||||
reader.readAsDataURL(file);
|
||||
await reader_loaded;
|
||||
|
||||
let img = mk('img');
|
||||
img.src = reader.result;
|
||||
await img.decode();
|
||||
|
||||
// Now we've got an <img> ready to go, and we can guess its layout based on its aspect
|
||||
// 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.append(mk('p', "This doesn't look like a tileset layout I understand, sorry!"));
|
||||
return;
|
||||
}
|
||||
|
||||
let tileset_ident = `new-custom-${this.custom_tileset_counter}`;
|
||||
let tileset_name = `New custom ${this.custom_tileset_counter}`;
|
||||
let tilesetdef = {
|
||||
ident: tileset_ident,
|
||||
name: tileset_name,
|
||||
canvas: tileset.image,
|
||||
tileset: tileset,
|
||||
layout: tileset.layout['#ident'],
|
||||
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) {
|
||||
let dd = this.tileset_els[slot_ident];
|
||||
let select = dd.querySelector('select');
|
||||
let tileset_ident = select.value;
|
||||
|
||||
let renderer = this.renderers[slot_ident];
|
||||
renderer.tileset = this.available_tilesets[tileset_ident].tileset;
|
||||
for (let canvas of dd.querySelectorAll('canvas')) {
|
||||
canvas.remove();
|
||||
}
|
||||
dd.append(
|
||||
// TODO allow me to draw an arbitrary tile to an arbitrary point on a given canvas!
|
||||
renderer.draw_single_tile_type('player'),
|
||||
renderer.draw_single_tile_type('chip'),
|
||||
renderer.draw_single_tile_type('exit'),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@ -3106,9 +3264,9 @@ class OptionsOverlay extends DialogOverlay {
|
||||
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
|
||||
// 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) {
|
||||
@ -3165,122 +3323,6 @@ class OptionsOverlay extends DialogOverlay {
|
||||
this.conductor.reload_all_options();
|
||||
|
||||
this.close();
|
||||
}, true);
|
||||
this.add_button("forget it", () => {
|
||||
// Restore the player's music volume just in case
|
||||
if (this.original_music_volume !== undefined) {
|
||||
this.conductor.player.music_audio_el.volume = this.original_music_volume;
|
||||
this.conductor.player.sfx_player.volume = this.original_sound_volume;
|
||||
}
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
super.open();
|
||||
|
||||
// Forcibly start the music player, since opening this dialog auto-pauses the game, and
|
||||
// anyway it's hard to gauge music volume if it's not playing
|
||||
if (this.resume_music_on_open && this.conductor.player.music_enabled) {
|
||||
this.conductor.player.music_audio_el.play();
|
||||
}
|
||||
}
|
||||
|
||||
_play_random_sfx() {
|
||||
let sfx = this.conductor.player.sfx_player;
|
||||
// Temporarily force enable it
|
||||
let was_enabled = sfx.enabled;
|
||||
sfx.enabled = true;
|
||||
sfx.play_once(util.random_choice([
|
||||
'blocked', 'door', 'get-chip', 'get-key', 'get-tool', 'socket', 'splash',
|
||||
]));
|
||||
sfx.enabled = was_enabled;
|
||||
}
|
||||
|
||||
async _load_custom_tileset(file) {
|
||||
// This is dumb and roundabout, but such is the web
|
||||
let reader = new FileReader;
|
||||
let reader_loaded = util.promise_event(reader, 'load', 'error');
|
||||
reader.readAsDataURL(file);
|
||||
await reader_loaded;
|
||||
|
||||
let img = mk('img');
|
||||
img.src = reader.result;
|
||||
await img.decode();
|
||||
|
||||
// Now we've got an <img> ready to go, and we can guess its layout based on its aspect
|
||||
// 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');
|
||||
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] = {
|
||||
ident: tileset_ident,
|
||||
name: tileset_name,
|
||||
canvas: tileset.image,
|
||||
tileset: tileset,
|
||||
layout: tileset.layout['#ident'],
|
||||
tile_width: tileset.size_x,
|
||||
tile_height: tileset.size_y,
|
||||
};
|
||||
}
|
||||
|
||||
update_selected_tileset(slot_ident) {
|
||||
let dd = this.tileset_els[slot_ident];
|
||||
let select = dd.querySelector('select');
|
||||
let tileset_ident = select.value;
|
||||
|
||||
let renderer = this.renderers[slot_ident];
|
||||
renderer.tileset = this.available_tilesets[tileset_ident].tileset;
|
||||
for (let canvas of dd.querySelectorAll('canvas')) {
|
||||
canvas.remove();
|
||||
}
|
||||
dd.append(
|
||||
// TODO allow me to draw an arbitrary tile to an arbitrary point on a given canvas!
|
||||
renderer.draw_single_tile_type('player'),
|
||||
renderer.draw_single_tile_type('chip'),
|
||||
renderer.draw_single_tile_type('exit'),
|
||||
);
|
||||
}
|
||||
|
||||
close() {
|
||||
|
||||
@ -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);
|
||||
|
||||
370
js/tileset.js
370
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;
|
||||
}
|
||||
|
||||
24
style.css
24
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user