Compare commits
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 396 B |
+21
-14
@@ -10,6 +10,7 @@
|
|||||||
<meta name="og:image" content="https://c.eev.ee/lexys-labyrinth/og-preview.png">
|
<meta name="og:image" content="https://c.eev.ee/lexys-labyrinth/og-preview.png">
|
||||||
<meta name="og:title" content="Lexy's Labyrinth">
|
<meta name="og:title" content="Lexy's Labyrinth">
|
||||||
<meta name="og:description" content="A (work in progress) reimplementation of Chip's Challenge 1 and 2, using entirely free assets.">
|
<meta name="og:description" content="A (work in progress) reimplementation of Chip's Challenge 1 and 2, using entirely free assets.">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
</head>
|
</head>
|
||||||
<body data-mode="splash">
|
<body data-mode="splash">
|
||||||
<header id="header-main">
|
<header id="header-main">
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main id="splash">
|
<main id="splash">
|
||||||
|
<div class="drag-overlay"></div>
|
||||||
<header>
|
<header>
|
||||||
<h1><img src="og-preview.png" alt="">Lexy's Labyrinth</h1>
|
<h1><img src="og-preview.png" alt="">Lexy's Labyrinth</h1>
|
||||||
</header>
|
</header>
|
||||||
@@ -57,21 +59,20 @@
|
|||||||
|
|
||||||
<section id="splash-upload-levels">
|
<section id="splash-upload-levels">
|
||||||
<h2>Other levels</h2>
|
<h2>Other levels</h2>
|
||||||
<p>You can play <code>CHIPS.DAT</code> from the original Microsoft version, any custom levels you have lying around, or perhaps ones you found on the <a href="https://sets.bitbusters.club/">Bit Busters Club set list</a>!</p>
|
<p>Load and play any levels you have on hand — both the original levels and any custom ones, perhaps ones you found on the <a href="https://sets.bitbusters.club/">Bit Busters Club set list</a>. Supports CCL/DAT, C2G, and individual C2Ms (though scores aren't saved for those).</p>
|
||||||
<!-- TODO explain how to find chips.dat or steam folder -->
|
<!-- TODO zip files! -->
|
||||||
<!-- TODO drag and drop? -->
|
<p>You can also drag and drop files or directories into this window.</p>
|
||||||
<input id="splash-upload" type="file" accept=".dat,.ccl,.c2m,.ccs">
|
<input id="splash-upload-file" type="file" accept=".dat,.ccl,.c2m,.ccs" multiple>
|
||||||
<button type="button" id="splash-upload-button" class="button-big">Open a local level<!-- TODO: <br>(or drag and drop a file into this window) --></button>
|
<input id="splash-upload-dir" type="file" webkitdirectory>
|
||||||
<p>Supports both the old Microsoft <code>CHIPS.DAT</code> format and the Steam <code>C2M</code> format.</p>
|
<button type="button" id="splash-upload-file-button" class="button-big">Load files</button>
|
||||||
<p>Does <em>not</em> yet support the Steam <code>C2G</code> format, so tragically, the original Steam levels can only be played one at a time. This should be fixed soon!</p>
|
<button type="button" id="splash-upload-dir-button" class="button-big">Load directory</button>
|
||||||
<!--
|
<p>If you still have the original Microsoft "BOWEP" game lying around, you can play the Chip's Challenge 1 levels by loading <code>CHIPS.DAT</code>.</p>
|
||||||
<p>If you own the Steam versions of <a href="https://store.steampowered.com/app/346850/Chips_Challenge_1/">Chip's Challenge 1</a> (<em>free!</em>) or <a href="https://store.steampowered.com/app/348300/Chips_Challenge_2/">Chip's Challenge 2</a> ($5 last I checked), you can play those too, even on Linux or Mac:</p>
|
<p>If you own the Steam versions of <a href="https://store.steampowered.com/app/346850/Chips_Challenge_1/">Chip's Challenge 1</a> (<em>free!</em>) or <a href="https://store.steampowered.com/app/348300/Chips_Challenge_2/">Chip's Challenge 2</a> ($5 last I checked), you can play those too, even on Linux or Mac:</p>
|
||||||
<ol class="normal-list">
|
<ol class="normal-list">
|
||||||
<li>Right-click the game in Steam and choose <em>Properties</em>. On the <em>Local Files</em> tab, click <em>Browse local files</em>.</li>
|
<li>Right-click the game in Steam and choose <em>Properties</em>. On the <em>Local Files</em> tab, click <em>Browse local files</em>.</li>
|
||||||
<li>Open the <code>data</code> folder, then <code>games</code>.</li>
|
<li>Open the <code>data</code> folder, then <code>games</code>.</li>
|
||||||
<li>You should see either a <code>cc1</code> or <code>cc2</code> folder. Drag it into this window.</li>
|
<li>You should see either a <code>cc1</code> or <code>cc2</code> folder. Drag it into this window, or load it with the button above.</li>
|
||||||
</ol>
|
</ol>
|
||||||
-->
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="splash-your-levels">
|
<section id="splash-your-levels">
|
||||||
@@ -116,10 +117,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="play-controls">
|
<div class="play-controls">
|
||||||
<button class="control-pause" type="button">Pause (p)</button>
|
<button class="control-pause" type="button">Pause <span class="keyhint">(p)</span></button>
|
||||||
<button class="control-restart" type="button">Restart</button>
|
<button class="control-restart" type="button">Restart</button>
|
||||||
<button class="control-undo" type="button">Undo</button>
|
<button class="control-undo" type="button">Undo</button>
|
||||||
<button class="control-rewind" type="button">Rewind (z)</button>
|
<button class="control-rewind" type="button">Rewind <span class="keyhint">(z)</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="demo-controls">
|
<div class="demo-controls">
|
||||||
<button class="demo-play" type="button">View replay</button>
|
<button class="demo-play" type="button">View replay</button>
|
||||||
@@ -152,8 +153,14 @@
|
|||||||
-->
|
-->
|
||||||
</header>
|
</header>
|
||||||
<div class="level"><!-- level canvas and any overlays go here --></div>
|
<div class="level"><!-- level canvas and any overlays go here --></div>
|
||||||
<div class="controls">
|
<nav class="controls">
|
||||||
|
<div id="editor-toolbar">
|
||||||
|
<!-- tools go here -->
|
||||||
|
</div>
|
||||||
<button id="editor-share-url" type="button">Share?</button>
|
<button id="editor-share-url" type="button">Share?</button>
|
||||||
|
<div id="editor-tool-help">
|
||||||
|
<strong>Pencil</strong> — <span>Select a tile and draw with the left mouse button. Erase with the right mouse button.</span>
|
||||||
|
</div>
|
||||||
<!--
|
<!--
|
||||||
<p style>
|
<p style>
|
||||||
Tip: Right click to color drop.<br>
|
Tip: Right click to color drop.<br>
|
||||||
@@ -178,7 +185,7 @@
|
|||||||
map size
|
map size
|
||||||
</pre>
|
</pre>
|
||||||
-->
|
-->
|
||||||
</div>
|
</nav>
|
||||||
<div class="palette"></div>
|
<div class="palette"></div>
|
||||||
<!-- TODO:
|
<!-- TODO:
|
||||||
controls
|
controls
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import * as util from './util.js';
|
||||||
|
|
||||||
|
export class StoredCell extends Array {
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StoredLevel {
|
||||||
|
constructor(number) {
|
||||||
|
// TODO still not sure this belongs here
|
||||||
|
this.number = number; // one-based
|
||||||
|
this.title = '';
|
||||||
|
this.password = null;
|
||||||
|
this.hint = '';
|
||||||
|
this.chips_required = 0;
|
||||||
|
this.time_limit = 0;
|
||||||
|
this.viewport_size = 9;
|
||||||
|
this.extra_chunks = [];
|
||||||
|
this.use_cc1_boots = false;
|
||||||
|
this.use_ccl_compat = false;
|
||||||
|
|
||||||
|
this.size_x = 0;
|
||||||
|
this.size_y = 0;
|
||||||
|
this.linear_cells = [];
|
||||||
|
|
||||||
|
// Maps of button positions to trap/cloner positions, as scalar indexes
|
||||||
|
// in the linear cell list
|
||||||
|
// TODO merge these imo
|
||||||
|
this.has_custom_connections = false;
|
||||||
|
this.custom_trap_wiring = {};
|
||||||
|
this.custom_cloner_wiring = {};
|
||||||
|
|
||||||
|
// New LL feature: custom camera regions, as lists of {x, y, width, height}
|
||||||
|
this.camera_regions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
scalar_to_coords(n) {
|
||||||
|
return [n % this.size_x, Math.floor(n / this.size_x)];
|
||||||
|
}
|
||||||
|
|
||||||
|
coords_to_scalar(x, y) {
|
||||||
|
return x + y * this.size_x;
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StoredGame {
|
||||||
|
constructor(identifier, level_loader) {
|
||||||
|
this.identifier = identifier;
|
||||||
|
this._level_loader = level_loader;
|
||||||
|
|
||||||
|
// Simple objects containing keys:
|
||||||
|
// title: level title
|
||||||
|
// index: level index, used internally only
|
||||||
|
// number: level number (may not match index due to C2G shenanigans)
|
||||||
|
// error: any error received while loading the level
|
||||||
|
// bytes: Uint8Array of the encoded level data
|
||||||
|
this.level_metadata = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO this may or may not work sensibly when correctly following a c2g
|
||||||
|
load_level(index) {
|
||||||
|
let meta = this.level_metadata[index];
|
||||||
|
if (! meta)
|
||||||
|
throw new util.LLError(`No such level number ${index}`);
|
||||||
|
if (meta.error)
|
||||||
|
throw meta.error;
|
||||||
|
|
||||||
|
if (meta.stored_level) {
|
||||||
|
// The editor stores inflated levels at times, so respect that
|
||||||
|
return meta.stored_level;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Otherwise, attempt to load the level
|
||||||
|
return this._level_loader(meta.bytes, meta.number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DIRECTIONS } from './defs.js';
|
import { DIRECTIONS } from './defs.js';
|
||||||
import * as util from './format-util.js';
|
import * as format_base from './format-base.js';
|
||||||
import TILE_TYPES from './tiletypes.js';
|
import TILE_TYPES from './tiletypes.js';
|
||||||
|
import * as util from './util.js';
|
||||||
|
|
||||||
const CC2_DEMO_INPUT_MASK = {
|
const CC2_DEMO_INPUT_MASK = {
|
||||||
drop: 0x01,
|
drop: 0x01,
|
||||||
@@ -13,9 +14,8 @@ const CC2_DEMO_INPUT_MASK = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class CC2Demo {
|
class CC2Demo {
|
||||||
constructor(buf) {
|
constructor(bytes) {
|
||||||
this.buf = buf;
|
this.bytes = bytes;
|
||||||
this.bytes = new Uint8Array(buf);
|
|
||||||
|
|
||||||
// byte 0 is unknown, always 0?
|
// byte 0 is unknown, always 0?
|
||||||
// Force floor seed can apparently be anything; my best guess, based on the Desert Oasis
|
// Force floor seed can apparently be anything; my best guess, based on the Desert Oasis
|
||||||
@@ -69,6 +69,7 @@ class CC2Demo {
|
|||||||
let modifier_wire = {
|
let modifier_wire = {
|
||||||
decode(tile, modifier) {
|
decode(tile, modifier) {
|
||||||
tile.wire_directions = modifier & 0x0f;
|
tile.wire_directions = modifier & 0x0f;
|
||||||
|
// TODO wait, what happens if you use wire tunnels on steel or something other than floor?
|
||||||
tile.wire_tunnel_directions = (modifier & 0xf0) >> 4;
|
tile.wire_tunnel_directions = (modifier & 0xf0) >> 4;
|
||||||
},
|
},
|
||||||
encode(tile) {
|
encode(tile) {
|
||||||
@@ -340,7 +341,9 @@ const TILE_ENCODING = {
|
|||||||
0x46: {
|
0x46: {
|
||||||
name: 'force_floor_all',
|
name: 'force_floor_all',
|
||||||
},
|
},
|
||||||
// 0x47: 'button_gray',
|
0x47: {
|
||||||
|
name: 'button_gray',
|
||||||
|
},
|
||||||
// FIXME swivel floors... argh...
|
// FIXME swivel floors... argh...
|
||||||
0x48: {
|
0x48: {
|
||||||
name: 'swivel_sw',
|
name: 'swivel_sw',
|
||||||
@@ -395,8 +398,18 @@ const TILE_ENCODING = {
|
|||||||
has_next: true,
|
has_next: true,
|
||||||
extra_args: [arg_direction],
|
extra_args: [arg_direction],
|
||||||
},
|
},
|
||||||
// 0x57: Timid teeth : '#direction', '#next'
|
0x57: {
|
||||||
// 0x58: Explosion animation (unused in main levels) : '#direction', '#next'
|
name: 'teeth_timid',
|
||||||
|
has_next: true,
|
||||||
|
extra_args: [arg_direction],
|
||||||
|
error: "Timid chomper is not yet implemented, sorry!",
|
||||||
|
},
|
||||||
|
0x58: {
|
||||||
|
// TODO??? unused in main levels -- name: 'doppelganger2',
|
||||||
|
has_next: true,
|
||||||
|
extra_args: [arg_direction],
|
||||||
|
error: "Explosion animation is not implemented, sorry!",
|
||||||
|
},
|
||||||
0x59: {
|
0x59: {
|
||||||
name: 'hiking_boots',
|
name: 'hiking_boots',
|
||||||
has_next: true,
|
has_next: true,
|
||||||
@@ -407,7 +420,11 @@ const TILE_ENCODING = {
|
|||||||
0x5b: {
|
0x5b: {
|
||||||
name: 'no_player1_sign',
|
name: 'no_player1_sign',
|
||||||
},
|
},
|
||||||
// 0x5c: Inverter gate (N) : Modifier allows other gates, see below
|
0x5c: {
|
||||||
|
// TODO (modifier chooses logic gate) name: 'doppelganger2',
|
||||||
|
// TODO modifier: ...
|
||||||
|
error: "Logic gates are not yet implemented, sorry!",
|
||||||
|
},
|
||||||
0x5e: {
|
0x5e: {
|
||||||
name: 'button_pink',
|
name: 'button_pink',
|
||||||
modifier: modifier_wire,
|
modifier: modifier_wire,
|
||||||
@@ -421,7 +438,11 @@ const TILE_ENCODING = {
|
|||||||
0x61: {
|
0x61: {
|
||||||
name: 'button_orange',
|
name: 'button_orange',
|
||||||
},
|
},
|
||||||
// 0x62: Lightning bolt : '#next'
|
0x62: {
|
||||||
|
name: 'lightning_bolt',
|
||||||
|
has_next: true,
|
||||||
|
error: "The lightning bolt is not yet implemented, sorry!",
|
||||||
|
},
|
||||||
0x63: {
|
0x63: {
|
||||||
name: 'tank_yellow',
|
name: 'tank_yellow',
|
||||||
has_next: true,
|
has_next: true,
|
||||||
@@ -430,8 +451,18 @@ const TILE_ENCODING = {
|
|||||||
0x64: {
|
0x64: {
|
||||||
name: 'button_yellow',
|
name: 'button_yellow',
|
||||||
},
|
},
|
||||||
// 0x65: Mirror Chip : '#direction', '#next'
|
0x65: {
|
||||||
// 0x66: Mirror Melinda : '#direction', '#next'
|
name: 'doppelganger1',
|
||||||
|
has_next: true,
|
||||||
|
extra_args: [arg_direction],
|
||||||
|
error: "Doppelganger Lexy is not yet implemented, sorry!",
|
||||||
|
},
|
||||||
|
0x66: {
|
||||||
|
name: 'doppelganger2',
|
||||||
|
has_next: true,
|
||||||
|
extra_args: [arg_direction],
|
||||||
|
error: "Doppelganger Cerise is not yet implemented, sorry!",
|
||||||
|
},
|
||||||
0x68: {
|
0x68: {
|
||||||
name: 'bowling_ball',
|
name: 'bowling_ball',
|
||||||
has_next: true,
|
has_next: true,
|
||||||
@@ -552,8 +583,14 @@ const TILE_ENCODING = {
|
|||||||
name: 'button_black',
|
name: 'button_black',
|
||||||
modifier: modifier_wire,
|
modifier: modifier_wire,
|
||||||
},
|
},
|
||||||
// 0x88: ON/OFF switch (OFF) :
|
0x88: {
|
||||||
// 0x89: ON/OFF switch (ON) :
|
name: 'light_switch_off',
|
||||||
|
error: "The light switch is not yet implemented, sorry!",
|
||||||
|
},
|
||||||
|
0x89: {
|
||||||
|
name: 'light_switch_on',
|
||||||
|
error: "The light switch is not yet implemented, sorry!",
|
||||||
|
},
|
||||||
0x8a: {
|
0x8a: {
|
||||||
name: 'thief_keys',
|
name: 'thief_keys',
|
||||||
},
|
},
|
||||||
@@ -573,9 +610,21 @@ const TILE_ENCODING = {
|
|||||||
name: 'xray_eye',
|
name: 'xray_eye',
|
||||||
has_next: true,
|
has_next: true,
|
||||||
},
|
},
|
||||||
// 0x8f: Thief bribe : '#next'
|
0x8f: {
|
||||||
// 0x90: Speed boots : '#next'
|
name: 'bribe',
|
||||||
// 0x92: Hook : '#next'
|
has_next: true,
|
||||||
|
error: "The bribe is not yet implemented, sorry!",
|
||||||
|
},
|
||||||
|
0x90: {
|
||||||
|
name: 'speed_boots',
|
||||||
|
has_next: true,
|
||||||
|
error: "The speed boots are not yet implemented, sorry!",
|
||||||
|
},
|
||||||
|
0x91: {
|
||||||
|
name: 'hook',
|
||||||
|
has_next: true,
|
||||||
|
error: "The hook is not yet implemented, sorry!",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const REVERSE_TILE_ENCODING = {};
|
const REVERSE_TILE_ENCODING = {};
|
||||||
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
|
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
|
||||||
@@ -616,21 +665,18 @@ function read_n_bytes(view, start, n) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decompress the little ad-hoc compression scheme used for both map data and
|
// Decompress the little ad-hoc compression scheme used for both map data and solution playback
|
||||||
// solution playback
|
function decompress(bytes) {
|
||||||
function decompress(buf) {
|
let decompressed_length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(0, true);
|
||||||
let decompressed_length = new DataView(buf).getUint16(0, true);
|
let outbytes = new Uint8Array(decompressed_length);
|
||||||
let out = new ArrayBuffer(decompressed_length);
|
|
||||||
let outbytes = new Uint8Array(out);
|
|
||||||
let bytes = new Uint8Array(buf);
|
|
||||||
let p = 2;
|
let p = 2;
|
||||||
let q = 0;
|
let q = 0;
|
||||||
while (p < buf.byteLength) {
|
while (p < bytes.length) {
|
||||||
let len = bytes[p];
|
let len = bytes[p];
|
||||||
p++;
|
p++;
|
||||||
if (len < 0x80) {
|
if (len < 0x80) {
|
||||||
// Data block
|
// Data block
|
||||||
outbytes.set(new Uint8Array(buf.slice(p, p + len)), q);
|
outbytes.set(new Uint8Array(bytes.buffer, bytes.byteOffset + p, len), q);
|
||||||
p += len;
|
p += len;
|
||||||
q += len;
|
q += len;
|
||||||
}
|
}
|
||||||
@@ -651,59 +697,85 @@ function decompress(buf) {
|
|||||||
}
|
}
|
||||||
if (q !== decompressed_length)
|
if (q !== decompressed_length)
|
||||||
throw new Error(`Expected to decode ${decompressed_length} bytes but got ${q} instead`);
|
throw new Error(`Expected to decode ${decompressed_length} bytes but got ${q} instead`);
|
||||||
return out;
|
return outbytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parse_level(buf, number = 1) {
|
// Iterates over a C2M file and yields: [section type, uint8 array view of the section]
|
||||||
let level = new util.StoredLevel(number);
|
function* read_c2m_sections(buf) {
|
||||||
let full_view = new DataView(buf);
|
let full_view = new DataView(buf);
|
||||||
let next_section_start = 0;
|
let next_section_start = 0;
|
||||||
let extra_hints = [];
|
|
||||||
let hint_tiles = [];
|
|
||||||
while (next_section_start < buf.byteLength) {
|
while (next_section_start < buf.byteLength) {
|
||||||
// Read section header and length
|
// Read section header and length
|
||||||
let section_start = next_section_start;
|
let section_start = next_section_start;
|
||||||
let section_type = util.string_from_buffer_ascii(buf.slice(section_start, section_start + 4));
|
let section_type = util.string_from_buffer_ascii(buf, section_start, 4);
|
||||||
let section_length = full_view.getUint32(section_start + 4, true);
|
let section_length = full_view.getUint32(section_start + 4, true);
|
||||||
next_section_start = section_start + 8 + section_length;
|
next_section_start = section_start + 8 + section_length;
|
||||||
if (next_section_start > buf.byteLength)
|
if (next_section_start > buf.byteLength)
|
||||||
throw new Error(`Section at byte ${section_start} of type '${section_type}' extends ${buf.length - next_section_start} bytes past the end of the file`);
|
throw new util.LLError(`Section at byte ${section_start} of type '${section_type}' extends ${buf.length - next_section_start} bytes past the end of the file`);
|
||||||
|
|
||||||
// This chunk marks the end of the file regardless
|
// This chunk marks the end of the file, full stop; a lot of canonical files have garbage
|
||||||
|
// newlines afterwards and will fail to continue to parse beyond this point
|
||||||
if (section_type === 'END ')
|
if (section_type === 'END ')
|
||||||
break;
|
return;
|
||||||
|
|
||||||
if (section_type === 'CC2M' || section_type === 'LOCK' || section_type === 'VERS' ||
|
yield [section_type, new Uint8Array(buf, section_start + 8, section_length)];
|
||||||
section_type === 'TITL' || section_type === 'AUTH' ||
|
}
|
||||||
section_type === 'CLUE' || section_type === 'NOTE')
|
}
|
||||||
|
|
||||||
|
export function parse_level_metadata(buf) {
|
||||||
|
let meta = {
|
||||||
|
title: null,
|
||||||
|
};
|
||||||
|
for (let [type, bytes] of read_c2m_sections(buf)) {
|
||||||
|
if (type === 'TITL') {
|
||||||
|
meta.title = util.string_from_buffer_ascii(bytes, 0, bytes.length - 1).replace(/\r\n/g, "\n");
|
||||||
|
// TODO anything else we want for now?
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse_level(buf, number = 1) {
|
||||||
|
if (ArrayBuffer.isView(buf)) {
|
||||||
|
buf = buf.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
let level = new format_base.StoredLevel(number);
|
||||||
|
let extra_hints = [];
|
||||||
|
let hint_tiles = [];
|
||||||
|
for (let [type, bytes] of read_c2m_sections(buf)) {
|
||||||
|
if (type === 'CC2M' || type === 'LOCK' || type === 'VERS' ||
|
||||||
|
type === 'TITL' || type === 'AUTH' ||
|
||||||
|
type === 'CLUE' || type === 'NOTE')
|
||||||
{
|
{
|
||||||
// These are all singular strings (with a terminating NUL, for some reason)
|
// These are all singular strings (with a terminating NUL, for some reason)
|
||||||
// XXX character encoding??
|
// XXX character encoding??
|
||||||
let str = util.string_from_buffer_ascii(buf.slice(section_start + 8, next_section_start - 1)).replace(/\r\n/g, "\n");
|
let str = util.string_from_buffer_ascii(bytes, 0, bytes.length - 1).replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
// TODO store more of this, at least for idempotence, maybe
|
// TODO store more of this, at least for idempotence, maybe
|
||||||
if (section_type === 'CC2M') {
|
if (type === 'CC2M') {
|
||||||
// File version, doesn't seem interesting
|
// File version, doesn't seem interesting
|
||||||
}
|
}
|
||||||
else if (section_type === 'LOCK') {
|
else if (type === 'LOCK') {
|
||||||
// Unclear, seems to be a comment about the editor...?
|
// Unclear, seems to be a comment about the editor...?
|
||||||
}
|
}
|
||||||
else if (section_type === 'VERS') {
|
else if (type === 'VERS') {
|
||||||
// Editor version which created this level
|
// Editor version which created this level
|
||||||
}
|
}
|
||||||
else if (section_type === 'TITL') {
|
else if (type === 'TITL') {
|
||||||
// Level title
|
// Level title
|
||||||
level.title = str;
|
level.title = str;
|
||||||
}
|
}
|
||||||
else if (section_type === 'AUTH') {
|
else if (type === 'AUTH') {
|
||||||
// Author's name
|
// Author's name
|
||||||
level.author = str;
|
level.author = str;
|
||||||
}
|
}
|
||||||
else if (section_type === 'CLUE') {
|
else if (type === 'CLUE') {
|
||||||
// Level hint
|
// Level hint
|
||||||
level.hint = str;
|
level.hint = str;
|
||||||
}
|
}
|
||||||
else if (section_type === 'NOTE') {
|
else if (type === 'NOTE') {
|
||||||
// Author's comments... but might also include multiple hints
|
// Author's comments... but might also include multiple hints
|
||||||
// for levels with multiple hint tiles, delineated by [CLUE].
|
// for levels with multiple hint tiles, delineated by [CLUE].
|
||||||
// For my purposes, extra hints are associated with the
|
// For my purposes, extra hints are associated with the
|
||||||
@@ -713,16 +785,15 @@ export function parse_level(buf, number = 1) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let section_buf = buf.slice(section_start + 8, next_section_start);
|
let view = new DataView(buf, bytes.byteOffset, bytes.byteLength);
|
||||||
let section_view = new DataView(buf, section_start + 8, section_length);
|
|
||||||
|
|
||||||
if (section_type === 'OPTN') {
|
if (type === 'OPTN') {
|
||||||
// Level options, which may be truncated at any point
|
// Level options, which may be truncated at any point
|
||||||
// TODO implement most of these
|
// TODO implement most of these
|
||||||
level.time_limit = section_view.getUint16(0, true);
|
level.time_limit = view.getUint16(0, true);
|
||||||
|
|
||||||
// TODO 0 - 10x10, 1 - 9x9, 2 - split, otherwise unknown which needs handling
|
// TODO 0 - 10x10, 1 - 9x9, 2 - split, otherwise unknown which needs handling
|
||||||
let viewport = section_view.getUint8(2, true);
|
let viewport = view.getUint8(2, true);
|
||||||
if (viewport === 0) {
|
if (viewport === 0) {
|
||||||
level.viewport_size = 10;
|
level.viewport_size = 10;
|
||||||
}
|
}
|
||||||
@@ -737,42 +808,40 @@ export function parse_level(buf, number = 1) {
|
|||||||
throw new Error(`Unrecognized viewport size option ${viewport}`);
|
throw new Error(`Unrecognized viewport size option ${viewport}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (section_view.byteLength <= 3)
|
if (view.byteLength <= 3)
|
||||||
continue;
|
continue;
|
||||||
//options.has_solution = section_view.getUint8(3, true);
|
//options.has_solution = view.getUint8(3, true);
|
||||||
|
|
||||||
if (section_view.byteLength <= 4)
|
if (view.byteLength <= 4)
|
||||||
continue;
|
continue;
|
||||||
//options.show_map_in_editor = section_view.getUint8(4, true);
|
//options.show_map_in_editor = view.getUint8(4, true);
|
||||||
|
|
||||||
if (section_view.byteLength <= 5)
|
if (view.byteLength <= 5)
|
||||||
continue;
|
continue;
|
||||||
//options.is_editable = section_view.getUint8(5, true);
|
//options.is_editable = view.getUint8(5, true);
|
||||||
|
|
||||||
if (section_view.byteLength <= 6)
|
if (view.byteLength <= 6)
|
||||||
continue;
|
continue;
|
||||||
//options.solution_hash = util.string_from_buffer_ascii(buf.slice(
|
//options.solution_hash = format_base.string_from_buffer_ascii(buf.slice(
|
||||||
//section_start + 6, section_start + 22));
|
//section_start + 6, section_start + 22));
|
||||||
|
|
||||||
if (section_view.byteLength <= 22)
|
if (view.byteLength <= 22)
|
||||||
continue;
|
continue;
|
||||||
//options.hide_logic = section_view.getUint8(22, true);
|
//options.hide_logic = view.getUint8(22, true);
|
||||||
|
|
||||||
if (section_view.byteLength <= 23)
|
if (view.byteLength <= 23)
|
||||||
continue;
|
continue;
|
||||||
level.use_cc1_boots = section_view.getUint8(23, true);
|
level.use_cc1_boots = view.getUint8(23, true);
|
||||||
|
|
||||||
if (section_view.byteLength <= 24)
|
if (view.byteLength <= 24)
|
||||||
continue;
|
continue;
|
||||||
//level.blob_behavior = section_view.getUint8(24, true);
|
//level.blob_behavior = view.getUint8(24, true);
|
||||||
}
|
}
|
||||||
else if (section_type === 'MAP ' || section_type === 'PACK') {
|
else if (type === 'MAP ' || type === 'PACK') {
|
||||||
let data = section_buf;
|
if (type === 'PACK') {
|
||||||
if (section_type === 'PACK') {
|
bytes = decompress(bytes);
|
||||||
data = decompress(data);
|
|
||||||
}
|
}
|
||||||
let bytes = new Uint8Array(data);
|
let map_view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
let map_view = new DataView(data);
|
|
||||||
let width = bytes[0];
|
let width = bytes[0];
|
||||||
let height = bytes[1];
|
let height = bytes[1];
|
||||||
level.size_x = width;
|
level.size_x = width;
|
||||||
@@ -785,17 +854,19 @@ export function parse_level(buf, number = 1) {
|
|||||||
let tile_byte = bytes[p];
|
let tile_byte = bytes[p];
|
||||||
p++;
|
p++;
|
||||||
if (tile_byte === undefined)
|
if (tile_byte === undefined)
|
||||||
throw new Error(`Read past end of file in cell ${n}`);
|
throw new util.LLError(`Read past end of file in cell ${n}`);
|
||||||
|
|
||||||
let spec = TILE_ENCODING[tile_byte];
|
let spec = TILE_ENCODING[tile_byte];
|
||||||
if (! spec)
|
if (! spec)
|
||||||
throw new Error(`Unrecognized tile type 0x${tile_byte.toString(16)}`);
|
throw new util.LLError(`Invalid tile type 0x${tile_byte.toString(16)}`);
|
||||||
|
if (spec.error)
|
||||||
|
throw new util.LLError(spec.error);
|
||||||
|
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (n = 0; n < width * height; n++) {
|
for (n = 0; n < width * height; n++) {
|
||||||
let cell = new util.StoredCell;
|
let cell = new format_base.StoredCell;
|
||||||
while (true) {
|
while (true) {
|
||||||
let spec = read_spec();
|
let spec = read_spec();
|
||||||
|
|
||||||
@@ -815,7 +886,7 @@ export function parse_level(buf, number = 1) {
|
|||||||
p += 4;
|
p += 4;
|
||||||
}
|
}
|
||||||
spec = read_spec();
|
spec = read_spec();
|
||||||
if (! spec.modifier) {
|
if (! spec.modifier && ! (spec.name instanceof Array)) {
|
||||||
console.warn("Got unexpected modifier for tile:", spec.name);
|
console.warn("Got unexpected modifier for tile:", spec.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -890,22 +961,36 @@ export function parse_level(buf, number = 1) {
|
|||||||
level.linear_cells.push(cell);
|
level.linear_cells.push(cell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (section_type === 'KEY ') {
|
else if (type === 'KEY ') {
|
||||||
}
|
}
|
||||||
else if (section_type === 'REPL' || section_type === 'PRPL') {
|
else if (type === 'REPL' || type === 'PRPL') {
|
||||||
// "Replay", i.e. demo solution
|
// "Replay", i.e. demo solution
|
||||||
let data = section_buf;
|
if (type === 'PRPL') {
|
||||||
if (section_type === 'PRPL') {
|
bytes = decompress(bytes);
|
||||||
data = decompress(data);
|
|
||||||
}
|
}
|
||||||
level.demo = new CC2Demo(data);
|
level.demo = new CC2Demo(bytes);
|
||||||
}
|
}
|
||||||
else if (section_type === 'RDNY') {
|
else if (type === 'RDNY') {
|
||||||
|
}
|
||||||
|
// TODO LL custom chunks, should distinguish somehow
|
||||||
|
else if (type === 'LXCM') {
|
||||||
|
// Camera regions
|
||||||
|
if (bytes.length % 4 !== 0)
|
||||||
|
throw new Error(`Expected LXCM chunk to be a multiple of 4 bytes; got ${bytes.length}`);
|
||||||
|
|
||||||
|
let p = 0;
|
||||||
|
while (p < bytes.length) {
|
||||||
|
let x = bytes[p + 0];
|
||||||
|
let y = bytes[p + 1];
|
||||||
|
let w = bytes[p + 2];
|
||||||
|
let h = bytes[p + 3];
|
||||||
|
// TODO validate? must be smaller than map?
|
||||||
|
level.camera_regions.push(new DOMRect(x, y, w, h));
|
||||||
|
p += 4;
|
||||||
}
|
}
|
||||||
else if (section_type === 'END ') {
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.warn(`Unrecognized section type '${section_type}' at offset ${section_start}`);
|
console.warn(`Unrecognized section type '${type}' at offset ${bytes.byteOffset}`);
|
||||||
// TODO save it, persist when editing level
|
// TODO save it, persist when editing level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1071,6 +1156,21 @@ export function synthesize_level(stored_level) {
|
|||||||
let c2m = new C2M;
|
let c2m = new C2M;
|
||||||
c2m.add_section('CC2M', '133');
|
c2m.add_section('CC2M', '133');
|
||||||
|
|
||||||
|
// Store camera regions
|
||||||
|
// TODO LL feature, should be distinguished somehow
|
||||||
|
if (stored_level.camera_regions.length > 0) {
|
||||||
|
let bytes = new Uint8Array(4 * stored_level.camera_regions.length);
|
||||||
|
let p = 0;
|
||||||
|
for (let region of stored_level.camera_regions) {
|
||||||
|
bytes[p + 0] = region.x;
|
||||||
|
bytes[p + 1] = region.y;
|
||||||
|
bytes[p + 2] = region.width;
|
||||||
|
bytes[p + 3] = region.height;
|
||||||
|
p += 4;
|
||||||
|
}
|
||||||
|
c2m.add_section('LXCM', bytes.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
// FIXME well this will not do
|
// FIXME well this will not do
|
||||||
let map_bytes = new Uint8Array(4096);
|
let map_bytes = new Uint8Array(4096);
|
||||||
let map_view = new DataView(map_bytes.buffer);
|
let map_view = new DataView(map_bytes.buffer);
|
||||||
@@ -1137,3 +1237,479 @@ export function synthesize_level(stored_level) {
|
|||||||
|
|
||||||
return c2m.serialize();
|
return c2m.serialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// C2G, the text format that stitches levels together into a game
|
||||||
|
|
||||||
|
// NOTE: C2G is surprisingly complicated for a game layout format, and most of its features are not
|
||||||
|
// currently supported. Most of them have also never been used in practice, so that's fine.
|
||||||
|
|
||||||
|
// TODO this is not quite right yet; the architect has more specific lexing documentation
|
||||||
|
|
||||||
|
// Split a statement into a number of tokens. This is, thankfully, relatively easy, due to the
|
||||||
|
// minimal syntax and the lack of string escapes (so we don't have to check for " vs \" vs \\").
|
||||||
|
// The tokens seem to be one of:
|
||||||
|
// - a bareword (could be a variable or keyword)
|
||||||
|
// - an operator
|
||||||
|
// - a literal number
|
||||||
|
// - a quoted string
|
||||||
|
// - a label
|
||||||
|
// - a comment
|
||||||
|
// And that's it! So here's a regex to find all of them, and then we just use matchAll.
|
||||||
|
const TOKENIZE_RX = RegExp(
|
||||||
|
// Eat any leading horizontal whitespace
|
||||||
|
'[ \\t]*(?:' +
|
||||||
|
// 1: Catch newlines as their own thing, since they are (sigh) important, sometimes
|
||||||
|
'(\\n)' +
|
||||||
|
// 2: Comments are preceded by ; or // for some reason and run to the end of the line
|
||||||
|
'|(?:;|//)(.*)' +
|
||||||
|
// 3: Strings are double-quoted (only!) and contain no escapes
|
||||||
|
'|"([^"]+?)"' +
|
||||||
|
// 4: Labels are indicated by a #, including when used with 'goto'
|
||||||
|
// (the exact set of allowed characters is unclear and i'm fudging it here)
|
||||||
|
'|#(\\w+)' +
|
||||||
|
// 5: Only decimal integers are allowed
|
||||||
|
'|(\\d+)' +
|
||||||
|
// 6: Operators are part of a fixed set
|
||||||
|
'|(==|<=|>=|!=|&&|\\|\\||[-+*/<>=&|&^])' +
|
||||||
|
// 7: Barewords appear to allow literally fucking anything as long as they start with a
|
||||||
|
// letter -- the official playcc2 contains `really?'"` as an accidental unquoted string and
|
||||||
|
// it's accepted but ignored, so I can only assume it's treated as a variable
|
||||||
|
// TODO i really don't like this, it's beyond error-prone
|
||||||
|
'|([a-zA-Z]\\S*)' +
|
||||||
|
// 8: Anything else is an error
|
||||||
|
'|(\\S+)' +
|
||||||
|
')', 'g');
|
||||||
|
const DIRECTIVES = {
|
||||||
|
// Important stuff
|
||||||
|
'chdir': ['string'],
|
||||||
|
'do': 'statement', // special
|
||||||
|
'game': ['string'],
|
||||||
|
'goto': ['label'],
|
||||||
|
'map': ['string'],
|
||||||
|
'music': ['string'],
|
||||||
|
'script': 'script', // special
|
||||||
|
// Weird stuff
|
||||||
|
'edit': [],
|
||||||
|
// Seemingly unused, or at least not understood
|
||||||
|
'art': ['string'],
|
||||||
|
'chain': ['string'],
|
||||||
|
'dlc': ['string'],
|
||||||
|
'end': [],
|
||||||
|
'main': [], // allegedly jumps to playcc2.c2g??
|
||||||
|
'wav': ['string'],
|
||||||
|
};
|
||||||
|
const OPERATORS = {
|
||||||
|
'==': {
|
||||||
|
argc: 2,
|
||||||
|
},
|
||||||
|
'<=': {
|
||||||
|
},
|
||||||
|
'>=': {
|
||||||
|
},
|
||||||
|
'!=': {
|
||||||
|
},
|
||||||
|
'<': {
|
||||||
|
},
|
||||||
|
'>': {
|
||||||
|
},
|
||||||
|
'=': {
|
||||||
|
},
|
||||||
|
'*': {
|
||||||
|
},
|
||||||
|
'/': {
|
||||||
|
},
|
||||||
|
'+': {
|
||||||
|
},
|
||||||
|
'-': {
|
||||||
|
},
|
||||||
|
'&&': {
|
||||||
|
},
|
||||||
|
'||': {
|
||||||
|
},
|
||||||
|
'&': {
|
||||||
|
},
|
||||||
|
'|': {
|
||||||
|
},
|
||||||
|
'%': {
|
||||||
|
},
|
||||||
|
'^': {
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function* tokenize(statement) {
|
||||||
|
for (let match of statement.matchAll(TOKENIZE_RX)) {
|
||||||
|
if (match[1] !== undefined) {
|
||||||
|
// Newline(s)
|
||||||
|
yield {type: 'newline'};
|
||||||
|
}
|
||||||
|
else if (match[2] !== undefined) {
|
||||||
|
// Comment, do nothing
|
||||||
|
}
|
||||||
|
else if (match[3] !== undefined) {
|
||||||
|
// String
|
||||||
|
yield {type: 'string', value: match[3]};
|
||||||
|
}
|
||||||
|
else if (match[4] !== undefined) {
|
||||||
|
// Label
|
||||||
|
yield {type: 'label', value: match[4].toLowerCase()};
|
||||||
|
}
|
||||||
|
else if (match[5] !== undefined) {
|
||||||
|
// Number
|
||||||
|
yield {type: 'number', value: parseInt(match[5], 10)};
|
||||||
|
}
|
||||||
|
else if (match[6] !== undefined) {
|
||||||
|
// Operator
|
||||||
|
yield {type: 'op', value: match[6]};
|
||||||
|
}
|
||||||
|
else if (match[7] !== undefined) {
|
||||||
|
// Bareword; either a directive or a variable name
|
||||||
|
let word = match[7].toLowerCase();
|
||||||
|
if (DIRECTIVES[word] !== undefined) {
|
||||||
|
yield {type: 'directive', value: word};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
yield {type: 'variable', value: word};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
yield {type: 'error', value: match[8]};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParseError extends Error {
|
||||||
|
constructor(message, parser) {
|
||||||
|
super(`${message} at line ${parser.lineno}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parser {
|
||||||
|
constructor(string) {
|
||||||
|
this.string = string;
|
||||||
|
this.lexer = tokenize(string);
|
||||||
|
this.lineno = 1;
|
||||||
|
this.done = false;
|
||||||
|
this._peek = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
peek() {
|
||||||
|
if (this._peek === null) {
|
||||||
|
let next = this.lexer.next();
|
||||||
|
if (! next.done) {
|
||||||
|
this._peek = next.value;
|
||||||
|
if (this._peek.type === 'error')
|
||||||
|
throw new ParseError(`Bad syntax: ${this._peek.value}`, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._peek;
|
||||||
|
}
|
||||||
|
|
||||||
|
advance() {
|
||||||
|
if (this.done)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let token;
|
||||||
|
if (this._peek !== null) {
|
||||||
|
token = this._peek;
|
||||||
|
this._peek = null;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let next = this.lexer.next();
|
||||||
|
if (next.done) {
|
||||||
|
this.done = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = next.value;
|
||||||
|
if (token.type === 'error')
|
||||||
|
throw new ParseError(`Bad syntax: ${token.value}`, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token && token.type === 'newline') {
|
||||||
|
this.lineno++;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
advance_ignore_newlines() {
|
||||||
|
if (this.done)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let token = this.advance();
|
||||||
|
while (token && token.type === 'newline') {
|
||||||
|
token = this.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_statement() {
|
||||||
|
let token = this.advance_ignore_newlines();
|
||||||
|
if (! token)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Check for a directive and handle it separately
|
||||||
|
if (token.type === 'directive') {
|
||||||
|
return this.parse_directive(token.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A string (outside of a script block) doesn't seem to do anything?
|
||||||
|
if (token.type === 'string') {
|
||||||
|
return {
|
||||||
|
kind: 'noop',
|
||||||
|
tokens: [token],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lone label is a label declaration
|
||||||
|
if (token.type === 'label') {
|
||||||
|
return {
|
||||||
|
kind: 'label',
|
||||||
|
name: token.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator is not a valid start; this uses RPN so values must come first
|
||||||
|
if (token.type === 'op')
|
||||||
|
throw new ParseError(`Unexpected operator: ${token.value}`, this);
|
||||||
|
|
||||||
|
// Otherwise (number, bareword presumed to be a variable), we have an RPN expression; keep
|
||||||
|
// consuming tokens until we finish the expression
|
||||||
|
let branches = [token];
|
||||||
|
while (true) {
|
||||||
|
let next = this.peek();
|
||||||
|
if (! next) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (next.type === 'number' || next.type === 'variable') {
|
||||||
|
let token = this.advance();
|
||||||
|
branches.push(token);
|
||||||
|
}
|
||||||
|
else if (next.type === 'op') {
|
||||||
|
let token = this.advance();
|
||||||
|
if (! token || token.type === 'newline')
|
||||||
|
break;
|
||||||
|
|
||||||
|
// All operators are binary, so pop the last two expressions
|
||||||
|
if (branches.length < 2)
|
||||||
|
throw new ParseError(`Not enough arguments for operator: ${token.value}`, this);
|
||||||
|
let a = branches.pop();
|
||||||
|
let b = branches.pop();
|
||||||
|
branches.push({
|
||||||
|
op: token.value,
|
||||||
|
left: a,
|
||||||
|
right: b,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO return now if we just did an =?
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'expression',
|
||||||
|
trees: branches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_directive(name) {
|
||||||
|
let argspec = DIRECTIVES[name];
|
||||||
|
if (argspec === 'statement') {
|
||||||
|
// TODO implement this for real
|
||||||
|
// eat the rest of the line for now
|
||||||
|
while (true) {
|
||||||
|
let token = this.advance();
|
||||||
|
if (! token || token.type === 'newline') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (argspec === 'script') {
|
||||||
|
// Script mode; expect a newline, then sequences of [string, values..., newline]
|
||||||
|
let lines = [];
|
||||||
|
let newline = this.advance();
|
||||||
|
if (newline && newline.type !== 'newline')
|
||||||
|
throw new ParseError(`Expected a newline after 'script' directive`, this);
|
||||||
|
while (true) {
|
||||||
|
let next = this.peek();
|
||||||
|
while (next && next.type === 'newline') {
|
||||||
|
this.advance();
|
||||||
|
next = this.peek();
|
||||||
|
}
|
||||||
|
if (! next)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// If this is a string, we're still in script mode; eat the whole line
|
||||||
|
if (next.type === 'string') {
|
||||||
|
let string = this.advance();
|
||||||
|
let args = [];
|
||||||
|
// TODO can args be expressions??
|
||||||
|
while (true) {
|
||||||
|
let arg = this.advance();
|
||||||
|
if (! arg || arg.type === 'newline') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (arg.type === 'number' || arg.type === 'variable') {
|
||||||
|
args.push(arg);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new ParseError(`Unexpected ${arg.type} token found in script mode: ${arg.value}`, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push({
|
||||||
|
string: string,
|
||||||
|
args: args,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If not a string, script mode is over
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'script',
|
||||||
|
lines: lines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Normal arguments
|
||||||
|
let args = [];
|
||||||
|
for (let argtype of argspec) {
|
||||||
|
let token = this.advance();
|
||||||
|
if (! token || token.type === 'newline') {
|
||||||
|
// If we're cut off early, the whole directive is ignored
|
||||||
|
return {
|
||||||
|
kind: 'noop',
|
||||||
|
directive: name,
|
||||||
|
tokens: args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (token.type === argtype) {
|
||||||
|
args.push(token);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new ParseError(`Directive ${name} expected a ${argtype} token but got ${token.type}`, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'directive',
|
||||||
|
name: name,
|
||||||
|
args: args,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C2G is a Chip's Challenge 2 format that describes the structure of a level set, which is helpful
|
||||||
|
// since CC2 levels are all stored in separate files
|
||||||
|
// XXX observations i have made about this hell format:
|
||||||
|
// - newlines are optional, except after: do, map, script, goto
|
||||||
|
// - `1 level = music "+Intro"` crashes the game
|
||||||
|
// - `map\n"path"` is completely ignored, and in fact newlines between a directive and its arguments
|
||||||
|
// in general seem to separate them
|
||||||
|
const MAX_SIMULTANEOUS_REQUESTS = 5;
|
||||||
|
/*async*/ export function parse_game(buf, source, base_path) {
|
||||||
|
// TODO maybe do something with this later
|
||||||
|
let warn = () => {};
|
||||||
|
|
||||||
|
let resolve;
|
||||||
|
let promise = new Promise((res, rej) => { resolve = res });
|
||||||
|
|
||||||
|
let game = new format_base.StoredGame(undefined, parse_level);
|
||||||
|
let parser;
|
||||||
|
let active_map_fetches = new Set;
|
||||||
|
let pending_map_fetches = [];
|
||||||
|
let _fetch_map = (path, n) => {
|
||||||
|
let promise = source.get(base_path + '/' + path);
|
||||||
|
active_map_fetches.add(promise);
|
||||||
|
|
||||||
|
let meta = {
|
||||||
|
// TODO this will not always fly, the slot is not the same as the number
|
||||||
|
index: n - 1,
|
||||||
|
number: n,
|
||||||
|
};
|
||||||
|
game.level_metadata[meta.index] = meta;
|
||||||
|
|
||||||
|
promise.then(buf => {
|
||||||
|
meta.bytes = new Uint8Array(buf);
|
||||||
|
Object.assign(meta, parse_level_metadata(buf));
|
||||||
|
})
|
||||||
|
.then(null, err => {
|
||||||
|
// TODO should have: what level, what file, position, etc attached to errors
|
||||||
|
console.error(err);
|
||||||
|
meta.error = err;
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Always remove our promise and start a new map load if any are waiting
|
||||||
|
active_map_fetches.delete(promise);
|
||||||
|
if (active_map_fetches.size < MAX_SIMULTANEOUS_REQUESTS && pending_map_fetches.length > 0) {
|
||||||
|
_fetch_map(...pending_map_fetches.shift());
|
||||||
|
}
|
||||||
|
else if (active_map_fetches.size === 0 && pending_map_fetches.length === 0 && parser.done) {
|
||||||
|
// FIXME this is a bit of a mess
|
||||||
|
resolve(game);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let fetch_map = (path, n) => {
|
||||||
|
if (active_map_fetches.size >= MAX_SIMULTANEOUS_REQUESTS) {
|
||||||
|
pending_map_fetches.push([path, n]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fetch_map(path, n);
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIXME and right off the bat we have an Issue: this is a text format so i want a string, not
|
||||||
|
// an arraybuffer!
|
||||||
|
let contents = util.string_from_buffer_ascii(buf);
|
||||||
|
parser = new Parser(contents);
|
||||||
|
let statements = [];
|
||||||
|
let level_number = 1;
|
||||||
|
while (! parser.done) {
|
||||||
|
let stmt = parser.parse_statement();
|
||||||
|
if (stmt === null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// TODO search 'do' as well
|
||||||
|
if (stmt.kind === 'directive' && stmt.name === 'map') {
|
||||||
|
let path = stmt.args[0].value;
|
||||||
|
path = path.replace(/\\/, '/');
|
||||||
|
fetch_map(path, level_number);
|
||||||
|
level_number++;
|
||||||
|
}
|
||||||
|
statements.push(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME grody
|
||||||
|
if (active_map_fetches.size === 0 && pending_map_fetches.length === 0) {
|
||||||
|
resolve(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(game);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual levels don't make sense on their own, but we can wrap them in a dummy one-level game
|
||||||
|
export function wrap_individual_level(buf) {
|
||||||
|
let game = new format_base.StoredGame(undefined, parse_level);
|
||||||
|
let meta = {
|
||||||
|
index: 0,
|
||||||
|
number: 1,
|
||||||
|
bytes: new Uint8Array(buf),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
Object.assign(meta, parse_level_metadata(buf));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
meta.error = e;
|
||||||
|
}
|
||||||
|
game.level_metadata.push(meta);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
+78
-21
@@ -1,5 +1,6 @@
|
|||||||
import * as util from './format-util.js';
|
import * as format_base from './format-base.js';
|
||||||
import TILE_TYPES from './tiletypes.js';
|
import TILE_TYPES from './tiletypes.js';
|
||||||
|
import * as util from './util.js';
|
||||||
|
|
||||||
const TILE_ENCODING = {
|
const TILE_ENCODING = {
|
||||||
0x00: 'floor',
|
0x00: 'floor',
|
||||||
@@ -119,18 +120,70 @@ const TILE_ENCODING = {
|
|||||||
0x6f: ['player', 'east'],
|
0x6f: ['player', 'east'],
|
||||||
};
|
};
|
||||||
|
|
||||||
function parse_level(buf, number) {
|
function decode_password(bytes, start, len) {
|
||||||
let level = new util.StoredLevel(number);
|
let password = [];
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
password.push(bytes[start + i] ^ 0x99);
|
||||||
|
}
|
||||||
|
return String.fromCharCode.apply(null, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse_level_metadata(bytes) {
|
||||||
|
let meta = {};
|
||||||
|
|
||||||
|
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
|
||||||
|
// Level number; rest of level header is unused
|
||||||
|
meta.number = view.getUint16(0, true);
|
||||||
|
|
||||||
|
// Map layout
|
||||||
|
// Same structure twice, for the two layers
|
||||||
|
let p = 8;
|
||||||
|
for (let l = 0; l < 2; l++) {
|
||||||
|
let layer_length = view.getUint16(p, true);
|
||||||
|
p += 2 + layer_length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional metadata fields
|
||||||
|
let meta_length = view.getUint16(p, true);
|
||||||
|
p += 2;
|
||||||
|
let end = p + meta_length;
|
||||||
|
while (p < end) {
|
||||||
|
// Common header
|
||||||
|
let field_type = view.getUint8(p, true);
|
||||||
|
let field_length = view.getUint8(p + 1, true);
|
||||||
|
p += 2;
|
||||||
|
if (field_type === 0x03) {
|
||||||
|
// Title, including trailing NUL
|
||||||
|
meta.title = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||||
|
}
|
||||||
|
else if (field_type === 0x06) {
|
||||||
|
// Password, with trailing NUL, and XORed with 0x99 (???)
|
||||||
|
meta.password = decode_password(bytes, p, field_length - 1);
|
||||||
|
}
|
||||||
|
else if (field_type === 0x07) {
|
||||||
|
// Hint, including trailing NUL, of course
|
||||||
|
meta.hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||||
|
}
|
||||||
|
p += field_length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse_level(bytes, number) {
|
||||||
|
let level = new format_base.StoredLevel(number);
|
||||||
|
level.has_custom_connections = true;
|
||||||
|
level.use_ccl_compat = true;
|
||||||
// Map size is always fixed as 32x32 in CC1
|
// Map size is always fixed as 32x32 in CC1
|
||||||
level.size_x = 32;
|
level.size_x = 32;
|
||||||
level.size_y = 32;
|
level.size_y = 32;
|
||||||
for (let i = 0; i < 1024; i++) {
|
for (let i = 0; i < 1024; i++) {
|
||||||
level.linear_cells.push(new util.StoredCell);
|
level.linear_cells.push(new format_base.StoredCell);
|
||||||
}
|
}
|
||||||
level.use_cc1_boots = true;
|
level.use_cc1_boots = true;
|
||||||
|
|
||||||
let view = new DataView(buf);
|
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
let bytes = new Uint8Array(buf);
|
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
let level_number = view.getUint16(0, true);
|
let level_number = view.getUint16(0, true);
|
||||||
@@ -161,7 +214,7 @@ function parse_level(buf, number) {
|
|||||||
// TODO could be more forgiving for goofy levels doing goofy things
|
// TODO could be more forgiving for goofy levels doing goofy things
|
||||||
if (! spec) {
|
if (! spec) {
|
||||||
let [x, y] = level.scalar_to_coords(c);
|
let [x, y] = level.scalar_to_coords(c);
|
||||||
throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y}) in level ${number}`);
|
throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let name, direction;
|
let name, direction;
|
||||||
@@ -225,11 +278,11 @@ function parse_level(buf, number) {
|
|||||||
}
|
}
|
||||||
else if (field_type === 0x03) {
|
else if (field_type === 0x03) {
|
||||||
// Title, including trailing NUL
|
// Title, including trailing NUL
|
||||||
level.title = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1));
|
level.title = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||||
}
|
}
|
||||||
else if (field_type === 0x04) {
|
else if (field_type === 0x04) {
|
||||||
// Trap linkages (MSCC only, not in Lynx or CC2)
|
// Trap linkages (MSCC only, not in Lynx or CC2)
|
||||||
let field_view = new DataView(buf.slice(p, p + field_length));
|
let field_view = new DataView(bytes.buffer, bytes.byteOffset + p, field_length);
|
||||||
let q = 0;
|
let q = 0;
|
||||||
while (q < field_length) {
|
while (q < field_length) {
|
||||||
let button_x = field_view.getUint16(q + 0, true);
|
let button_x = field_view.getUint16(q + 0, true);
|
||||||
@@ -243,7 +296,7 @@ function parse_level(buf, number) {
|
|||||||
}
|
}
|
||||||
else if (field_type === 0x05) {
|
else if (field_type === 0x05) {
|
||||||
// Cloner linkages (MSCC only, not in Lynx or CC2)
|
// Cloner linkages (MSCC only, not in Lynx or CC2)
|
||||||
let field_view = new DataView(buf.slice(p, p + field_length));
|
let field_view = new DataView(bytes.buffer, bytes.byteOffset + p, field_length);
|
||||||
let q = 0;
|
let q = 0;
|
||||||
while (q < field_length) {
|
while (q < field_length) {
|
||||||
let button_x = field_view.getUint16(q + 0, true);
|
let button_x = field_view.getUint16(q + 0, true);
|
||||||
@@ -255,16 +308,12 @@ function parse_level(buf, number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (field_type === 0x06) {
|
else if (field_type === 0x06) {
|
||||||
// Password, with trailing NUL, and otherwise XORed with 0x99 (?!)
|
// Password, with trailing NUL, and otherwise XORed with 0x99 (???)
|
||||||
let password = [];
|
level.password = decode_password(bytes, p, field_length - 1);
|
||||||
for (let i = 0; i < field_length - 1; i++) {
|
|
||||||
password.push(view.getUint8(p + i, true) ^ 0x99);
|
|
||||||
}
|
|
||||||
level.password = String.fromCharCode.apply(null, password);
|
|
||||||
}
|
}
|
||||||
else if (field_type === 0x07) {
|
else if (field_type === 0x07) {
|
||||||
// Hint, including trailing NUL, of course
|
// Hint, including trailing NUL, of course
|
||||||
level.hint = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1));
|
level.hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||||
}
|
}
|
||||||
else if (field_type === 0x08) {
|
else if (field_type === 0x08) {
|
||||||
// Password, but not encoded
|
// Password, but not encoded
|
||||||
@@ -281,7 +330,7 @@ function parse_level(buf, number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parse_game(buf) {
|
export function parse_game(buf) {
|
||||||
let game = new util.StoredGame;
|
let game = new format_base.StoredGame(null, parse_level);
|
||||||
|
|
||||||
let full_view = new DataView(buf);
|
let full_view = new DataView(buf);
|
||||||
let magic = full_view.getUint32(0, true);
|
let magic = full_view.getUint32(0, true);
|
||||||
@@ -303,11 +352,19 @@ export function parse_game(buf) {
|
|||||||
let p = 6;
|
let p = 6;
|
||||||
for (let l = 1; l <= level_count; l++) {
|
for (let l = 1; l <= level_count; l++) {
|
||||||
let length = full_view.getUint16(p, true);
|
let length = full_view.getUint16(p, true);
|
||||||
let level_buf = buf.slice(p + 2, p + 2 + length);
|
let bytes = new Uint8Array(buf, p + 2, length);
|
||||||
p += 2 + length;
|
p += 2 + length;
|
||||||
|
|
||||||
let level = parse_level(level_buf, l);
|
let meta;
|
||||||
game.levels.push(level);
|
try {
|
||||||
|
meta = parse_level_metadata(bytes);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
meta = {error: e};
|
||||||
|
}
|
||||||
|
meta.index = l - 1;
|
||||||
|
meta.bytes = bytes;
|
||||||
|
game.level_metadata.push(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
return game;
|
return game;
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
export function string_from_buffer_ascii(buf) {
|
|
||||||
return String.fromCharCode.apply(null, new Uint8Array(buf));
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StoredCell extends Array {
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StoredLevel {
|
|
||||||
constructor(number) {
|
|
||||||
this.number = number; // one-based
|
|
||||||
this.title = '';
|
|
||||||
this.password = null;
|
|
||||||
this.hint = '';
|
|
||||||
this.chips_required = 0;
|
|
||||||
this.time_limit = 0;
|
|
||||||
this.viewport_size = 9;
|
|
||||||
this.extra_chunks = [];
|
|
||||||
this.use_cc1_boots = false;
|
|
||||||
|
|
||||||
this.size_x = 0;
|
|
||||||
this.size_y = 0;
|
|
||||||
this.linear_cells = [];
|
|
||||||
|
|
||||||
// Maps of button positions to trap/cloner positions, as scalar indexes
|
|
||||||
// in the linear cell list
|
|
||||||
this.custom_trap_wiring = {};
|
|
||||||
this.custom_cloner_wiring = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
scalar_to_coords(n) {
|
|
||||||
return [n % this.size_x, Math.floor(n / this.size_x)];
|
|
||||||
}
|
|
||||||
|
|
||||||
coords_to_scalar(x, y) {
|
|
||||||
return x + y * this.size_x;
|
|
||||||
}
|
|
||||||
|
|
||||||
check() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StoredGame {
|
|
||||||
constructor(identifier) {
|
|
||||||
this.identifier = identifier;
|
|
||||||
this.levels = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+246
-91
@@ -125,6 +125,17 @@ export class Cell extends Array {
|
|||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get_wired_tile() {
|
||||||
|
let ret = null;
|
||||||
|
for (let tile of this) {
|
||||||
|
if (tile.wire_directions || tile.wire_tunnel_directions) {
|
||||||
|
ret = tile;
|
||||||
|
// Don't break; we want the topmost tile!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
blocks_leaving(actor, direction) {
|
blocks_leaving(actor, direction) {
|
||||||
for (let tile of this) {
|
for (let tile of this) {
|
||||||
if (tile !== actor &&
|
if (tile !== actor &&
|
||||||
@@ -148,6 +159,8 @@ export class Cell extends Array {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Cell.prototype.was_powered = false;
|
||||||
|
Cell.prototype.is_powered = false;
|
||||||
|
|
||||||
class GameEnded extends Error {}
|
class GameEnded extends Error {}
|
||||||
|
|
||||||
@@ -162,7 +175,7 @@ export class Level {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restart(compat) {
|
restart(compat) {
|
||||||
this.compat = {};
|
this.compat = compat;
|
||||||
|
|
||||||
// playing: normal play
|
// playing: normal play
|
||||||
// success: has been won
|
// success: has been won
|
||||||
@@ -205,6 +218,9 @@ export class Level {
|
|||||||
|
|
||||||
let n = 0;
|
let n = 0;
|
||||||
let connectables = [];
|
let connectables = [];
|
||||||
|
// Handle CC2 wiring; a contiguous region of wire is all updated as a single unit, so detect
|
||||||
|
// those units ahead of time for simplicity and call them "clusters"
|
||||||
|
this.wire_clusters = [];
|
||||||
// FIXME handle traps correctly:
|
// FIXME handle traps correctly:
|
||||||
// - if an actor is in the cell, set the trap to open and unstick everything in it
|
// - if an actor is in the cell, set the trap to open and unstick everything in it
|
||||||
for (let y = 0; y < this.height; y++) {
|
for (let y = 0; y < this.height; y++) {
|
||||||
@@ -237,25 +253,19 @@ export class Level {
|
|||||||
|
|
||||||
if (tile.type.is_player) {
|
if (tile.type.is_player) {
|
||||||
// TODO handle multiple players, also chip and melinda both
|
// TODO handle multiple players, also chip and melinda both
|
||||||
// TODO complain if no chip
|
// TODO complain if no player
|
||||||
this.player = tile;
|
this.player = tile;
|
||||||
// Always put the player at the start of the actor list
|
|
||||||
// (accomplished traditionally with a swap)
|
|
||||||
this.actors.push(this.actors[0]);
|
|
||||||
this.actors[0] = tile;
|
|
||||||
}
|
}
|
||||||
else if (tile.type.is_actor) {
|
if (tile.type.is_actor) {
|
||||||
if (has_cloner) {
|
if (has_cloner) {
|
||||||
|
// TODO is there any reason not to add clone templates to the actor
|
||||||
|
// list?
|
||||||
tile.stuck = true;
|
tile.stuck = true;
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
if (has_trap) {
|
|
||||||
// FIXME wait, not if the trap is open! crap
|
|
||||||
tile.stuck = true;
|
|
||||||
}
|
}
|
||||||
|
if (! tile.stuck) {
|
||||||
this.actors.push(tile);
|
this.actors.push(tile);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
cell._add(tile);
|
cell._add(tile);
|
||||||
|
|
||||||
if (tile.type.connects_to) {
|
if (tile.type.connects_to) {
|
||||||
@@ -272,10 +282,10 @@ export class Level {
|
|||||||
let x = cell.x;
|
let x = cell.x;
|
||||||
let y = cell.y;
|
let y = cell.y;
|
||||||
let goal = connectable.type.connects_to;
|
let goal = connectable.type.connects_to;
|
||||||
let found = false;
|
|
||||||
|
|
||||||
// Check for custom wiring, for MSCC .DAT levels
|
// Check for custom wiring, for MSCC .DAT levels
|
||||||
let n = x + y * this.width;
|
if (this.stored_level.has_custom_connections) {
|
||||||
|
let n = this.stored_level.coords_to_scalar(x, y);
|
||||||
let target_cell_n = null;
|
let target_cell_n = null;
|
||||||
if (goal === 'trap') {
|
if (goal === 'trap') {
|
||||||
target_cell_n = this.stored_level.custom_trap_wiring[n] ?? null;
|
target_cell_n = this.stored_level.custom_trap_wiring[n] ?? null;
|
||||||
@@ -283,49 +293,37 @@ export class Level {
|
|||||||
else if (goal === 'cloner') {
|
else if (goal === 'cloner') {
|
||||||
target_cell_n = this.stored_level.custom_cloner_wiring[n] ?? null;
|
target_cell_n = this.stored_level.custom_cloner_wiring[n] ?? null;
|
||||||
}
|
}
|
||||||
if (target_cell_n) {
|
if (target_cell_n && target_cell_n < this.width * this.height) {
|
||||||
// TODO this N could be outside the map bounds
|
let [tx, ty] = this.stored_level.scalar_to_coords(target_cell_n);
|
||||||
let target_cell_x = target_cell_n % this.width;
|
for (let tile of this.cells[ty][tx]) {
|
||||||
let target_cell_y = Math.floor(target_cell_n / this.width);
|
|
||||||
for (let tile of this.cells[target_cell_y][target_cell_x]) {
|
|
||||||
if (tile.type.name === goal) {
|
if (tile.type.name === goal) {
|
||||||
connectable.connection = tile;
|
connectable.connection = tile;
|
||||||
found = true;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (found)
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, look in reading order
|
// Otherwise, look in reading order
|
||||||
let direction = 1;
|
for (let tile of this.iter_tiles_in_reading_order(cell, goal)) {
|
||||||
if (connectable.type.connect_order === 'backward') {
|
// TODO ideally this should be a weak connection somehow, since dynamite can destroy
|
||||||
direction = -1;
|
// empty cloners and probably traps too
|
||||||
|
connectable.connection = tile;
|
||||||
|
// Just grab the first
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
for (let i = 0; i < num_cells - 1; i++) {
|
|
||||||
x += direction;
|
|
||||||
if (x >= this.width) {
|
|
||||||
x -= this.width;
|
|
||||||
y = (y + 1) % this.height;
|
|
||||||
}
|
|
||||||
else if (x < 0) {
|
|
||||||
x += this.width;
|
|
||||||
y = (y - 1 + this.height) % this.height;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let tile of this.cells[y][x]) {
|
// Finally, let all tiles do any custom init behavior
|
||||||
if (tile.type.name === goal) {
|
for (let row of this.cells) {
|
||||||
// TODO should be weak, but you can't destroy cloners so in practice not a concern
|
for (let cell of row) {
|
||||||
connectable.connection = tile;
|
for (let tile of cell) {
|
||||||
found = true;
|
if (tile.type.on_ready) {
|
||||||
break;
|
tile.type.on_ready(tile, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (found)
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
// TODO soft warn for e.g. a button with no cloner? (or a cloner with no button?)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +369,13 @@ export class Level {
|
|||||||
// immediately), we still want every time's animation to finish, or it'll look like some
|
// immediately), we still want every time's animation to finish, or it'll look like some
|
||||||
// objects move backwards when the death screen appears!
|
// objects move backwards when the death screen appears!
|
||||||
let cell_steppers = [];
|
let cell_steppers = [];
|
||||||
for (let actor of this.actors) {
|
// Note that we iterate in reverse order, DESPITE keeping dead actors around with null
|
||||||
|
// cells, to match the Lynx and CC2 behavior. This is actually important in some cases;
|
||||||
|
// check out the start of CCLP3 #54, where the gliders will eat the blue key immediately if
|
||||||
|
// they act in forward order! (More subtly, even the earlier passes do things like advance
|
||||||
|
// the RNG, so for replay compatibility they need to be in reverse order too.)
|
||||||
|
for (let i = this.actors.length - 1; i >= 0; i--) {
|
||||||
|
let actor = this.actors[i];
|
||||||
// Actors with no cell were destroyed
|
// Actors with no cell were destroyed
|
||||||
if (! actor.cell)
|
if (! actor.cell)
|
||||||
continue;
|
continue;
|
||||||
@@ -412,23 +416,23 @@ export class Level {
|
|||||||
this.step_on_cell(actor, cell);
|
this.step_on_cell(actor, cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now we handle wiring
|
||||||
|
this.update_wiring();
|
||||||
|
|
||||||
// Only reset the player's is_pushing between movement, so it lasts for the whole push
|
// Only reset the player's is_pushing between movement, so it lasts for the whole push
|
||||||
if (this.player.movement_cooldown <= 0) {
|
if (this.player.movement_cooldown <= 0) {
|
||||||
this.player.is_pushing = false;
|
this.player.is_pushing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: actors decide their upcoming movement simultaneously
|
// Second pass: actors decide their upcoming movement simultaneously
|
||||||
for (let actor of this.actors) {
|
for (let i = this.actors.length - 1; i >= 0; i--) {
|
||||||
|
let actor = this.actors[i];
|
||||||
if (! actor.cell)
|
if (! actor.cell)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (actor.movement_cooldown > 0)
|
if (actor.movement_cooldown > 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// XXX does the cooldown drop while in a trap? is this even right?
|
|
||||||
if (actor.stuck && ! actor.type.is_player)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Teeth can only move the first 4 of every 8 tics, though "first"
|
// Teeth can only move the first 4 of every 8 tics, though "first"
|
||||||
// can be adjusted
|
// can be adjusted
|
||||||
if (actor.slide_mode == null &&
|
if (actor.slide_mode == null &&
|
||||||
@@ -439,10 +443,15 @@ export class Level {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let direction_preference;
|
let direction_preference;
|
||||||
// Actors can't make voluntary moves on ice, so they're stuck with
|
if (this.compat.sliding_tanks_ignore_button &&
|
||||||
// whatever they've got
|
actor.slide_mode && actor.pending_reverse)
|
||||||
|
{
|
||||||
|
this._set_prop(actor, 'pending_reverse', false);
|
||||||
|
}
|
||||||
if (actor.slide_mode === 'ice') {
|
if (actor.slide_mode === 'ice') {
|
||||||
direction_preference = [actor.direction];
|
// Actors can't make voluntary moves on ice; they just slide
|
||||||
|
actor.decision = actor.direction;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else if (actor.slide_mode === 'force') {
|
else if (actor.slide_mode === 'force') {
|
||||||
// Only the player can make voluntary moves on a force floor,
|
// Only the player can make voluntary moves on a force floor,
|
||||||
@@ -455,25 +464,38 @@ export class Level {
|
|||||||
p1_primary_direction &&
|
p1_primary_direction &&
|
||||||
actor.last_move_was_force)
|
actor.last_move_was_force)
|
||||||
{
|
{
|
||||||
direction_preference = [p1_primary_direction];
|
actor.decision = p1_primary_direction;
|
||||||
this._set_prop(actor, 'last_move_was_force', false);
|
this._set_prop(actor, 'last_move_was_force', false);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
direction_preference = [actor.direction];
|
actor.decision = actor.direction;
|
||||||
if (actor === this.player) {
|
if (actor === this.player) {
|
||||||
this._set_prop(actor, 'last_move_was_force', true);
|
this._set_prop(actor, 'last_move_was_force', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else if (actor === this.player) {
|
else if (actor === this.player) {
|
||||||
if (p1_primary_direction) {
|
if (p1_primary_direction) {
|
||||||
direction_preference = [p1_primary_direction];
|
actor.decision = p1_primary_direction;
|
||||||
this._set_prop(actor, 'last_move_was_force', false);
|
this._set_prop(actor, 'last_move_was_force', false);
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else if (actor.type.movement_mode === 'forward') {
|
else if (actor.type.movement_mode === 'forward') {
|
||||||
// blue tank behavior: keep moving forward
|
// blue tank behavior: keep moving forward, reverse if the flag is set
|
||||||
direction_preference = [actor.direction];
|
let direction = actor.direction;
|
||||||
|
if (actor.pending_reverse) {
|
||||||
|
direction = DIRECTIONS[actor.direction].opposite;
|
||||||
|
this._set_prop(actor, 'pending_reverse', false);
|
||||||
|
}
|
||||||
|
// Tanks are controlled explicitly so they don't check if they're blocked
|
||||||
|
// TODO tanks in traps turn around, but tanks on cloners do not, and i use the same
|
||||||
|
// prop for both
|
||||||
|
if (! actor.cell.some(tile => tile.type.name === 'cloner')) {
|
||||||
|
actor.decision = direction;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else if (actor.type.movement_mode === 'follow-left') {
|
else if (actor.type.movement_mode === 'follow-left') {
|
||||||
// bug behavior: always try turning as left as possible, and
|
// bug behavior: always try turning as left as possible, and
|
||||||
@@ -555,15 +577,9 @@ export class Level {
|
|||||||
|
|
||||||
// Check which of those directions we *can*, probably, move in
|
// Check which of those directions we *can*, probably, move in
|
||||||
// TODO i think player on force floor will still have some issues here
|
// TODO i think player on force floor will still have some issues here
|
||||||
if (direction_preference) {
|
if (direction_preference && ! actor.stuck) {
|
||||||
// Players and sliding actors always move the way they want, even if blocked
|
|
||||||
if (actor.type.is_player || actor.slide_mode) {
|
|
||||||
actor.decision = direction_preference[0];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let direction of direction_preference) {
|
for (let direction of direction_preference) {
|
||||||
let dest_cell = this.cell_with_offset(actor.cell, direction);
|
let dest_cell = this.get_neighboring_cell(actor.cell, direction);
|
||||||
if (! dest_cell)
|
if (! dest_cell)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -579,7 +595,8 @@ export class Level {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Third pass: everyone actually moves
|
// Third pass: everyone actually moves
|
||||||
for (let actor of this.actors) {
|
for (let i = this.actors.length - 1; i >= 0; i--) {
|
||||||
|
let actor = this.actors[i];
|
||||||
if (! actor.cell)
|
if (! actor.cell)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -605,7 +622,7 @@ export class Level {
|
|||||||
if (actor.type.is_player && dir2 &&
|
if (actor.type.is_player && dir2 &&
|
||||||
! old_cell.blocks_leaving(actor, dir2))
|
! old_cell.blocks_leaving(actor, dir2))
|
||||||
{
|
{
|
||||||
let neighbor = this.cell_with_offset(old_cell, dir2);
|
let neighbor = this.get_neighboring_cell(old_cell, dir2);
|
||||||
if (neighbor) {
|
if (neighbor) {
|
||||||
let could_push = ! neighbor.blocks_entering(actor, dir2, this, true);
|
let could_push = ! neighbor.blocks_entering(actor, dir2, this, true);
|
||||||
for (let tile of Array.from(neighbor)) {
|
for (let tile of Array.from(neighbor)) {
|
||||||
@@ -682,7 +699,7 @@ export class Level {
|
|||||||
|
|
||||||
let move = DIRECTIONS[direction].movement;
|
let move = DIRECTIONS[direction].movement;
|
||||||
if (!actor.cell) console.error(actor);
|
if (!actor.cell) console.error(actor);
|
||||||
let goal_cell = this.cell_with_offset(actor.cell, direction);
|
let goal_cell = this.get_neighboring_cell(actor.cell, direction);
|
||||||
|
|
||||||
// TODO this could be a lot simpler if i could early-return! should ice bumping be
|
// TODO this could be a lot simpler if i could early-return! should ice bumping be
|
||||||
// somewhere else?
|
// somewhere else?
|
||||||
@@ -795,10 +812,9 @@ export class Level {
|
|||||||
if (actor.cell === goal_cell)
|
if (actor.cell === goal_cell)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// TODO undo this stuff?
|
this._set_prop(actor, 'previous_cell', actor.cell);
|
||||||
actor.previous_cell = actor.cell;
|
this._set_prop(actor, 'animation_speed', speed);
|
||||||
actor.animation_speed = speed;
|
this._set_prop(actor, 'animation_progress', 0);
|
||||||
actor.animation_progress = 0;
|
|
||||||
|
|
||||||
let original_cell = actor.cell;
|
let original_cell = actor.cell;
|
||||||
this.remove_tile(actor);
|
this.remove_tile(actor);
|
||||||
@@ -877,7 +893,7 @@ export class Level {
|
|||||||
}
|
}
|
||||||
this.remove_tile(tile);
|
this.remove_tile(tile);
|
||||||
}
|
}
|
||||||
else if (tile.type.is_teleporter) {
|
else if (tile.type.teleport_dest_order) {
|
||||||
teleporter = tile;
|
teleporter = tile;
|
||||||
}
|
}
|
||||||
else if (tile.type.on_arrive) {
|
else if (tile.type.on_arrive) {
|
||||||
@@ -888,42 +904,147 @@ export class Level {
|
|||||||
// Handle teleporting, now that the dust has cleared
|
// Handle teleporting, now that the dust has cleared
|
||||||
// FIXME something funny happening here, your input isn't ignore while walking out of it?
|
// FIXME something funny happening here, your input isn't ignore while walking out of it?
|
||||||
if (teleporter) {
|
if (teleporter) {
|
||||||
let goal = teleporter;
|
for (let dest of teleporter.type.teleport_dest_order(teleporter, this)) {
|
||||||
// TODO in pathological cases this might infinite loop
|
|
||||||
while (true) {
|
|
||||||
goal = goal.connection;
|
|
||||||
|
|
||||||
// Teleporters already containing an actor are blocked and unusable
|
// Teleporters already containing an actor are blocked and unusable
|
||||||
if (goal.cell.some(tile => tile.type.is_actor && tile !== actor))
|
if (dest.cell.some(tile => tile.type.is_actor && tile !== actor))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Physically move the actor to the new teleporter
|
// Physically move the actor to the new teleporter
|
||||||
// XXX is this right, compare with tile world? i overhear it's actually implemented as a slide?
|
// XXX is this right, compare with tile world? i overhear it's actually implemented as a slide?
|
||||||
// XXX not especially undo-efficient
|
// XXX not especially undo-efficient
|
||||||
this.remove_tile(actor);
|
this.remove_tile(actor);
|
||||||
this.add_tile(actor, goal.cell);
|
this.add_tile(actor, dest.cell);
|
||||||
if (this.attempt_step(actor, actor.direction)) {
|
if (this.attempt_step(actor, actor.direction)) {
|
||||||
// Success, teleportation complete
|
// Success, teleportation complete
|
||||||
// Sound plays from the origin cell simply because that's where the sfx player
|
// Sound plays from the origin cell simply because that's where the sfx player
|
||||||
// thinks the player is currently
|
// thinks the player is currently; position isn't updated til next turn
|
||||||
this.sfx.play_once('teleport', cell);
|
this.sfx.play_once('teleport', dest.cell);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (goal === teleporter)
|
|
||||||
// We've tried every teleporter, including the one they
|
|
||||||
// stepped on, so leave them on it
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Otherwise, try the next one
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cell_with_offset(cell, direction) {
|
// Update the state of all wired tiles in the game.
|
||||||
|
// XXX need to be clear on the order of events here. say everything starts out unpowered.
|
||||||
|
// then:
|
||||||
|
// 1. you step on a pink button, which flags itself as going to be powered next frame
|
||||||
|
// 2. this pass happens. every unpowered-but-wired cell is inspected. if a powered one is
|
||||||
|
// found, floodfill from there
|
||||||
|
// FIXME can probably skip this if we know there are no wires at all, like in a CCL, or just an
|
||||||
|
// unwired map
|
||||||
|
// FIXME this feels inefficient. most of the time none of the inputs have changed so none of
|
||||||
|
// this needs to happen at all
|
||||||
|
// FIXME none of this is currently undoable
|
||||||
|
update_wiring() {
|
||||||
|
// Turn off power to every cell
|
||||||
|
// TODO wonder if i need a linear cell list, or even a flat list of all tiles (that sounds
|
||||||
|
// like hell to keep updated though)
|
||||||
|
for (let row of this.cells) {
|
||||||
|
for (let cell of row) {
|
||||||
|
cell.was_powered = cell.is_powered;
|
||||||
|
cell.is_powered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through the grid looking for emitters — tiles that are generating current — and
|
||||||
|
// propagated it via flood-fill through neighboring wires
|
||||||
|
for (let row of this.cells) {
|
||||||
|
for (let cell of row) {
|
||||||
|
// TODO probably this should set a prop on the tile
|
||||||
|
if (! cell.some(tile => tile.type.is_emitting && tile.type.is_emitting(tile, this)))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// We have an emitter! Flood-fill outwards
|
||||||
|
let neighbors = [cell];
|
||||||
|
for (let neighbor of neighbors) {
|
||||||
|
// Power it even if it's not wired itself, so that e.g. purple tiles work
|
||||||
|
neighbor.is_powered = true;
|
||||||
|
|
||||||
|
let wire = neighbor.get_wired_tile();
|
||||||
|
if (! wire)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Emit along every wire direction, and add any unpowered neighbors to the
|
||||||
|
// pending list to continue the floodfill
|
||||||
|
// TODO but only if wires connect
|
||||||
|
// TODO handle wire tunnels
|
||||||
|
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
|
||||||
|
if (! (wire.wire_directions & dirinfo.bit))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
let neighbor2, wire2;
|
||||||
|
let opposite_bit = DIRECTIONS[dirinfo.opposite].bit;
|
||||||
|
if (wire.wire_tunnel_directions & dirinfo.bit) {
|
||||||
|
// Search in the given direction until we find a matching tunnel
|
||||||
|
// FIXME these act like nested parens!
|
||||||
|
let x = neighbor.x;
|
||||||
|
let y = neighbor.y;
|
||||||
|
let nesting = 0;
|
||||||
|
while (true) {
|
||||||
|
x += dirinfo.movement[0];
|
||||||
|
y += dirinfo.movement[1];
|
||||||
|
if (! this.is_point_within_bounds(x, y))
|
||||||
|
break;
|
||||||
|
|
||||||
|
let candidate = this.cells[y][x];
|
||||||
|
wire2 = candidate.get_wired_tile();
|
||||||
|
if (wire2 && (wire2.wire_tunnel_directions ?? 0) & opposite_bit) {
|
||||||
|
neighbor2 = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Otherwise this is easy
|
||||||
|
neighbor2 = this.get_neighboring_cell(neighbor, direction);
|
||||||
|
wire2 = neighbor2.get_wired_tile();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (neighbor2 && ! neighbor2.is_powered &&
|
||||||
|
// Unwired tiles are OK; they might be something activated by power.
|
||||||
|
// Wired tiles that do NOT connect to us are ignored.
|
||||||
|
(! wire2 || wire2.wire_directions & opposite_bit))
|
||||||
|
{
|
||||||
|
neighbors.push(neighbor2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inform any affected cells of power changes
|
||||||
|
for (let row of this.cells) {
|
||||||
|
for (let cell of row) {
|
||||||
|
if (cell.was_powered !== cell.is_powered) {
|
||||||
|
let method = cell.is_powered ? 'on_power' : 'on_depower';
|
||||||
|
for (let tile of cell) {
|
||||||
|
if (tile.type[method]) {
|
||||||
|
tile.type[method](tile, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performs a depth-first search for connected wires and wire objects, extending out from the
|
||||||
|
// given starting cell
|
||||||
|
*follow_circuit(cell) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Board inspection
|
||||||
|
|
||||||
|
is_point_within_bounds(x, y) {
|
||||||
|
return (x >= 0 && x < this.width && y >= 0 && y < this.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
get_neighboring_cell(cell, direction) {
|
||||||
let move = DIRECTIONS[direction].movement;
|
let move = DIRECTIONS[direction].movement;
|
||||||
let goal_x = cell.x + move[0];
|
let goal_x = cell.x + move[0];
|
||||||
let goal_y = cell.y + move[1];
|
let goal_y = cell.y + move[1];
|
||||||
if (goal_x >= 0 && goal_x < this.width && goal_y >= 0 && goal_y < this.height) {
|
if (this.is_point_within_bounds(goal_x, goal_y)) {
|
||||||
return this.cells[goal_y][goal_x];
|
return this.cells[goal_y][goal_x];
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -931,6 +1052,39 @@ export class Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Iterates over the grid in (reverse?) reading order and yields all tiles with the given name.
|
||||||
|
// The starting cell is iterated last.
|
||||||
|
*iter_tiles_in_reading_order(start_cell, name, reverse = false) {
|
||||||
|
let x = start_cell.x;
|
||||||
|
let y = start_cell.y;
|
||||||
|
while (true) {
|
||||||
|
if (reverse) {
|
||||||
|
x -= 1;
|
||||||
|
if (x < 0) {
|
||||||
|
x = this.width - 1;
|
||||||
|
y = (y - 1 + this.height) % this.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
x += 1;
|
||||||
|
if (x >= this.width) {
|
||||||
|
x = 0;
|
||||||
|
y = (y + 1) % this.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cell = this.cells[y][x];
|
||||||
|
for (let tile of cell) {
|
||||||
|
if (tile.type.name === name) {
|
||||||
|
yield tile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell === start_cell)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Undo handling
|
// Undo handling
|
||||||
|
|
||||||
@@ -1078,8 +1232,8 @@ export class Level {
|
|||||||
spawn_animation(cell, name) {
|
spawn_animation(cell, name) {
|
||||||
let type = TILE_TYPES[name];
|
let type = TILE_TYPES[name];
|
||||||
let tile = new Tile(type);
|
let tile = new Tile(type);
|
||||||
tile.animation_speed = type.ttl;
|
this._set_prop(tile, 'animation_speed', tile.type.ttl);
|
||||||
tile.animation_progress = 0;
|
this._set_prop(tile, 'animation_progress', 0);
|
||||||
cell._add(tile);
|
cell._add(tile);
|
||||||
this.actors.push(tile);
|
this.actors.push(tile);
|
||||||
this.pending_undo.push(() => {
|
this.pending_undo.push(() => {
|
||||||
@@ -1103,6 +1257,7 @@ export class Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Give an item to an actor, even if it's not supposed to have an inventory
|
||||||
give_actor(actor, name) {
|
give_actor(actor, name) {
|
||||||
if (! actor.type.is_actor)
|
if (! actor.type.is_actor)
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
import { mk, mk_svg, walk_grid } from './util.js';
|
||||||
|
|
||||||
|
// Superclass for the main display modes: the player, the editor, and the splash screen
|
||||||
|
export class PrimaryView {
|
||||||
|
constructor(conductor, root) {
|
||||||
|
this.conductor = conductor;
|
||||||
|
this.root = root;
|
||||||
|
this.active = false;
|
||||||
|
this._done_setup = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setup() {}
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
this.root.removeAttribute('hidden');
|
||||||
|
this.active = true;
|
||||||
|
if (! this._done_setup) {
|
||||||
|
this.setup();
|
||||||
|
this._done_setup = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
this.root.setAttribute('hidden', '');
|
||||||
|
this.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stackable modal overlay of some kind, usually a dialog
|
||||||
|
export class Overlay {
|
||||||
|
constructor(conductor, root) {
|
||||||
|
this.conductor = conductor;
|
||||||
|
this.root = root;
|
||||||
|
|
||||||
|
// Don't propagate clicks on the root element, so they won't trigger a
|
||||||
|
// parent overlay's automatic dismissal
|
||||||
|
this.root.addEventListener('click', ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
// FIXME ah, but keystrokes can still go to the game, including
|
||||||
|
// spacebar to begin it if it was waiting. how do i completely disable
|
||||||
|
// an entire chunk of the page?
|
||||||
|
if (this.conductor.player.state === 'playing') {
|
||||||
|
this.conductor.player.set_state('paused');
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlay = mk('div.overlay', this.root);
|
||||||
|
document.body.append(overlay);
|
||||||
|
|
||||||
|
// Remove the overlay when clicking outside the element
|
||||||
|
overlay.addEventListener('click', ev => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.root.closest('.overlay').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay styled like a dialog box
|
||||||
|
export class DialogOverlay extends Overlay {
|
||||||
|
constructor(conductor) {
|
||||||
|
super(conductor, mk('div.dialog'));
|
||||||
|
|
||||||
|
this.root.append(
|
||||||
|
this.header = mk('header'),
|
||||||
|
this.main = mk('section'),
|
||||||
|
this.footer = mk('footer'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_title(title) {
|
||||||
|
this.header.textContent = '';
|
||||||
|
this.header.append(mk('h1', {}, title));
|
||||||
|
}
|
||||||
|
|
||||||
|
add_button(label, onclick) {
|
||||||
|
let button = mk('button', {type: 'button'}, label);
|
||||||
|
button.addEventListener('click', onclick);
|
||||||
|
this.footer.append(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yes/no popup dialog
|
||||||
|
export class ConfirmOverlay extends DialogOverlay {
|
||||||
|
constructor(conductor, message, what) {
|
||||||
|
super(conductor);
|
||||||
|
this.set_title("just checking");
|
||||||
|
this.main.append(mk('p', {}, message));
|
||||||
|
let yes = mk('button', {type: 'button'}, "yep");
|
||||||
|
let no = mk('button', {type: 'button'}, "nope");
|
||||||
|
yes.addEventListener('click', ev => {
|
||||||
|
this.close();
|
||||||
|
what();
|
||||||
|
});
|
||||||
|
no.addEventListener('click', ev => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
this.footer.append(yes, no);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,796 @@
|
|||||||
|
import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
|
||||||
|
import * as c2g from './format-c2g.js';
|
||||||
|
import { PrimaryView, DialogOverlay } from './main-base.js';
|
||||||
|
import CanvasRenderer from './renderer-canvas.js';
|
||||||
|
import TILE_TYPES from './tiletypes.js';
|
||||||
|
import { mk, mk_svg, walk_grid } from './util.js';
|
||||||
|
|
||||||
|
class EditorShareOverlay extends DialogOverlay {
|
||||||
|
constructor(conductor, url) {
|
||||||
|
super(conductor);
|
||||||
|
this.set_title("give this to friends");
|
||||||
|
this.main.append(mk('p', "Give this URL out to let others try your level:"));
|
||||||
|
this.main.append(mk('p.editor-share-url', {}, url));
|
||||||
|
let copy_button = mk('button', {type: 'button'}, "Copy to clipboard");
|
||||||
|
copy_button.addEventListener('click', ev => {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
// TODO feedback?
|
||||||
|
});
|
||||||
|
this.main.append(copy_button);
|
||||||
|
|
||||||
|
let ok = mk('button', {type: 'button'}, "neato");
|
||||||
|
ok.addEventListener('click', ev => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
this.footer.append(ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores and controls what the mouse is doing during a movement, mostly by dispatching to functions
|
||||||
|
// defined for the individual tools
|
||||||
|
const MOUSE_BUTTON_MASKS = [1, 4, 2]; // MouseEvent.button/buttons are ordered differently
|
||||||
|
class MouseOperation {
|
||||||
|
constructor(editor, ev, target = null) {
|
||||||
|
this.editor = editor;
|
||||||
|
this.target = target;
|
||||||
|
this.button_mask = MOUSE_BUTTON_MASKS[ev.button];
|
||||||
|
|
||||||
|
// Client coordinates of the initial click
|
||||||
|
this.mx0 = ev.clientX;
|
||||||
|
this.my0 = ev.clientY;
|
||||||
|
// Real cell coordinates (i.e. including fractional position within a cell) of the click
|
||||||
|
[this.gx0f, this.gy0f] = this.editor.renderer.real_cell_coords_from_event(ev);
|
||||||
|
// Cell coordinates
|
||||||
|
this.gx0 = Math.floor(this.gx0f);
|
||||||
|
this.gy0 = Math.floor(this.gy0f);
|
||||||
|
|
||||||
|
// Same as above but for the previous mouse position
|
||||||
|
this.mx1 = this.mx0;
|
||||||
|
this.my1 = this.mx1;
|
||||||
|
this.gx1f = this.gx0f;
|
||||||
|
this.gy1f = this.gy0f;
|
||||||
|
this.gx1 = this.gx0;
|
||||||
|
this.gy1 = this.gy0;
|
||||||
|
|
||||||
|
this.start(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
cell(gx, gy) {
|
||||||
|
return this.editor.stored_level.cells[Math.floor(gy)][Math.floor(gx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
do_mousemove(ev) {
|
||||||
|
let [gxf, gyf] = this.editor.renderer.real_cell_coords_from_event(ev);
|
||||||
|
let gx = Math.floor(gxf);
|
||||||
|
let gy = Math.floor(gyf);
|
||||||
|
|
||||||
|
this.step(ev.clientX, ev.clientY, gxf, gyf, gx, gy);
|
||||||
|
|
||||||
|
this.mx1 = ev.clientX;
|
||||||
|
this.my1 = ev.clientY;
|
||||||
|
this.gx1f = gxf;
|
||||||
|
this.gy1f = gyf;
|
||||||
|
this.gx1 = gx;
|
||||||
|
this.gy1 = gy;
|
||||||
|
}
|
||||||
|
|
||||||
|
do_commit() {
|
||||||
|
this.commit();
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
do_abort() {
|
||||||
|
this.abort();
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement these
|
||||||
|
start() {}
|
||||||
|
step(x, y) {}
|
||||||
|
commit() {}
|
||||||
|
abort() {}
|
||||||
|
cleanup() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PanOperation extends MouseOperation {
|
||||||
|
step(mx, my) {
|
||||||
|
this.editor.viewport_el.scrollLeft -= mx - this.mx1;
|
||||||
|
this.editor.viewport_el.scrollTop -= my - this.my1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DrawOperation extends MouseOperation {
|
||||||
|
}
|
||||||
|
|
||||||
|
class PencilOperation extends DrawOperation {
|
||||||
|
start() {
|
||||||
|
this.editor.place_in_cell(this.gx1, this.gy1, this.editor.palette_selection);
|
||||||
|
}
|
||||||
|
step(mx, my, gxf, gyf) {
|
||||||
|
for (let [x, y] of walk_grid(this.gx1f, this.gy1f, gxf, gyf)) {
|
||||||
|
this.editor.place_in_cell(x, y, this.editor.palette_selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ForceFloorOperation extends DrawOperation {
|
||||||
|
start() {
|
||||||
|
// Begin by placing an all-way force floor under the mouse
|
||||||
|
this.editor.place_in_cell(x, y, 'force_floor_all');
|
||||||
|
}
|
||||||
|
step(mx, my, gxf, gyf) {
|
||||||
|
// Walk the mouse movement and change each we touch to match the direction we
|
||||||
|
// crossed the border
|
||||||
|
// FIXME occasionally i draw a tetris S kinda shape and both middle parts point
|
||||||
|
// the same direction, but shouldn't
|
||||||
|
let i = 0;
|
||||||
|
let prevx, prevy;
|
||||||
|
for (let [x, y] of walk_grid(this.gx1f, this.gy1f, gxf, gyf)) {
|
||||||
|
i++;
|
||||||
|
// The very first cell is the one the mouse was already in, and we don't
|
||||||
|
// have a movement direction yet, so leave that alone
|
||||||
|
if (i === 1) {
|
||||||
|
prevx = x;
|
||||||
|
prevy = y;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name;
|
||||||
|
if (x === prevx) {
|
||||||
|
if (y > prevy) {
|
||||||
|
name = 'force_floor_s';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
name = 'force_floor_n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (x > prevx) {
|
||||||
|
name = 'force_floor_e';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
name = 'force_floor_w';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second cell tells us the direction to use for the first, assuming it
|
||||||
|
// had some kind of force floor
|
||||||
|
if (i === 2) {
|
||||||
|
let prevcell = this.editor.stored_level.cells[prevy][prevx];
|
||||||
|
if (prevcell[0].type.name.startsWith('force_floor_')) {
|
||||||
|
prevcell[0].type = TILE_TYPES[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawing a loop with force floors creates ice (but not in the previous
|
||||||
|
// cell, obviously)
|
||||||
|
let cell = this.editor.stored_level.cells[y][x];
|
||||||
|
if (cell[0].type.name.startsWith('force_floor_') &&
|
||||||
|
cell[0].type.name !== name)
|
||||||
|
{
|
||||||
|
name = 'ice';
|
||||||
|
}
|
||||||
|
this.editor.place_in_cell(x, y, name);
|
||||||
|
|
||||||
|
prevx = x;
|
||||||
|
prevy = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiles the "adjust" tool will turn into each other
|
||||||
|
const ADJUST_TOGGLES = {
|
||||||
|
floor_custom_green: 'wall_custom_green',
|
||||||
|
floor_custom_pink: 'wall_custom_pink',
|
||||||
|
floor_custom_yellow: 'wall_custom_yellow',
|
||||||
|
floor_custom_blue: 'wall_custom_blue',
|
||||||
|
wall_custom_green: 'floor_custom_green',
|
||||||
|
wall_custom_pink: 'floor_custom_pink',
|
||||||
|
wall_custom_yellow: 'floor_custom_yellow',
|
||||||
|
wall_custom_blue: 'floor_custom_blue',
|
||||||
|
fake_floor: 'fake_wall',
|
||||||
|
fake_wall: 'fake_floor',
|
||||||
|
wall_invisible: 'wall_appearing',
|
||||||
|
wall_appearing: 'wall_invisible',
|
||||||
|
green_floor: 'green_wall',
|
||||||
|
green_wall: 'green_floor',
|
||||||
|
green_bomb: 'green_chip',
|
||||||
|
green_chip: 'green_bomb',
|
||||||
|
purple_floor: 'purple_wall',
|
||||||
|
purple_wall: 'purple_floor',
|
||||||
|
thief_keys: 'thief_tools',
|
||||||
|
thief_tools: 'thief_keys',
|
||||||
|
};
|
||||||
|
class AdjustOperation extends MouseOperation {
|
||||||
|
start() {
|
||||||
|
let cell = this.editor.stored_level.cells[this.gy1][this.gx1];
|
||||||
|
for (let tile of cell) {
|
||||||
|
// Toggle tiles that go in obvious pairs
|
||||||
|
let other = ADJUST_TOGGLES[tile.type.name];
|
||||||
|
if (other) {
|
||||||
|
tile.type = TILE_TYPES[other];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate actors
|
||||||
|
if (TILE_TYPES[tile.type.name].is_actor) {
|
||||||
|
tile.direction = DIRECTIONS[tile.direction ?? 'south'].right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Adjust tool doesn't support dragging
|
||||||
|
// TODO should it?
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME currently allows creating outside the map bounds and moving beyond the right/bottom, sigh
|
||||||
|
class CameraOperation extends MouseOperation {
|
||||||
|
start(ev) {
|
||||||
|
this.offset_x = 0;
|
||||||
|
this.offset_y = 0;
|
||||||
|
this.resize_x = 0;
|
||||||
|
this.resize_y = 0;
|
||||||
|
|
||||||
|
let cursor;
|
||||||
|
|
||||||
|
this.target = ev.target.closest('.overlay-camera');
|
||||||
|
if (! this.target) {
|
||||||
|
// Clicking in empty space creates a new camera region
|
||||||
|
this.mode = 'create';
|
||||||
|
cursor = 'move';
|
||||||
|
this.region = new DOMRect(this.gx0, this.gy0, 1, 1);
|
||||||
|
this.target = mk_svg('rect.overlay-camera', {
|
||||||
|
x: this.gx0, y: this.gy1, width: 1, height: 1,
|
||||||
|
'data-region-index': this.editor.stored_level.camera_regions.length,
|
||||||
|
});
|
||||||
|
this.editor.connections_g.append(this.target);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.region = this.editor.stored_level.camera_regions[parseInt(this.target.getAttribute('data-region-index'), 10)];
|
||||||
|
|
||||||
|
// If we're grabbing an edge, resize it
|
||||||
|
let rect = this.target.getBoundingClientRect();
|
||||||
|
let grab_left = (this.mx0 < rect.left + 16);
|
||||||
|
let grab_right = (this.mx0 > rect.right - 16);
|
||||||
|
let grab_top = (this.my0 < rect.top + 16);
|
||||||
|
let grab_bottom = (this.my0 > rect.bottom - 16);
|
||||||
|
if (grab_left || grab_right || grab_top || grab_bottom) {
|
||||||
|
this.mode = 'resize';
|
||||||
|
|
||||||
|
if (grab_left) {
|
||||||
|
this.resize_edge_x = -1;
|
||||||
|
}
|
||||||
|
else if (grab_right) {
|
||||||
|
this.resize_edge_x = 1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.resize_edge_x = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grab_top) {
|
||||||
|
this.resize_edge_y = -1;
|
||||||
|
}
|
||||||
|
else if (grab_bottom) {
|
||||||
|
this.resize_edge_y = 1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.resize_edge_y = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((grab_top && grab_left) || (grab_bottom && grab_right)) {
|
||||||
|
cursor = 'nwse-resize';
|
||||||
|
}
|
||||||
|
else if ((grab_top && grab_right) || (grab_bottom && grab_left)) {
|
||||||
|
cursor = 'nesw-resize';
|
||||||
|
}
|
||||||
|
else if (grab_top || grab_bottom) {
|
||||||
|
cursor = 'ns-resize';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
cursor = 'ew-resize';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.mode = 'move';
|
||||||
|
cursor = 'move';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editor.viewport_el.style.cursor = cursor;
|
||||||
|
|
||||||
|
// Create a text element to show the size while editing
|
||||||
|
this.size_text = mk_svg('text.overlay-edit-tip', {
|
||||||
|
// Center it within the rectangle probably (x and y are set in _update_size_text)
|
||||||
|
'text-anchor': 'middle', 'dominant-baseline': 'middle',
|
||||||
|
});
|
||||||
|
this._update_size_text();
|
||||||
|
this.editor.svg_overlay.append(this.size_text);
|
||||||
|
}
|
||||||
|
_update_size_text() {
|
||||||
|
this.size_text.setAttribute('x', this.region.x + this.offset_x + (this.region.width + this.resize_x) / 2);
|
||||||
|
this.size_text.setAttribute('y', this.region.y + this.offset_y + (this.region.height + this.resize_y) / 2);
|
||||||
|
this.size_text.textContent = `${this.region.width + this.resize_x} × ${this.region.height + this.resize_y}`;
|
||||||
|
}
|
||||||
|
step(mx, my, gxf, gyf, gx, gy) {
|
||||||
|
// FIXME not right if we zoom, should use gxf
|
||||||
|
let dx = Math.floor((mx - this.mx0) / this.editor.conductor.tileset.size_x + 0.5);
|
||||||
|
let dy = Math.floor((my - this.my0) / this.editor.conductor.tileset.size_y + 0.5);
|
||||||
|
|
||||||
|
let stored_level = this.editor.stored_level;
|
||||||
|
if (this.mode === 'create') {
|
||||||
|
// Just make the new region span between the original click and the new position
|
||||||
|
this.region.x = Math.min(gx, this.gx0);
|
||||||
|
this.region.y = Math.min(gy, this.gy0);
|
||||||
|
this.region.width = Math.max(gx, this.gx0) + 1 - this.region.x;
|
||||||
|
this.region.height = Math.max(gy, this.gy0) + 1 - this.region.y;
|
||||||
|
}
|
||||||
|
else if (this.mode === 'move') {
|
||||||
|
// Keep it within the map!
|
||||||
|
this.offset_x = Math.max(- this.region.x, Math.min(stored_level.size_x - this.region.width, dx));
|
||||||
|
this.offset_y = Math.max(- this.region.y, Math.min(stored_level.size_y - this.region.height, dy));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Resize, based on the edge we originally grabbed
|
||||||
|
if (this.resize_edge_x < 0) {
|
||||||
|
// Left
|
||||||
|
dx = Math.max(-this.region.x, Math.min(this.region.width - 1, dx));
|
||||||
|
this.resize_x = -dx;
|
||||||
|
this.offset_x = dx;
|
||||||
|
}
|
||||||
|
else if (this.resize_edge_x > 0) {
|
||||||
|
// Right
|
||||||
|
dx = Math.max(-(this.region.width - 1), Math.min(stored_level.size_x - this.region.right, dx));
|
||||||
|
this.resize_x = dx;
|
||||||
|
this.offset_x = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.resize_edge_y < 0) {
|
||||||
|
// Top
|
||||||
|
dy = Math.max(-this.region.y, Math.min(this.region.height - 1, dy));
|
||||||
|
this.resize_y = -dy;
|
||||||
|
this.offset_y = dy;
|
||||||
|
}
|
||||||
|
else if (this.resize_edge_y > 0) {
|
||||||
|
// Bottom
|
||||||
|
dy = Math.max(-(this.region.height - 1), Math.min(stored_level.size_y - this.region.bottom, dy));
|
||||||
|
this.resize_y = dy;
|
||||||
|
this.offset_y = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.target.setAttribute('x', this.region.x + this.offset_x);
|
||||||
|
this.target.setAttribute('y', this.region.y + this.offset_y);
|
||||||
|
this.target.setAttribute('width', this.region.width + this.resize_x);
|
||||||
|
this.target.setAttribute('height', this.region.height + this.resize_y);
|
||||||
|
this._update_size_text();
|
||||||
|
}
|
||||||
|
commit() {
|
||||||
|
if (this.mode === 'create') {
|
||||||
|
// Region is already updated, just add it to the level
|
||||||
|
this.editor.stored_level.camera_regions.push(this.region);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Actually edit the underlying region
|
||||||
|
this.region.x += this.offset_x;
|
||||||
|
this.region.y += this.offset_y;
|
||||||
|
this.region.width += this.resize_x;
|
||||||
|
this.region.height += this.resize_y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
abort() {
|
||||||
|
if (this.mode === 'create') {
|
||||||
|
// The element was fake, so delete it
|
||||||
|
this.target.remove();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Move the element back to its original location
|
||||||
|
this.target.setAttribute('x', this.region.x);
|
||||||
|
this.target.setAttribute('y', this.region.y);
|
||||||
|
this.target.setAttribute('width', this.region.width);
|
||||||
|
this.target.setAttribute('height', this.region.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanup() {
|
||||||
|
this.editor.viewport_el.style.cursor = '';
|
||||||
|
this.size_text.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CameraEraseOperation extends MouseOperation {
|
||||||
|
start(ev) {
|
||||||
|
let target = ev.target.closest('.overlay-camera');
|
||||||
|
if (target) {
|
||||||
|
let index = parseInt(target.getAttribute('data-region-index'), 10);
|
||||||
|
target.remove();
|
||||||
|
this.editor.stored_level.camera_regions.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDITOR_TOOLS = {
|
||||||
|
pencil: {
|
||||||
|
icon: 'icons/tool-pencil.png',
|
||||||
|
name: "Pencil",
|
||||||
|
desc: "Draw individual tiles",
|
||||||
|
op1: PencilOperation,
|
||||||
|
//op2: EraseOperation,
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
// TODO not implemented
|
||||||
|
icon: 'icons/tool-line.png',
|
||||||
|
name: "Line",
|
||||||
|
desc: "Draw straight lines",
|
||||||
|
},
|
||||||
|
box: {
|
||||||
|
// TODO not implemented
|
||||||
|
icon: 'icons/tool-box.png',
|
||||||
|
name: "Box",
|
||||||
|
desc: "Fill a rectangular area with tiles",
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
// TODO not implemented
|
||||||
|
icon: 'icons/tool-fill.png',
|
||||||
|
name: "Fill",
|
||||||
|
desc: "Flood-fill an area with tiles",
|
||||||
|
},
|
||||||
|
'force-floors': {
|
||||||
|
icon: 'icons/tool-force-floors.png',
|
||||||
|
name: "Force floors",
|
||||||
|
desc: "Draw force floors in the direction you draw",
|
||||||
|
op1: ForceFloorOperation,
|
||||||
|
},
|
||||||
|
adjust: {
|
||||||
|
icon: 'icons/tool-adjust.png',
|
||||||
|
name: "Adjust",
|
||||||
|
desc: "Toggle blocks and rotate actors",
|
||||||
|
op1: AdjustOperation,
|
||||||
|
},
|
||||||
|
connect: {
|
||||||
|
// TODO not implemented
|
||||||
|
icon: 'icons/tool-connect.png',
|
||||||
|
name: "Connect",
|
||||||
|
desc: "Set up CC1 clone and trap connections",
|
||||||
|
},
|
||||||
|
wire: {
|
||||||
|
// TODO not implemented
|
||||||
|
icon: 'icons/tool-wire.png',
|
||||||
|
name: "Wire",
|
||||||
|
desc: "Draw CC2 wiring",
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
icon: 'icons/tool-camera.png',
|
||||||
|
name: "Camera",
|
||||||
|
desc: "Draw and edit custom camera regions",
|
||||||
|
help: "Draw and edit camera regions. Right-click to erase a region. When the player is within a camera region, the camera will avoid showing anything outside that region. LL only.",
|
||||||
|
op1: CameraOperation,
|
||||||
|
op2: CameraEraseOperation,
|
||||||
|
},
|
||||||
|
// TODO text tool; thin walls tool; ice tool; map generator?; subtools for select tool (copy, paste, crop)
|
||||||
|
// TODO interesting option: rotate an actor as you draw it by dragging? or hold a key like in
|
||||||
|
// slade when you have some selected?
|
||||||
|
// TODO ah, railroads...
|
||||||
|
};
|
||||||
|
const EDITOR_TOOL_ORDER = ['pencil', 'force-floors', 'adjust', 'camera'];
|
||||||
|
|
||||||
|
// TODO this MUST use a LL tileset!
|
||||||
|
const EDITOR_PALETTE = [{
|
||||||
|
title: "Basics",
|
||||||
|
tiles: [
|
||||||
|
'player',
|
||||||
|
'chip', 'chip_extra',
|
||||||
|
'floor', 'wall', 'hint', 'socket', 'exit',
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
title: "Terrain",
|
||||||
|
tiles: [
|
||||||
|
'popwall',
|
||||||
|
'fake_floor', 'fake_wall',
|
||||||
|
'wall_invisible', 'wall_appearing',
|
||||||
|
'gravel',
|
||||||
|
'dirt',
|
||||||
|
'door_blue', 'door_red', 'door_yellow', 'door_green',
|
||||||
|
'water', 'turtle', 'fire',
|
||||||
|
'ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se',
|
||||||
|
'force_floor_n', 'force_floor_s', 'force_floor_w', 'force_floor_e', 'force_floor_all',
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
title: "Items",
|
||||||
|
tiles: [
|
||||||
|
'key_blue', 'key_red', 'key_yellow', 'key_green',
|
||||||
|
'flippers', 'fire_boots', 'cleats', 'suction_boots',
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
title: "Creatures",
|
||||||
|
tiles: [
|
||||||
|
'tank_blue',
|
||||||
|
'ball',
|
||||||
|
'fireball',
|
||||||
|
'glider',
|
||||||
|
'bug',
|
||||||
|
'paramecium',
|
||||||
|
'walker',
|
||||||
|
'teeth',
|
||||||
|
'blob',
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
title: "Mechanisms",
|
||||||
|
tiles: [
|
||||||
|
'bomb',
|
||||||
|
'dirt_block',
|
||||||
|
'ice_block',
|
||||||
|
'button_gray',
|
||||||
|
'button_green',
|
||||||
|
'green_floor',
|
||||||
|
'green_wall',
|
||||||
|
'green_chip',
|
||||||
|
'green_bomb',
|
||||||
|
'button_blue',
|
||||||
|
'button_red', 'cloner',
|
||||||
|
'button_brown', 'trap',
|
||||||
|
'teleport_blue',
|
||||||
|
'teleport_red',
|
||||||
|
'teleport_green',
|
||||||
|
'teleport_yellow',
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
|
||||||
|
export class Editor extends PrimaryView {
|
||||||
|
constructor(conductor) {
|
||||||
|
super(conductor, document.body.querySelector('main#editor'));
|
||||||
|
|
||||||
|
this.viewport_el = this.root.querySelector('.level');
|
||||||
|
|
||||||
|
// FIXME don't hardcode size here, convey this to renderer some other way
|
||||||
|
this.renderer = new CanvasRenderer(this.conductor.tileset, 32);
|
||||||
|
|
||||||
|
// FIXME need this in load_level which is called even if we haven't been setup yet
|
||||||
|
this.connections_g = mk_svg('g');
|
||||||
|
}
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
// Level canvas and mouse handling
|
||||||
|
// This SVG draws vectors on top of the editor, like monster paths and button connections
|
||||||
|
// FIXME change viewBox in load_level, can't right now because order of ops
|
||||||
|
this.svg_overlay = mk_svg('svg.level-editor-overlay', {viewBox: '0 0 32 32'}, this.connections_g);
|
||||||
|
this.viewport_el.append(this.renderer.canvas, this.svg_overlay);
|
||||||
|
this.mouse_op = null;
|
||||||
|
this.viewport_el.addEventListener('mousedown', ev => {
|
||||||
|
this.cancel_mouse_operation();
|
||||||
|
|
||||||
|
if (ev.button === 0) {
|
||||||
|
// Left button: activate tool
|
||||||
|
let op_type = EDITOR_TOOLS[this.current_tool].op1;
|
||||||
|
if (! op_type)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.mouse_op = new op_type(this, ev);
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
this.renderer.draw();
|
||||||
|
}
|
||||||
|
else if (ev.button === 1) {
|
||||||
|
// Middle button: always pan
|
||||||
|
this.mouse_op = new PanOperation(this, ev);
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
else if (ev.button === 2) {
|
||||||
|
// Right button: activate tool's alt mode
|
||||||
|
let op_type = EDITOR_TOOLS[this.current_tool].op2;
|
||||||
|
if (! op_type)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.mouse_op = new op_type(this, ev);
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
this.renderer.draw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.viewport_el.addEventListener('mousemove', ev => {
|
||||||
|
if (! this.mouse_op)
|
||||||
|
return;
|
||||||
|
if ((ev.buttons & this.mouse_op.button_mask) === 0) {
|
||||||
|
this.cancel_mouse_operation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mouse_op.do_mousemove(ev);
|
||||||
|
|
||||||
|
this.renderer.draw();
|
||||||
|
});
|
||||||
|
// TODO should this happen for a mouseup anywhere?
|
||||||
|
this.viewport_el.addEventListener('mouseup', ev => {
|
||||||
|
if (this.mouse_op) {
|
||||||
|
this.mouse_op.do_commit();
|
||||||
|
this.mouse_op = null;
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Disable context menu, which interferes with right-click tools
|
||||||
|
this.viewport_el.addEventListener('contextmenu', ev => {
|
||||||
|
ev.preventDefault();
|
||||||
|
});
|
||||||
|
window.addEventListener('blur', ev => {
|
||||||
|
this.cancel_mouse_operation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toolbar buttons
|
||||||
|
this.root.querySelector('#editor-share-url').addEventListener('click', ev => {
|
||||||
|
let buf = c2g.synthesize_level(this.stored_level);
|
||||||
|
// FIXME Not ideal, but btoa() wants a string rather than any of the myriad binary types
|
||||||
|
let stringy_buf = Array.from(new Uint8Array(buf)).map(n => String.fromCharCode(n)).join('');
|
||||||
|
// Make URL-safe and strip trailing padding
|
||||||
|
let data = btoa(stringy_buf).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
|
||||||
|
let url = new URL(location);
|
||||||
|
url.searchParams.delete('level');
|
||||||
|
url.searchParams.delete('setpath');
|
||||||
|
url.searchParams.append('level', data);
|
||||||
|
new EditorShareOverlay(this.conductor, url.toString()).open();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toolbox
|
||||||
|
let toolbox = mk('div.icon-button-set')
|
||||||
|
this.root.querySelector('.controls').append(toolbox);
|
||||||
|
this.tool_button_els = {};
|
||||||
|
for (let toolname of EDITOR_TOOL_ORDER) {
|
||||||
|
let tooldef = EDITOR_TOOLS[toolname];
|
||||||
|
let button = mk(
|
||||||
|
'button', {
|
||||||
|
type: 'button',
|
||||||
|
'data-tool': toolname,
|
||||||
|
},
|
||||||
|
mk('img', {
|
||||||
|
src: tooldef.icon,
|
||||||
|
alt: tooldef.name,
|
||||||
|
title: `${tooldef.name}: ${tooldef.desc}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.tool_button_els[toolname] = button;
|
||||||
|
toolbox.append(button);
|
||||||
|
}
|
||||||
|
this.current_tool = 'pencil';
|
||||||
|
this.tool_button_els['pencil'].classList.add('-selected');
|
||||||
|
toolbox.addEventListener('click', ev => {
|
||||||
|
let button = ev.target.closest('.icon-button-set button');
|
||||||
|
if (! button)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.select_tool(button.getAttribute('data-tool'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tile palette
|
||||||
|
let palette_el = this.root.querySelector('.palette');
|
||||||
|
this.palette = {}; // name => element
|
||||||
|
for (let sectiondef of EDITOR_PALETTE) {
|
||||||
|
let section_el = mk('section');
|
||||||
|
palette_el.append(mk('h2', sectiondef.title), section_el);
|
||||||
|
for (let name of sectiondef.tiles) {
|
||||||
|
let entry = this.renderer.create_tile_type_canvas(name);
|
||||||
|
entry.setAttribute('data-tile-name', name);
|
||||||
|
entry.classList = 'palette-entry';
|
||||||
|
this.palette[name] = entry;
|
||||||
|
section_el.append(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
palette_el.addEventListener('click', ev => {
|
||||||
|
let entry = ev.target.closest('canvas.palette-entry');
|
||||||
|
if (! entry)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.select_palette(entry.getAttribute('data-tile-name'));
|
||||||
|
});
|
||||||
|
this.palette_selection = null;
|
||||||
|
this.select_palette('floor');
|
||||||
|
}
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
super.activate();
|
||||||
|
this.renderer.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
load_game(stored_game) {
|
||||||
|
}
|
||||||
|
|
||||||
|
load_level(stored_level) {
|
||||||
|
// TODO support a game too i guess
|
||||||
|
this.stored_level = stored_level;
|
||||||
|
|
||||||
|
// XXX need this for renderer compat. but i guess it's nice in general idk
|
||||||
|
this.stored_level.cells = [];
|
||||||
|
let row;
|
||||||
|
for (let [i, cell] of this.stored_level.linear_cells.entries()) {
|
||||||
|
if (i % this.stored_level.size_x === 0) {
|
||||||
|
row = [];
|
||||||
|
this.stored_level.cells.push(row);
|
||||||
|
}
|
||||||
|
row.push(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load connections
|
||||||
|
this.connections_g.textContent = '';
|
||||||
|
for (let [src, dest] of Object.entries(this.stored_level.custom_trap_wiring)) {
|
||||||
|
let [sx, sy] = this.stored_level.scalar_to_coords(src);
|
||||||
|
let [dx, dy] = this.stored_level.scalar_to_coords(dest);
|
||||||
|
this.connections_g.append(
|
||||||
|
mk_svg('rect.overlay-cxn', {x: sx, y: sy, width: 1, height: 1}),
|
||||||
|
mk_svg('line.overlay-cxn', {x1: sx + 0.5, y1: sy + 0.5, x2: dx + 0.5, y2: dy + 0.5}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// TODO why are these in connections_g lol
|
||||||
|
for (let [i, region] of this.stored_level.camera_regions.entries()) {
|
||||||
|
let el = mk_svg('rect.overlay-camera', {x: region.x, y: region.y, width: region.width, height: region.height});
|
||||||
|
this.connections_g.append(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer.set_level(stored_level);
|
||||||
|
if (this.active) {
|
||||||
|
this.renderer.draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select_tool(tool) {
|
||||||
|
if (tool === this.current_tool)
|
||||||
|
return;
|
||||||
|
if (! this.tool_button_els[tool])
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.tool_button_els[this.current_tool].classList.remove('-selected');
|
||||||
|
this.current_tool = tool;
|
||||||
|
this.tool_button_els[this.current_tool].classList.add('-selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
select_palette(name) {
|
||||||
|
if (name === this.palette_selection)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.palette_selection) {
|
||||||
|
this.palette[this.palette_selection].classList.remove('--selected');
|
||||||
|
}
|
||||||
|
this.palette_selection = name;
|
||||||
|
if (this.palette_selection) {
|
||||||
|
this.palette[this.palette_selection].classList.add('--selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some tools obviously don't work with a palette selection, in which case changing tiles
|
||||||
|
// should default you back to the pencil
|
||||||
|
if (this.current_tool === 'adjust') {
|
||||||
|
this.select_tool('pencil');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
place_in_cell(x, y, name) {
|
||||||
|
// TODO weird api?
|
||||||
|
if (! name)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let type = TILE_TYPES[name];
|
||||||
|
let cell = this.stored_level.cells[y][x];
|
||||||
|
// For terrain tiles, erase the whole cell. For other tiles, only
|
||||||
|
// replace whatever's on the same layer
|
||||||
|
// TODO probably not the best heuristic yet, since i imagine you can
|
||||||
|
// combine e.g. the tent with thin walls
|
||||||
|
if (type.draw_layer === 0) {
|
||||||
|
cell.length = 0;
|
||||||
|
cell.push({type});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
for (let i = cell.length - 1; i >= 0; i--) {
|
||||||
|
if (cell[i].type.draw_layer === type.draw_layer) {
|
||||||
|
cell.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell.push({type});
|
||||||
|
cell.sort((a, b) => a.type.draw_layer - b.type.draw_layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel_mouse_operation() {
|
||||||
|
if (this.mouse_op) {
|
||||||
|
this.mouse_op.do_abort();
|
||||||
|
this.mouse_op = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+423
-684
File diff suppressed because it is too large.
Load diff
+31
-6
@@ -42,6 +42,15 @@ export class CanvasRenderer {
|
|||||||
return [x, y];
|
return [x, y];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
real_cell_coords_from_event(ev) {
|
||||||
|
let rect = this.canvas.getBoundingClientRect();
|
||||||
|
let scale_x = rect.width / this.canvas.width;
|
||||||
|
let scale_y = rect.height / this.canvas.height;
|
||||||
|
let x = (ev.clientX - rect.x) / scale_x / this.tileset.size_x + this.viewport_x;
|
||||||
|
let y = (ev.clientY - rect.y) / scale_y / this.tileset.size_y + this.viewport_y;
|
||||||
|
return [x, y];
|
||||||
|
}
|
||||||
|
|
||||||
// Draw to a canvas using tile coordinates
|
// Draw to a canvas using tile coordinates
|
||||||
blit(ctx, sx, sy, dx, dy, w = 1, h = w) {
|
blit(ctx, sx, sy, dx, dy, w = 1, h = w) {
|
||||||
let tw = this.tileset.size_x;
|
let tw = this.tileset.size_x;
|
||||||
@@ -76,8 +85,24 @@ export class CanvasRenderer {
|
|||||||
[px, py] = [0, 0];
|
[px, py] = [0, 0];
|
||||||
}
|
}
|
||||||
// Figure out where to start drawing
|
// Figure out where to start drawing
|
||||||
let x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, px - xmargin));
|
// TODO support overlapping regions better
|
||||||
let y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, py - ymargin));
|
let x0 = px - xmargin;
|
||||||
|
let y0 = py - ymargin;
|
||||||
|
// FIXME editor vs player again ugh, which is goofy since none of this is even relevant;
|
||||||
|
// maybe need to have a separate positioning method
|
||||||
|
if (this.level.stored_level) {
|
||||||
|
for (let region of this.level.stored_level.camera_regions) {
|
||||||
|
if (px >= region.left && px < region.right &&
|
||||||
|
py >= region.top && py < region.bottom)
|
||||||
|
{
|
||||||
|
x0 = Math.max(region.left, Math.min(region.right - this.viewport_size_x, x0));
|
||||||
|
y0 = Math.max(region.top, Math.min(region.bottom - this.viewport_size_y, y0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always keep us within the map bounds
|
||||||
|
x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, x0));
|
||||||
|
y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, y0));
|
||||||
// Round to the pixel grid
|
// Round to the pixel grid
|
||||||
x0 = Math.floor(x0 * tw + 0.5) / tw;
|
x0 = Math.floor(x0 * tw + 0.5) / tw;
|
||||||
y0 = Math.floor(y0 * th + 0.5) / th;
|
y0 = Math.floor(y0 * th + 0.5) / th;
|
||||||
@@ -86,12 +111,12 @@ export class CanvasRenderer {
|
|||||||
// The viewport might not be aligned to the grid, so split off any fractional part.
|
// The viewport might not be aligned to the grid, so split off any fractional part.
|
||||||
let xf0 = Math.floor(x0);
|
let xf0 = Math.floor(x0);
|
||||||
let yf0 = Math.floor(y0);
|
let yf0 = Math.floor(y0);
|
||||||
// Note that when the viewport is exactly aligned to the grid, we need to draw the cells
|
// We need to draw one cell beyond the viewport, or we'll miss objects partway through
|
||||||
// just outside of it, or we'll miss objects partway through crossing the border
|
// crossing the border moving away from us
|
||||||
if (xf0 === x0 && xf0 > 0) {
|
if (xf0 > 0) {
|
||||||
xf0 -= 1;
|
xf0 -= 1;
|
||||||
}
|
}
|
||||||
if (yf0 === y0 && yf0 > 0) {
|
if (yf0 > 0) {
|
||||||
yf0 -= 1;
|
yf0 -= 1;
|
||||||
}
|
}
|
||||||
// Find where to stop drawing. As with above, if we're aligned to the grid, we need to
|
// Find where to stop drawing. As with above, if we're aligned to the grid, we need to
|
||||||
|
|||||||
+11
-1
@@ -43,7 +43,7 @@ export default [{
|
|||||||
title: "conundrum",
|
title: "conundrum",
|
||||||
author: "fluffy",
|
author: "fluffy",
|
||||||
url: 'https://beesbuzz.biz/',
|
url: 'https://beesbuzz.biz/',
|
||||||
beepbox: 'https://www.beepbox.co/#8n31sbk0l00e0rt2mm0a7g0rj07i0r1o3210T1v1L4u01q3d7fay3z6C0c1A5F4B0V1Q0248Pac74E0085T1v1L4u01q1d1f4y4z9C0c1A1F0B0V1Q200ePd593E0787T5v1L4u05q3d0f1y4z0C2c0h0H_--D-quSRIAJJST4v1L4u04q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00bkzgid18Q4zgid0000000018Nwz55p88Nhnh5t000000ichgR5zkicg004hcx4O4jhkohd14z4isp25PFJvy1wqY58llp2GhKFLh_wFLh-czArdWfRuKrdQ_p13c5FKfZ_tRBPjub_aq_TYtjhZgtBdJlldtlldtlleKGGCKGGCK1wqoiGGiOdI3j1RlkOJBtdZllmG1wqoiGGCK1JFxaEm1STTQMBlldtlldtlJKoHrarqGGqWGGqWGGth7ihT4sChON7a4sIpqPhDkCz9FFOG8YwzOF8Wp8WFOHyfAVARQVB4ughVkAtcAtkVl97Op8Wd8Td6nl9EOaqSqcyIz9UzMbaXbAenShhAPvehhrBRApcVmnlsV5vaGysD9CKsyyLbH8OpOIKGVEGKttV56jdkV55uTnpeldpuptSltdRKpEPpFFOW8YwzOF8Wp8WFOGU0',
|
beepbox: 'https://www.beepbox.co/#8n31sbk0l00e0rt2mm0a7g0rj07i0r1o3210T1v1L4u01q3d7fay3z6C0c1A5F4B0V1Q0248Pac74E0085T1v1L4u01q1d1f4y4z9C0c1A1F0B0V1Q200ePd593E0787T5v1L4u05q3d0f1y4z0C2c0h0H_--D-quSRIAJJST4v1L4u04q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4zgid18Q4zgid0000000018Nwz55p88Nhnh5t000000ichgR5zkicg004hcx4O4jhkohd14z4isp25PFJvy1wqY58llp2GhKFLh_wFLh-czArdWfRuKrdQ_p13c5FKfZ_tRBPjub_aq_TYtjhZgtBdJlldtlldtlleKGGCKGGCK1wqoiGGiOdI3j1RlkOJBtdZllmG1wqoiGGCK1JFxaEm1STTQMBlldtlldtlJKoHrarqGGqWGGqWGGth7ihT4sChON7a4sIpqPhDkCz9FFOG8YwzOF8Wp8WFOHyfAVARQVB4ughVkAtcAtkVl97Op8Wd8Td6nl9EOaqSqcyIz9UzMbaXbAenShhAPvehhrBRApcVmnlsV5vaGysD9CKsyyLbH8OpOIKGVEGKttV56jdkV55uTnpeldpuptSltdRKpEPpFFOW8YwzOF8Wp8WFOGU0',
|
||||||
path: 'music/conundrum.ogg',
|
path: 'music/conundrum.ogg',
|
||||||
}, {
|
}, {
|
||||||
title: "kinda song",
|
title: "kinda song",
|
||||||
@@ -51,4 +51,14 @@ export default [{
|
|||||||
twitter: "glitchedpuppet",
|
twitter: "glitchedpuppet",
|
||||||
beepbox: 'https://jummbus.bitbucket.io/#j2N07Unnamedn310s0k0l00e0jt2mm0a7g0jj07i0r1O_U0000o3210T1v0wL0OD0Ou01q1d5f6y0z6C1c0A1F2B5V6Q20a0Pe64bE0171T1v0pL0OD0Ou92q1d4f7y2z1C0c2AbF6B6V9Q0490Pb976E0001T1v0pL0OD0Ou94q1d2f7y2z1C0c2A9F5B5V6Q290dPa883E0011T4v0pL0OD0Ouf0q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4x8Qd000lBu7014x4i4Qd3gQlmoh4ia2cz8OcChA4h4y8w01cPhjl0p27hFCLwATnMkCKChW2ZcngbUxcnikQ7sQO_inQ5WCkXyW9jbZ9vlO_lODhdcDjQNtgJ0Gp7IpAukFczR2FyX2frIzQ4zOGOOfpauP9vainRihQPK4tClqoKMnaAzQQnQQnRknXx7pBnK2OFjSO_oELFAzFIOWa8WF8WpHW3nEdv26LgqZcLQQap7Iu6P9j5R2Q2Q2Q2VeRfbEbWGq2-DinQBZttkHUMRWPn9HFAuwzEe3E8W2ehFyUsNncLQThuCnW2_aGydcngbkO_rZdkPjdcSpvx9jbZ3cRZtcO_lipvFSFkO_lN4YlAjBpdp6hahFyWz5OXbWFbWF8YEmChy3wWiehcK0bWaoEIlChw3JHp5K5E5E5w1sPb5P2f9as0LVds0bkbukQni0JFyQ5c0bokRyXrbxqxr2CyWSOSAzw1qxqxrNrxqxrhtwJgJMJJhvmbibikO8J5JwJEJEJtE',
|
beepbox: 'https://jummbus.bitbucket.io/#j2N07Unnamedn310s0k0l00e0jt2mm0a7g0jj07i0r1O_U0000o3210T1v0wL0OD0Ou01q1d5f6y0z6C1c0A1F2B5V6Q20a0Pe64bE0171T1v0pL0OD0Ou92q1d4f7y2z1C0c2AbF6B6V9Q0490Pb976E0001T1v0pL0OD0Ou94q1d2f7y2z1C0c2A9F5B5V6Q290dPa883E0011T4v0pL0OD0Ouf0q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4x8Qd000lBu7014x4i4Qd3gQlmoh4ia2cz8OcChA4h4y8w01cPhjl0p27hFCLwATnMkCKChW2ZcngbUxcnikQ7sQO_inQ5WCkXyW9jbZ9vlO_lODhdcDjQNtgJ0Gp7IpAukFczR2FyX2frIzQ4zOGOOfpauP9vainRihQPK4tClqoKMnaAzQQnQQnRknXx7pBnK2OFjSO_oELFAzFIOWa8WF8WpHW3nEdv26LgqZcLQQap7Iu6P9j5R2Q2Q2Q2VeRfbEbWGq2-DinQBZttkHUMRWPn9HFAuwzEe3E8W2ehFyUsNncLQThuCnW2_aGydcngbkO_rZdkPjdcSpvx9jbZ3cRZtcO_lipvFSFkO_lN4YlAjBpdp6hahFyWz5OXbWFbWF8YEmChy3wWiehcK0bWaoEIlChw3JHp5K5E5E5w1sPb5P2f9as0LVds0bkbukQni0JFyQ5c0bokRyXrbxqxr2CyWSOSAzw1qxqxrNrxqxrhtwJgJMJJhvmbibikO8J5JwJEJEJtE',
|
||||||
path: 'music/kinda-song.ogg',
|
path: 'music/kinda-song.ogg',
|
||||||
|
}, {
|
||||||
|
title: "learning has occurred",
|
||||||
|
author: "jneen",
|
||||||
|
twitter: "jneen_",
|
||||||
|
path: 'music/learning-has-occurred.ogg',
|
||||||
|
}, {
|
||||||
|
title: "escape on star road",
|
||||||
|
author: "jneen",
|
||||||
|
twitter: "jneen_",
|
||||||
|
path: 'music/escape-on-star-road.ogg',
|
||||||
}];
|
}];
|
||||||
+73
-13
@@ -197,9 +197,15 @@ export const CC2_TILESET_LAYOUT = {
|
|||||||
wired: [[4, 10], [5, 10], [6, 10], [7, 10]],
|
wired: [[4, 10], [5, 10], [6, 10], [7, 10]],
|
||||||
},
|
},
|
||||||
popwall: [8, 10],
|
popwall: [8, 10],
|
||||||
|
popwall2: [8, 10],
|
||||||
gravel: [9, 10],
|
gravel: [9, 10],
|
||||||
ball: [[10, 10], [11, 10], [12, 10], [13, 10], [14, 10]],
|
ball: [[10, 10], [11, 10], [12, 10], [13, 10], [14, 10]],
|
||||||
steel: [15, 10],
|
steel: {
|
||||||
|
// Wiring!
|
||||||
|
base: [15, 10],
|
||||||
|
wired: [9, 26],
|
||||||
|
is_wired_optional: true,
|
||||||
|
},
|
||||||
|
|
||||||
// TODO only animates while moving
|
// TODO only animates while moving
|
||||||
teeth: {
|
teeth: {
|
||||||
@@ -215,7 +221,7 @@ export const CC2_TILESET_LAYOUT = {
|
|||||||
swivel_ne: [11, 11],
|
swivel_ne: [11, 11],
|
||||||
swivel_se: [12, 11],
|
swivel_se: [12, 11],
|
||||||
swivel_floor: [13, 11],
|
swivel_floor: [13, 11],
|
||||||
// TODO some kinda four-edges thing again
|
'#wire-tunnel': [14, 11],
|
||||||
stopwatch_penalty: [15, 11],
|
stopwatch_penalty: [15, 11],
|
||||||
paramecium: {
|
paramecium: {
|
||||||
north: [[0, 12], [1, 12], [2, 12]],
|
north: [[0, 12], [1, 12], [2, 12]],
|
||||||
@@ -343,7 +349,7 @@ export const CC2_TILESET_LAYOUT = {
|
|||||||
west: [[6, 24], [7, 24]],
|
west: [[6, 24], [7, 24]],
|
||||||
},
|
},
|
||||||
bogus_player_drowned: {
|
bogus_player_drowned: {
|
||||||
overlay: [3, 3], // splash
|
overlay: [5, 5], // splash
|
||||||
base: 'water',
|
base: 'water',
|
||||||
},
|
},
|
||||||
bogus_player_burned_fire: {
|
bogus_player_burned_fire: {
|
||||||
@@ -365,18 +371,45 @@ export const CC2_TILESET_LAYOUT = {
|
|||||||
'#powered': [15, 26],
|
'#powered': [15, 26],
|
||||||
|
|
||||||
player2: {
|
player2: {
|
||||||
moving: {
|
|
||||||
north: [[0, 27], [1, 27], [2, 27], [3, 27], [4, 27], [5, 27], [6, 27], [7, 27]],
|
|
||||||
south: [[0, 28], [1, 28], [2, 28], [3, 28], [4, 28], [5, 28], [6, 28], [7, 28]],
|
|
||||||
west: [[8, 28], [9, 28], [10, 28], [11, 28], [12, 28], [13, 28], [14, 28], [15, 28]],
|
|
||||||
east: [[8, 27], [9, 27], [10, 27], [11, 27], [12, 27], [13, 27], [14, 27], [15, 27]],
|
|
||||||
},
|
|
||||||
normal: {
|
normal: {
|
||||||
north: [0, 27],
|
north: [0, 27],
|
||||||
south: [0, 28],
|
south: [0, 28],
|
||||||
west: [8, 28],
|
west: [8, 28],
|
||||||
east: [8, 27],
|
east: [8, 27],
|
||||||
},
|
},
|
||||||
|
blocked: 'pushing',
|
||||||
|
moving: {
|
||||||
|
north: [[0, 27], [1, 27], [2, 27], [3, 27], [4, 27], [5, 27], [6, 27], [7, 27]],
|
||||||
|
south: [[0, 28], [1, 28], [2, 28], [3, 28], [4, 28], [5, 28], [6, 28], [7, 28]],
|
||||||
|
west: [[8, 28], [9, 28], [10, 28], [11, 28], [12, 28], [13, 28], [14, 28], [15, 28]],
|
||||||
|
east: [[8, 27], [9, 27], [10, 27], [11, 27], [12, 27], [13, 27], [14, 27], [15, 27]],
|
||||||
|
},
|
||||||
|
pushing: {
|
||||||
|
north: [8, 29],
|
||||||
|
east: [9, 29],
|
||||||
|
south: [10, 29],
|
||||||
|
west: [11, 29],
|
||||||
|
},
|
||||||
|
swimming: {
|
||||||
|
north: [[0, 29], [1, 29]],
|
||||||
|
east: [[2, 29], [3, 29]],
|
||||||
|
south: [[4, 29], [5, 29]],
|
||||||
|
west: [[6, 29], [7, 29]],
|
||||||
|
},
|
||||||
|
// The classic CC2 behavior, spinning on ice
|
||||||
|
skating: [[0, 27], [8, 27], [0, 28], [8, 28]],
|
||||||
|
// TODO i don't know what CC2 does
|
||||||
|
forced: {
|
||||||
|
north: [2, 27],
|
||||||
|
east: [10, 27],
|
||||||
|
south: [2, 28],
|
||||||
|
west: [10, 28],
|
||||||
|
},
|
||||||
|
// These are frames from the splash/explosion animations
|
||||||
|
drowned: [5, 5],
|
||||||
|
burned: [1, 5],
|
||||||
|
exploded: [1, 5],
|
||||||
|
failed: [1, 5],
|
||||||
},
|
},
|
||||||
fire: [
|
fire: [
|
||||||
[12, 29],
|
[12, 29],
|
||||||
@@ -451,6 +484,7 @@ export const TILE_WORLD_TILESET_LAYOUT = {
|
|||||||
wall_appearing: [2, 12],
|
wall_appearing: [2, 12],
|
||||||
gravel: [2, 13],
|
gravel: [2, 13],
|
||||||
popwall: [2, 14],
|
popwall: [2, 14],
|
||||||
|
popwall2: [2, 14],
|
||||||
hint: [2, 15],
|
hint: [2, 15],
|
||||||
|
|
||||||
thinwall_se: [3, 0],
|
thinwall_se: [3, 0],
|
||||||
@@ -564,6 +598,7 @@ export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
|
|||||||
teeth: Object.assign({}, CC2_TILESET_LAYOUT.teeth, {
|
teeth: Object.assign({}, CC2_TILESET_LAYOUT.teeth, {
|
||||||
north: [[0, 32], [1, 32], [2, 32], [1, 32]],
|
north: [[0, 32], [1, 32], [2, 32], [1, 32]],
|
||||||
}),
|
}),
|
||||||
|
popwall2: [9, 32],
|
||||||
|
|
||||||
// Extra player sprites
|
// Extra player sprites
|
||||||
player: Object.assign({}, CC2_TILESET_LAYOUT.player, {
|
player: Object.assign({}, CC2_TILESET_LAYOUT.player, {
|
||||||
@@ -581,6 +616,7 @@ export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
|
|||||||
west: [7, 33],
|
west: [7, 33],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// TODO player2 equivalents
|
||||||
bogus_player_burned_fire: {
|
bogus_player_burned_fire: {
|
||||||
overlay: [6, 33],
|
overlay: [6, 33],
|
||||||
base: 'fire',
|
base: 'fire',
|
||||||
@@ -635,10 +671,10 @@ export class Tileset {
|
|||||||
coords = drawspec.tile;
|
coords = drawspec.tile;
|
||||||
}
|
}
|
||||||
else if (drawspec.wired) {
|
else if (drawspec.wired) {
|
||||||
if (tile && tile.wire_directions !== undefined && tile.wire_directions !== 0) {
|
if (tile && tile.wire_directions) {
|
||||||
// TODO all four is a different thing entirely
|
// TODO all four is a different thing entirely with two separate parts, ugh
|
||||||
// Draw the appropriate wire underlay
|
// Draw the appropriate wire underlay
|
||||||
this.draw_type('#unpowered', tile, tic, blit);
|
this.draw_type(tile.cell.is_powered ? '#powered' : '#unpowered', tile, tic, blit);
|
||||||
|
|
||||||
// Draw a masked part of the base tile
|
// Draw a masked part of the base tile
|
||||||
let wiredir = tile.wire_directions;
|
let wiredir = tile.wire_directions;
|
||||||
@@ -695,7 +731,7 @@ export class Tileset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply custom per-type visual states
|
// Apply custom per-type visual states
|
||||||
if (TILE_TYPES[name].visual_state) {
|
if (TILE_TYPES[name] && TILE_TYPES[name].visual_state) {
|
||||||
// Note that these accept null, too, and return a default
|
// Note that these accept null, too, and return a default
|
||||||
let state = TILE_TYPES[name].visual_state(tile);
|
let state = TILE_TYPES[name].visual_state(tile);
|
||||||
// If it's a string, that's an alias for another state
|
// If it's a string, that's an alias for another state
|
||||||
@@ -766,6 +802,30 @@ export class Tileset {
|
|||||||
blit(coords[0], coords[1]);
|
blit(coords[0], coords[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wired tiles may also have tunnels, drawn on top of everything else
|
||||||
|
if (drawspec.wired && tile && tile.wire_tunnel_directions) {
|
||||||
|
let tunnel_coords = this.layout['#wire-tunnel'];
|
||||||
|
let tunnel_width = 6/32;
|
||||||
|
let tunnel_length = 12/32;
|
||||||
|
let tunnel_offset = (1 - tunnel_width) / 2;
|
||||||
|
if (tile.wire_tunnel_directions & DIRECTIONS['north'].bit) {
|
||||||
|
blit(tunnel_coords[0] + tunnel_offset, tunnel_coords[1],
|
||||||
|
tunnel_offset, 0, tunnel_width, tunnel_length);
|
||||||
|
}
|
||||||
|
if (tile.wire_tunnel_directions & DIRECTIONS['south'].bit) {
|
||||||
|
blit(tunnel_coords[0] + tunnel_offset, tunnel_coords[1] + 1 - tunnel_length,
|
||||||
|
tunnel_offset, 1 - tunnel_length, tunnel_width, tunnel_length);
|
||||||
|
}
|
||||||
|
if (tile.wire_tunnel_directions & DIRECTIONS['west'].bit) {
|
||||||
|
blit(tunnel_coords[0], tunnel_coords[1] + tunnel_offset,
|
||||||
|
0, tunnel_offset, tunnel_length, tunnel_width);
|
||||||
|
}
|
||||||
|
if (tile.wire_tunnel_directions & DIRECTIONS['east'].bit) {
|
||||||
|
blit(tunnel_coords[0] + 1 - tunnel_length, tunnel_coords[1] + tunnel_offset,
|
||||||
|
1 - tunnel_length, tunnel_offset, tunnel_length, tunnel_width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Special behavior for special objects
|
// Special behavior for special objects
|
||||||
// TODO? hardcode this less?
|
// TODO? hardcode this less?
|
||||||
if (name === 'floor_letter') {
|
if (name === 'floor_letter') {
|
||||||
|
|||||||
+239
-54
@@ -1,4 +1,5 @@
|
|||||||
import { DIRECTIONS } from './defs.js';
|
import { DIRECTIONS } from './defs.js';
|
||||||
|
import { random_choice } from './util.js';
|
||||||
|
|
||||||
// Draw layers
|
// Draw layers
|
||||||
const LAYER_TERRAIN = 0;
|
const LAYER_TERRAIN = 0;
|
||||||
@@ -106,34 +107,62 @@ const TILE_TYPES = {
|
|||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
blocks_monsters: true,
|
blocks_monsters: true,
|
||||||
blocks_blocks: true,
|
blocks_blocks: true,
|
||||||
|
on_ready(me, level) {
|
||||||
|
if (level.compat.auto_convert_ccl_popwalls &&
|
||||||
|
me.cell.some(tile => tile.type.is_actor))
|
||||||
|
{
|
||||||
|
// Fix blocks and other actors on top of popwalls by turning them into double
|
||||||
|
// popwalls, which preserves CC2 popwall behavior
|
||||||
|
me.type = TILE_TYPES['popwall2'];
|
||||||
|
}
|
||||||
|
},
|
||||||
on_depart(me, level, other) {
|
on_depart(me, level, other) {
|
||||||
level.transmute_tile(me, 'wall');
|
level.transmute_tile(me, 'wall');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// FIXME these should be OVERLAY by cc2 rules, but the cc1 tiles are opaque and cover everything else
|
// LL specific tile that can only be stepped on /twice/, originally used to repair differences
|
||||||
thinwall_n: {
|
// with popwall behavior between Lynx and Steam
|
||||||
|
popwall2: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
|
blocks_monsters: true,
|
||||||
|
blocks_blocks: true,
|
||||||
|
on_depart(me, level, other) {
|
||||||
|
level.transmute_tile(me, 'popwall');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// FIXME in a cc1 tileset, these tiles are opaque >:S
|
||||||
|
thinwall_n: {
|
||||||
|
draw_layer: LAYER_OVERLAY,
|
||||||
thin_walls: new Set(['north']),
|
thin_walls: new Set(['north']),
|
||||||
},
|
},
|
||||||
thinwall_s: {
|
thinwall_s: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_OVERLAY,
|
||||||
thin_walls: new Set(['south']),
|
thin_walls: new Set(['south']),
|
||||||
},
|
},
|
||||||
thinwall_e: {
|
thinwall_e: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_OVERLAY,
|
||||||
thin_walls: new Set(['east']),
|
thin_walls: new Set(['east']),
|
||||||
},
|
},
|
||||||
thinwall_w: {
|
thinwall_w: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_OVERLAY,
|
||||||
thin_walls: new Set(['west']),
|
thin_walls: new Set(['west']),
|
||||||
},
|
},
|
||||||
thinwall_se: {
|
thinwall_se: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_OVERLAY,
|
||||||
thin_walls: new Set(['south', 'east']),
|
thin_walls: new Set(['south', 'east']),
|
||||||
},
|
},
|
||||||
fake_wall: {
|
fake_wall: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
blocks_all: true,
|
blocks_all: true,
|
||||||
|
on_ready(me, level) {
|
||||||
|
if (level.compat.auto_convert_ccl_blue_walls &&
|
||||||
|
me.cell.some(tile => tile.type.is_actor))
|
||||||
|
{
|
||||||
|
// Blocks can be pushed off of blue walls in TW Lynx, which only works due to a tiny
|
||||||
|
// quirk of the engine that I don't want to replicate, so replace them with popwalls
|
||||||
|
me.type = TILE_TYPES['popwall'];
|
||||||
|
}
|
||||||
|
},
|
||||||
on_bump(me, level, other) {
|
on_bump(me, level, other) {
|
||||||
if (other.type.can_reveal_walls) {
|
if (other.type.can_reveal_walls) {
|
||||||
level.transmute_tile(me, 'wall');
|
level.transmute_tile(me, 'wall');
|
||||||
@@ -142,7 +171,8 @@ const TILE_TYPES = {
|
|||||||
},
|
},
|
||||||
fake_floor: {
|
fake_floor: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
blocks_all: true,
|
blocks_monsters: true,
|
||||||
|
blocks_blocks: true,
|
||||||
on_bump(me, level, other) {
|
on_bump(me, level, other) {
|
||||||
if (other.type.can_reveal_walls) {
|
if (other.type.can_reveal_walls) {
|
||||||
level.transmute_tile(me, 'floor');
|
level.transmute_tile(me, 'floor');
|
||||||
@@ -354,7 +384,7 @@ const TILE_TYPES = {
|
|||||||
level.transmute_tile(me, 'dirt');
|
level.transmute_tile(me, 'dirt');
|
||||||
}
|
}
|
||||||
else if (other.type.name === 'ice_block') {
|
else if (other.type.name === 'ice_block') {
|
||||||
level.remove_tile(other);
|
level.transmute_tile(other, 'splash');
|
||||||
level.transmute_tile(me, 'ice');
|
level.transmute_tile(me, 'ice');
|
||||||
}
|
}
|
||||||
else if (other.type.is_player) {
|
else if (other.type.is_player) {
|
||||||
@@ -558,10 +588,16 @@ const TILE_TYPES = {
|
|||||||
},
|
},
|
||||||
green_floor: {
|
green_floor: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
|
on_gray_button(me, level) {
|
||||||
|
level.transmute_tile(me, 'green_wall');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
green_wall: {
|
green_wall: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
blocks_all: true,
|
blocks_all: true,
|
||||||
|
on_gray_button(me, level) {
|
||||||
|
level.transmute_tile(me, 'green_floor');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
green_chip: {
|
green_chip: {
|
||||||
draw_layer: LAYER_ITEM,
|
draw_layer: LAYER_ITEM,
|
||||||
@@ -575,6 +611,7 @@ const TILE_TYPES = {
|
|||||||
level.remove_tile(me);
|
level.remove_tile(me);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Not affected by gray buttons
|
||||||
},
|
},
|
||||||
green_bomb: {
|
green_bomb: {
|
||||||
draw_layer: LAYER_ITEM,
|
draw_layer: LAYER_ITEM,
|
||||||
@@ -589,15 +626,32 @@ const TILE_TYPES = {
|
|||||||
level.transmute_tile(other, 'explosion');
|
level.transmute_tile(other, 'explosion');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Not affected by gray buttons
|
||||||
},
|
},
|
||||||
purple_floor: {
|
purple_floor: {
|
||||||
// TODO wired
|
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
|
on_gray_button(me, level) {
|
||||||
|
level.transmute_tile(me, 'purple_wall');
|
||||||
|
},
|
||||||
|
on_power(me, level) {
|
||||||
|
me.type.on_gray_button(me, level);
|
||||||
|
},
|
||||||
|
on_depower(me, level) {
|
||||||
|
me.type.on_gray_button(me, level);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
purple_wall: {
|
purple_wall: {
|
||||||
// TODO wired
|
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
blocks_all: true,
|
blocks_all: true,
|
||||||
|
on_gray_button(me, level) {
|
||||||
|
level.transmute_tile(me, 'purple_floor');
|
||||||
|
},
|
||||||
|
on_power(me, level) {
|
||||||
|
me.type.on_gray_button(me, level);
|
||||||
|
},
|
||||||
|
on_depower(me, level) {
|
||||||
|
me.type.on_gray_button(me, level);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
cloner: {
|
cloner: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
@@ -628,11 +682,40 @@ const TILE_TYPES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Also clones on rising pulse or gray button
|
||||||
|
on_power(me, level) {
|
||||||
|
me.type.activate(me, level);
|
||||||
|
},
|
||||||
|
on_gray_button(me, level) {
|
||||||
|
me.type.activate(me, level);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
trap: {
|
trap: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
|
on_ready(me, level) {
|
||||||
|
// Trap any actor standing on us, unless we already know we're pressed
|
||||||
|
if (me.presses)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (let tile of me.cell) {
|
||||||
|
if (tile.type.is_actor) {
|
||||||
|
tile.stuck = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
add_press_ready(me, level, other) {
|
||||||
|
// Same as below, but without using the undo stack or ejection
|
||||||
|
me.presses = (me.presses ?? 0) + 1;
|
||||||
|
if (me.presses === 1) {
|
||||||
|
for (let tile of me.cell) {
|
||||||
|
if (tile.type.is_actor) {
|
||||||
|
tile.stuck = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
on_arrive(me, level, other) {
|
on_arrive(me, level, other) {
|
||||||
if (me.open) {
|
if (me.presses) {
|
||||||
// Lynx: Traps immediately eject their contents, if possible
|
// Lynx: Traps immediately eject their contents, if possible
|
||||||
// TODO compat this, cc2 doens't do it!
|
// TODO compat this, cc2 doens't do it!
|
||||||
//level.attempt_step(other, other.direction);
|
//level.attempt_step(other, other.direction);
|
||||||
@@ -641,8 +724,42 @@ const TILE_TYPES = {
|
|||||||
level.set_actor_stuck(other, true);
|
level.set_actor_stuck(other, true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
add_press(me, level) {
|
||||||
|
level._set_prop(me, 'presses', (me.presses ?? 0) + 1);
|
||||||
|
if (me.presses === 1) {
|
||||||
|
// Free everything on us, if we went from 0 to 1 presses (i.e. closed to open)
|
||||||
|
for (let tile of Array.from(me.cell)) {
|
||||||
|
if (tile.type.is_actor) {
|
||||||
|
level.set_actor_stuck(tile, false);
|
||||||
|
// Forcibly move anything released from a trap, to keep it in sync with
|
||||||
|
// whatever pushed the button
|
||||||
|
level.attempt_step(tile, tile.direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove_press(me, level) {
|
||||||
|
level._set_prop(me, 'presses', me.presses - 1);
|
||||||
|
if (me.presses === 0) {
|
||||||
|
// Trap everything on us, if we went from 1 to 0 presses (i.e. open to closed)
|
||||||
|
for (let tile of me.cell) {
|
||||||
|
if (tile.type.is_actor) {
|
||||||
|
level.set_actor_stuck(tile, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
on_power(me, level) {
|
||||||
|
// Treat being powered or not as an extra kind of brown button press
|
||||||
|
me.type.add_press(me, level);
|
||||||
|
console.log("powering UP", me.presses);
|
||||||
|
},
|
||||||
|
on_depower(me, level) {
|
||||||
|
me.type.remove_press(me, level);
|
||||||
|
console.log("powering down...", me.presses);
|
||||||
|
},
|
||||||
visual_state(me) {
|
visual_state(me) {
|
||||||
if (me && me.open) {
|
if (me && me.presses) {
|
||||||
return 'open';
|
return 'open';
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -689,37 +806,57 @@ const TILE_TYPES = {
|
|||||||
},
|
},
|
||||||
teleport_blue: {
|
teleport_blue: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
connects_to: 'teleport_blue',
|
teleport_dest_order(me, level) {
|
||||||
connect_order: 'backward',
|
return level.iter_tiles_in_reading_order(me.cell, 'teleport_blue', true);
|
||||||
is_teleporter: true,
|
},
|
||||||
},
|
},
|
||||||
teleport_red: {
|
teleport_red: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
connects_to: 'teleport_red',
|
teleport_dest_order(me, level) {
|
||||||
connect_order: 'forward',
|
// FIXME you can control your exit direction from red teleporters
|
||||||
is_teleporter: true,
|
return level.iter_tiles_in_reading_order(me.cell, 'teleport_red');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
teleport_green: {
|
teleport_green: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
// connects_to: 'teleport_red',
|
teleport_dest_order(me, level) {
|
||||||
// connect_order: 'forward',
|
// FIXME exit direction is random; unclear if it's any direction or only unblocked ones
|
||||||
// is_teleporter: true,
|
let all = Array.from(level.iter_tiles_in_reading_order(me.cell, 'teleport_green'));
|
||||||
// FIXME completely different behavior from other teleporters
|
// FIXME this should use the lynxish rng
|
||||||
|
return [random_choice(all), me];
|
||||||
|
},
|
||||||
},
|
},
|
||||||
teleport_yellow: {
|
teleport_yellow: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
connects_to: 'teleport_yellow',
|
teleport_dest_order(me, level) {
|
||||||
connect_order: 'backward',
|
|
||||||
is_teleporter: true,
|
|
||||||
// FIXME special pickup behavior
|
// FIXME special pickup behavior
|
||||||
|
return level.iter_tiles_in_reading_order(me.cell, 'teleport_yellow', true);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// FIXME do i want these as separate objects? what would they do, turn into each other? or should it be one with state?
|
|
||||||
flame_jet_off: {
|
flame_jet_off: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
|
activate(me, level) {
|
||||||
|
level.transmute_tile(me, 'flame_jet_on');
|
||||||
|
},
|
||||||
|
on_gray_button(me, level) {
|
||||||
|
me.type.activate(me, level);
|
||||||
|
},
|
||||||
|
on_power(me, level) {
|
||||||
|
me.type.activate(me, level);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
flame_jet_on: {
|
flame_jet_on: {
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
// FIXME every tic, kills every actor in the cell
|
// FIXME every tic, kills every actor in the cell
|
||||||
|
activate(me, level) {
|
||||||
|
level.transmute_tile(me, 'flame_jet_off');
|
||||||
|
},
|
||||||
|
on_gray_button(me, level) {
|
||||||
|
me.type.activate(me, level);
|
||||||
|
},
|
||||||
|
on_power(me, level) {
|
||||||
|
me.type.activate(me, level);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// Buttons
|
// Buttons
|
||||||
button_blue: {
|
button_blue: {
|
||||||
@@ -731,7 +868,7 @@ const TILE_TYPES = {
|
|||||||
for (let actor of level.actors) {
|
for (let actor of level.actors) {
|
||||||
// TODO generify somehow??
|
// TODO generify somehow??
|
||||||
if (actor.type.name === 'tank_blue') {
|
if (actor.type.name === 'tank_blue') {
|
||||||
level.set_actor_direction(actor, DIRECTIONS[actor.direction].opposite);
|
level._set_prop(actor, 'pending_reverse', ! actor.pending_reverse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -745,7 +882,8 @@ const TILE_TYPES = {
|
|||||||
level.sfx.play_once('button-press', me.cell);
|
level.sfx.play_once('button-press', me.cell);
|
||||||
|
|
||||||
// Move all yellow tanks one tile in the direction of the pressing actor
|
// Move all yellow tanks one tile in the direction of the pressing actor
|
||||||
for (let actor of level.actors) {
|
for (let i = level.actors.length - 1; i >= 0; i--) {
|
||||||
|
let actor = level.actors[i];
|
||||||
// TODO generify somehow??
|
// TODO generify somehow??
|
||||||
if (actor.type.name === 'tank_yellow') {
|
if (actor.type.name === 'tank_yellow') {
|
||||||
level.attempt_step(actor, other.direction);
|
level.attempt_step(actor, other.direction);
|
||||||
@@ -790,36 +928,32 @@ const TILE_TYPES = {
|
|||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
connects_to: 'trap',
|
connects_to: 'trap',
|
||||||
connect_order: 'forward',
|
connect_order: 'forward',
|
||||||
on_arrive(me, level, other) {
|
on_ready(me, level) {
|
||||||
level.sfx.play_once('button-press', me.cell);
|
// Inform the trap of any actors that start out holding us down
|
||||||
|
|
||||||
if (me.connection && me.connection.cell) {
|
|
||||||
let trap = me.connection;
|
let trap = me.connection;
|
||||||
level._set_prop(trap, 'open', true);
|
if (! (trap && trap.cell))
|
||||||
for (let tile of trap.cell) {
|
return;
|
||||||
|
|
||||||
|
for (let tile of me.cell) {
|
||||||
if (tile.type.is_actor) {
|
if (tile.type.is_actor) {
|
||||||
if (tile.stuck) {
|
trap.type.add_press_ready(trap, level);
|
||||||
level.set_actor_stuck(tile, false);
|
|
||||||
}
|
|
||||||
// Forcibly move anything released from a trap, to keep
|
|
||||||
// it in sync with whatever pushed the button
|
|
||||||
level.attempt_step(tile, tile.direction);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
on_arrive(me, level, other) {
|
||||||
|
level.sfx.play_once('button-press', me.cell);
|
||||||
|
|
||||||
|
let trap = me.connection;
|
||||||
|
if (trap && trap.cell) {
|
||||||
|
trap.type.add_press(trap, level);
|
||||||
|
}
|
||||||
|
},
|
||||||
on_depart(me, level, other) {
|
on_depart(me, level, other) {
|
||||||
// TODO this doesn't play if you walk straight across
|
|
||||||
level.sfx.play_once('button-release', me.cell);
|
level.sfx.play_once('button-release', me.cell);
|
||||||
|
|
||||||
if (me.connection && me.connection.cell) {
|
|
||||||
let trap = me.connection;
|
let trap = me.connection;
|
||||||
level._set_prop(trap, 'open', false);
|
if (trap && trap.cell) {
|
||||||
for (let tile of trap.cell) {
|
trap.type.remove_press(trap, level);
|
||||||
if (tile.type.is_actor) {
|
|
||||||
level.set_actor_stuck(tile, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -830,8 +964,9 @@ const TILE_TYPES = {
|
|||||||
on_arrive(me, level, other) {
|
on_arrive(me, level, other) {
|
||||||
level.sfx.play_once('button-press', me.cell);
|
level.sfx.play_once('button-press', me.cell);
|
||||||
|
|
||||||
if (me.connection && me.connection.cell) {
|
let cloner = me.connection;
|
||||||
me.connection.type.activate(me.connection, level);
|
if (cloner && cloner.cell) {
|
||||||
|
cloner.type.activate(cloner, level);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
on_depart(me, level, other) {
|
on_depart(me, level, other) {
|
||||||
@@ -843,13 +978,47 @@ const TILE_TYPES = {
|
|||||||
// FIXME toggles flame jets, connected somehow, ???
|
// FIXME toggles flame jets, connected somehow, ???
|
||||||
},
|
},
|
||||||
button_pink: {
|
button_pink: {
|
||||||
// TODO not implemented
|
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
|
is_emitting(me, level) {
|
||||||
|
// We emit current as long as there's an actor on us
|
||||||
|
return me.cell.some(tile => tile.type.is_actor);
|
||||||
|
},
|
||||||
|
on_arrive(me, level, other) {
|
||||||
|
level.sfx.play_once('button-press', me.cell);
|
||||||
|
},
|
||||||
|
on_depart(me, level, other) {
|
||||||
|
level.sfx.play_once('button-release', me.cell);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
button_black: {
|
button_black: {
|
||||||
// TODO not implemented
|
// TODO not implemented
|
||||||
draw_layer: LAYER_TERRAIN,
|
draw_layer: LAYER_TERRAIN,
|
||||||
},
|
},
|
||||||
|
button_gray: {
|
||||||
|
// TODO only partially implemented
|
||||||
|
draw_layer: LAYER_TERRAIN,
|
||||||
|
on_arrive(me, level, other) {
|
||||||
|
level.sfx.play_once('button-press', me.cell);
|
||||||
|
|
||||||
|
for (let x = Math.max(0, me.cell.x - 2); x <= Math.min(level.width - 1, me.cell.x + 2); x++) {
|
||||||
|
for (let y = Math.max(0, me.cell.y - 2); y <= Math.min(level.height - 1, me.cell.y + 2); y++) {
|
||||||
|
let cell = level.cells[y][x];
|
||||||
|
// TODO wait is this right
|
||||||
|
if (cell === me.cell)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (let tile of cell) {
|
||||||
|
if (tile.type.on_gray_button) {
|
||||||
|
tile.type.on_gray_button(tile, level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
on_depart(me, level, other) {
|
||||||
|
level.sfx.play_once('button-release', me.cell);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Time alteration
|
// Time alteration
|
||||||
stopwatch_bonus: {
|
stopwatch_bonus: {
|
||||||
@@ -1017,17 +1186,32 @@ const TILE_TYPES = {
|
|||||||
movement_speed: 4,
|
movement_speed: 4,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Keys
|
// Keys, whose behavior varies
|
||||||
// Note that red and blue keys do NOT block monsters, but yellow and green DO
|
|
||||||
key_red: {
|
key_red: {
|
||||||
|
// TODO Red key can ONLY be picked up by players (and doppelgangers), no other actor that
|
||||||
|
// has an inventory
|
||||||
draw_layer: LAYER_ITEM,
|
draw_layer: LAYER_ITEM,
|
||||||
is_item: true,
|
is_item: true,
|
||||||
is_key: true,
|
is_key: true,
|
||||||
},
|
},
|
||||||
key_blue: {
|
key_blue: {
|
||||||
|
// Blue key is picked up by dirt blocks and all monsters, including those that don't have an
|
||||||
|
// inventory normally
|
||||||
draw_layer: LAYER_ITEM,
|
draw_layer: LAYER_ITEM,
|
||||||
is_item: true,
|
is_item: true,
|
||||||
is_key: true,
|
is_key: true,
|
||||||
|
on_arrive(me, level, other) {
|
||||||
|
// Call it... everything except ice and directional blocks? These rules are weird.
|
||||||
|
// Note that the game itself normally handles picking items up, so we only get here for
|
||||||
|
// actors who aren't supposed to have an inventory
|
||||||
|
// TODO make this a... flag? i don't know?
|
||||||
|
// TODO major difference from lynx...
|
||||||
|
if (other.type.name !== 'ice_block' && other.type.name !== 'directional_block') {
|
||||||
|
if (level.give_actor(other, me.type.name)) {
|
||||||
|
level.remove_tile(me);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
key_yellow: {
|
key_yellow: {
|
||||||
draw_layer: LAYER_ITEM,
|
draw_layer: LAYER_ITEM,
|
||||||
@@ -1176,6 +1360,7 @@ const TILE_TYPES = {
|
|||||||
infinite_items: {
|
infinite_items: {
|
||||||
key_yellow: true,
|
key_yellow: true,
|
||||||
},
|
},
|
||||||
|
visual_state: player_visual_state,
|
||||||
},
|
},
|
||||||
chip: {
|
chip: {
|
||||||
draw_layer: LAYER_ITEM,
|
draw_layer: LAYER_ITEM,
|
||||||
|
|||||||
+183
-4
@@ -1,7 +1,13 @@
|
|||||||
|
// Base class for custom errors
|
||||||
|
export class LLError extends Error {}
|
||||||
|
|
||||||
|
// Random choice
|
||||||
export function random_choice(list) {
|
export function random_choice(list) {
|
||||||
return list[Math.floor(Math.random() * list.length)];
|
return list[Math.floor(Math.random() * list.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// DOM stuff
|
||||||
function _mk(el, children) {
|
function _mk(el, children) {
|
||||||
if (children.length > 0) {
|
if (children.length > 0) {
|
||||||
if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") {
|
if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") {
|
||||||
@@ -29,6 +35,70 @@ export function mk_svg(tag_selector, ...children) {
|
|||||||
return _mk(el, children);
|
return _mk(el, children);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function handle_drop(element, options) {
|
||||||
|
let dropzone_class = options.dropzone_class ?? null;
|
||||||
|
let on_drop = options.on_drop;
|
||||||
|
|
||||||
|
let require_file = options.require_file ?? false;
|
||||||
|
let is_valid = ev => {
|
||||||
|
// TODO this requires files, should make some args for this
|
||||||
|
if (options.require_file) {
|
||||||
|
let dt = ev.dataTransfer;
|
||||||
|
if (! dt || dt.items.length === 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Only test the first item I guess? If it's a file then they should all be files
|
||||||
|
if (dt.items[0].kind !== 'file')
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_drop = () => {
|
||||||
|
if (dropzone_class !== null) {
|
||||||
|
element.classList.remove(dropzone_class);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO should have a filter function for when a drag is valid but i forget which of these
|
||||||
|
// should have that
|
||||||
|
element.addEventListener('dragenter', ev => {
|
||||||
|
if (! is_valid(ev))
|
||||||
|
return;
|
||||||
|
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (dropzone_class !== null) {
|
||||||
|
element.classList.add(dropzone_class);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
element.addEventListener('dragover', ev => {
|
||||||
|
if (! is_valid(ev))
|
||||||
|
return;
|
||||||
|
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
});
|
||||||
|
element.addEventListener('dragleave', ev => {
|
||||||
|
if (ev.relatedTarget && element.contains(ev.relatedTarget))
|
||||||
|
return;
|
||||||
|
|
||||||
|
end_drop();
|
||||||
|
});
|
||||||
|
element.addEventListener('drop', ev => {
|
||||||
|
if (! is_valid(ev))
|
||||||
|
return;
|
||||||
|
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
end_drop();
|
||||||
|
on_drop(ev);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function promise_event(element, success_event, failure_event) {
|
export function promise_event(element, success_event, failure_event) {
|
||||||
let resolve, reject;
|
let resolve, reject;
|
||||||
let promise = new Promise((res, rej) => {
|
let promise = new Promise((res, rej) => {
|
||||||
@@ -36,21 +106,21 @@ export function promise_event(element, success_event, failure_event) {
|
|||||||
reject = rej;
|
reject = rej;
|
||||||
});
|
});
|
||||||
|
|
||||||
let success_handler = e => {
|
let success_handler = ev => {
|
||||||
element.removeEventListener(success_event, success_handler);
|
element.removeEventListener(success_event, success_handler);
|
||||||
if (failure_event) {
|
if (failure_event) {
|
||||||
element.removeEventListener(failure_event, failure_handler);
|
element.removeEventListener(failure_event, failure_handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(e);
|
resolve(ev);
|
||||||
};
|
};
|
||||||
let failure_handler = e => {
|
let failure_handler = ev => {
|
||||||
element.removeEventListener(success_event, success_handler);
|
element.removeEventListener(success_event, success_handler);
|
||||||
if (failure_event) {
|
if (failure_event) {
|
||||||
element.removeEventListener(failure_event, failure_handler);
|
element.removeEventListener(failure_event, failure_handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(e);
|
reject(ev);
|
||||||
};
|
};
|
||||||
|
|
||||||
element.addEventListener(success_event, success_handler);
|
element.addEventListener(success_event, success_handler);
|
||||||
@@ -61,6 +131,7 @@ export function promise_event(element, success_event, failure_event) {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function fetch(url) {
|
export async function fetch(url) {
|
||||||
let xhr = new XMLHttpRequest;
|
let xhr = new XMLHttpRequest;
|
||||||
let promise = promise_event(xhr, 'load', 'error');
|
let promise = promise_event(xhr, 'load', 'error');
|
||||||
@@ -68,9 +139,19 @@ export async function fetch(url) {
|
|||||||
xhr.responseType = 'arraybuffer';
|
xhr.responseType = 'arraybuffer';
|
||||||
xhr.send();
|
xhr.send();
|
||||||
await promise;
|
await promise;
|
||||||
|
if (xhr.status !== 200)
|
||||||
|
throw new Error(`Failed to load ${url} -- ${xhr.status} ${xhr.statusText}`);
|
||||||
return xhr.response;
|
return xhr.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function string_from_buffer_ascii(buf, start = 0, len) {
|
||||||
|
if (ArrayBuffer.isView(buf)) {
|
||||||
|
start += buf.byteOffset;
|
||||||
|
buf = buf.buffer;
|
||||||
|
}
|
||||||
|
return String.fromCharCode.apply(null, new Uint8Array(buf, start, len));
|
||||||
|
}
|
||||||
|
|
||||||
// Cast a line through a grid and yield every cell it touches
|
// Cast a line through a grid and yield every cell it touches
|
||||||
export function* walk_grid(x0, y0, x1, y1) {
|
export function* walk_grid(x0, y0, x1, y1) {
|
||||||
// TODO if the ray starts outside the grid (extremely unlikely), we should
|
// TODO if the ray starts outside the grid (extremely unlikely), we should
|
||||||
@@ -171,3 +252,101 @@ export function* walk_grid(x0, y0, x1, y1) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Root class to indirect over where we might get files from
|
||||||
|
// - a pool of uploaded in-memory files
|
||||||
|
// - a single uploaded zip file
|
||||||
|
// - a local directory provided via the webkit Entry api
|
||||||
|
// - HTTP (but only for files we choose ourselves, not arbitrary ones, due to CORS)
|
||||||
|
// Note that where possible, these classes lowercase all filenames, in keeping with C2G's implicit
|
||||||
|
// requirement that filenames are case-insensitive :/
|
||||||
|
class FileSource {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
// Get a file's contents as an ArrayBuffer
|
||||||
|
async get(path) {}
|
||||||
|
}
|
||||||
|
// Files we have had uploaded one at a time (note that each upload becomes its own source)
|
||||||
|
export class FileFileSource extends FileSource {
|
||||||
|
constructor(files) {
|
||||||
|
super();
|
||||||
|
this.files = {};
|
||||||
|
for (let file of files) {
|
||||||
|
this.files[(file.webkitRelativePath ?? file.name).toLowerCase()] = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(path) {
|
||||||
|
let file = this.files[path.toLowerCase()];
|
||||||
|
if (file) {
|
||||||
|
return file.arrayBuffer();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return Promise.reject(new Error(`No such file was provided: ${path}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Regular HTTP fetch
|
||||||
|
export class HTTPFileSource extends FileSource {
|
||||||
|
// Should be given a URL object as a root
|
||||||
|
constructor(root) {
|
||||||
|
super();
|
||||||
|
this.root = root;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(path) {
|
||||||
|
let url = new URL(path, this.root);
|
||||||
|
return fetch(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// WebKit Entry interface
|
||||||
|
// XXX this does not appear to work if you drag in a link to a directory but that is probably beyond
|
||||||
|
// my powers to fix
|
||||||
|
export class EntryFileSource extends FileSource {
|
||||||
|
constructor(entries) {
|
||||||
|
super();
|
||||||
|
this.files = {};
|
||||||
|
let file_count = 0;
|
||||||
|
|
||||||
|
let read_directory = async (directory_entry, dir_prefix) => {
|
||||||
|
let reader = directory_entry.createReader();
|
||||||
|
let all_entries = [];
|
||||||
|
while (true) {
|
||||||
|
let entries = await new Promise((res, rej) => reader.readEntries(res, rej));
|
||||||
|
all_entries.push.apply(all_entries, entries);
|
||||||
|
if (entries.length === 0)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handle_entries(all_entries, dir_prefix);
|
||||||
|
};
|
||||||
|
let handle_entries = (entries, dir_prefix) => {
|
||||||
|
file_count += entries.length;
|
||||||
|
if (file_count > 4096)
|
||||||
|
throw new LLError("Found way too many files; did you drag in the wrong directory?");
|
||||||
|
|
||||||
|
let dir_promises = [];
|
||||||
|
for (let entry of entries) {
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
dir_promises.push(read_directory(entry, dir_prefix + entry.name + '/'));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.files[(dir_prefix + entry.name).toLowerCase()] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(dir_promises);
|
||||||
|
};
|
||||||
|
|
||||||
|
this._loaded_promise = handle_entries(entries, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(path) {
|
||||||
|
let entry = this.files[path.toLowerCase()];
|
||||||
|
if (! entry)
|
||||||
|
throw new LLError(`No such file in local directory: ${path}`);
|
||||||
|
|
||||||
|
let file = await new Promise((res, rej) => entry.file(res, rej));
|
||||||
|
return await file.arrayBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -82,6 +82,12 @@ p:first-child {
|
|||||||
p:last-child {
|
p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
color: #c0c0e0;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #c0c0c0;
|
color: #c0c0c0;
|
||||||
@@ -154,6 +160,11 @@ a:active {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
}
|
}
|
||||||
|
.dialog pre.error {
|
||||||
|
color: #400000;
|
||||||
|
background: #f0d0d0;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Individual overlays */
|
/* Individual overlays */
|
||||||
table.level-browser {
|
table.level-browser {
|
||||||
@@ -188,6 +199,10 @@ table.level-browser tr.--unvisited {
|
|||||||
color: #606060;
|
color: #606060;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
table.level-browser tr.--error {
|
||||||
|
color: #600000;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
table.level-browser tbody tr {
|
table.level-browser tbody tr {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -281,7 +296,7 @@ body > header {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
|
|
||||||
margin: 0.25em;
|
padding: 0.5em;
|
||||||
line-height: 1.125;
|
line-height: 1.125;
|
||||||
}
|
}
|
||||||
body > header h1 {
|
body > header h1 {
|
||||||
@@ -291,14 +306,13 @@ body > header h2 {
|
|||||||
font-size: 1.33em;
|
font-size: 1.33em;
|
||||||
}
|
}
|
||||||
body > header h3 {
|
body > header h3 {
|
||||||
font-size: 1.25em;
|
font-size: 1.75em;
|
||||||
}
|
}
|
||||||
body > header > nav {
|
body > header > nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
margin: 0.25rem 0.5rem;
|
|
||||||
}
|
}
|
||||||
body > header button {
|
body > header button {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
@@ -317,8 +331,25 @@ body[data-mode=player] #editor-play {
|
|||||||
order: 3;
|
order: 3;
|
||||||
color: #606060;
|
color: #606060;
|
||||||
}
|
}
|
||||||
#header-level {
|
|
||||||
font-size: 1.5em;
|
@media (max-width: 800px) {
|
||||||
|
body > header {
|
||||||
|
padding: 0.25em;
|
||||||
|
}
|
||||||
|
/* All these headings are way too big on phones */
|
||||||
|
body > header h1 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
}
|
||||||
|
body > header h2 {
|
||||||
|
font-size: 1.125em;
|
||||||
|
}
|
||||||
|
body > header h3 {
|
||||||
|
font-size: 1.0625em;
|
||||||
|
}
|
||||||
|
body > header p {
|
||||||
|
/* "a game by eevee" takes up too much space :( */
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**************************************************************************************************/
|
/**************************************************************************************************/
|
||||||
@@ -334,10 +365,31 @@ body[data-mode=player] #editor-play {
|
|||||||
;
|
;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
padding: 1em 10%;
|
padding: 1em 10%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
#splash > .drag-overlay {
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
font-size: 10vmin;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: #fff2;
|
||||||
|
border: 0.25rem dashed white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
text-shadow: 0 1px 5px black;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#splash.--drag-hover > .drag-overlay {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
#splash > header {
|
#splash > header {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
@@ -371,7 +423,8 @@ body[data-mode=player] #editor-play {
|
|||||||
#splash > #splash-upload-levels {
|
#splash > #splash-upload-levels {
|
||||||
grid-area: upload;
|
grid-area: upload;
|
||||||
}
|
}
|
||||||
#splash-upload {
|
#splash-upload-file,
|
||||||
|
#splash-upload-dir {
|
||||||
/* Hide the file upload control, which is ugly */
|
/* Hide the file upload control, which is ugly */
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -379,6 +432,23 @@ body[data-mode=player] #editor-play {
|
|||||||
grid-area: yours;
|
grid-area: yours;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
#splash {
|
||||||
|
/* Grid layout doesn't fit, just stack everything */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* 10% padding is way way too much */
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
/* Shrink logo and title */
|
||||||
|
#splash > header img {
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
#splash > header h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
button.level-pack-button {
|
button.level-pack-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid:
|
grid:
|
||||||
@@ -469,7 +539,7 @@ button.level-pack-button p {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
font-size: calc(0.5 * var(--tile-width) * var(--scale));
|
font-size: calc(0.5 * var(--tile-width) * var(--scale));
|
||||||
padding: 2%;
|
padding: 2%;
|
||||||
background: #0009;
|
background: #0009;
|
||||||
@@ -552,7 +622,6 @@ dl.score-chart .-sum {
|
|||||||
.bonus output {
|
.bonus output {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
padding: 0.125em;
|
|
||||||
min-width: 2em;
|
min-width: 2em;
|
||||||
min-height: 1em;
|
min-height: 1em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -584,9 +653,11 @@ dl.score-chart .-sum {
|
|||||||
}
|
}
|
||||||
#player .bonus {
|
#player .bonus {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
#player.--bonus-visible .bonus {
|
#player.--bonus-visible .bonus {
|
||||||
visibility: initial;
|
visibility: initial;
|
||||||
|
display: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
@@ -644,11 +715,14 @@ dl.score-chart .-sum {
|
|||||||
#player-music {
|
#player-music {
|
||||||
grid-area: music;
|
grid-area: music;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
margin: 0 1em;
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
color: #909090;
|
color: #909090;
|
||||||
}
|
}
|
||||||
#player-music #player-music-left {
|
#player-music #player-music-left {
|
||||||
flex: 1 0 auto;
|
/* allow me to wrap if need be */
|
||||||
|
flex: 1 0 0px;
|
||||||
}
|
}
|
||||||
#player-music #player-music-right {
|
#player-music #player-music-right {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -709,6 +783,45 @@ main.--has-demo .demo-controls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
#player {
|
||||||
|
/* sentinel for js */
|
||||||
|
--is-portrait: 1;
|
||||||
|
/* The play area isn't necessarily the biggest thing any more, and it's ugly when stretched */
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#player > .-main-area {
|
||||||
|
/* Rearrange the grid to be vertical */
|
||||||
|
grid:
|
||||||
|
"level level"
|
||||||
|
"chips inventory"
|
||||||
|
"time inventory"
|
||||||
|
"bonus inventory"
|
||||||
|
/ min-content min-content
|
||||||
|
;
|
||||||
|
row-gap: 0.5em;
|
||||||
|
column-gap: 1em;
|
||||||
|
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
#player .inventory {
|
||||||
|
/* stick me in the center right */
|
||||||
|
place-self: center end;
|
||||||
|
}
|
||||||
|
#player .message {
|
||||||
|
/* Overlay hints on the inventory area */
|
||||||
|
grid-row: chips / bonus;
|
||||||
|
grid-column: level;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: calc(var(--tile-height) * var(--scale) / 2.5);
|
||||||
|
}
|
||||||
|
#player-music {
|
||||||
|
/* Stack the title/artist on the volume, since they don't fit well side by side */
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**************************************************************************************************/
|
/**************************************************************************************************/
|
||||||
/* Editor */
|
/* Editor */
|
||||||
|
|
||||||
@@ -716,9 +829,9 @@ main.--has-demo .demo-controls {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid:
|
grid:
|
||||||
"controls level" min-content
|
"controls controls" min-content
|
||||||
"palette level" 1fr
|
"palette level" 1fr
|
||||||
/ minmax(25%, auto) auto
|
/ minmax(25%, auto) 1fr
|
||||||
;
|
;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
|
|
||||||
@@ -731,7 +844,12 @@ main.--has-demo .demo-controls {
|
|||||||
grid-area: level;
|
grid-area: level;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
/* Padding and background make it easier to tell when we're at the edge of the map */
|
||||||
|
/* TODO padding should be half a cell, and svg should respect it too */
|
||||||
|
/* padding: 1em; */
|
||||||
|
background: #202020;
|
||||||
}
|
}
|
||||||
|
/* SVG overlays */
|
||||||
#editor svg.level-editor-overlay {
|
#editor svg.level-editor-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -741,16 +859,30 @@ main.--has-demo .demo-controls {
|
|||||||
height: 1024px;
|
height: 1024px;
|
||||||
/* allow clicks to go through us! */
|
/* allow clicks to go through us! */
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
|
||||||
#editor .level-editor-overlay rect.overlay-cxn {
|
/* default svg properties */
|
||||||
stroke-width: 0.0625;
|
stroke-width: 0.0625;
|
||||||
stroke: red;
|
|
||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
#editor .level-editor-overlay line.overlay-cxn {
|
#editor .level-editor-overlay rect.overlay-cxn {
|
||||||
stroke-width: 0.0625;
|
|
||||||
stroke: red;
|
stroke: red;
|
||||||
}
|
}
|
||||||
|
#editor .level-editor-overlay line.overlay-cxn {
|
||||||
|
stroke: red;
|
||||||
|
}
|
||||||
|
#editor .level-editor-overlay rect.overlay-camera {
|
||||||
|
stroke: #808080;
|
||||||
|
fill: #80808040;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
#editor .level-editor-overlay text {
|
||||||
|
/* Each cell is one "pixel", so text needs to be real small */
|
||||||
|
font-size: 1px;
|
||||||
|
}
|
||||||
|
#editor .level-editor-overlay text.overlay-edit-tip {
|
||||||
|
stroke: none;
|
||||||
|
fill: black;
|
||||||
|
}
|
||||||
|
|
||||||
#editor .controls {
|
#editor .controls {
|
||||||
grid-area: controls;
|
grid-area: controls;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
Binary file not shown.
Reference in new issue
Block a user