Compare commits
58
Commits
1.2
..
frame-based
No files matched your search
@@ -45,4 +45,4 @@ Give it a try, I guess! [https://c.eev.ee/lexys-labyrinth/](https://c.eev.ee/le
|
||||
- [Tile World](https://wiki.bitbusters.club/Tile_World) for being an incredible reference on Lynx mechanics
|
||||
- Everyone who contributed music — see [`js/soundtrack.js`](js/soundtrack.js) for a list!
|
||||
|
||||
Not associated with or blessed by Chuck Somerville, Niffler, or AOP.
|
||||
Not associated with or blessed by Chuck Sommerville, Niffler, or AOP.
|
||||
+122
-65
@@ -13,6 +13,35 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
</head>
|
||||
<body data-mode="splash">
|
||||
<svg id="svg-iconsheet">
|
||||
<defs>
|
||||
<!-- Actions -->
|
||||
<g id="svg-icon-up">
|
||||
<path d="M0,12 l8,-8 l8,8 z"></path>
|
||||
</g>
|
||||
<g id="svg-icon-right">
|
||||
<use href="#svg-icon-up" transform="rotate(90 8 8)"></use>
|
||||
</g>
|
||||
<g id="svg-icon-down">
|
||||
<use href="#svg-icon-up" transform="rotate(180 8 8)"></use>
|
||||
</g>
|
||||
<g id="svg-icon-left">
|
||||
<use href="#svg-icon-up" transform="rotate(270 8 8)"></use>
|
||||
</g>
|
||||
<g id="svg-icon-drop">
|
||||
<path d="M6,0 h4 v9 h3 l-5,5 h7 v2 h-14 v-2 h7 l-5,-5 h3"></path>
|
||||
</g>
|
||||
<g id="svg-icon-cycle">
|
||||
<path d="M2,3 H11 V1 l4,4 -4,4 V7 H2 Z"></path>
|
||||
<path d="M14,9 H5 V7 l-4,4 4,4 v-2 h9 z"></path>
|
||||
</g>
|
||||
<g id="svg-icon-swap">
|
||||
<path d="m 7,1 h 2 l 1,1 V 6 L 9,7 v 4 L 8,11.5 7,11 V 7 L 6,6 V 2 Z"></path>
|
||||
<path d="M 8,13 13,11 8,9 3,11 Z m 0,2 7,-3 V 11 L 8,8 1,11 v 1 z"></path>
|
||||
<ellipse cx="5.5" cy="11" rx="0.75" ry="0.5"></ellipse>
|
||||
</g>
|
||||
</defs>
|
||||
</svg>
|
||||
<header id="header-main">
|
||||
<img id="header-icon" src="icon.png" alt="">
|
||||
<h1>Lexy's Labyrinth</h1>
|
||||
@@ -26,9 +55,10 @@
|
||||
<header id="header-pack">
|
||||
<h2 id="level-pack-name">Chip's Challenge Level Pack 1</h2>
|
||||
<nav>
|
||||
<button id="main-test-pack" type="button">Bulk test</button>
|
||||
<button id="main-change-pack" type="button">Change pack</button>
|
||||
<button id="player-edit" type="button">Edit</button>
|
||||
<button id="editor-play" type="button">Test</button>
|
||||
<button id="editor-play" type="button">Play</button>
|
||||
</nav>
|
||||
</header>
|
||||
<header id="header-level">
|
||||
@@ -86,7 +116,36 @@
|
||||
</section>
|
||||
</main>
|
||||
<main id="player" hidden>
|
||||
<section class="-main-area">
|
||||
<div id="player-main">
|
||||
<div class="controls">
|
||||
<div class="play-controls">
|
||||
<button class="control-pause" type="button" title="pause">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M2,1 h4 v14 h-4 z M10,1 h4 v14 h-4 z"></svg>
|
||||
<span class="keyhint">p</span></button>
|
||||
<button class="control-restart" type="button" title="restart">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M13,13 A 7,7 270 1,1 13,3 L15,1 15,7 9,7 11,5 A 4,4 270 1,0 11,11 z"></svg>
|
||||
</button>
|
||||
<button class="control-undo" type="button" title="undo">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M6,5 6,2 1,7 6,12 6,9 A 10,10 60 0,1 15,12 A 10,10 90 0,0 6,5"></svg>
|
||||
</button>
|
||||
<button class="control-rewind" type="button" title="rewind">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M1,8 7,2 7,14 z M9,8 15,2 15,14 z"></svg>
|
||||
<span class="keyhint">z</span></button>
|
||||
<label><input class="control-turn-based" type="checkbox"> Turn-based mode</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="action-drop" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-drop"></use></svg>
|
||||
drop <span class="keyhint">q</span></button>
|
||||
<button class="action-cycle" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-cycle"></use></svg>
|
||||
cycle <span class="keyhint">e</span></button>
|
||||
<button class="action-swap" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-swap"></use></svg>
|
||||
switch <span class="keyhint">c</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<section id="player-game-area">
|
||||
<div class="level"><!-- level canvas and any overlays go here --></div>
|
||||
<div class="overlay-message">
|
||||
<h1 class="-top"></h1>
|
||||
@@ -109,8 +168,19 @@
|
||||
</div>
|
||||
<div class="inventory"></div>
|
||||
</section>
|
||||
<div id="player-music">
|
||||
<div id="player-music-left">
|
||||
🎵 <a id="player-music-title">title</a> by <a id="player-music-author">author</a>
|
||||
</div>
|
||||
<div id="player-music-right">
|
||||
<input id="player-music-volume" type="range" min="0" max="1" step="0.05" value="1">
|
||||
<input id="player-music-unmute" type="checkbox" checked>
|
||||
</div>
|
||||
<audio loop preload="auto">
|
||||
</div>
|
||||
</div>
|
||||
<form id="player-debug">
|
||||
<div class="-time">
|
||||
<h3>Time</h3>
|
||||
<table class="-time-controls">
|
||||
<tr>
|
||||
<td><button type="button" class="-time-button" data-dt="-1">← 1 tic</button></td>
|
||||
@@ -131,24 +201,60 @@
|
||||
<td><button type="button" class="-time-button" data-dt="20">1 s →</button></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="-buttons" id="player-debug-time-buttons">
|
||||
<!-- populated in js -->
|
||||
</div>
|
||||
<p>Game speed:
|
||||
<select name="speed">
|
||||
<option value="6">6× — 120 tics per second</option>
|
||||
<option value="3">3× — 60 tics per second</option>
|
||||
<option value="2">2× — 40 tics per second</option>
|
||||
<option value="3/2">1½× — 30 tics per second</option>
|
||||
<option value="1" selected>1× — 20 tics per second</option>
|
||||
<option value="1/2">½× — 10 tics per second</option>
|
||||
<option value="1/3">⅓× — 6⅔ tics per second</option>
|
||||
<option value="1/4">¼× — 5 tics per second</option>
|
||||
<option value="100">100× faster</option>
|
||||
<option value="50">50× faster</option>
|
||||
<option value="20">20× faster</option>
|
||||
<option value="10">10× faster</option>
|
||||
<option value="5">5× faster</option>
|
||||
<option value="3">3× faster</option>
|
||||
<option value="2">2× faster</option>
|
||||
<option value="3/2">1½× faster</option>
|
||||
<option value="1" selected>Normal speed</option>
|
||||
<option value="1/2">2× slower</option>
|
||||
<option value="1/3">3× slower</option>
|
||||
<option value="1/4">4× slower</option>
|
||||
</select>
|
||||
</p>
|
||||
|
||||
<h3>Inventory</h3>
|
||||
<div class="-inventory">
|
||||
<!-- populated in js -->
|
||||
</div>
|
||||
<div class="input"></div>
|
||||
<div class="-options">
|
||||
|
||||
<h3>Replay</h3>
|
||||
<!-- TODO...
|
||||
play back replay
|
||||
record replay, including altering it from here
|
||||
stop replay, without restarting the level
|
||||
show progress in %, length in tics + time
|
||||
browse replay? jump to any point? label points???
|
||||
edit manually?
|
||||
-->
|
||||
<div class="-replay-columns">
|
||||
<div id="player-debug-input"></div>
|
||||
<div class="-replay-status">
|
||||
<!-- This should be a fixed height and is always showing one of the following -->
|
||||
<div class="-none">No replay in progress</div>
|
||||
<div class="-playback">
|
||||
<progress max="0" value="0"></progress>
|
||||
<output>100%</output>
|
||||
<span>0 tics (0:00s)</span>
|
||||
<button disabled>Relinquish control</button>
|
||||
</div>
|
||||
<div class="-recording">Recording...</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- js inserts a bunch of stuff here -->
|
||||
|
||||
<h3>Misc</h3>
|
||||
<p>Viewport size:
|
||||
<select id="player-debug-viewport">
|
||||
<option value="default" selected>Level default (9 or 10)</option>
|
||||
<option value="default" selected>Standard</option>
|
||||
<option value="12">12 × 12</option>
|
||||
<option value="16">16 × 16</option>
|
||||
<option value="24">24 × 24</option>
|
||||
@@ -167,14 +273,10 @@
|
||||
<li><label><input type="checkbox" disabled> Player levitates</label></li>
|
||||
-->
|
||||
</ul>
|
||||
<p>(Middle-click to teleport.)</p>
|
||||
</div>
|
||||
<div class="-inventory">
|
||||
<!-- populated in js -->
|
||||
</div>
|
||||
<div class="-buttons">
|
||||
<div class="-buttons" id="player-debug-misc-buttons">
|
||||
<!-- populated in js -->
|
||||
</div>
|
||||
<p>Tip: Middle-click to teleport.</p>
|
||||
|
||||
<!-- TODO?
|
||||
- inspect with mouse
|
||||
@@ -191,51 +293,6 @@
|
||||
- total bonus flags?
|
||||
-->
|
||||
</form>
|
||||
<div id="player-music">
|
||||
<div id="player-music-left">
|
||||
🎵 <a id="player-music-title">title</a> by <a id="player-music-author">author</a>
|
||||
</div>
|
||||
<div id="player-music-right">
|
||||
<input id="player-music-volume" type="range" min="0" max="1" step="0.05" value="1">
|
||||
<input id="player-music-unmute" type="checkbox" checked>
|
||||
</div>
|
||||
<audio loop preload="auto">
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="play-controls">
|
||||
<button class="control-pause" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="pause"><path d="M2,1 h4 v14 h-4 z M10,1 h4 v14 h-4 z"></svg>
|
||||
<span class="keyhint">p</span></button>
|
||||
<button class="control-restart" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="restart"><path d="M13,13 A 7,7 270 1,1 13,3 L15,1 15,7 9,7 11,5 A 4,4 270 1,0 11,11 z"></svg>
|
||||
</button>
|
||||
<button class="control-undo" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="undo"><path d="M6,5 6,2 1,7 6,12 6,9 A 10,10 60 0,1 15,12 A 10,10 90 0,0 6,5"></svg>
|
||||
</button>
|
||||
<button class="control-rewind" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="rewind"><path d="M1,8 7,2 7,14 z M9,8 15,2 15,14 z"></svg>
|
||||
<span class="keyhint">z</span></button>
|
||||
<label><input class="control-turn-based" type="checkbox"> Turn-based mode</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="action-drop" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="pause"><path d="M6,0 h4 v9 h3 l-5,5 h7 v2 h-14 v-2 h7 l-5,-5 h3"></svg>
|
||||
drop <span class="keyhint">q</span></button>
|
||||
<button class="action-cycle" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="pause">
|
||||
<path d="M2,3 H11 V1 l4,4 -4,4 V7 H2 Z"></path>
|
||||
<path d="M14,9 H5 V7 l-4,4 4,4 v-2 h9 z"></path>
|
||||
</svg>
|
||||
cycle <span class="keyhint">e</span></button>
|
||||
<button class="action-swap" type="button">
|
||||
<svg class="svg-icon" viewBox="0 0 16 16" title="pause">
|
||||
<path d="m 7,1 h 2 l 1,1 V 6 L 9,7 v 4 L 8,11.5 7,11 V 7 L 6,6 V 2 Z"></path>
|
||||
<path d="M 8,13 13,11 8,9 3,11 Z m 0,2 7,-3 V 11 L 8,8 1,11 v 1 z"></path>
|
||||
<ellipse cx="5.5" cy="11" rx="0.75" ry="0.5"></ellipse>
|
||||
</svg>
|
||||
switch <span class="keyhint">c</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<main id="editor" hidden>
|
||||
<header>
|
||||
|
||||
+12
@@ -41,6 +41,18 @@ export const DIRECTIONS = {
|
||||
// Should match the bit ordering above, and CC2's order
|
||||
export const DIRECTION_ORDER = ['north', 'east', 'south', 'west'];
|
||||
|
||||
export const INPUT_BITS = {
|
||||
drop: 0x01,
|
||||
down: 0x02,
|
||||
left: 0x04,
|
||||
right: 0x08,
|
||||
up: 0x10,
|
||||
swap: 0x20,
|
||||
cycle: 0x40,
|
||||
// Not real input; used to force advancement for turn-based mode
|
||||
wait: 0x8000,
|
||||
};
|
||||
|
||||
// TODO cc2 order is: swivel, thinwalls, canopy (and yes you can have them all in the same tile)
|
||||
export const DRAW_LAYERS = {
|
||||
terrain: 0,
|
||||
|
||||
@@ -3,6 +3,51 @@ import * as util from './util.js';
|
||||
export class StoredCell extends Array {
|
||||
}
|
||||
|
||||
export class Replay {
|
||||
constructor(initial_force_floor_direction, blob_seed, inputs = null) {
|
||||
this.initial_force_floor_direction = initial_force_floor_direction;
|
||||
this.blob_seed = blob_seed;
|
||||
this.inputs = inputs ?? new Uint8Array;
|
||||
this.duration = this.inputs.length;
|
||||
this.cursor = 0;
|
||||
}
|
||||
|
||||
get(t) {
|
||||
if (this.duration <= 0) {
|
||||
return 0;
|
||||
}
|
||||
else if (t < this.duration) {
|
||||
return this.inputs[t];
|
||||
}
|
||||
else {
|
||||
// Last input is implicitly repeated indefinitely
|
||||
return this.inputs[this.duration - 1];
|
||||
}
|
||||
}
|
||||
|
||||
set(t, input) {
|
||||
if (t >= this.inputs.length) {
|
||||
let new_inputs = new Uint8Array(this.inputs.length + 1024);
|
||||
for (let i = 0; i < this.inputs.length; i++) {
|
||||
new_inputs[i] = this.inputs[i];
|
||||
}
|
||||
this.inputs = new_inputs;
|
||||
}
|
||||
this.inputs[t] = input;
|
||||
if (t >= this.duration) {
|
||||
this.duration = t + 1;
|
||||
}
|
||||
}
|
||||
|
||||
clone() {
|
||||
let new_inputs = new Uint8Array(this.duration);
|
||||
for (let i = 0; i < this.duration; i++) {
|
||||
new_inputs[i] = this.inputs[i];
|
||||
}
|
||||
return new this.constructor(this.initial_force_floor_direction, this.blob_seed, new_inputs);
|
||||
}
|
||||
}
|
||||
|
||||
export class StoredLevel {
|
||||
constructor(number) {
|
||||
// TODO still not sure this belongs here
|
||||
@@ -22,6 +67,12 @@ export class StoredLevel {
|
||||
// 2 - extra random (like deterministic, but initial seed is "actually" random)
|
||||
this.blob_behavior = 1;
|
||||
|
||||
// Lazy-loading that allows for checking existence (see methods below)
|
||||
// TODO this needs a better interface, these get accessed too much atm
|
||||
this._replay = null;
|
||||
this._replay_data = null;
|
||||
this._replay_decoder = null;
|
||||
|
||||
this.size_x = 0;
|
||||
this.size_y = 0;
|
||||
this.linear_cells = [];
|
||||
@@ -47,6 +98,17 @@ export class StoredLevel {
|
||||
|
||||
check() {
|
||||
}
|
||||
|
||||
get has_replay() {
|
||||
return this._replay || (this._replay_data && this._replay_decoder);
|
||||
}
|
||||
|
||||
get replay() {
|
||||
if (! this._replay) {
|
||||
this._replay = this._replay_decoder(this._replay_data);
|
||||
}
|
||||
return this._replay;
|
||||
}
|
||||
}
|
||||
|
||||
export class StoredPack {
|
||||
|
||||
+137
-47
@@ -3,66 +3,98 @@ import * as format_base from './format-base.js';
|
||||
import TILE_TYPES from './tiletypes.js';
|
||||
import * as util from './util.js';
|
||||
|
||||
const CC2_DEMO_INPUT_MASK = {
|
||||
drop: 0x01,
|
||||
down: 0x02,
|
||||
left: 0x04,
|
||||
right: 0x08,
|
||||
up: 0x10,
|
||||
swap: 0x20,
|
||||
cycle: 0x40,
|
||||
};
|
||||
|
||||
class CC2Demo {
|
||||
constructor(bytes) {
|
||||
this.bytes = bytes;
|
||||
export function decode_replay(bytes) {
|
||||
if (bytes instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
// byte 0 is unknown, always 0?
|
||||
// Force floor seed can apparently be anything; my best guess, based on the Desert Oasis
|
||||
// replay, is that it's just incremented and allowed to overflow, so taking it mod 4 gives
|
||||
// the correct starting direction
|
||||
this.initial_force_floor_direction = ['north', 'east', 'south', 'west'][this.bytes[1] % 4];
|
||||
this.blob_seed = this.bytes[2];
|
||||
}
|
||||
let initial_force_floor_direction = DIRECTION_ORDER[bytes[1] % 4];
|
||||
let blob_seed = bytes[2];
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
let l = this.bytes.length;
|
||||
// Add up the total length
|
||||
let duration = 0;
|
||||
let l = bytes.length ?? bytes.byteLength;
|
||||
if (l % 2 === 0) {
|
||||
l--;
|
||||
}
|
||||
let input = new Set;
|
||||
for (let p = 3; p < l; p += 2) {
|
||||
let delay = bytes[p];
|
||||
if (delay === 0xff)
|
||||
break;
|
||||
duration += delay;
|
||||
}
|
||||
duration = Math.floor(duration / 3) + 1; // leave room for final input
|
||||
|
||||
// Inflate the replay into an array of byte-per-tic
|
||||
let inputs = new Uint8Array(duration);
|
||||
let i = 0;
|
||||
let t = 0;
|
||||
let input = 0;
|
||||
for (let p = 3; p < l; p += 2) {
|
||||
// The first byte measures how long the /previous/ input remains
|
||||
// valid, so yield that first. Note that this is measured in 60Hz
|
||||
// frames, so we need to convert to 20Hz tics by subtracting 3
|
||||
// frames at a time.
|
||||
let delay = this.bytes[p];
|
||||
let delay = bytes[p];
|
||||
if (delay === 0xff)
|
||||
break;
|
||||
|
||||
t += delay;
|
||||
while (t >= 3) {
|
||||
t -= 3;
|
||||
yield input;
|
||||
inputs[i] = input;
|
||||
i++;
|
||||
}
|
||||
|
||||
let input_mask = this.bytes[p + 1];
|
||||
let is_player_2 = ((input_mask & 0x80) !== 0);
|
||||
input = bytes[p + 1];
|
||||
let is_player_2 = ((input & 0x80) !== 0);
|
||||
// TODO handle player 2
|
||||
if (is_player_2)
|
||||
continue;
|
||||
}
|
||||
inputs[i] = input;
|
||||
|
||||
for (let [action, bit] of Object.entries(CC2_DEMO_INPUT_MASK)) {
|
||||
if ((input_mask & bit) === 0) {
|
||||
input.delete(action);
|
||||
return new format_base.Replay(initial_force_floor_direction, blob_seed, inputs);
|
||||
}
|
||||
|
||||
export function encode_replay(replay, stored_level = null) {
|
||||
let out = new Uint8Array(1024);
|
||||
out[0] = 0;
|
||||
out[1] = DIRECTIONS[replay.initial_force_floor_direction].index;
|
||||
out[2] = replay.blob_seed;
|
||||
let p = 3;
|
||||
let prev_input = null;
|
||||
let count = 0;
|
||||
for (let i = 0; i < replay.duration; i++) {
|
||||
if (p >= out.length - 4) {
|
||||
let new_out = new Uint8Array(Math.floor(out.length * 1.5));
|
||||
for (let j = 0; j < out.length; j++) {
|
||||
new_out[j] = out[j];
|
||||
}
|
||||
out = new_out;
|
||||
}
|
||||
|
||||
let input = replay.inputs[i];
|
||||
if (input !== prev_input || count >= 252) {
|
||||
out[p] = count;
|
||||
out[p + 1] = input;
|
||||
p += 2;
|
||||
count = 3;
|
||||
prev_input = input;
|
||||
}
|
||||
else {
|
||||
input.add(action);
|
||||
}
|
||||
}
|
||||
count += 3;
|
||||
}
|
||||
}
|
||||
out[p] = 0xff;
|
||||
p += 1;
|
||||
out = out.subarray(0, p);
|
||||
// TODO stick it on the level if given?
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +112,7 @@ let modifier_wire = {
|
||||
let arg_direction = {
|
||||
size: 1,
|
||||
decode(tile, dirbyte) {
|
||||
let direction = ['north', 'east', 'south', 'west'][dirbyte & 0x03];
|
||||
let direction = DIRECTION_ORDER[dirbyte & 0x03];
|
||||
tile.direction = direction;
|
||||
},
|
||||
encode(tile) {
|
||||
@@ -323,7 +355,10 @@ const TILE_ENCODING = {
|
||||
name: 'bomb',
|
||||
has_next: true,
|
||||
},
|
||||
//0x41: Open trap (unused in main levels) :
|
||||
0x41: {
|
||||
// FIXME cc2lp1 uses this, i don't know what it actually does
|
||||
error: "Open trap is not yet implemented!",
|
||||
},
|
||||
0x42: {
|
||||
name: 'trap',
|
||||
},
|
||||
@@ -344,18 +379,21 @@ const TILE_ENCODING = {
|
||||
0x47: {
|
||||
name: 'button_gray',
|
||||
},
|
||||
// FIXME swivel floors... argh...
|
||||
0x48: {
|
||||
name: 'swivel_sw',
|
||||
dummy_terrain: 'swivel_floor',
|
||||
},
|
||||
0x49: {
|
||||
name: 'swivel_nw',
|
||||
dummy_terrain: 'swivel_floor',
|
||||
},
|
||||
0x4a: {
|
||||
name: 'swivel_ne',
|
||||
dummy_terrain: 'swivel_floor',
|
||||
},
|
||||
0x4b: {
|
||||
name: 'swivel_se',
|
||||
dummy_terrain: 'swivel_floor',
|
||||
},
|
||||
0x4c: {
|
||||
name: 'stopwatch_bonus',
|
||||
@@ -384,7 +422,7 @@ const TILE_ENCODING = {
|
||||
tile.track_switch = null;
|
||||
}
|
||||
// Initial actor facing is in the highest nybble
|
||||
tile.entered_direction = (mask >> 12) & 0x03;
|
||||
tile.entered_direction = DIRECTION_ORDER[(mask >> 12) & 0x03];
|
||||
},
|
||||
encode(tile) {
|
||||
let ret = tile.tracks & 0x3f;
|
||||
@@ -448,7 +486,7 @@ const TILE_ENCODING = {
|
||||
tile.memory = modifier - 0x1e;
|
||||
}
|
||||
else {
|
||||
tile.direction = ['north', 'east', 'south', 'west'][modifier & 0x03];
|
||||
tile.direction = DIRECTION_ORDER[modifier & 0x03];
|
||||
let type = modifier >> 2;
|
||||
if (type < 6) {
|
||||
tile.gate_type = ['not', 'and', 'or', 'xor', 'latch-cw', 'nand'][type];
|
||||
@@ -986,7 +1024,6 @@ export function parse_level(buf, number = 1) {
|
||||
let name = spec.name;
|
||||
|
||||
// Make a tile template, possibly dealing with some special cases
|
||||
// FIXME restore this
|
||||
if (name === '#thinwall/canopy') {
|
||||
// Thin walls and the canopy are combined into a single byte for some
|
||||
// reason; split them apart here. Which ones we get is determined by a
|
||||
@@ -1016,7 +1053,7 @@ export function parse_level(buf, number = 1) {
|
||||
}
|
||||
else if (name instanceof Array) {
|
||||
// Custom floors and walls are one of several options, chosen by modifier
|
||||
name = name[modifier];
|
||||
name = name[modifier % name.length];
|
||||
}
|
||||
|
||||
let type = TILE_TYPES[name];
|
||||
@@ -1027,6 +1064,13 @@ export function parse_level(buf, number = 1) {
|
||||
spec.modifier.decode(tile, modifier);
|
||||
}
|
||||
|
||||
// Swivels come with their own specific flooring
|
||||
// TODO this should go on the bottom
|
||||
// TODO we should sort and also only allow one thing per layer
|
||||
if (spec.dummy_terrain) {
|
||||
cell.push({type: TILE_TYPES[spec.dummy_terrain]});
|
||||
}
|
||||
|
||||
if (type.is_required_chip) {
|
||||
level.chips_required++;
|
||||
}
|
||||
@@ -1058,9 +1102,11 @@ export function parse_level(buf, number = 1) {
|
||||
else if (type === 'REPL' || type === 'PRPL') {
|
||||
// "Replay", i.e. demo solution
|
||||
if (type === 'PRPL') {
|
||||
// TODO is even this necessary though
|
||||
bytes = decompress(bytes);
|
||||
}
|
||||
level.demo = new CC2Demo(bytes);
|
||||
level._replay_data = bytes;
|
||||
level._replay_decoder = decode_replay;
|
||||
}
|
||||
else if (type === 'RDNY') {
|
||||
}
|
||||
@@ -1199,8 +1245,7 @@ function compress(buf) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// FIXME don't love this slice
|
||||
return outbytes.buffer.slice(0, q);
|
||||
return outbytes.subarray(0, q);
|
||||
}
|
||||
|
||||
class C2M {
|
||||
@@ -1293,19 +1338,47 @@ export function synthesize_level(stored_level) {
|
||||
c2m.add_section('LXCM', bytes.buffer);
|
||||
}
|
||||
|
||||
// FIXME well this will not do
|
||||
let map_bytes = new Uint8Array(4096);
|
||||
let map_bytes = new Uint8Array(1024);
|
||||
let map_view = new DataView(map_bytes.buffer);
|
||||
map_bytes[0] = stored_level.size_x;
|
||||
map_bytes[1] = stored_level.size_y;
|
||||
let hints = [];
|
||||
let p = 2;
|
||||
for (let cell of stored_level.linear_cells) {
|
||||
// If we're in danger of running out of room in our buffer, add another kilobyte and copy
|
||||
if (p >= map_bytes.length - 64) {
|
||||
let new_bytes = new Uint8Array(map_bytes.length + 1024);
|
||||
for (let i = 0; i < map_bytes.length; i++) {
|
||||
new_bytes[i] = map_bytes[i];
|
||||
}
|
||||
map_bytes = new_bytes;
|
||||
map_view = new DataView(map_bytes.buffer);
|
||||
}
|
||||
|
||||
// TODO complain if duplicates on a layer
|
||||
let dummy_terrain_tile = null;
|
||||
for (let i = cell.length - 1; i >= 0; i--) {
|
||||
let tile = cell[i];
|
||||
// FIXME does not yet support canopy or thin walls >:S
|
||||
let spec = REVERSE_TILE_ENCODING[tile.type.name];
|
||||
|
||||
// Handle the swivel, a tile that draws as an overlay but is stored like terrain. In a
|
||||
// level, it has two parts: the swivel itself, and a dummy swivel_floor terrain which is
|
||||
// unencodable. To encode that, we notice when we hit a swivel (which happens first),
|
||||
// save it until we reach the terrain layer, and then sub it in instead.
|
||||
// TODO if i follow in tyler's footsteps and give swivel its own layer then i'll need to
|
||||
// complicate this somewhat
|
||||
if (tile.type.draw_layer === 0 && dummy_terrain_tile) {
|
||||
tile = dummy_terrain_tile;
|
||||
spec = REVERSE_TILE_ENCODING[tile.type.name];
|
||||
}
|
||||
else if (spec.dummy_terrain) {
|
||||
// This is a swivel, a tile that draws as an overlay but is stored like terrain, so
|
||||
// wait until we hit terrain and store it then
|
||||
dummy_terrain_tile = tile;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (spec.modifier) {
|
||||
let mod = spec.modifier.encode(tile);
|
||||
if (mod === 0) {
|
||||
@@ -1349,6 +1422,7 @@ export function synthesize_level(stored_level) {
|
||||
// TODO assert that the bottom tile has no next, and all the others do
|
||||
}
|
||||
}
|
||||
map_bytes = map_bytes.subarray(0, p);
|
||||
|
||||
// Collect hints first so we can put them in the comment field
|
||||
// FIXME this does not respect global hint, but then, neither does the editor.
|
||||
@@ -1357,14 +1431,31 @@ export function synthesize_level(stored_level) {
|
||||
hints.unshift('');
|
||||
c2m.add_section('NOTE', hints.join('\n[CLUE]\n'));
|
||||
|
||||
// FIXME ack, ArrayBuffer.slice makes a copy actually! and i use it a lot in this file i think!!
|
||||
let map_buf = map_bytes.buffer.slice(0, p);
|
||||
let compressed_map = compress(map_buf);
|
||||
let compressed_map = compress(map_bytes);
|
||||
if (compressed_map) {
|
||||
c2m.add_section('PACK', compressed_map);
|
||||
}
|
||||
else {
|
||||
c2m.add_section('MAP ', map_buf);
|
||||
c2m.add_section('MAP ', map_bytes);
|
||||
}
|
||||
|
||||
// Add the replay, if any
|
||||
if (stored_level.has_replay) {
|
||||
let replay_bytes;
|
||||
if (stored_level._replay_data && stored_level._replay_decoder === decode_replay) {
|
||||
replay_bytes = stored_level._replay_data;
|
||||
}
|
||||
else {
|
||||
replay_bytes = encode_replay(stored_level.replay, stored_level);
|
||||
}
|
||||
|
||||
let compressed_replay = compress(replay_bytes);
|
||||
if (compressed_replay) {
|
||||
c2m.add_section('PRPL', compressed_replay);
|
||||
}
|
||||
else {
|
||||
c2m.add_section('REPL', replay_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
c2m.add_section('END ', '');
|
||||
@@ -1399,7 +1490,7 @@ const TOKENIZE_RX = RegExp(
|
||||
// 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+)' +
|
||||
@@ -1826,7 +1917,6 @@ const MAX_SIMULTANEOUS_REQUESTS = 5;
|
||||
resolve(game);
|
||||
}
|
||||
|
||||
console.log(game);
|
||||
return promise;
|
||||
}
|
||||
|
||||
|
||||
+851
-356
File diff suppressed because it is too large.
Load diff
@@ -120,6 +120,17 @@ export class ConfirmOverlay extends DialogOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
export function flash_button(button) {
|
||||
button.classList.add('--button-glow-ok');
|
||||
window.setTimeout(() => {
|
||||
button.classList.add('--button-glow');
|
||||
button.classList.remove('--button-glow-ok');
|
||||
}, 10);
|
||||
window.setTimeout(() => {
|
||||
button.classList.remove('--button-glow');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
export function load_json_from_storage(key) {
|
||||
return JSON.parse(window.localStorage.getItem(key));
|
||||
}
|
||||
|
||||
+25
-9
@@ -2,10 +2,11 @@ import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
|
||||
import { TILES_WITH_PROPS } from './editor-tile-overlays.js';
|
||||
import * as format_base from './format-base.js';
|
||||
import * as c2g from './format-c2g.js';
|
||||
import { PrimaryView, TransientOverlay, DialogOverlay, load_json_from_storage, save_json_to_storage } from './main-base.js';
|
||||
import { PrimaryView, TransientOverlay, DialogOverlay, flash_button, load_json_from_storage, save_json_to_storage } from './main-base.js';
|
||||
import CanvasRenderer from './renderer-canvas.js';
|
||||
import TILE_TYPES from './tiletypes.js';
|
||||
import { SVG_NS, mk, mk_svg, string_from_buffer_ascii, bytestring_to_buffer, walk_grid } from './util.js';
|
||||
import * as util from './util.js';
|
||||
|
||||
class EditorPackMetaOverlay extends DialogOverlay {
|
||||
constructor(conductor, stored_pack) {
|
||||
@@ -53,7 +54,7 @@ class EditorLevelMetaOverlay extends DialogOverlay {
|
||||
text = "No time limit";
|
||||
}
|
||||
else {
|
||||
text = `${time_limit} (${Math.floor(time_limit / 60)}:${('0' + String(time_limit % 60)).slice(-2)})`;
|
||||
text = `${time_limit} (${util.format_duration(time_limit)})`;
|
||||
}
|
||||
time_limit_output.textContent = text;
|
||||
};
|
||||
@@ -266,8 +267,8 @@ class EditorShareOverlay extends DialogOverlay {
|
||||
this.main.append(mk('p.editor-share-url', {}, url));
|
||||
let copy_button = mk('button', {type: 'button'}, "Copy to clipboard");
|
||||
copy_button.addEventListener('click', ev => {
|
||||
flash_button(ev.target);
|
||||
navigator.clipboard.writeText(url);
|
||||
// TODO feedback?
|
||||
});
|
||||
this.main.append(copy_button);
|
||||
|
||||
@@ -681,7 +682,7 @@ class WireOperation extends DrawOperation {
|
||||
for (let [qx, qy] of walk_grid(
|
||||
this.gx1f * 4, this.gy1f * 4, gxf * 4, gyf * 4,
|
||||
// See comment in iter_touched_cells
|
||||
-1, -1, this.editor.stored_level.size_x * 2, this.editor.stored_level.size_y * 2))
|
||||
-1, -1, this.editor.stored_level.size_x * 4, this.editor.stored_level.size_y * 4))
|
||||
{
|
||||
if (prevqx === null || prevqy === null) {
|
||||
prevqx = qx;
|
||||
@@ -1622,12 +1623,27 @@ export class Editor extends PrimaryView {
|
||||
if (this.stored_level) {
|
||||
this.save_button.disabled = ! this.conductor.stored_game.editor_metadata;
|
||||
}
|
||||
_make_button("Share", ev => {
|
||||
_make_button("Download", ev => {
|
||||
// TODO also allow download as CCL
|
||||
// TODO support getting warnings + errors out of synthesis
|
||||
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 blob = new Blob([buf]);
|
||||
let url = URL.createObjectURL(blob);
|
||||
// To download a file, um, make an <a> and click it. Not kidding
|
||||
let a = mk('a', {
|
||||
href: url,
|
||||
download: (this.stored_level.title || 'untitled') + '.c2m',
|
||||
});
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
// Absolutely no idea when I'm allowed to revoke this, but surely a minute is safe
|
||||
window.setTimeout(() => {
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, 60 * 1000);
|
||||
});
|
||||
_make_button("Share", ev => {
|
||||
let data = util.b64encode(c2g.synthesize_level(this.stored_level));
|
||||
let url = new URL(location);
|
||||
url.searchParams.delete('level');
|
||||
url.searchParams.delete('setpath');
|
||||
|
||||
+560
-212
File diff suppressed because it is too large.
Load diff
+11
-8
@@ -131,8 +131,9 @@ export const CC2_TILESET_LAYOUT = {
|
||||
canopy: [14, 3],
|
||||
// canopy xray
|
||||
|
||||
// TODO lit
|
||||
dynamite: [0, 4],
|
||||
// FIXME lit frames
|
||||
dynamite_lit: [0, 4],
|
||||
bomb: [5, 4],
|
||||
green_bomb: [6, 4],
|
||||
// TODO bomb fuse tile, ugh
|
||||
@@ -349,7 +350,8 @@ export const CC2_TILESET_LAYOUT = {
|
||||
south: [[1, 17], [0, 17]],
|
||||
west: [[5, 17], [4, 17]],
|
||||
},
|
||||
bowling_ball: [6, 17], // TODO also +18 when rolling
|
||||
bowling_ball: [6, 17],
|
||||
rolling_ball: [[6, 17], [7, 17]],
|
||||
tank_yellow: {
|
||||
north: [[8, 17], [9, 17]],
|
||||
east: [[10, 17], [11, 17]],
|
||||
@@ -907,7 +909,7 @@ export class Tileset {
|
||||
// Deal with animation
|
||||
if (coords[0] instanceof Array) {
|
||||
if (tic !== null) {
|
||||
if (tile && tile.animation_speed) {
|
||||
if (tile && tile.movement_speed) {
|
||||
// This tile reports its own animation timing (in tics), so trust that, and just
|
||||
// use the current tic's fraction.
|
||||
// That said: adjusting animation speed complicates this slightly. Consider the
|
||||
@@ -919,7 +921,7 @@ export class Tileset {
|
||||
// first half; if it started on tics 5-8, play the second half. They could get
|
||||
// out of sync if the player hesitates, but no one will notice that, and this
|
||||
// approach minimizes storing extra state.
|
||||
let i = (tile.animation_progress + tic % 1) / tile.animation_speed;
|
||||
let i = ((tile.movement_speed - tile.movement_cooldown) + tic % 1) / tile.movement_speed;
|
||||
// But do NOT do this for explosions or splashes, which have a fixed duration
|
||||
// and only play once
|
||||
if (this.animation_slowdown > 1 && ! tile.type.ttl) {
|
||||
@@ -927,7 +929,7 @@ export class Tileset {
|
||||
// 1/N of it before the game ends (or loops) the animation.
|
||||
// So increase by [0..N-1] to get it in some other range, then divide by N
|
||||
// to scale back down to [0, 1)
|
||||
i += Math.floor(tic / tile.animation_speed % this.animation_slowdown);
|
||||
i += Math.floor(tic / tile.movement_speed % this.animation_slowdown);
|
||||
i /= this.animation_slowdown;
|
||||
}
|
||||
coords = coords[Math.floor(i * coords.length)];
|
||||
@@ -1126,6 +1128,7 @@ export class Tileset {
|
||||
// Draw the base tile
|
||||
blit(drawspec.base[0], drawspec.base[1]);
|
||||
|
||||
// FIXME some tiles don't have active wiring on all sides...
|
||||
// Draw the wire part as a single rectangle, initially just a small dot in the
|
||||
// center, but extending out to any edge that has a wire present
|
||||
let x0 = 0.5 - wire_radius;
|
||||
@@ -1212,7 +1215,7 @@ export class Tileset {
|
||||
// Deal with animation
|
||||
if (coords[0] instanceof Array) {
|
||||
if (tic !== null) {
|
||||
if (tile && tile.animation_speed) {
|
||||
if (tile && tile.movement_speed) {
|
||||
// This tile reports its own animation timing (in tics), so trust that, and just
|
||||
// use the current tic's fraction.
|
||||
// That said: adjusting animation speed complicates this slightly. Consider the
|
||||
@@ -1224,7 +1227,7 @@ export class Tileset {
|
||||
// first half; if it started on tics 5-8, play the second half. They could get
|
||||
// out of sync if the player hesitates, but no one will notice that, and this
|
||||
// approach minimizes storing extra state.
|
||||
let i = (tile.animation_progress + tic % 1) / tile.animation_speed;
|
||||
let i = ((tile.movement_speed - tile.movement_cooldown) + tic % 1) / tile.movement_speed;
|
||||
// But do NOT do this for explosions or splashes, which have a fixed duration
|
||||
// and only play once
|
||||
if (this.animation_slowdown > 1 && ! tile.type.ttl) {
|
||||
@@ -1232,7 +1235,7 @@ export class Tileset {
|
||||
// 1/N of it before the game ends (or loops) the animation.
|
||||
// So increase by [0..N-1] to get it in some other range, then divide by N
|
||||
// to scale back down to [0, 1)
|
||||
i += Math.floor(tic / tile.animation_speed % this.animation_slowdown);
|
||||
i += Math.floor(tic / tile.movement_speed % this.animation_slowdown);
|
||||
i /= this.animation_slowdown;
|
||||
}
|
||||
coords = coords[Math.floor(i * coords.length)];
|
||||
|
||||
+280
-70
@@ -1,4 +1,4 @@
|
||||
import { COLLISION, DIRECTIONS, DRAW_LAYERS } from './defs.js';
|
||||
import { COLLISION, DIRECTIONS, DIRECTION_ORDER, DRAW_LAYERS } from './defs.js';
|
||||
import { random_choice } from './util.js';
|
||||
|
||||
function activate_me(me, level) {
|
||||
@@ -27,7 +27,7 @@ function on_ready_force_floor(me, level) {
|
||||
let neighbor = level.get_neighboring_cell(me.cell, actor.direction);
|
||||
if (! neighbor)
|
||||
return;
|
||||
if (! neighbor.blocks_entering(actor, actor.direction, level, true))
|
||||
if (! neighbor.blocks_entering(actor, actor.direction, level, 'trace'))
|
||||
return;
|
||||
let item = me.cell.get_item();
|
||||
if (! item)
|
||||
@@ -40,7 +40,7 @@ function on_ready_force_floor(me, level) {
|
||||
}
|
||||
|
||||
function blocks_leaving_thin_walls(me, actor, direction) {
|
||||
return me.type.thin_walls.has(direction);
|
||||
return me.type.thin_walls.has(direction) && actor.type.name !== 'ghost';
|
||||
}
|
||||
|
||||
function player_visual_state(me) {
|
||||
@@ -82,7 +82,7 @@ function player_visual_state(me) {
|
||||
else if (me.is_pushing) {
|
||||
return 'pushing';
|
||||
}
|
||||
else if (me.animation_speed) {
|
||||
else if (me.movement_speed) {
|
||||
return 'moving';
|
||||
}
|
||||
else {
|
||||
@@ -92,18 +92,18 @@ function player_visual_state(me) {
|
||||
|
||||
// Logic for chasing after the player (or running away); shared by both teeth and mimics
|
||||
function pursue_player(me, level) {
|
||||
let player = level.player;
|
||||
let target_cell = player.cell;
|
||||
// CC2 behavior (not Lynx (TODO compat?)): pursue the cell the player is leaving, if
|
||||
// they're still mostly in it
|
||||
if (player.previous_cell && player.animation_speed &&
|
||||
player.animation_progress <= player.animation_speed / 2)
|
||||
{
|
||||
target_cell = player.previous_cell;
|
||||
}
|
||||
// Teeth can only move the first 4 of every 8 tics, and mimics only the first 4 of every 16,
|
||||
// though "first" can be adjusted
|
||||
if ((level.tic_counter + level.step_parity) % (me.type.movement_parity * 4) >= 4)
|
||||
return null;
|
||||
|
||||
let dx = me.cell.x - target_cell.x;
|
||||
let dy = me.cell.y - target_cell.y;
|
||||
let player = level.player;
|
||||
// CC2 behavior (not Lynx (TODO compat?)): pursue the player's apparent position, not just the
|
||||
// cell they're in
|
||||
let [px, py] = player.visual_position();
|
||||
|
||||
let dx = me.cell.x - px;
|
||||
let dy = me.cell.y - py;
|
||||
let preferred_horizontal, preferred_vertical;
|
||||
if (dx > 0) {
|
||||
preferred_horizontal = 'west';
|
||||
@@ -159,7 +159,7 @@ const TILE_TYPES = {
|
||||
wall: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
blocks_collision: COLLISION.all_but_ghost,
|
||||
on_bump(me, level, other) {
|
||||
on_bumped(me, level, other) {
|
||||
if (other.has_item('foil')) {
|
||||
level.transmute_tile(me, 'steel');
|
||||
}
|
||||
@@ -184,7 +184,7 @@ const TILE_TYPES = {
|
||||
wall_invisible: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
blocks_collision: COLLISION.all_but_ghost,
|
||||
on_bump(me, level, other) {
|
||||
on_bumped(me, level, other) {
|
||||
if (other.type.can_reveal_walls) {
|
||||
level.spawn_animation(me.cell, 'wall_invisible_revealed');
|
||||
}
|
||||
@@ -193,7 +193,7 @@ const TILE_TYPES = {
|
||||
wall_appearing: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
blocks_collision: COLLISION.all_but_ghost,
|
||||
on_bump(me, level, other) {
|
||||
on_bumped(me, level, other) {
|
||||
if (other.type.can_reveal_walls) {
|
||||
level.transmute_tile(me, 'wall');
|
||||
}
|
||||
@@ -262,7 +262,7 @@ const TILE_TYPES = {
|
||||
me.type = TILE_TYPES['popwall'];
|
||||
}
|
||||
},
|
||||
on_bump(me, level, other) {
|
||||
on_bumped(me, level, other) {
|
||||
if (other.type.can_reveal_walls) {
|
||||
level.transmute_tile(me, 'wall');
|
||||
}
|
||||
@@ -271,7 +271,7 @@ const TILE_TYPES = {
|
||||
fake_floor: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
blocks_collision: COLLISION.block_cc1 | COLLISION.monster_solid,
|
||||
on_bump(me, level, other) {
|
||||
on_bumped(me, level, other) {
|
||||
if (other.type.can_reveal_walls) {
|
||||
level.transmute_tile(me, 'floor');
|
||||
}
|
||||
@@ -284,8 +284,7 @@ const TILE_TYPES = {
|
||||
popdown_floor: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
blocks_collision: COLLISION.block_cc1 | COLLISION.block_cc2,
|
||||
// FIXME should be on_approach
|
||||
on_arrive(me, level, other) {
|
||||
on_approach(me, level, other) {
|
||||
// FIXME could probably do this with state? or, eh
|
||||
level.transmute_tile(me, 'popdown_floor_visible');
|
||||
},
|
||||
@@ -321,6 +320,7 @@ const TILE_TYPES = {
|
||||
swivel_floor: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
},
|
||||
// TODO thin walls explicitly draw over swivels, so they might have their own layer
|
||||
swivel_ne: {
|
||||
draw_layer: DRAW_LAYERS.overlay,
|
||||
thin_walls: new Set(['north', 'east']),
|
||||
@@ -609,9 +609,15 @@ const TILE_TYPES = {
|
||||
level.remove_tile(other);
|
||||
level.transmute_tile(me, 'water');
|
||||
}
|
||||
else if (other.type.is_player) {
|
||||
else if (other.type.is_real_player) {
|
||||
level.fail('burned');
|
||||
}
|
||||
else if (other.type.name === 'ghost') {
|
||||
if (other.has_item('fire_boots')) {
|
||||
// ?????
|
||||
level.transmute_tile(me, 'floor');
|
||||
}
|
||||
}
|
||||
else {
|
||||
level.remove_tile(other);
|
||||
}
|
||||
@@ -619,7 +625,11 @@ const TILE_TYPES = {
|
||||
},
|
||||
water: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
blocks_collision: COLLISION.ghost,
|
||||
blocks(me, level, other) {
|
||||
// Water blocks ghosts... unless they have flippers
|
||||
if (other.type.name === 'ghost' && ! other.has_item('flippers'))
|
||||
return true;
|
||||
},
|
||||
on_arrive(me, level, other) {
|
||||
// TODO cc1 allows items under water, i think; water was on the upper layer
|
||||
level.sfx.play_once('splash', me.cell);
|
||||
@@ -635,7 +645,7 @@ const TILE_TYPES = {
|
||||
level.transmute_tile(other, 'splash');
|
||||
level.transmute_tile(me, 'ice');
|
||||
}
|
||||
else if (other.type.is_player) {
|
||||
else if (other.type.is_real_player) {
|
||||
level.fail('drowned');
|
||||
}
|
||||
else {
|
||||
@@ -771,7 +781,7 @@ const TILE_TYPES = {
|
||||
// TODO ms: this is random, and an acting wall to monsters (!)
|
||||
// TODO lynx/cc2 check this at decision time, which may affect ordering
|
||||
on_arrive(me, level, other) {
|
||||
level.set_actor_direction(other, level.get_force_floor_direction());
|
||||
//level.set_actor_direction(other, level.get_force_floor_direction());
|
||||
},
|
||||
},
|
||||
slime: {
|
||||
@@ -785,7 +795,7 @@ const TILE_TYPES = {
|
||||
else if (other.type.name === 'ghost' || other.type.name === 'blob') {
|
||||
// No effect
|
||||
}
|
||||
else if (other.type.is_player) {
|
||||
else if (other.type.is_real_player) {
|
||||
level.fail('slimed');
|
||||
}
|
||||
else {
|
||||
@@ -804,7 +814,7 @@ const TILE_TYPES = {
|
||||
},
|
||||
on_arrive(me, level, other) {
|
||||
level.remove_tile(me);
|
||||
if (other.type.is_player) {
|
||||
if (other.type.is_real_player) {
|
||||
level.fail('exploded');
|
||||
}
|
||||
else {
|
||||
@@ -821,14 +831,14 @@ const TILE_TYPES = {
|
||||
// TODO bribe sound
|
||||
return;
|
||||
}
|
||||
if (! other.type.is_real_player)
|
||||
return;
|
||||
|
||||
let lost = level.take_all_tools_from_actor(other);
|
||||
if (other.type.is_player) {
|
||||
if (level.bonus_points > 0) {
|
||||
lost = true;
|
||||
}
|
||||
level.adjust_bonus(0, 0.5);
|
||||
}
|
||||
if (lost && other === level.player) {
|
||||
level.sfx.play_once('thief', me.cell);
|
||||
}
|
||||
@@ -842,14 +852,14 @@ const TILE_TYPES = {
|
||||
// TODO bribe sound
|
||||
return;
|
||||
}
|
||||
if (! other.type.is_real_player)
|
||||
return;
|
||||
|
||||
let lost = level.take_all_keys_from_actor(other);
|
||||
if (other.type.is_player) {
|
||||
if (level.bonus_points > 0) {
|
||||
lost = true;
|
||||
}
|
||||
level.adjust_bonus(0, 0.5);
|
||||
}
|
||||
if (lost && other === level.player) {
|
||||
level.sfx.play_once('thief', me.cell);
|
||||
}
|
||||
@@ -897,6 +907,18 @@ const TILE_TYPES = {
|
||||
ice_block: true,
|
||||
frame_block: true,
|
||||
},
|
||||
on_bumped(me, level, other) {
|
||||
// Fireballs melt ice blocks on regular floor
|
||||
// XXX what if i'm in motion?
|
||||
if (other.type.name === 'fireball') {
|
||||
let terrain = me.cell.get_terrain();
|
||||
if (terrain.type.name === 'floor') {
|
||||
level.remove_tile(me);
|
||||
level.transmute_tile(terrain, 'water');
|
||||
// FIXME splash?
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
frame_block: {
|
||||
// TODO directional, obviously
|
||||
@@ -957,7 +979,7 @@ const TILE_TYPES = {
|
||||
is_required_chip: true,
|
||||
on_arrive(me, level, other) {
|
||||
level.remove_tile(me);
|
||||
if (other.type.is_player) {
|
||||
if (other.type.is_real_player) {
|
||||
level.fail('exploded');
|
||||
}
|
||||
else {
|
||||
@@ -1013,7 +1035,7 @@ const TILE_TYPES = {
|
||||
// This temporary flag tells us to let it leave; it doesn't need to be undoable, since
|
||||
// it doesn't persist for more than a tic
|
||||
actor._clone_release = true;
|
||||
if (level.take_actor_turn(actor, direction, true)) {
|
||||
if (level.attempt_out_of_turn_step(actor, direction)) {
|
||||
// FIXME add this underneath, just above the cloner, so the new actor is on top
|
||||
let new_template = new actor.constructor(type, direction);
|
||||
// TODO maybe make a type method for this
|
||||
@@ -1048,7 +1070,7 @@ const TILE_TYPES = {
|
||||
if (tile.type.is_actor) {
|
||||
// Forcibly move anything released from a trap, to keep it in sync with
|
||||
// whatever pushed the button
|
||||
level.attempt_step(tile, tile.direction);
|
||||
level.attempt_out_of_turn_step(tile, tile.direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1118,12 +1140,16 @@ const TILE_TYPES = {
|
||||
// FIXME blue teleporters transmit current 4 ways. red don't transmit it at all
|
||||
teleport_blue: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
teleport_dest_order(me, level, other) {
|
||||
wire_propagation_mode: 'all',
|
||||
*teleport_dest_order(me, level, other) {
|
||||
let exit_direction = other.direction;
|
||||
if (! me.wire_directions) {
|
||||
// TODO cc2 has a bug where, once it wraps around to the bottom right, it seems to
|
||||
// forget that it was ever looking for an unwired teleport and will just grab the
|
||||
// first one it sees
|
||||
return level.iter_tiles_in_reading_order(me.cell, 'teleport_blue', true);
|
||||
for (let dest of level.iter_tiles_in_reading_order(me.cell, 'teleport_blue', true)) {
|
||||
yield [dest, exit_direction];
|
||||
}
|
||||
}
|
||||
|
||||
// Wired blue teleports form a network, which means we have to walk all wires from this
|
||||
@@ -1145,7 +1171,6 @@ const TILE_TYPES = {
|
||||
let found = [];
|
||||
let seen = new Set;
|
||||
while (seeds.length > 0) {
|
||||
console.log(seeds);
|
||||
let next_seeds = [];
|
||||
for (let cell of seeds) {
|
||||
if (seen.has(cell))
|
||||
@@ -1203,12 +1228,14 @@ const TILE_TYPES = {
|
||||
) % level_size);
|
||||
}
|
||||
found.sort((a, b) => dest_indices.get(b) - dest_indices.get(a));
|
||||
return found;
|
||||
for (let dest of found) {
|
||||
yield [dest, exit_direction];
|
||||
}
|
||||
},
|
||||
},
|
||||
teleport_red: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
teleport_try_all_directions: true,
|
||||
wire_propagation_mode: 'none',
|
||||
teleport_allow_override: true,
|
||||
_is_active(me) {
|
||||
return ! (me.wire_directions && (me.cell.powered_edges & me.wire_directions) === 0);
|
||||
@@ -1220,13 +1247,21 @@ const TILE_TYPES = {
|
||||
// has the bizarre behavior of NOT considering a red teleporter wired if none of its
|
||||
// wires are directly connected to another neighboring wire.
|
||||
// FIXME implement that, merge current code with is_cell_wired
|
||||
if (! this._is_active(me)) {
|
||||
yield me;
|
||||
return;
|
||||
let iterable;
|
||||
if (this._is_active(me)) {
|
||||
iterable = level.iter_tiles_in_reading_order(me.cell, 'teleport_red');
|
||||
}
|
||||
for (let tile of level.iter_tiles_in_reading_order(me.cell, 'teleport_red')) {
|
||||
else {
|
||||
iterable = [me];
|
||||
}
|
||||
let exit_direction = other.direction;
|
||||
for (let tile of iterable) {
|
||||
if (tile === me || this._is_active(tile)) {
|
||||
yield tile;
|
||||
// Red teleporters allow exiting in any direction, searching clockwise
|
||||
yield [tile, exit_direction];
|
||||
yield [tile, DIRECTIONS[exit_direction].right];
|
||||
yield [tile, DIRECTIONS[exit_direction].opposite];
|
||||
yield [tile, DIRECTIONS[exit_direction].left];
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1239,28 +1274,38 @@ const TILE_TYPES = {
|
||||
},
|
||||
teleport_green: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
teleport_try_all_directions: true,
|
||||
teleport_dest_order(me, level, other) {
|
||||
let all = Array.from(level.iter_tiles_in_reading_order(me.cell, 'teleport_green'));
|
||||
if (all.length <= 1) {
|
||||
// If this is the only teleporter, just walk out the other side — and, crucially, do
|
||||
// NOT advance the PRNG
|
||||
return [me];
|
||||
return [[me, other.direction]];
|
||||
}
|
||||
// Note the iterator starts on the /next/ teleporter, so there's an implicit +1 here.
|
||||
// The -1 is to avoid spitting us back out of the same teleporter, which will be last in
|
||||
// the list
|
||||
let target = all[level.prng() % (all.length - 1)];
|
||||
// Also set the actor's (initial) exit direction
|
||||
level.set_actor_direction(other, ['north', 'east', 'south', 'west'][level.prng() % 4]);
|
||||
return [target, me];
|
||||
// Also pick the actor's exit direction
|
||||
let exit_direction = DIRECTION_ORDER[level.prng() % 4];
|
||||
return [
|
||||
// Green teleporters allow exiting in any direction, similar to red, but only on the
|
||||
// one they found; if that fails, you walk straight across the one you entered
|
||||
[target, exit_direction],
|
||||
[target, DIRECTIONS[exit_direction].right],
|
||||
[target, DIRECTIONS[exit_direction].opposite],
|
||||
[target, DIRECTIONS[exit_direction].left],
|
||||
[me, other.direction],
|
||||
];
|
||||
},
|
||||
},
|
||||
teleport_yellow: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
teleport_allow_override: true,
|
||||
teleport_dest_order(me, level, other) {
|
||||
return level.iter_tiles_in_reading_order(me.cell, 'teleport_yellow', true);
|
||||
*teleport_dest_order(me, level, other) {
|
||||
let exit_direction = other.direction;
|
||||
for (let dest of level.iter_tiles_in_reading_order(me.cell, 'teleport_yellow', true)) {
|
||||
yield [dest, exit_direction];
|
||||
}
|
||||
},
|
||||
},
|
||||
// Flame jet rules:
|
||||
@@ -1297,7 +1342,7 @@ const TILE_TYPES = {
|
||||
// Note that blocks, fireballs, and anything with fire boots are immune
|
||||
// TODO would be neat if this understood "ignores anything with fire immunity" but that
|
||||
// might be a bit too high-level for this game
|
||||
if (other.type.is_player) {
|
||||
if (other.type.is_real_player) {
|
||||
level.fail('burned');
|
||||
}
|
||||
else {
|
||||
@@ -1331,14 +1376,22 @@ const TILE_TYPES = {
|
||||
on_arrive(me, level, other) {
|
||||
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. But pretend
|
||||
// it's simultaneous, so one in front will block one behind as usual.
|
||||
// XXX this feels really weird, i'm wondering if cc2 does some other weird thing like
|
||||
// not move any actor out of its cell until its first cooldown tick??
|
||||
// FIXME not sure this interacts with railroads correctly
|
||||
let unblocked_tanks = [];
|
||||
for (let i = level.actors.length - 1; i >= 0; i--) {
|
||||
let actor = level.actors[i];
|
||||
// TODO generify somehow??
|
||||
if (actor.type.name === 'tank_yellow') {
|
||||
level.attempt_step(actor, other.direction);
|
||||
if (actor.type.name === 'tank_yellow' && level.check_movement(actor, actor.cell, other.direction, 'trace')) {
|
||||
unblocked_tanks.push(actor);
|
||||
}
|
||||
}
|
||||
for (let actor of unblocked_tanks) {
|
||||
level.attempt_out_of_turn_step(actor, other.direction);
|
||||
}
|
||||
},
|
||||
on_depart(me, level, other) {
|
||||
level.sfx.play_once('button-release', me.cell);
|
||||
@@ -1370,7 +1423,7 @@ const TILE_TYPES = {
|
||||
},
|
||||
on_arrive(me, level, other) {
|
||||
level.sfx.play_once('button-press', me.cell);
|
||||
level.toggle_green_objects = ! level.toggle_green_objects;
|
||||
this.do_button(level);
|
||||
},
|
||||
on_depart(me, level, other) {
|
||||
level.sfx.play_once('button-release', me.cell);
|
||||
@@ -1451,6 +1504,7 @@ const TILE_TYPES = {
|
||||
button_pink: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
is_power_source: true,
|
||||
wire_propagation_mode: 'none',
|
||||
get_emitting_edges(me, level) {
|
||||
// We emit current as long as there's an actor fully on us
|
||||
if (me.cell.some(tile => tile.type.is_actor && tile.movement_cooldown === 0)) {
|
||||
@@ -1811,7 +1865,7 @@ const TILE_TYPES = {
|
||||
movement_parity: 2,
|
||||
decide_movement(me, level) {
|
||||
let preference = pursue_player(me, level);
|
||||
if (level.player.type.name === 'player2') {
|
||||
if (preference && level.player.type.name === 'player2') {
|
||||
// Run away from Cerise
|
||||
for (let [i, direction] of preference.entries()) {
|
||||
preference[i] = DIRECTIONS[direction].opposite;
|
||||
@@ -1830,7 +1884,7 @@ const TILE_TYPES = {
|
||||
movement_parity: 2,
|
||||
decide_movement(me, level) {
|
||||
let preference = pursue_player(me, level);
|
||||
if (level.player.type.name === 'player') {
|
||||
if (preference && level.player.type.name === 'player') {
|
||||
// Run away from Lexy
|
||||
for (let [i, direction] of preference.entries()) {
|
||||
preference[i] = DIRECTIONS[direction].opposite;
|
||||
@@ -1896,7 +1950,6 @@ const TILE_TYPES = {
|
||||
decide_movement: pursue_player,
|
||||
},
|
||||
rover: {
|
||||
// TODO this guy is a nightmare
|
||||
// TODO pushes blocks apparently??
|
||||
draw_layer: DRAW_LAYERS.actor,
|
||||
is_actor: true,
|
||||
@@ -1904,7 +1957,8 @@ const TILE_TYPES = {
|
||||
collision_mask: COLLISION.rover,
|
||||
blocks_collision: COLLISION.all_but_player,
|
||||
can_reveal_walls: true,
|
||||
movement_speed: 4,
|
||||
movement_speed: 8,
|
||||
movement_parity: 2,
|
||||
on_ready(me, level) {
|
||||
me.current_emulatee = 0;
|
||||
me.attempted_moves = 0;
|
||||
@@ -1916,11 +1970,31 @@ const TILE_TYPES = {
|
||||
level._set_tile_prop(me, 'current_emulatee', (me.current_emulatee + 1) % me.type._emulatees.length);
|
||||
}
|
||||
|
||||
me.attempted_moves += 1;
|
||||
level._set_tile_prop(me, 'attempted_moves', me.attempted_moves + 1);
|
||||
let emulatee = me.type._emulatees[me.current_emulatee];
|
||||
let preference = TILE_TYPES[emulatee].decide_movement(me, level);
|
||||
// Rig up the preference so a failure counts as two moves
|
||||
if (preference) {
|
||||
let last_direction = preference[preference.length - 1];
|
||||
if (typeof last_direction === 'function') {
|
||||
// This is tricky! We want this function to only be called ONCE max, since the
|
||||
// walker uses it to carefully tune the PRNG, so replace it with one that lazily
|
||||
// overwrites 'last_direction'
|
||||
preference.pop();
|
||||
let lazy = last_direction;
|
||||
preference.push(function() {
|
||||
last_direction = lazy();
|
||||
return last_direction;
|
||||
});
|
||||
}
|
||||
preference.push(function() {
|
||||
me.attempted_moves += 1;
|
||||
level._set_tile_prop(me, 'attempted_moves', me.attempted_moves + 1);
|
||||
return last_direction;
|
||||
});
|
||||
}
|
||||
// TODO need to rig this so a failure counts as two moves
|
||||
// TODO obeys step while emulating teeth (gah, i guess move that in here too)
|
||||
// FIXME obeys step while emulating teeth (gah, i guess move that in here too)
|
||||
return preference;
|
||||
},
|
||||
visual_state(me) {
|
||||
@@ -2019,14 +2093,138 @@ const TILE_TYPES = {
|
||||
is_item: true,
|
||||
is_tool: true,
|
||||
blocks_collision: COLLISION.block_cc1 | COLLISION.monster_solid,
|
||||
// FIXME does a thing when dropped, but that isn't implemented at all yet
|
||||
on_drop(level, owner) {
|
||||
// FIXME not if the cell has a no sign
|
||||
if (! owner.type.is_real_player) {
|
||||
// Only players can activate dynamite
|
||||
return;
|
||||
}
|
||||
return 'dynamite_lit';
|
||||
},
|
||||
},
|
||||
dynamite_lit: {
|
||||
draw_layer: DRAW_LAYERS.item,
|
||||
is_actor: true,
|
||||
is_monster: true,
|
||||
// FIXME ???????
|
||||
collision_mask: COLLISION.block_cc2,
|
||||
// FIXME inherits a copy of player's inventory!
|
||||
// FIXME holds down buttons, so needs an on_arrive
|
||||
// FIXME speaking of buttons, destroyed actors should on_depart
|
||||
on_tic(me, level) {
|
||||
// FIXME When Chip or Melinda leaves a tile with a time bomb and no no sign on it, the
|
||||
// time bomb will count down for about 4.3 seconds before exploding; it does not matter
|
||||
// whether the player dropped the item (e.g. if the player teleported)????
|
||||
if (me.slide_mode || me.movement_cooldown)
|
||||
return;
|
||||
if (me.timer === undefined) {
|
||||
me.timer = 86; // FIXME?? wiki just says about 4.3 seconds what
|
||||
return;
|
||||
}
|
||||
|
||||
me.timer -= 1;
|
||||
if (me.timer > 0)
|
||||
return;
|
||||
|
||||
// Kaboom! Blow up a 5x5 square
|
||||
level.sfx.play_once('bomb', me.cell);
|
||||
let x = me.cell.x, y = me.cell.y;
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
// Exclude the far corners
|
||||
if (Math.abs(dx) + Math.abs(dy) >= 4)
|
||||
continue;
|
||||
|
||||
let cell = level.cell(x + dx, y + dy);
|
||||
if (! cell)
|
||||
continue;
|
||||
|
||||
let tiles = Array.from(cell);
|
||||
tiles.sort((a, b) => b.type.draw_layer - a.type.draw_layer);
|
||||
let actor, terrain, removed_anything;
|
||||
for (let tile of tiles) {
|
||||
if (tile.type.name === 'canopy') {
|
||||
// Canopy protects everything else
|
||||
break;
|
||||
}
|
||||
if (tile.type.is_actor) {
|
||||
actor = tile;
|
||||
}
|
||||
|
||||
if (tile.type.draw_layer === 0) {
|
||||
// Terrain gets transmuted afterwards
|
||||
terrain = tile;
|
||||
}
|
||||
else {
|
||||
// Everything else is destroyed
|
||||
level.remove_tile(tile);
|
||||
removed_anything = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (actor) {
|
||||
// Actors protect terrain, but floor becomes fire
|
||||
if (terrain && terrain.type.name === 'floor' && terrain.wire_directions === 0 && terrain.wire_tunnel_directions === 0) {
|
||||
if (actor.type.name === 'ice_block') {
|
||||
level.transmute_tile(terrain, 'water');
|
||||
}
|
||||
else {
|
||||
level.transmute_tile(terrain, 'fire');
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (terrain) {
|
||||
// Anything other than these babies gets blown up and turned into floor
|
||||
if (!(
|
||||
terrain.type.name === 'steel' || terrain.type.name === 'socket' || terrain.type.name === 'logic_gate' || terrain.type.name === 'floor'))
|
||||
{
|
||||
level.transmute_tile(terrain, 'floor');
|
||||
removed_anything = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed_anything) {
|
||||
level.spawn_animation(cell, 'explosion');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
bowling_ball: {
|
||||
// TODO not implemented, rolls when dropped, has an inventory, yadda yadda
|
||||
draw_layer: DRAW_LAYERS.item,
|
||||
is_item: true,
|
||||
is_tool: true,
|
||||
blocks_collision: COLLISION.block_cc1 | COLLISION.monster_solid,
|
||||
on_drop(level, owner) {
|
||||
return 'rolling_ball';
|
||||
},
|
||||
},
|
||||
rolling_ball: {
|
||||
draw_layer: DRAW_LAYERS.actor,
|
||||
is_actor: true,
|
||||
has_inventory: true,
|
||||
// FIXME ???????
|
||||
collision_mask: COLLISION.block_cc2,
|
||||
// FIXME do i start moving immediately when dropped, or next turn?
|
||||
movement_speed: 4,
|
||||
decide_movement(me, level) {
|
||||
return [me.direction];
|
||||
},
|
||||
// FIXME feel like this should be on_blocked?
|
||||
// FIXME specific case for player!
|
||||
on_bump(me, level, other) {
|
||||
if (me.slide_mode)
|
||||
return;
|
||||
if (other.type.is_actor) {
|
||||
level.transmute_tile(me, 'explosion');
|
||||
level.transmute_tile(other, 'explosion');
|
||||
return false;
|
||||
}
|
||||
else if (other.blocks(me, me.direction, level)) {
|
||||
level.transmute_tile(me, 'explosion');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
xray_eye: {
|
||||
// TODO not implemented
|
||||
@@ -2134,6 +2332,7 @@ const TILE_TYPES = {
|
||||
is_player: true,
|
||||
is_monster: true,
|
||||
collision_mask: COLLISION.player1,
|
||||
// FIXME these fuckers should block each OTHER though
|
||||
blocks_collision: COLLISION.all_but_player,
|
||||
has_inventory: true,
|
||||
can_reveal_walls: true, // XXX i think?
|
||||
@@ -2283,7 +2482,7 @@ const TILE_TYPES = {
|
||||
},
|
||||
exit: {
|
||||
draw_layer: DRAW_LAYERS.terrain,
|
||||
blocks_collision: COLLISION.monster_solid & ~COLLISION.rover,
|
||||
blocks_collision: COLLISION.block_cc1 | COLLISION.monster_solid & ~COLLISION.rover,
|
||||
on_arrive(me, level, other) {
|
||||
if (other.type.is_player) {
|
||||
level.win();
|
||||
@@ -2297,14 +2496,22 @@ const TILE_TYPES = {
|
||||
is_actor: true,
|
||||
collision_mask: 0,
|
||||
blocks_collision: COLLISION.player,
|
||||
ttl: 6,
|
||||
ttl: 16,
|
||||
// If anything else even begins to step on an animation, it's erased
|
||||
// FIXME possibly erased too fast; cc2 shows it briefly? could i get away with on_arrive here?
|
||||
on_approach(me, level, other) {
|
||||
level.remove_tile(me);
|
||||
},
|
||||
},
|
||||
explosion: {
|
||||
draw_layer: DRAW_LAYERS.overlay,
|
||||
is_actor: true,
|
||||
collision_mask: 0,
|
||||
blocks_collision: COLLISION.player,
|
||||
ttl: 6,
|
||||
ttl: 16,
|
||||
on_approach(me, level, other) {
|
||||
level.remove_tile(me);
|
||||
},
|
||||
},
|
||||
// Used as an easy way to show an invisible wall when bumped
|
||||
wall_invisible_revealed: {
|
||||
@@ -2313,7 +2520,7 @@ const TILE_TYPES = {
|
||||
collision_mask: 0,
|
||||
blocks_collision: 0,
|
||||
// determined experimentally
|
||||
ttl: 12,
|
||||
ttl: 36,
|
||||
},
|
||||
// Custom VFX (identical function, but different aesthetic)
|
||||
splash_slime: {
|
||||
@@ -2321,7 +2528,10 @@ const TILE_TYPES = {
|
||||
is_actor: true,
|
||||
collision_mask: 0,
|
||||
blocks_collision: COLLISION.player,
|
||||
ttl: 6,
|
||||
ttl: 16,
|
||||
on_approach(me, level, other) {
|
||||
level.remove_tile(me);
|
||||
},
|
||||
},
|
||||
|
||||
// Invalid tiles that appear in some CCL levels because community level
|
||||
|
||||
+46
@@ -104,6 +104,12 @@ export function handle_drop(element, options) {
|
||||
});
|
||||
}
|
||||
|
||||
export function sleep(t) {
|
||||
return new Promise(res => {
|
||||
setTimeout(res, t);
|
||||
});
|
||||
}
|
||||
|
||||
export function promise_event(element, success_event, failure_event) {
|
||||
let resolve, reject;
|
||||
let promise = new Promise((res, rej) => {
|
||||
@@ -162,6 +168,46 @@ export function bytestring_to_buffer(bytestring) {
|
||||
return Uint8Array.from(bytestring, c => c.charCodeAt(0)).buffer;
|
||||
}
|
||||
|
||||
export function b64encode(value) {
|
||||
if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
|
||||
value = string_from_buffer_ascii(value);
|
||||
}
|
||||
// Make URL-safe and strip trailing padding
|
||||
return btoa(value).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function b64decode(data) {
|
||||
return bytestring_to_buffer(atob(data.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
}
|
||||
|
||||
export function format_duration(seconds, places = 0) {
|
||||
let mins = Math.floor(seconds / 60);
|
||||
let secs = seconds % 60;
|
||||
return `${mins}:${secs < 10 ? '0' : ''}${secs.toFixed(places)}`;
|
||||
}
|
||||
|
||||
export class DelayTimer {
|
||||
constructor() {
|
||||
this.active = false;
|
||||
this._handle = null;
|
||||
this._bound_alarm = this._alarm.bind(this);
|
||||
}
|
||||
|
||||
set(duration) {
|
||||
if (this._handle) {
|
||||
window.clearTimeout(this._handle);
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
this._handle = window.setTimeout(this._bound_alarm, duration);
|
||||
}
|
||||
|
||||
_alarm() {
|
||||
this._handle = null;
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cast a line through a grid and yield every cell it touches
|
||||
export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
|
||||
// TODO if the ray starts outside the grid (extremely unlikely), we should
|
||||
|
||||
@@ -69,6 +69,12 @@ button.button-big {
|
||||
margin: 0.5em 0;
|
||||
padding: 1em;
|
||||
}
|
||||
button.--button-glow-ok {
|
||||
background: hsl(225, 100%, 50%);
|
||||
}
|
||||
button.--button-glow {
|
||||
transition: background-color 0.5s ease-out;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
@@ -123,6 +129,10 @@ a:active {
|
||||
color: hsl(0, 50%, 60%);
|
||||
}
|
||||
|
||||
svg#svg-iconsheet {
|
||||
/* This is a collection of SVG icons to be re-<use>d */
|
||||
display: none;
|
||||
}
|
||||
svg.svg-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
@@ -155,9 +165,9 @@ svg.svg-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
min-width: 33%;
|
||||
max-width: 75%;
|
||||
max-height: 75%;
|
||||
min-width: 33vw;
|
||||
max-width: 75vw;
|
||||
max-height: 75vh;
|
||||
border: 1px solid black;
|
||||
color: black;
|
||||
background: #f4f4f4;
|
||||
@@ -185,6 +195,7 @@ svg.svg-icon {
|
||||
display: none;
|
||||
}
|
||||
.dialog > section {
|
||||
flex: auto;
|
||||
overflow: auto;
|
||||
padding: 1em;
|
||||
}
|
||||
@@ -302,7 +313,6 @@ label.option {
|
||||
}
|
||||
label.option:hover {
|
||||
outline: 2px solid #d0d0d0;
|
||||
outline-radius: 2px;
|
||||
}
|
||||
label.option .option-label {
|
||||
flex: 1;
|
||||
@@ -536,16 +546,83 @@ button.level-pack-button p {
|
||||
color: #c0c0c0;
|
||||
}
|
||||
|
||||
/* "Bulk test" button, only available in debug mode */
|
||||
#main-test-pack {
|
||||
display: none;
|
||||
}
|
||||
body.--debug #main-test-pack {
|
||||
display: initial;
|
||||
}
|
||||
.packtest-dialog {
|
||||
width: 75vw;
|
||||
height: 75vh;
|
||||
}
|
||||
ol.packtest-summary {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 1em;
|
||||
border: 1px solid #606060;
|
||||
}
|
||||
ol.packtest-summary > li {
|
||||
/* Give a meaty flex-basis; the dialog has a max-width so it won't blow out, and these will
|
||||
* simply shrink if necessary */
|
||||
flex: 1 1 1em;
|
||||
background: white;
|
||||
}
|
||||
.packtest-row {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.packtest-row > p {
|
||||
flex: 9;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.packtest-row > button {
|
||||
flex: 1;
|
||||
}
|
||||
.packtest-results {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.packtest-results > li {
|
||||
padding: 0.25em;
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.packtest-results > li > canvas {
|
||||
display: block;
|
||||
margin: 0.5em auto;
|
||||
}
|
||||
ol.packtest-colorcoded > li[data-status=no-replay] {
|
||||
background: hsl(0, 0%, 25%);
|
||||
}
|
||||
ol.packtest-colorcoded > li[data-status=running] {
|
||||
background: hsl(30, 100%, 75%);
|
||||
}
|
||||
ol.packtest-colorcoded > li[data-status=success] {
|
||||
background: hsl(120, 60%, 75%);
|
||||
}
|
||||
ol.packtest-colorcoded > li[data-status=failure] {
|
||||
background: hsl(0, 60%, 60%);
|
||||
}
|
||||
ol.packtest-colorcoded > li[data-status=short] {
|
||||
background: hsl(330, 60%, 75%);
|
||||
}
|
||||
ol.packtest-colorcoded > li[data-status=error] {
|
||||
background: black;
|
||||
}
|
||||
|
||||
|
||||
/**************************************************************************************************/
|
||||
/* Player */
|
||||
|
||||
#player {
|
||||
flex: 0;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
gap: 0.5em;
|
||||
margin: auto; /* center in both directions baby */
|
||||
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
@@ -554,8 +631,20 @@ button.level-pack-button p {
|
||||
--tile-height: 32px;
|
||||
--scale: 1;
|
||||
}
|
||||
#player > .-main-area {
|
||||
#player-main {
|
||||
/* This element basically just exists so that #player can have relative positioning and the
|
||||
* debug panel can use that to sit against the right edge; absolute positioning excludes
|
||||
* margins, so if it were positioned as a child of THIS element, it would be stuffed into the
|
||||
* game area (oops!) */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
gap: 0.5em;
|
||||
margin: auto; /* center in both directions baby */
|
||||
}
|
||||
#player-game-area {
|
||||
isolation: isolate;
|
||||
align-self: center; /* don't stretch if the buttons or music blow out somehow */
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid:
|
||||
@@ -564,9 +653,8 @@ button.level-pack-button p {
|
||||
"level bonus" min-content
|
||||
"level message" 1fr
|
||||
"level inventory" min-content
|
||||
"level actions" min-content
|
||||
/* Need explicit min-content to force the hint to wrap */
|
||||
/ min-content min-content min-content
|
||||
/ min-content min-content
|
||||
;
|
||||
column-gap: 2em;
|
||||
row-gap: 0.5em;
|
||||
@@ -609,7 +697,7 @@ button.level-pack-button p {
|
||||
text-shadow: 0 2px 1px black;
|
||||
}
|
||||
/* Allow clicking through the overlay in debug mode */
|
||||
#player.--debug .overlay-message {
|
||||
body.--debug .overlay-message {
|
||||
pointer-events: none;
|
||||
}
|
||||
#player .overlay-message p {
|
||||
@@ -777,7 +865,6 @@ dl.score-chart .-sum {
|
||||
color: white;
|
||||
}
|
||||
#player .actions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
@@ -842,60 +929,73 @@ dl.score-chart .-sum {
|
||||
}
|
||||
|
||||
/* Debug stuff */
|
||||
#player.--debug #player-debug {
|
||||
display: grid;
|
||||
body.--debug #player-debug {
|
||||
display: flex;
|
||||
}
|
||||
#player-debug {
|
||||
flex: none;
|
||||
align-self: center;
|
||||
display: none;
|
||||
grid:
|
||||
"time inventory options"
|
||||
"buttons buttons buttons"
|
||||
/ 2fr 1fr 2fr
|
||||
;
|
||||
/* FIXME this blows out the height instead of scrolling and i cannot figure out how to make it
|
||||
* stop, so here is an arbitrary max height on it which i extremely hate */
|
||||
overflow: auto;
|
||||
max-height: 75vh;
|
||||
flex-direction: column;
|
||||
justify-content: start;
|
||||
gap: 0.25em;
|
||||
padding: 0.5em;
|
||||
background-image: repeating-linear-gradient(135deg,
|
||||
hsl(0, 0%, 4%) 0em,
|
||||
hsl(0, 0%, 4%) 1em,
|
||||
hsl(15, 25%, 8%) 1em,
|
||||
hsl(15, 25%, 8%) 2em);
|
||||
border: 0.25em solid hsla(30, 100%, 50%, 0.5);
|
||||
hsl(0, 0%, 4%) 0px,
|
||||
hsl(0, 0%, 4%) 16px,
|
||||
hsl(30, 25%, 8%) 16px,
|
||||
hsl(30, 25%, 8%) 32px);
|
||||
border: 0.25em solid hsl(30, 75%, 50%);
|
||||
border-right-width: 0;
|
||||
box-shadow: inset 0 0 0.25em 0.25em #0004;
|
||||
|
||||
}
|
||||
#player-debug > .-time {
|
||||
grid-area: time;
|
||||
#player-debug > h3 {
|
||||
margin: -0.25em -0.25em 0.25em;
|
||||
padding: 0 0.5em;
|
||||
background: hsl(30, 60%, 20%);
|
||||
color: black;
|
||||
}
|
||||
#player-debug > .-time table.-time-controls {
|
||||
#player-debug > * + h3 {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
#player-debug > .-time table.-time-controls button {
|
||||
#player-debug hr {
|
||||
border: none;
|
||||
margin: 0.25em 10%;
|
||||
border-bottom: 1px solid #404040;
|
||||
}
|
||||
#player-debug table.-time-controls {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
#player-debug table.-time-controls button {
|
||||
width: 100%;
|
||||
text-align: inherit;
|
||||
}
|
||||
#player-debug > .-time table.-time-controls td:nth-child(1) {
|
||||
#player-debug table.-time-controls td:nth-child(1) {
|
||||
/* go backwards button */
|
||||
text-align: left;
|
||||
}
|
||||
#player-debug > .-time table.-time-controls td:nth-child(2) {
|
||||
#player-debug table.-time-controls td:nth-child(2) {
|
||||
/* value */
|
||||
width: 6em;
|
||||
width: 4em;
|
||||
text-align: right;
|
||||
}
|
||||
#player-debug > .-time table.-time-controls td:nth-child(3) {
|
||||
#player-debug table.-time-controls td:nth-child(3) {
|
||||
/* label */
|
||||
}
|
||||
#player-debug > .-time table.-time-controls td:nth-child(4) {
|
||||
#player-debug table.-time-controls td:nth-child(4) {
|
||||
/* go forward button */
|
||||
text-align: right;
|
||||
}
|
||||
#player-debug > .-options {
|
||||
grid-area: options;
|
||||
}
|
||||
#player-debug > .-inventory {
|
||||
grid-area: inventory;
|
||||
#player-debug .-inventory {
|
||||
display: grid;
|
||||
margin: auto;
|
||||
grid: auto-fill var(--tile-height) / repeat(4, var(--tile-width));
|
||||
margin: 0 auto;
|
||||
grid: auto-fill var(--tile-height) / repeat(8, var(--tile-width));
|
||||
gap: 2px;
|
||||
}
|
||||
#player-debug > .-inventory > button {
|
||||
@@ -905,41 +1005,107 @@ dl.score-chart .-sum {
|
||||
display: block;
|
||||
}
|
||||
#player-debug > .-inventory > button.-wide {
|
||||
grid-column: span 4;
|
||||
grid-column: span 8;
|
||||
padding: 0.25em;
|
||||
}
|
||||
#player-debug > .-buttons {
|
||||
grid-area: buttons;
|
||||
#player-debug .-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.25em;
|
||||
}
|
||||
.input {
|
||||
#player-debug .-buttons button {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
#player-debug .-replay-columns {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5em;
|
||||
}
|
||||
#player-debug .-replay-columns .-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
#player-debug-input {
|
||||
flex: none;
|
||||
display: grid;
|
||||
display: none;
|
||||
grid:
|
||||
"drop up cycle" 1.5em
|
||||
"left swap right" 1.5em
|
||||
". down . " 1.5em
|
||||
/ 1.5em 1.5em 1.5em
|
||||
"drop up cycle" 1em
|
||||
"left . right" 1em
|
||||
". down swap" 1em
|
||||
/ 1em 1em 1em
|
||||
;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.input-action {
|
||||
padding: 0.25em;
|
||||
line-height: 1;
|
||||
color: #fff4;
|
||||
background: #202020;
|
||||
#player-debug-input > svg {
|
||||
fill: #404040;
|
||||
}
|
||||
.input-action[data-action=up] { grid-area: up; }
|
||||
.input-action[data-action=down] { grid-area: down; }
|
||||
.input-action[data-action=left] { grid-area: left; }
|
||||
.input-action[data-action=right] { grid-area: right; }
|
||||
.input-action[data-action=swap] { grid-area: swap; }
|
||||
.input-action[data-action=cycle] { grid-area: cycle; }
|
||||
.input-action[data-action=drop] { grid-area: drop; }
|
||||
.input-action.--pressed {
|
||||
color: white;
|
||||
background: hsl(225, 75%, 25%);
|
||||
#player-debug-input > svg.--held {
|
||||
fill: white;
|
||||
}
|
||||
#player-debug > .-replay-available {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
}
|
||||
#player-debug > .-replay-available > h4 {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
#player-debug > .-replay-available > p {
|
||||
flex: 2 0 0;
|
||||
margin: 0;
|
||||
}
|
||||
#player-debug .-replay-status {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 4em;
|
||||
}
|
||||
#player-debug .-replay-status > .-none {
|
||||
}
|
||||
#player.--replay-playback #player-debug .-replay-status > .-none,
|
||||
#player.--replay-recording #player-debug .-replay-status > .-none {
|
||||
display: none;
|
||||
}
|
||||
#player-debug .-replay-status > .-playback {
|
||||
flex: 1;
|
||||
display: none;
|
||||
grid:
|
||||
"duration percent" 1em
|
||||
"progress progress" 0.5em
|
||||
"button button" 2em
|
||||
/ 3fr 1fr
|
||||
;
|
||||
gap: 0.25em;
|
||||
align-items: center;
|
||||
}
|
||||
#player.--replay-playback #player-debug .-replay-status > .-playback {
|
||||
display: grid;
|
||||
}
|
||||
#player-debug .-replay-status > .-playback > progress {
|
||||
grid-area: progress;
|
||||
height: 100%;
|
||||
}
|
||||
#player-debug .-replay-status > .-playback > output {
|
||||
grid-area: percent;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
#player-debug .-replay-status > .-playback > span {
|
||||
grid-area: duration;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#player-debug .-replay-status > .-playback > button {
|
||||
grid-area: button;
|
||||
}
|
||||
#player-debug .-replay-status > .-recording {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#player.--replay-recording #player-debug .-replay-status > .-recording {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
@@ -953,14 +1119,13 @@ dl.score-chart .-sum {
|
||||
#player .controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
#player > .-main-area {
|
||||
#player-game-area {
|
||||
/* Rearrange the grid to be vertical */
|
||||
grid:
|
||||
"level level"
|
||||
"chips inventory" calc((var(--tile-height) * var(--scale) * 2 - 1em) / 3)
|
||||
"time inventory" calc((var(--tile-height) * var(--scale) * 2 - 1em) / 3)
|
||||
"bonus inventory" calc((var(--tile-height) * var(--scale) * 2 - 1em) / 3)
|
||||
"actions actions" min-content
|
||||
/* FIXME ideally the first column would be 1fr so the hearts/time have space, but that
|
||||
* allows hints to grow to the entire width of the window, which incredibly sucks. i
|
||||
* don't know how to get around this except by giving the grid a fixed width, which i
|
||||
|
||||
Reference in new issue
Block a user