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:
parent
3e7390ffc0
commit
51bc3dfe83
@ -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 { compat_flags_for_ruleset } from '../defs.js';
|
||||||
import { Level } from '../game.js';
|
import { Level } from '../game.js';
|
||||||
import * as format_c2g from '../format-c2g.js';
|
import * as format_c2g from '../format-c2g.js';
|
||||||
import * as format_dat from '../format-dat.js';
|
import * as format_dat from '../format-dat.js';
|
||||||
import * as format_tws from '../format-tws.js';
|
import * as format_tws from '../format-tws.js';
|
||||||
import * as util from '../util.js';
|
import * as util from '../util.js';
|
||||||
|
import { LocalDirectorySource } from './lib.js';
|
||||||
import { argv, exit, stderr, stdout } from 'process';
|
|
||||||
import { opendir, readFile, stat } from 'fs/promises';
|
|
||||||
import { performance } from 'perf_hooks';
|
|
||||||
|
|
||||||
// TODO arguments:
|
// TODO arguments:
|
||||||
// - custom pack to test, possibly its solutions, possibly its ruleset (or default to steam-strict/lynx)
|
// - 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?
|
// - support for xfails somehow?
|
||||||
// TODO use this for a test suite
|
// 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) {
|
function pad(s, n) {
|
||||||
return s.substring(0, n).padEnd(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
|
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'
|
-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
|
-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
|
-h, --help ignore other arguments and show this message
|
||||||
|
|
||||||
Supports the same filetypes as Lexy's Labyrinth: DAT/CCL, C2M, or a directory
|
Supports the same filetypes as Lexy's Labyrinth: DAT/CCL, C2M, or a directory
|
||||||
|
|||||||
48
js/headless/lib.js
Normal file
48
js/headless/lib.js
Normal 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
70
js/headless/render.mjs
Normal 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();
|
||||||
243
js/main.js
243
js/main.js
@ -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 } 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 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';
|
||||||
@ -747,7 +747,6 @@ class Player extends PrimaryView {
|
|||||||
let [x, y] = this.renderer.point_to_real_cell_coords(touch.clientX, touch.clientY);
|
let [x, y] = this.renderer.point_to_real_cell_coords(touch.clientX, touch.clientY);
|
||||||
let dx = x - px;
|
let dx = x - px;
|
||||||
let dy = y - py;
|
let dy = y - py;
|
||||||
console.log(dx, dy);
|
|
||||||
// Divine a direction from the results
|
// Divine a direction from the results
|
||||||
let action;
|
let action;
|
||||||
if (Math.abs(dx) > Math.abs(dy)) {
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||||||
@ -1216,7 +1215,7 @@ class Player extends PrimaryView {
|
|||||||
}
|
}
|
||||||
tooltip.inventory.textContent = inv.join(', ');
|
tooltip.inventory.textContent = inv.join(', ');
|
||||||
});
|
});
|
||||||
this.renderer.canvas.addEventListener('mouseout', ev => {
|
this.renderer.canvas.addEventListener('mouseout', () => {
|
||||||
if (this.debug.actor_tooltip) {
|
if (this.debug.actor_tooltip) {
|
||||||
this.debug.actor_tooltip.element.classList.remove('--visible');
|
this.debug.actor_tooltip.element.classList.remove('--visible');
|
||||||
}
|
}
|
||||||
@ -1268,6 +1267,7 @@ class Player extends PrimaryView {
|
|||||||
|
|
||||||
if (this.level) {
|
if (this.level) {
|
||||||
this.update_tileset();
|
this.update_tileset();
|
||||||
|
this.adjust_scale(); // in case tile size changed
|
||||||
this._redraw();
|
this._redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2771,7 +2771,7 @@ class OptionsOverlay extends DialogOverlay {
|
|||||||
// FIXME again, wait, or what?
|
// FIXME again, wait, or what?
|
||||||
img.src = newdef.src;
|
img.src = newdef.src;
|
||||||
newdef.tileset = new Tileset(
|
newdef.tileset = new Tileset(
|
||||||
img, TILESET_LAYOUTS[newdef.layout] ?? 'lexy',
|
img, TILESET_LAYOUTS[newdef.layout ?? 'lexy'],
|
||||||
newdef.tile_width, newdef.tile_height);
|
newdef.tile_width, newdef.tile_height);
|
||||||
}
|
}
|
||||||
this.available_tilesets[ident] = newdef;
|
this.available_tilesets[ident] = newdef;
|
||||||
@ -2797,11 +2797,14 @@ class OptionsOverlay extends DialogOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
select.value = conductor.options.tilesets[slot.ident] ?? 'lexy';
|
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);
|
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.tileset_els[slot.ident] = el;
|
||||||
this.update_selected_tileset(slot.ident);
|
this.update_selected_tileset(slot.ident);
|
||||||
|
|
||||||
@ -2811,19 +2814,13 @@ class OptionsOverlay extends DialogOverlay {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.custom_tileset_counter = 1;
|
this.custom_tileset_counter = 1;
|
||||||
dl.append(
|
dl.append(mk('dd',
|
||||||
mk('dd',
|
mk('p', "You can also load a custom tileset, which will be saved in browser storage."),
|
||||||
"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('br'),
|
mk('p', "(Steam tilesets can be found in ", mk('code', "data/bmp"), " within the game's local files)."),
|
||||||
"Supports the Tile World static layout and the Steam layout.",
|
mk('p', mk('input', {type: 'file', name: 'custom-tileset'})),
|
||||||
mk('br'),
|
mk('div.option-load-tileset'),
|
||||||
"(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',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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;
|
||||||
@ -2836,7 +2833,7 @@ class OptionsOverlay extends DialogOverlay {
|
|||||||
this._load_custom_tileset(ev.target.files[0]);
|
this._load_custom_tileset(ev.target.files[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.add_button("save", ev => {
|
this.add_button("save", () => {
|
||||||
let options = this.conductor.options;
|
let options = this.conductor.options;
|
||||||
options.music_volume = parseFloat(this.root.elements['music-volume'].value);
|
options.music_volume = parseFloat(this.root.elements['music-volume'].value);
|
||||||
options.music_enabled = this.root.elements['music-enabled'].checked;
|
options.music_enabled = this.root.elements['music-enabled'].checked;
|
||||||
@ -2904,7 +2901,7 @@ class OptionsOverlay extends DialogOverlay {
|
|||||||
|
|
||||||
this.close();
|
this.close();
|
||||||
});
|
});
|
||||||
this.add_button("forget it", ev => {
|
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) {
|
||||||
this.conductor.player.music_audio_el.volume = this.original_music_volume;
|
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
|
// 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');
|
||||||
let layout;
|
let tileset;
|
||||||
// Note: Animated Tile World has a 1px gap between rows to indicate where animations
|
try {
|
||||||
// start and also a single extra 1px column on the left, which I super don't support :(
|
tileset = infer_tileset_from_image(img, (w, h) => mk('canvas', {width: w, height: h}));
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (! layout) {
|
catch (e) {
|
||||||
|
console.error(e);
|
||||||
result_el.textContent = '';
|
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;
|
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);
|
let renderer = new CanvasRenderer(tileset, 1);
|
||||||
result_el.textContent = '';
|
result_el.textContent = '';
|
||||||
|
let buttons = mk('p');
|
||||||
result_el.append(
|
result_el.append(
|
||||||
`This looks like a ${layout['#name']} tileset with ${tw}×${th} tiles.`,
|
mk('p', `This looks like a ${tileset.layout['#name']} tileset with ${tileset.size_x}×${tileset.size_y} tiles.`),
|
||||||
mk('br'),
|
mk('p',
|
||||||
renderer.draw_single_tile_type('player'),
|
renderer.draw_single_tile_type('player'),
|
||||||
renderer.draw_single_tile_type('chip'),
|
renderer.draw_single_tile_type('chip'),
|
||||||
renderer.draw_single_tile_type('exit'),
|
renderer.draw_single_tile_type('exit'),
|
||||||
mk('br'),
|
),
|
||||||
|
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;
|
this.custom_tileset_counter += 1;
|
||||||
for (let slot of TILESET_SLOTS) {
|
for (let slot of TILESET_SLOTS) {
|
||||||
if (! layout['#supported-versions'].has(slot.ident))
|
if (! tileset.layout['#supported-versions'].has(slot.ident))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
let dd = this.tileset_els[slot.ident];
|
let dd = this.tileset_els[slot.ident];
|
||||||
let select = dd.querySelector('select');
|
let select = dd.querySelector('select');
|
||||||
select.append(mk('option', {value: tileset_ident}, tileset_name));
|
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;
|
select.value = tileset_ident;
|
||||||
this.update_selected_tileset(slot.ident);
|
this.update_selected_tileset(slot.ident);
|
||||||
});
|
});
|
||||||
result_el.append(button);
|
buttons.append(button);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.available_tilesets[tileset_ident] = {
|
this.available_tilesets[tileset_ident] = {
|
||||||
ident: tileset_ident,
|
ident: tileset_ident,
|
||||||
name: tileset_name,
|
name: tileset_name,
|
||||||
canvas: canvas,
|
canvas: tileset.image,
|
||||||
tileset: tileset,
|
tileset: tileset,
|
||||||
layout: layout['#ident'],
|
layout: tileset.layout['#ident'],
|
||||||
tile_width: tw,
|
tile_width: tileset.size_x,
|
||||||
tile_height: th,
|
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) {
|
update_selected_tileset(slot_ident) {
|
||||||
let dd = this.tileset_els[slot_ident];
|
let dd = this.tileset_els[slot_ident];
|
||||||
let select = dd.querySelector('select');
|
let select = dd.querySelector('select');
|
||||||
@ -3671,49 +3624,12 @@ class Conductor {
|
|||||||
}
|
}
|
||||||
this.set_compat(this._compat_ruleset, this.compat);
|
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
|
// Bind the header buttons
|
||||||
document.querySelector('#main-options').addEventListener('click', ev => {
|
document.querySelector('#main-options').addEventListener('click', () => {
|
||||||
new OptionsOverlay(this).open();
|
new OptionsOverlay(this).open();
|
||||||
});
|
});
|
||||||
document.querySelector('#main-compat').addEventListener('click', ev => {
|
document.querySelector('#main-compat').addEventListener('click', () => {
|
||||||
new CompatOverlay(this).open();
|
new CompatOverlay(this).open();
|
||||||
});
|
});
|
||||||
document.querySelector('#main-compat output').textContent = COMPAT_RULESET_LABELS[this._compat_ruleset ?? 'custom'];
|
document.querySelector('#main-compat output').textContent = COMPAT_RULESET_LABELS[this._compat_ruleset ?? 'custom'];
|
||||||
@ -3787,6 +3703,77 @@ class Conductor {
|
|||||||
).open();
|
).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();
|
this.update_nav_buttons();
|
||||||
document.querySelector('#loading').setAttribute('hidden', '');
|
document.querySelector('#loading').setAttribute('hidden', '');
|
||||||
@ -4118,6 +4105,7 @@ async function main() {
|
|||||||
let query = new URLSearchParams(location.search);
|
let query = new URLSearchParams(location.search);
|
||||||
|
|
||||||
let conductor = new Conductor();
|
let conductor = new Conductor();
|
||||||
|
await conductor.load();
|
||||||
window._conductor = conductor;
|
window._conductor = conductor;
|
||||||
|
|
||||||
// Allow putting us in debug mode automatically if we're in development
|
// 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;
|
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)
|
// Pick a level (set)
|
||||||
// TODO error handling :(
|
// TODO error handling :(
|
||||||
let path = query.get('setpath');
|
let path = query.get('setpath');
|
||||||
|
|||||||
542
js/tileset.js
542
js/tileset.js
@ -371,8 +371,8 @@ export const CC2_TILESET_LAYOUT = {
|
|||||||
walker: {
|
walker: {
|
||||||
__special__: 'double-size-monster',
|
__special__: 'double-size-monster',
|
||||||
base: [0, 13],
|
base: [0, 13],
|
||||||
vertical: [[1, 13], [2, 13], [3, 13], [4, 13], [5, 13], [6, 13], [7, 13]],
|
vertical: [null, [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]],
|
horizontal: [null, [8, 13], [10, 13], [12, 13], [14, 13], [8, 14], [10, 14], [12, 14]],
|
||||||
},
|
},
|
||||||
helmet: [0, 14],
|
helmet: [0, 14],
|
||||||
stopwatch_toggle: [14, 14],
|
stopwatch_toggle: [14, 14],
|
||||||
@ -381,8 +381,8 @@ export const CC2_TILESET_LAYOUT = {
|
|||||||
blob: {
|
blob: {
|
||||||
__special__: 'double-size-monster',
|
__special__: 'double-size-monster',
|
||||||
base: [0, 15],
|
base: [0, 15],
|
||||||
vertical: [[1, 15], [2, 15], [3, 15], [4, 15], [5, 15], [6, 15], [7, 15]],
|
vertical: [null, [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]],
|
horizontal: [null, [8, 15], [10, 15], [12, 15], [14, 15], [8, 16], [10, 16], [12, 16]],
|
||||||
},
|
},
|
||||||
// (cc2 editor copy/paste outline)
|
// (cc2 editor copy/paste outline)
|
||||||
floor_mimic: {
|
floor_mimic: {
|
||||||
@ -830,6 +830,7 @@ export const CC2_TILESET_LAYOUT = {
|
|||||||
..._omit_custom_lexy_vfx,
|
..._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 = {
|
export const TILE_WORLD_TILESET_LAYOUT = {
|
||||||
'#ident': 'tw-static',
|
'#ident': 'tw-static',
|
||||||
'#name': "Tile World (static)",
|
'#name': "Tile World (static)",
|
||||||
@ -876,9 +877,13 @@ export const TILE_WORLD_TILESET_LAYOUT = {
|
|||||||
ice_ne: [1, 11],
|
ice_ne: [1, 11],
|
||||||
ice_se: [1, 12],
|
ice_se: [1, 12],
|
||||||
ice_sw: [1, 13],
|
ice_sw: [1, 13],
|
||||||
// FIXME this stuff needs like reveal and whatnot
|
fake_floor: {
|
||||||
fake_wall: [1, 14],
|
__special__: 'perception',
|
||||||
fake_floor: [1, 15],
|
modes: new Set(['palette', 'editor', 'xray']),
|
||||||
|
hidden: [1, 15],
|
||||||
|
revealed: [1, 14],
|
||||||
|
},
|
||||||
|
fake_wall: [1, 15],
|
||||||
|
|
||||||
// TODO overlay buffer?? [2, 0]
|
// TODO overlay buffer?? [2, 0]
|
||||||
thief_tools: [2, 1],
|
thief_tools: [2, 1],
|
||||||
@ -1003,6 +1008,7 @@ export const TILE_WORLD_TILESET_LAYOUT = {
|
|||||||
},
|
},
|
||||||
skating: 'normal',
|
skating: 'normal',
|
||||||
forced: 'normal',
|
forced: 'normal',
|
||||||
|
drowned: [3, 3],
|
||||||
burned: [3, 4], // TODO TW's lynx mode doesn't use this! it uses the generic failed
|
burned: [3, 4], // TODO TW's lynx mode doesn't use this! it uses the generic failed
|
||||||
exploded: [3, 6],
|
exploded: [3, 6],
|
||||||
failed: [3, 7],
|
failed: [3, 7],
|
||||||
@ -2012,7 +2018,15 @@ export const LL_TILESET_LAYOUT = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TILESET_LAYOUTS = {
|
export const TILESET_LAYOUTS = {
|
||||||
|
// MS layout, either abbreviated or full
|
||||||
'tw-static': TILE_WORLD_TILESET_LAYOUT,
|
'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,
|
cc2: CC2_TILESET_LAYOUT,
|
||||||
lexy: LL_TILESET_LAYOUT,
|
lexy: LL_TILESET_LAYOUT,
|
||||||
};
|
};
|
||||||
@ -2188,7 +2202,13 @@ export class Tileset {
|
|||||||
n = drawspec.idle_frame_index ?? 0;
|
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
|
// 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) {
|
_draw_thin_walls_cc1(drawspec, name, tile, packet) {
|
||||||
let edges = tile ? tile.edges : 0x0f;
|
let edges = tile ? tile.edges : 0x0f;
|
||||||
|
|
||||||
// This is kinda best-effort since the tiles are opaque and not designed to combine
|
// This is kinda best-effort since the tiles are not designed to combine
|
||||||
if (edges === (DIRECTIONS['south'].bit | DIRECTIONS['east'].bit)) {
|
if ((edges & DIRECTIONS['south'].bit) && (edges & DIRECTIONS['east'].bit)) {
|
||||||
packet.blit(...drawspec.southeast);
|
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) {
|
else if (edges & DIRECTIONS['south'].bit) {
|
||||||
packet.blit(...drawspec.south);
|
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);
|
packet.blit(...drawspec.west);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2441,50 +2463,67 @@ export class Tileset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_draw_double_size_monster(drawspec, name, tile, packet) {
|
_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.
|
// moving from AND the tile they're moving into.
|
||||||
// First, of course, this only happens if they're moving at all.
|
// CC2 also has an individual 1×1 static tile, used in all four directions.
|
||||||
if (! tile || ! tile.movement_speed) {
|
if ((! tile || ! tile.movement_speed) && drawspec.base) {
|
||||||
this.draw_drawspec(drawspec.base, name, tile, packet);
|
this.draw_drawspec(drawspec.base, name, tile, packet);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// They only support horizontal and vertical moves, not all four directions. The other two
|
// CC2 only supports horizontal and vertical moves, not all four directions. The other two
|
||||||
// directions are simply the animations played in reverse.
|
// directions are the animations played in reverse. TW's large layout supports all four.
|
||||||
let axis_cels;
|
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;
|
let w = 1, h = 1, x = 0, y = 0, sx = 0, sy = 0, reverse = false;
|
||||||
if (tile.direction === 'north') {
|
if (direction === 'north') {
|
||||||
axis_cels = drawspec.vertical;
|
if (! axis_cels) {
|
||||||
reverse = true;
|
axis_cels = drawspec.vertical;
|
||||||
|
reverse = true;
|
||||||
|
}
|
||||||
h = 2;
|
h = 2;
|
||||||
sy = 1;
|
sy = 1;
|
||||||
}
|
}
|
||||||
else if (tile.direction === 'south') {
|
else if (direction === 'south') {
|
||||||
axis_cels = drawspec.vertical;
|
if (! axis_cels) {
|
||||||
|
axis_cels = drawspec.vertical;
|
||||||
|
}
|
||||||
h = 2;
|
h = 2;
|
||||||
y = -1;
|
y = -1;
|
||||||
sy = -1;
|
sy = -1;
|
||||||
}
|
}
|
||||||
else if (tile.direction === 'west') {
|
else if (direction === 'west') {
|
||||||
axis_cels = drawspec.horizontal;
|
if (! axis_cels) {
|
||||||
reverse = true;
|
axis_cels = drawspec.horizontal;
|
||||||
|
reverse = true;
|
||||||
|
}
|
||||||
w = 2;
|
w = 2;
|
||||||
sx = 1;
|
sx = 1;
|
||||||
}
|
}
|
||||||
else if (tile.direction === 'east') {
|
else if (direction === 'east') {
|
||||||
axis_cels = drawspec.horizontal;
|
if (! axis_cels) {
|
||||||
|
axis_cels = drawspec.horizontal;
|
||||||
|
}
|
||||||
w = 2;
|
w = 2;
|
||||||
x = -1;
|
x = -1;
|
||||||
sx = -1;
|
sx = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let p = tile.movement_progress(packet.update_progress, packet.update_rate);
|
let index;
|
||||||
let index = Math.floor(p * (axis_cels.length + 1));
|
if (tile && tile.movement_speed) {
|
||||||
if (index === 0 || index > axis_cels.length) {
|
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);
|
packet.blit_aligned(...drawspec.base, 0, 0, 1, 1, sx, sy);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
let cel = reverse ? axis_cels[axis_cels.length - index] : axis_cels[index - 1];
|
|
||||||
packet.blit_aligned(...cel, 0, 0, w, h, x, y);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -560,7 +560,7 @@ const TILE_TYPES = {
|
|||||||
// FIXME it's possible to have a switch but no tracks...
|
// FIXME it's possible to have a switch but no tracks...
|
||||||
me.track_switch = null; // null, or 0-5 indicating the active switched track
|
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
|
// 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';
|
me.entered_direction = 'north';
|
||||||
},
|
},
|
||||||
// TODO feel like "ignores" was the wrong idea and there should just be some magic flags for
|
// TODO feel like "ignores" was the wrong idea and there should just be some magic flags for
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user