Add support for TW large tilesets, real MS tilesets, better tileset detection, and an attempted fix for CC1 thin wall tiles

This commit is contained in:
Eevee (Evelyn Woods) 2021-06-03 02:15:45 -06:00
parent 3e7390ffc0
commit 51bc3dfe83
6 changed files with 743 additions and 206 deletions

View File

@ -1,13 +1,14 @@
import { readFile, stat } from 'fs/promises';
import { performance } from 'perf_hooks';
import { argv, exit, stderr, stdout } from 'process';
import { compat_flags_for_ruleset } from '../defs.js';
import { Level } from '../game.js';
import * as format_c2g from '../format-c2g.js';
import * as format_dat from '../format-dat.js';
import * as format_tws from '../format-tws.js';
import * as util from '../util.js';
import { argv, exit, stderr, stdout } from 'process';
import { opendir, readFile, stat } from 'fs/promises';
import { performance } from 'perf_hooks';
import { LocalDirectorySource } from './lib.js';
// TODO arguments:
// - custom pack to test, possibly its solutions, possibly its ruleset (or default to steam-strict/lynx)
@ -17,39 +18,6 @@ import { performance } from 'perf_hooks';
// - support for xfails somehow?
// TODO use this for a test suite
export class LocalDirectorySource extends util.FileSource {
constructor(root) {
super();
this.root = root;
this.files = {};
this._loaded_promise = this._scan_dir('/');
}
async _scan_dir(path) {
let dir = await opendir(this.root + path);
for await (let dirent of dir) {
if (dirent.isDirectory()) {
await this._scan_dir(path + dirent.name + '/');
}
else {
let filepath = path + dirent.name;
this.files[filepath.toLowerCase()] = filepath;
if (this.files.size > 2000)
throw `way, way too many files in local directory source ${this.root}`;
}
}
}
async get(path) {
let realpath = this.files[path.toLowerCase()];
if (realpath) {
return (await readFile(this.root + realpath)).buffer;
}
else {
throw new Error(`No such file: ${path}`);
}
}
}
function pad(s, n) {
return s.substring(0, n).padEnd(n, " ");
@ -330,7 +298,7 @@ may be run with different compat modes.
don't support built-in replays, this must be a TWS file
-l level range to play back; either 'all' or a string like '1-4,10'
-f force the next argument to be interpreted as a file path, if for
some perverse reason you have a level file named '-c'
some perverse reason you have a level file named '-c'
-h, --help ignore other arguments and show this message
Supports the same filetypes as Lexy's Labyrinth: DAT/CCL, C2M, or a directory

48
js/headless/lib.js Normal file
View File

@ -0,0 +1,48 @@
import { opendir, readFile } from 'fs/promises';
import canvas from 'canvas';
import CanvasRenderer from '../renderer-canvas.js';
import * as util from '../util.js';
export class NodeCanvasRenderer extends CanvasRenderer {
static make_canvas(w, h) {
return canvas.createCanvas(w, h);
}
}
export class LocalDirectorySource extends util.FileSource {
constructor(root) {
super();
this.root = root;
this.files = {};
this._loaded_promise = this._scan_dir('/');
}
async _scan_dir(path) {
let dir = await opendir(this.root + path);
for await (let dirent of dir) {
if (dirent.isDirectory()) {
await this._scan_dir(path + dirent.name + '/');
}
else {
let filepath = path + dirent.name;
this.files[filepath.toLowerCase()] = filepath;
if (this.files.size > 2000)
throw `way, way too many files in local directory source ${this.root}`;
}
}
}
async get(path) {
let realpath = this.files[path.toLowerCase()];
if (realpath) {
return (await readFile(this.root + realpath)).buffer;
}
else {
throw new Error(`No such file: ${path}`);
}
}
}

70
js/headless/render.mjs Normal file
View File

@ -0,0 +1,70 @@
import { readFile, writeFile } from 'fs/promises';
import * as process from 'process';
import canvas from 'canvas';
import minimist from 'minimist';
import * as format_c2g from '../format-c2g.js';
import { infer_tileset_from_image } from '../tileset.js';
import { NodeCanvasRenderer } from './lib.js';
const USAGE = `\
Usage: render.mjs [OPTION]... LEVELFILE OUTFILE
Renders the level contained in LEVELFILE to a PNG and saves it to OUTFILE.
Arguments:
-t FILE path to a tileset to use
-e render in editor mode: use the revealed forms of tiles and
show facing directions
-l NUM choose the level number to render, if LEVELFILE is a pack
[default: 1]
-r REGION specify the region to render; see below
REGION may be one of:
initial an area the size of the level's viewport, centered on the
player's initial position
all the entire level
WxH an area W by H, centered on the player's initial position
...etc...
`;
async function main() {
let args = minimist(process.argv.slice(2), {
alias: {
tileset: ['t'],
},
});
// assert _.length is 2
let [pack_path, dest_path] = args._;
// TODO i need a more consistent and coherent way to turn a path into a level pack, currently
// this is only a single c2m
let pack_data = await readFile(pack_path);
let stored_level = format_c2g.parse_level(pack_data.buffer);
let img = await canvas.loadImage(args.tileset ?? 'tileset-lexy.png');
let tileset = infer_tileset_from_image(img);
let renderer = new NodeCanvasRenderer(tileset);
renderer.set_level(stored_level);
let i = stored_level.linear_cells.findIndex(cell => cell.some(tile => tile && tile.type.is_real_player));
if (i < 0) {
console.log("???");
process.stderr.write("error: no players in this level\n");
process.exit(1);
}
let [x, y] = stored_level.scalar_to_coords(i);
let w = stored_level.viewport_size;
let h = w;
// TODO this is probably duplicated from the renderer, and could also be reused in the editor
// TODO handle a map smaller than the viewport
let x0 = Math.max(0, x - w / 2);
let y0 = Math.max(0, y - h / 2);
renderer.draw_static_region(x0, y0, x0 + w, y0 + h);
await writeFile(dest_path, renderer.canvas.toBuffer());
}
main();

View File

@ -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 } from './tileset.js';
import { Tileset, TILESET_LAYOUTS, 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';
@ -747,7 +747,6 @@ class Player extends PrimaryView {
let [x, y] = this.renderer.point_to_real_cell_coords(touch.clientX, touch.clientY);
let dx = x - px;
let dy = y - py;
console.log(dx, dy);
// Divine a direction from the results
let action;
if (Math.abs(dx) > Math.abs(dy)) {
@ -1216,7 +1215,7 @@ class Player extends PrimaryView {
}
tooltip.inventory.textContent = inv.join(', ');
});
this.renderer.canvas.addEventListener('mouseout', ev => {
this.renderer.canvas.addEventListener('mouseout', () => {
if (this.debug.actor_tooltip) {
this.debug.actor_tooltip.element.classList.remove('--visible');
}
@ -1268,6 +1267,7 @@ class Player extends PrimaryView {
if (this.level) {
this.update_tileset();
this.adjust_scale(); // in case tile size changed
this._redraw();
}
}
@ -2771,7 +2771,7 @@ class OptionsOverlay extends DialogOverlay {
// FIXME again, wait, or what?
img.src = newdef.src;
newdef.tileset = new Tileset(
img, TILESET_LAYOUTS[newdef.layout] ?? 'lexy',
img, TILESET_LAYOUTS[newdef.layout ?? 'lexy'],
newdef.tile_width, newdef.tile_height);
}
this.available_tilesets[ident] = newdef;
@ -2797,11 +2797,14 @@ class OptionsOverlay extends DialogOverlay {
}
}
select.value = conductor.options.tilesets[slot.ident] ?? 'lexy';
select.addEventListener('change', ev => {
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);
let el = mk('dd.option-tileset', select, " ");
this.tileset_els[slot.ident] = el;
this.update_selected_tileset(slot.ident);
@ -2811,19 +2814,13 @@ class OptionsOverlay extends DialogOverlay {
);
}
this.custom_tileset_counter = 1;
dl.append(
mk('dd',
"You can also load a custom tileset, which will be saved in browser storage.",
mk('br'),
"Supports the Tile World static layout and the Steam layout.",
mk('br'),
"(Steam tilesets can be found in ", mk('code', "data/bmp"), " within the game's local files).",
mk('br'),
mk('input', {type: 'file', name: 'custom-tileset'}),
mk('div.option-load-tileset',
),
),
);
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'})),
mk('div.option-load-tileset'),
));
// Load current values
this.root.elements['music-volume'].value = this.conductor.options.music_volume ?? 1.0;
@ -2836,7 +2833,7 @@ class OptionsOverlay extends DialogOverlay {
this._load_custom_tileset(ev.target.files[0]);
});
this.add_button("save", ev => {
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;
@ -2904,7 +2901,7 @@ class OptionsOverlay extends DialogOverlay {
this.close();
});
this.add_button("forget it", ev => {
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;
@ -2950,103 +2947,59 @@ 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');
let layout;
// Note: Animated Tile World has a 1px gap between rows to indicate where animations
// start and also a single extra 1px column on the left, which I super don't support :(
for (let try_layout of Object.values(TILESET_LAYOUTS)) {
let [w, h] = try_layout['#dimensions'];
// XXX this assumes square tiles, but i have written mountains of code that doesn't!
if (img.naturalWidth * h === img.naturalHeight * w) {
layout = try_layout;
break;
}
let tileset;
try {
tileset = infer_tileset_from_image(img, (w, h) => mk('canvas', {width: w, height: h}));
}
if (! layout) {
catch (e) {
console.error(e);
result_el.textContent = '';
result_el.append("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;
}
// Load into a canvas and erase the background, if any
let canvas = mk('canvas', {width: img.naturalWidth, height: img.naturalHeight});
let ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
this._erase_background(canvas, ctx, layout);
let [w, h] = layout['#dimensions'];
let tw = Math.floor(img.naturalWidth / w);
let th = Math.floor(img.naturalHeight / h);
let tileset = new Tileset(canvas, layout, tw, th);
let renderer = new CanvasRenderer(tileset, 1);
result_el.textContent = '';
let buttons = mk('p');
result_el.append(
`This looks like a ${layout['#name']} tileset with ${tw}×${th} tiles.`,
mk('br'),
renderer.draw_single_tile_type('player'),
renderer.draw_single_tile_type('chip'),
renderer.draw_single_tile_type('exit'),
mk('br'),
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 (! layout['#supported-versions'].has(slot.ident))
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}`, ev => {
let button = util.mk_button(`Use for ${slot.name}`, () => {
select.value = tileset_ident;
this.update_selected_tileset(slot.ident);
});
result_el.append(button);
buttons.append(button);
}
this.available_tilesets[tileset_ident] = {
ident: tileset_ident,
name: tileset_name,
canvas: canvas,
canvas: tileset.image,
tileset: tileset,
layout: layout['#ident'],
tile_width: tw,
tile_height: th,
layout: tileset.layout['#ident'],
tile_width: tileset.size_x,
tile_height: tileset.size_y,
};
}
_erase_background(canvas, ctx, layout) {
let trans = layout['#transparent-color'];
if (! trans)
return;
let image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
let px = image_data.data;
if (trans.length === 2) {
// Read the background color from a pixel
let i = trans[0] + trans[1] * canvas.width;
if (px[i + 3] === 0) {
// Background is already transparent!
return;
}
trans = [px[i], px[i + 1], px[i + 2], px[i + 3]];
}
for (let i = 0; i < canvas.width * canvas.height * 4; i += 4) {
if (px[i] === trans[0] && px[i + 1] === trans[1] &&
px[i + 2] === trans[2] && px[i + 3] === trans[3])
{
px[i] = 0;
px[i + 1] = 0;
px[i + 2] = 0;
px[i + 3] = 0;
}
}
ctx.putImageData(image_data, 0, 0);
}
update_selected_tileset(slot_ident) {
let dd = this.tileset_els[slot_ident];
let select = dd.querySelector('select');
@ -3671,49 +3624,12 @@ class Conductor {
}
this.set_compat(this._compat_ruleset, this.compat);
this._loaded_tilesets = {}; // tileset ident => tileset
this.tilesets = {}; // slot (game type) => tileset
for (let slot of TILESET_SLOTS) {
let tileset_ident = this.options.tilesets[slot.ident] ?? 'lexy';
let tilesetdef;
if (BUILTIN_TILESETS[tileset_ident]) {
tilesetdef = BUILTIN_TILESETS[tileset_ident];
}
else {
tilesetdef = load_json_from_storage(CUSTOM_TILESET_PREFIX + tileset_ident);
if (! tilesetdef) {
tileset_ident = 'lexy';
tilesetdef = BUILTIN_TILESETS['lexy'];
}
}
if (this._loaded_tilesets[tileset_ident]) {
this.tilesets[slot.ident] = this._loaded_tilesets[tileset_ident];
continue;
}
let layout = TILESET_LAYOUTS[tilesetdef.layout] ?? 'lexy';
let img = new Image;
img.src = tilesetdef.src;
// FIXME wait on the img to decode (well i can't in a constructor), or what?
let tileset = new Tileset(img, layout, tilesetdef.tile_width, tilesetdef.tile_height);
this.tilesets[slot.ident] = tileset;
this._loaded_tilesets[tileset_ident] = tileset;
}
this.splash = new Splash(this);
this.editor = new Editor(this);
this.player = new Player(this);
this.reload_all_options();
this.loaded_in_editor = false;
this.loaded_in_player = false;
// Bind the header buttons
document.querySelector('#main-options').addEventListener('click', ev => {
document.querySelector('#main-options').addEventListener('click', () => {
new OptionsOverlay(this).open();
});
document.querySelector('#main-compat').addEventListener('click', ev => {
document.querySelector('#main-compat').addEventListener('click', () => {
new CompatOverlay(this).open();
});
document.querySelector('#main-compat output').textContent = COMPAT_RULESET_LABELS[this._compat_ruleset ?? 'custom'];
@ -3787,6 +3703,77 @@ class Conductor {
).open();
}
});
}
// Finish loading; must call me!
async load() {
this._loaded_tilesets = {}; // tileset ident => tileset
this.tilesets = {}; // slot (game type) => tileset
let tileset_promises = [];
for (let slot of TILESET_SLOTS) {
let tileset_ident = this.options.tilesets[slot.ident] ?? 'lexy';
let tilesetdef;
if (BUILTIN_TILESETS[tileset_ident]) {
tilesetdef = BUILTIN_TILESETS[tileset_ident];
}
else {
tilesetdef = load_json_from_storage(CUSTOM_TILESET_PREFIX + tileset_ident);
if (! tilesetdef) {
tileset_ident = 'lexy';
tilesetdef = BUILTIN_TILESETS['lexy'];
}
}
if (this._loaded_tilesets[tileset_ident]) {
this.tilesets[slot.ident] = this._loaded_tilesets[tileset_ident];
continue;
}
let layout = TILESET_LAYOUTS[tilesetdef.layout];
let img = new Image;
// FIXME make a promise out of the image, don't finish loading until it's done; note
// that the editor relies on having a tileset available immediately, ugh
let promise = util.promise_event(img, 'load', 'error').then(() => {
let tileset;
if (tilesetdef.layout === 'tw-animated') {
// This layout is dynamic so we need to reparse it
let canvas = mk('canvas', {width: img.naturalWidth, height: img.naturalHeight});
canvas.getContext('2d').drawImage(img, 0, 0);
try {
tileset = parse_tile_world_large_tileset(canvas);
}
catch (err) {
// Don't break the whole app on a broken stored tileset; instead leave it
// empty and default to Lexy in a moment
console.error(err);
return;
}
}
else {
tileset = new Tileset(img, layout, tilesetdef.tile_width, tilesetdef.tile_height);
}
this.tilesets[slot.ident] = tileset;
this._loaded_tilesets[tileset_ident] = tileset;
});
img.src = tilesetdef.src;
tileset_promises.push(promise);
}
await Promise.all(tileset_promises);
// Replace any missing tilesets with the default
for (let slot of TILESET_SLOTS) {
if (slot.ident !== 'll' && ! (slot.ident in this.tilesets)) {
this.tilesets[slot.ident] = this.tilesets['ll'];
}
}
this.splash = new Splash(this);
this.editor = new Editor(this);
this.player = new Player(this);
this.reload_all_options();
this.loaded_in_editor = false;
this.loaded_in_player = false;
this.update_nav_buttons();
document.querySelector('#loading').setAttribute('hidden', '');
@ -4118,6 +4105,7 @@ async function main() {
let query = new URLSearchParams(location.search);
let conductor = new Conductor();
await conductor.load();
window._conductor = conductor;
// Allow putting us in debug mode automatically if we're in development
@ -4125,11 +4113,6 @@ async function main() {
conductor.player._start_in_debug_mode = true;
}
// Cheap hack to make sure the tileset(s) have loaded before we go any further
// FIXME this can wait until we switch, it's not needed for the splash screen! but it's here
// for the moment because of ?level, do a better fix
await Promise.all(Object.values(conductor.tilesets).map(tileset => tileset.image.decode()));
// Pick a level (set)
// TODO error handling :(
let path = query.get('setpath');

View File

@ -371,8 +371,8 @@ export const CC2_TILESET_LAYOUT = {
walker: {
__special__: 'double-size-monster',
base: [0, 13],
vertical: [[1, 13], [2, 13], [3, 13], [4, 13], [5, 13], [6, 13], [7, 13]],
horizontal: [[8, 13], [10, 13], [12, 13], [14, 13], [8, 14], [10, 14], [12, 14]],
vertical: [null, [1, 13], [2, 13], [3, 13], [4, 13], [5, 13], [6, 13], [7, 13]],
horizontal: [null, [8, 13], [10, 13], [12, 13], [14, 13], [8, 14], [10, 14], [12, 14]],
},
helmet: [0, 14],
stopwatch_toggle: [14, 14],
@ -381,8 +381,8 @@ export const CC2_TILESET_LAYOUT = {
blob: {
__special__: 'double-size-monster',
base: [0, 15],
vertical: [[1, 15], [2, 15], [3, 15], [4, 15], [5, 15], [6, 15], [7, 15]],
horizontal: [[8, 15], [10, 15], [12, 15], [14, 15], [8, 16], [10, 16], [12, 16]],
vertical: [null, [1, 15], [2, 15], [3, 15], [4, 15], [5, 15], [6, 15], [7, 15]],
horizontal: [null, [8, 15], [10, 15], [12, 15], [14, 15], [8, 16], [10, 16], [12, 16]],
},
// (cc2 editor copy/paste outline)
floor_mimic: {
@ -830,6 +830,7 @@ export const CC2_TILESET_LAYOUT = {
..._omit_custom_lexy_vfx,
};
// (This is really the MSCC layout, but often truncated in such a way that only TW can use it)
export const TILE_WORLD_TILESET_LAYOUT = {
'#ident': 'tw-static',
'#name': "Tile World (static)",
@ -876,9 +877,13 @@ export const TILE_WORLD_TILESET_LAYOUT = {
ice_ne: [1, 11],
ice_se: [1, 12],
ice_sw: [1, 13],
// FIXME this stuff needs like reveal and whatnot
fake_wall: [1, 14],
fake_floor: [1, 15],
fake_floor: {
__special__: 'perception',
modes: new Set(['palette', 'editor', 'xray']),
hidden: [1, 15],
revealed: [1, 14],
},
fake_wall: [1, 15],
// TODO overlay buffer?? [2, 0]
thief_tools: [2, 1],
@ -1003,6 +1008,7 @@ export const TILE_WORLD_TILESET_LAYOUT = {
},
skating: 'normal',
forced: 'normal',
drowned: [3, 3],
burned: [3, 4], // TODO TW's lynx mode doesn't use this! it uses the generic failed
exploded: [3, 6],
failed: [3, 7],
@ -2012,7 +2018,15 @@ export const LL_TILESET_LAYOUT = {
};
export const TILESET_LAYOUTS = {
// MS layout, either abbreviated or full
'tw-static': TILE_WORLD_TILESET_LAYOUT,
// "Large" (and dynamic, so not actually defined here) TW layout
'tw-animated': {
'#ident': 'tw-animated',
'#name': "Tile World (animated)",
'#supported-versions': new Set(['cc1']),
..._omit_custom_lexy_vfx,
},
cc2: CC2_TILESET_LAYOUT,
lexy: LL_TILESET_LAYOUT,
};
@ -2188,7 +2202,13 @@ export class Tileset {
n = drawspec.idle_frame_index ?? 0;
}
packet.blit(...frames[n]);
if (drawspec.triple) {
// Lynx-style big splashes and explosions
packet.blit(...frames[n], 0, 0, 3, 3, -1, -1);
}
else {
packet.blit(...frames[n]);
}
}
// Simple overlaying used for green/purple toggle tiles and doppelgangers. Draw the base (a
@ -2371,20 +2391,22 @@ export class Tileset {
_draw_thin_walls_cc1(drawspec, name, tile, packet) {
let edges = tile ? tile.edges : 0x0f;
// This is kinda best-effort since the tiles are opaque and not designed to combine
if (edges === (DIRECTIONS['south'].bit | DIRECTIONS['east'].bit)) {
// This is kinda best-effort since the tiles are not designed to combine
if ((edges & DIRECTIONS['south'].bit) && (edges & DIRECTIONS['east'].bit)) {
packet.blit(...drawspec.southeast);
}
else if (edges & DIRECTIONS['north'].bit) {
packet.blit(...drawspec.north);
}
else if (edges & DIRECTIONS['east'].bit) {
packet.blit(...drawspec.east);
}
else if (edges & DIRECTIONS['south'].bit) {
packet.blit(...drawspec.south);
}
else {
else if (edges & DIRECTIONS['east'].bit) {
packet.blit(...drawspec.east);
}
if (edges & DIRECTIONS['north'].bit) {
packet.blit(...drawspec.north);
}
if (edges & DIRECTIONS['west'].bit) {
packet.blit(...drawspec.west);
}
}
@ -2441,50 +2463,67 @@ export class Tileset {
}
_draw_double_size_monster(drawspec, name, tile, packet) {
// CC2's tileset has double-size art for blobs and walkers that spans the tile they're
// CC2 and Lynx have double-size art for blobs and walkers that spans the tile they're
// moving from AND the tile they're moving into.
// First, of course, this only happens if they're moving at all.
if (! tile || ! tile.movement_speed) {
// CC2 also has an individual 1×1 static tile, used in all four directions.
if ((! tile || ! tile.movement_speed) && drawspec.base) {
this.draw_drawspec(drawspec.base, name, tile, packet);
return;
}
// They only support horizontal and vertical moves, not all four directions. The other two
// directions are simply the animations played in reverse.
let axis_cels;
// CC2 only supports horizontal and vertical moves, not all four directions. The other two
// directions are the animations played in reverse. TW's large layout supports all four.
let direction = (tile ? tile.direction : null) ?? 'south';
let axis_cels = drawspec[direction];
let w = 1, h = 1, x = 0, y = 0, sx = 0, sy = 0, reverse = false;
if (tile.direction === 'north') {
axis_cels = drawspec.vertical;
reverse = true;
if (direction === 'north') {
if (! axis_cels) {
axis_cels = drawspec.vertical;
reverse = true;
}
h = 2;
sy = 1;
}
else if (tile.direction === 'south') {
axis_cels = drawspec.vertical;
else if (direction === 'south') {
if (! axis_cels) {
axis_cels = drawspec.vertical;
}
h = 2;
y = -1;
sy = -1;
}
else if (tile.direction === 'west') {
axis_cels = drawspec.horizontal;
reverse = true;
else if (direction === 'west') {
if (! axis_cels) {
axis_cels = drawspec.horizontal;
reverse = true;
}
w = 2;
sx = 1;
}
else if (tile.direction === 'east') {
axis_cels = drawspec.horizontal;
else if (direction === 'east') {
if (! axis_cels) {
axis_cels = drawspec.horizontal;
}
w = 2;
x = -1;
sx = -1;
}
let p = tile.movement_progress(packet.update_progress, packet.update_rate);
let index = Math.floor(p * (axis_cels.length + 1));
if (index === 0 || index > axis_cels.length) {
let index;
if (tile && tile.movement_speed) {
let p = tile.movement_progress(packet.update_progress, packet.update_rate);
index = Math.floor(p * axis_cels.length);
}
else {
index = drawspec.idle_frame_index ?? 0;
}
let cel = reverse ? axis_cels[axis_cels.length - index + 1] : axis_cels[index];
if (cel === null) {
// null means use the 1x1 "base" tile instead
packet.blit_aligned(...drawspec.base, 0, 0, 1, 1, sx, sy);
}
else {
let cel = reverse ? axis_cels[axis_cels.length - index] : axis_cels[index - 1];
packet.blit_aligned(...cel, 0, 0, w, h, x, y);
}
}
@ -2690,3 +2729,432 @@ export class Tileset {
}
}
}
const TILE_WORLD_LARGE_TILE_ORDER = [
'floor',
'force_floor_n', 'force_floor_w', 'force_floor_s', 'force_floor_e', 'force_floor_all',
'ice', 'ice_se', 'ice_sw', 'ice_ne', 'ice_nw',
'gravel', 'dirt', 'water', 'fire', 'bomb', 'trap', 'thief_tools', 'hint',
'button_blue', 'button_green', 'button_red', 'button_brown',
'teleport_blue', 'wall',
'#thin_walls/north', '#thin_walls/west', '#thin_walls/south', '#thin_walls/east', '#thin_walls/southeast',
'fake_wall', 'green_floor', 'green_wall', 'popwall', 'cloner',
'door_red', 'door_blue', 'door_yellow', 'door_green',
'socket', 'exit', 'chip',
'key_red', 'key_blue', 'key_yellow', 'key_green',
'cleats', 'suction_boots', 'fire_boots', 'flippers',
// Bogus tiles
'bogus_exit_1', 'bogus_exit_2',
'bogus_player_burned_fire', 'bogus_player_burned', 'bogus_player_win', 'bogus_player_drowned',
'player1_swimming_n', 'player1_swimming_w', 'player1_swimming_s', 'player1_swimming_e',
// Actors
'#player1-moving', '#player1-pushing', 'dirt_block',
'tank_blue', 'ball', 'glider', 'fireball', 'bug', 'paramecium', 'teeth', 'blob', 'walker',
// Animations, which can be 3×3
'splash', 'explosion', 'disintegrate',
];
export function parse_tile_world_large_tileset(canvas) {
let ctx = canvas.getContext('2d');
let tw = null;
let layout = {
...TILESET_LAYOUTS['tw-animated'],
player: {
__special__: 'visual-state',
normal: 'moving',
blocked: 'pushing',
moving: {},
pushing: {},
swimming: 'moving',
// TODO in tile world, skating and forced both just slide the static sprite
skating: 'moving',
forced: 'moving',
exited: 'normal',
// FIXME really these should play to completion, like lynx...
drowned: null,
// slimed: n/a
burned: null,
exploded: null,
failed: null,
// fell: n/a
},
thin_walls: {
__special__: 'thin_walls_cc1',
},
};
let image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
let px = image_data.data; // 🙄
let uses_alpha;
let is_transparent;
if (px[7] === 0) {
uses_alpha = true;
// FIXME does tile world actually support this? and handle it like this? probably find out
is_transparent = i => {
return px[i + 3] === 0;
};
}
else {
uses_alpha = false;
let r = px[4];
let g = px[5];
let b = px[6];
is_transparent = i => {
return px[i] === r && px[i + 1] === g && px[i + 2] === b;
};
}
// Scan out the rows first so we know them ahead of time
let th = null;
let prev_y = null;
let row_heights = {}; // first row => height in tiles
for (let y = 0; y < canvas.height; y++) {
let i = y * canvas.width * 4;
if (is_transparent(i))
continue;
if (prev_y !== null) {
let row_height = y - prev_y - 1; // fencepost
if (th === null) {
th = row_height;
if (th < 4)
throw new Error(`Bad tile height ${th}, this may not be a TW large tileset`);
}
if (row_height % th !== 0) {
console.warn("Tile height seems to be", th, "but row between", prev_y, "and", y,
"is not an integral multiple");
}
row_heights[prev_y] = row_height / th;
}
prev_y = y;
}
let i = 0;
let t = 0;
for (let y = 0; y < canvas.height; y++) {
let is_divider_row = false;
let prev_x = null;
for (let x = 0; x < canvas.width; x++) {
let trans = is_transparent(i);
if (trans && ! uses_alpha) {
px[i] = px[i + 1] = px[i + 2] = px[i + 3] = 0;
}
i += 4;
if (x === 0) {
is_divider_row = ! trans;
prev_x = x;
continue;
}
if (! (is_divider_row && ! trans))
continue;
if (t >= TILE_WORLD_LARGE_TILE_ORDER.length)
continue;
// This is an opaque pixel in a divider row, which marks the end of a tile
if (tw === null) {
tw = x - prev_x;
if (tw < 4)
throw new Error(`Bad tile width ${tw}, this may not be a TW large tileset`);
}
let name = TILE_WORLD_LARGE_TILE_ORDER[t];
let spec;
let num_columns = (x - prev_x) / tw;
let num_rows = row_heights[y];
if (num_rows === 0 || num_columns === 0)
throw new Error(`Bad row/column count (${num_rows}, ${num_columns}) at ${x}, ${y}`);
let x0 = (prev_x + 1) / tw;
let y0 = (y + 1) / th;
if (num_rows === 1 && num_columns === 1) {
spec = [x0, y0];
}
else if (59 <= t && t <= 71) {
// Actors have special layouts, one of several options
if (num_rows === 1 && num_columns === 2) {
// NS, EW
spec = {
north: [x0, y0],
south: [x0, y0],
east: [x0 + 1, y0],
west: [x0 + 1, y0],
};
}
else if (num_rows === 1 && num_columns === 4) {
// N, W, S, E
spec = {
north: [x0, y0],
west: [x0 + 1, y0],
south: [x0 + 2, y0],
east: [x0 + 3, y0],
};
}
else if (num_rows === 2 && num_columns === 1) {
// NS; EW
spec = {
north: [x0, y0],
south: [x0, y0],
east: [x0, y0 + 1],
west: [x0, y0 + 1],
};
}
else if (num_rows === 2 && num_columns === 2) {
// N, W; S, E
spec = {
north: [x0, y0],
west: [x0 + 1, y0],
south: [x0, y0 + 1],
east: [x0 + 1, y0 + 1],
};
}
else if (num_rows === 2 && num_columns === 8) {
// N N N N, W W W W; S S S S, E E E E
spec = {
__special__: 'animated',
// FIXME when global?
global: false,
duration: 1,
idle_frame_index: 1,
north: [[x0, y0], [x0 + 1, y0], [x0 + 2, y0], [x0 + 3, y0]],
west: [[x0 + 4, y0], [x0 + 5, y0], [x0 + 6, y0], [x0 + 7, y0]],
south: [[x0, y0 + 1], [x0 + 1, y0 + 1], [x0 + 2, y0 + 1], [x0 + 3, y0 + 1]],
east: [[x0 + 4, y0 + 1], [x0 + 5, y0 + 1], [x0 + 6, y0 + 1], [x0 + 7, y0 + 1]],
};
}
else if (num_rows === 2 && num_columns === 16) {
// Double-tile arranged as:
// NNNN SSSS WWWWWWWW
// NNNN SSSS EEEEEEEE
spec = {
__special__: 'double-size-monster',
idle_frame_index: 3,
north: [[x0, y0], [x0 + 1, y0], [x0 + 2, y0], [x0 + 3, y0]],
south: [[x0 + 4, y0], [x0 + 5, y0], [x0 + 6, y0], [x0 + 7, y0]],
west: [[x0 + 8, y0], [x0 + 10, y0], [x0 + 12, y0], [x0 + 14, y0]],
east: [[x0 + 8, y0 + 1], [x0 + 10, y0 + 1], [x0 + 12, y0 + 1], [x0 + 14, y0 + 1]],
};
}
else {
throw new Error(`Invalid layout for ${name}: ${num_columns} tiles wide by ${num_rows} tiles tall`);
}
}
else if (t >= 72) {
// One of the explosion animations; should be a single row, must be 6 or 12 frames,
// BUT is allowed to be triple size
spec = {
__special__: 'animated',
global: false,
duration: 1,
all: [],
};
for (let f = 0; f < num_columns; f += num_rows) {
spec['all'].push([x0 + f, y0]);
}
if (num_rows === 3) {
spec.triple = true;
}
}
else {
// Everyone else is a static tile, automatically animated
// TODO enforce only one row
spec = {
__special__: 'animated',
duration: 3 * num_columns, // one tic per frame
all: [],
};
for (let f = 0; f < num_columns; f++) {
spec['all'].push([x0 + f, y0]);
}
}
// Handle some special specs
if (name === '#player1-moving') {
layout['player']['moving'] = spec;
}
else if (name === '#player1-pushing') {
layout['player']['pushing'] = spec;
}
else if (name.startsWith('#thin_walls/')) {
let direction = name.match(/\/(\w+)$/)[1];
layout['thin_walls'][direction] = spec;
// Erase the floor
for (let f = 0; f < num_columns; f += 1) {
erase_thin_wall_floor(
image_data, prev_x + 1 + f * tw, y + 1,
layout['floor'][0] * tw, layout['floor'][1] * th,
tw, th);
}
}
else {
layout[name] = spec;
if (name === 'floor') {
layout['wall_appearing'] = spec;
layout['wall_invisible'] = spec;
}
else if (name === 'wall') {
layout['wall_invisible_revealed'] = spec;
}
else if (name === 'fake_wall') {
layout['fake_floor'] = spec;
}
else if (name === 'splash' || name === 'explosion') {
let n = Math.floor(0.25 * spec['all'].length);
let cel = spec['all'][n];
if (spec.triple) {
cel = [cel[0] + 1, cel[1] + 1];
}
// TODO remove these sometime
if (name === 'splash') {
layout['player']['drowned'] = cel;
}
else if (name === 'explosion') {
layout['player']['burned'] = cel;
layout['player']['exploded'] = cel;
layout['player']['failed'] = cel;
}
}
}
prev_x = x;
t += 1;
}
}
ctx.putImageData(image_data, 0, 0);
return new Tileset(canvas, layout, tw, th);
}
// MSCC repeats all the actor columns three times: once for an actor on top of normal floor (which
// we don't use because we expect everything transparent), once for the actor on a solid background,
// and once for a mask used to cut it out. Combine (3) with (2) and write it atop (1).
function apply_mscc_mask(canvas) {
let ctx = canvas.getContext('2d');
let image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
let px = image_data.data;
let tw = canvas.width / 13;
let dest_x0 = tw * 4;
let src_x0 = tw * 7;
let mask_x0 = tw * 10;
for (let y = 0; y < canvas.height; y++) {
let dest_i = (y * canvas.width + dest_x0) * 4;
let src_i = (y * canvas.width + src_x0) * 4;
let mask_i = (y * canvas.width + mask_x0) * 4;
for (let dx = 0; dx < tw * 3; dx++) {
px[dest_i + 0] = px[src_i + 0];
px[dest_i + 1] = px[src_i + 1];
px[dest_i + 2] = px[src_i + 2];
px[dest_i + 3] = px[mask_i];
dest_i += 4;
src_i += 4;
mask_i += 4;
}
}
// Resizing the canvas clears it, so do that first
canvas.width = canvas.height / 16 * 7;
ctx.putImageData(image_data, 0, 0);
}
// CC1 considered thin walls to be a floor tile, but CC2 makes them a transparent overlay. LL is
// designed to work like CC2, so to make a CC1 tileset work, try erasing the floor out from under a
// thin wall. This is extremely best-effort; it won't work very well if the wall has a shadow or
// happens to share some pixels with the floor below.
function erase_thin_wall_floor(image_data, wall_x0, wall_y0, floor_x0, floor_y0, tile_width, tile_height) {
let px = image_data.data;
for (let dy = 0; dy < tile_height; dy++) {
let wall_i = ((wall_y0 + dy) * image_data.width + wall_x0) * 4;
let floor_i = ((floor_y0 + dy) * image_data.width + floor_x0) * 4;
for (let dx = 0; dx < tile_width; dx++) {
if (px[wall_i + 3] > 0 && px[wall_i] === px[floor_i] &&
px[wall_i + 1] === px[floor_i + 1] && px[wall_i + 2] === px[floor_i + 2])
{
px[wall_i + 3] = 0;
}
wall_i += 4;
floor_i += 4;
}
}
}
function erase_mscc_thin_wall_floors(image_data, layout, tw, th) {
let floor_spec = layout['floor'];
let floor_x = floor_spec[0] * tw;
let floor_y = floor_spec[1] * th;
for (let direction of ['north', 'south', 'east', 'west', 'southeast']) {
let spec = layout['thin_walls'][direction];
erase_thin_wall_floor(image_data, spec[0] * tw, spec[1] * th, floor_x, floor_y, tw, th);
}
}
function erase_tileset_background(image_data, layout) {
let trans = layout['#transparent-color'];
if (! trans)
return;
let px = image_data.data;
if (trans.length === 2) {
// Read the background color from a pixel
let i = trans[0] + trans[1] * image_data.width;
if (px[i + 3] === 0) {
// Background is already transparent!
return;
}
trans = [px[i], px[i + 1], px[i + 2], px[i + 3]];
}
for (let i = 0; i < image_data.width * image_data.height * 4; i += 4) {
if (px[i] === trans[0] && px[i + 1] === trans[1] &&
px[i + 2] === trans[2] && px[i + 3] === trans[3])
{
px[i] = 0;
px[i + 1] = 0;
px[i + 2] = 0;
px[i + 3] = 0;
}
}
return true;
}
export function infer_tileset_from_image(img, make_canvas) {
// 99% of the time, we'll need a canvas anyway, so might as well create it now
let canvas = make_canvas(img.naturalWidth, img.naturalHeight);
let ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// Determine the layout from the image dimensions. Try the usual suspects first
let aspect_ratio = img.naturalWidth / img.naturalHeight;
// Special case: the "full" MS layout, which MSCC uses internally; it's the same layout as TW's
// abbreviated one, but it needs its "mask" columns converted to a regular alpha channel
if (aspect_ratio === 13/16) {
apply_mscc_mask(canvas);
aspect_ratio = 7/16;
}
for (let layout of Object.values(TILESET_LAYOUTS)) {
if (! ('#dimensions' in layout))
continue;
let [w, h] = layout['#dimensions'];
// XXX this assumes square tiles, but i have written mountains of code that doesn't!
if (w / h === aspect_ratio) {
let image_data = ctx.getImageData(0, 0, canvas.width, canvas.height);
let did_anything = erase_tileset_background(image_data.data, layout);
let tw = Math.floor(canvas.width / w);
let th = Math.floor(canvas.height / h);
if (layout['#ident'] === 'tw-static') {
did_anything = true;
erase_mscc_thin_wall_floors(image_data, layout, tw, th);
}
if (did_anything) {
ctx.putImageData(image_data, 0, 0);
}
return new Tileset(canvas, layout, tw, th);
}
}
// Anything else could be Tile World's "large" layout, which has no fixed dimensions
return parse_tile_world_large_tileset(canvas);
}

View File

@ -560,7 +560,7 @@ const TILE_TYPES = {
// FIXME it's possible to have a switch but no tracks...
me.track_switch = null; // null, or 0-5 indicating the active switched track
// If there's already an actor on us, it's treated as though it entered the tile moving
// in this direction, which is given in the save file and defaults to zero i.e. north
// in this direction
me.entered_direction = 'north';
},
// TODO feel like "ignores" was the wrong idea and there should just be some magic flags for