Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c879f99d2 | ||
|
|
b82e112cbc | ||
|
|
2fa84e0477 | ||
|
|
6d003287e4 | ||
|
|
e963c83c4d | ||
|
|
0cd7537ce6 | ||
|
|
ed67f371cb | ||
|
|
246e56187c | ||
|
|
214eaad1f5 | ||
|
|
9e90b18f1f | ||
|
|
d4910a4147 | ||
|
|
fe096436da | ||
|
|
7b5e9b564d | ||
|
|
98c77ed798 | ||
|
|
68eb16f7e7 | ||
|
|
559730eae4 | ||
|
|
aa4b3f3794 | ||
|
|
0e752972f0 | ||
|
|
38d7b55032 | ||
|
|
913a8144f1 | ||
|
|
4772d63719 | ||
|
|
626d146375 | ||
|
|
3c7b8948ae | ||
|
|
d1f0ac4956 | ||
|
|
b891d6f38c | ||
|
|
20b19c53ff | ||
|
|
c900ec80db | ||
|
|
63da1ff38c | ||
|
|
3f6278f281 | ||
|
|
4527eb972e | ||
|
|
d54ba0a191 | ||
|
|
45a8e0055d | ||
|
|
f6ee09b6c7 | ||
|
|
e33c35bbe0 | ||
|
|
1481047b94 | ||
|
|
037d9d86fb | ||
|
|
9763ceaa1c | ||
|
|
5a17b9022d | ||
|
|
0efbefb999 | ||
|
|
55c4c574ec | ||
|
|
df0ab43e70 | ||
|
|
097a4b04d8 | ||
|
|
7e210de5e7 | ||
|
|
991704ee19 | ||
|
|
c5f2728ad0 | ||
|
|
6c3cf8b4b4 | ||
|
|
1cb92a454d | ||
|
|
430fa5c354 | ||
|
|
5da2cf14db | ||
|
|
e7903d5895 | ||
|
|
6a92641d57 | ||
|
|
90a8f73b93 | ||
|
|
13918a579f | ||
|
|
20e2b64390 | ||
|
|
0a5e5c66c2 | ||
|
|
5f80e880c2 | ||
|
|
3a9e7c1cd8 | ||
|
|
abbda898c7 | ||
|
|
1170c5970e | ||
|
|
39f0f20dc6 | ||
|
|
04d6b3dddb | ||
|
|
c45ebe60e1 | ||
|
|
b360fa3998 | ||
|
|
29fbb56c88 | ||
|
|
a31c8b8a86 | ||
|
|
3dfa9bd361 | ||
|
|
43d5d65366 | ||
|
|
0098660d7b | ||
|
|
cd2d28dedd | ||
|
|
b6f38f835d | ||
|
|
b44da28020 | ||
|
|
06ceb827f3 | ||
|
|
17f4e77054 | ||
|
|
939c71aab7 | ||
|
|
af57e8a33e | ||
|
|
e3d8a0f669 | ||
|
|
3cf81b53ad | ||
|
|
5e2dfdd926 | ||
|
|
c624964b76 | ||
|
|
e9650db4d8 | ||
|
|
5aeeb8a974 | ||
|
|
e11a5956bd | ||
|
|
618f292ec9 | ||
|
|
849010fc75 | ||
|
|
2439048f59 | ||
|
|
ed5f76221b | ||
|
|
eaa3bf6965 | ||
|
|
ba11e48c7d | ||
|
|
7e0c1b0337 | ||
|
|
48482b2a65 | ||
|
|
e1e99e73e7 | ||
|
|
3802b10956 | ||
|
|
bef5550a95 | ||
|
|
933d20d559 | ||
|
|
cddc274701 | ||
|
|
fe4c111fa9 | ||
|
|
a06f53af29 | ||
|
|
52bc2bdf8e | ||
|
|
f7b8d3c7bc | ||
|
|
01dd4eb1a8 | ||
|
|
a3b283b51e | ||
|
|
1df89884ed | ||
|
|
2b35dd5bce | ||
|
|
ebe848ec99 | ||
|
|
9bf418258f | ||
|
|
65664bba7b | ||
|
|
d7e1b969e8 | ||
|
|
fd590f8353 | ||
|
|
f417162f6f | ||
|
|
25cb6f2f05 | ||
|
|
f422b4b395 | ||
|
|
f896e1bdfd | ||
|
|
80edfa1ae9 | ||
|
|
64ca8f008c | ||
|
|
2ee86b50d2 | ||
|
|
f140804713 | ||
|
|
a1f357f317 | ||
|
|
7ba261c7d9 | ||
|
|
7a9e3a6eb1 | ||
|
|
b0650e7d6e | ||
|
|
6d6f4f7c47 | ||
|
|
816b249f67 | ||
|
|
50ebd95509 | ||
|
|
15a8be1c15 | ||
|
|
a088e50b3b | ||
|
|
1e02c6aa6f | ||
|
|
2c95c7eacd | ||
|
|
b4ebdf069d | ||
|
|
45dbeacc4a | ||
|
|
6d580af817 | ||
|
|
bcbb536bdc | ||
|
|
c8de4edfff | ||
|
|
91a5ab6786 | ||
|
|
4ac01a403f | ||
|
|
9309e9c838 | ||
|
|
77afca5799 | ||
|
|
4ebe5c1149 | ||
|
|
34e430e8a1 | ||
|
|
47313521ed | ||
|
|
6f27332cce | ||
|
|
073aba65ab | ||
|
|
71abc13330 | ||
|
|
8feb732a8f | ||
|
|
a87db67d84 | ||
|
|
d675cddafb | ||
|
|
2df4dc5829 | ||
|
|
42d543b235 | ||
|
|
94a7ec5a2c | ||
|
|
590ecb36ae | ||
|
|
96bc4e0a3c | ||
|
|
51bc3dfe83 | ||
|
|
3e7390ffc0 | ||
|
|
ca1a48c0fe |
BIN
icon-debug.png
|
Before Width: | Height: | Size: 508 B After Width: | Height: | Size: 522 B |
BIN
icon.png
|
Before Width: | Height: | Size: 333 B After Width: | Height: | Size: 494 B |
BIN
icons/layer-actor.png
Normal file
|
After Width: | Height: | Size: 490 B |
BIN
icons/layer-all.png
Normal file
|
After Width: | Height: | Size: 597 B |
BIN
icons/layer-canopy.png
Normal file
|
After Width: | Height: | Size: 456 B |
BIN
icons/layer-item.png
Normal file
|
After Width: | Height: | Size: 451 B |
BIN
icons/layer-item_mod.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
icons/layer-swivel.png
Normal file
|
After Width: | Height: | Size: 421 B |
BIN
icons/layer-terrain.png
Normal file
|
After Width: | Height: | Size: 395 B |
BIN
icons/layer-thin_wall.png
Normal file
|
After Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 235 B After Width: | Height: | Size: 478 B |
|
Before Width: | Height: | Size: 235 B After Width: | Height: | Size: 478 B |
|
Before Width: | Height: | Size: 299 B After Width: | Height: | Size: 496 B |
|
Before Width: | Height: | Size: 155 B After Width: | Height: | Size: 421 B |
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 395 B |
|
Before Width: | Height: | Size: 276 B After Width: | Height: | Size: 440 B |
|
Before Width: | Height: | Size: 207 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 226 B After Width: | Height: | Size: 381 B |
BIN
icons/tool-ice.png
Normal file
|
After Width: | Height: | Size: 484 B |
|
Before Width: | Height: | Size: 154 B After Width: | Height: | Size: 423 B |
|
Before Width: | Height: | Size: 184 B After Width: | Height: | Size: 368 B |
BIN
icons/tool-rotate.png
Normal file
|
After Width: | Height: | Size: 439 B |
|
Before Width: | Height: | Size: 147 B After Width: | Height: | Size: 408 B |
BIN
icons/tool-select-wand.png
Normal file
|
After Width: | Height: | Size: 498 B |
BIN
icons/tool-text.png
Normal file
|
After Width: | Height: | Size: 452 B |
BIN
icons/tool-thin-walls.png
Normal file
|
After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 185 B After Width: | Height: | Size: 385 B |
|
Before Width: | Height: | Size: 259 B After Width: | Height: | Size: 488 B |
28
index.html
@ -115,6 +115,19 @@
|
||||
<path d="M1,6 a5,5 0 1,1 10,0 a5,5 0 1,1 -10,0 m2,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0"></path>
|
||||
<path d="M14,12 l-2,2 -4,-4 2,-2 4,4"></path>
|
||||
</g>
|
||||
<g id="svg-icon-mouse1">
|
||||
<path d="
|
||||
M9,2 a3,3 0 0,1 3,3 v5 a4,4 0 0,1 -8,0 v-5 a3,3 0 0,1 3,-3 z
|
||||
M9,3 v5 h-4 v-3 a2,2 0 0,1 2,-2 h1 z
|
||||
"></path>
|
||||
<!--M9,3 a2,2 0 0,0 -2,2 v3 h3 z-->
|
||||
</g>
|
||||
<g id="svg-icon-mouse2">
|
||||
<path d="
|
||||
M9,2 a3,3 0 0,1 3,3 v5 a4,4 0 0,1 -8,0 v-5 a3,3 0 0,1 3,-3 z
|
||||
M7,3 h2 a2,2 0 0,1 2,2 v3 h-4 v-5 z
|
||||
"></path>
|
||||
</g>
|
||||
</defs>
|
||||
</svg>
|
||||
<header id="header-main">
|
||||
@ -122,7 +135,7 @@
|
||||
<h1><a href="https://github.com/eevee/lexys-labyrinth">Lexy's Labyrinth</a></h1>
|
||||
<p>— an <a href="https://github.com/eevee/lexys-labyrinth">open source</a> game by <a href="https://eev.ee/">eevee</a></p>
|
||||
<nav>
|
||||
<button id="main-compat" type="button">mode: <output>lexy</output></button>
|
||||
<button id="main-compat" type="button"><img src="icons/compat-lexy.png" alt=""> <output>lexy</output></button>
|
||||
<button id="main-options" type="button">options</button>
|
||||
</nav>
|
||||
</header>
|
||||
@ -151,6 +164,7 @@
|
||||
<h1>oops!</h1>
|
||||
<p>Sorry, the game was unable to load at all.</p>
|
||||
<p>If you have JavaScript partly or wholly blocked, I salute you! ...but this is an interactive game and cannot work without it.</p>
|
||||
<p>If not, it's possible that the game updated, but you have a mix of old and new code. Try a hard refresh (Ctrl-Shift-R).</p>
|
||||
<p class="-with-error">I did manage to capture this error, which you might be able to <a href="https://github.com/eevee/lexys-labyrinth/issues/new">report somewhere</a>:</p>
|
||||
<pre class="-with-error stack-trace"></pre>
|
||||
</main>
|
||||
@ -202,8 +216,8 @@
|
||||
<div class="button-row">
|
||||
<input id="splash-upload-file" type="file" accept=".dat,.ccl,.c2m,.ccs,.zip" multiple>
|
||||
<input id="splash-upload-dir" type="file" webkitdirectory>
|
||||
<button type="button" id="splash-upload-file-button" class="button-big">Load files</button>
|
||||
<button type="button" id="splash-upload-dir-button" class="button-big">Load directory</button>
|
||||
<button type="button" id="splash-upload-file-button" class="button-big button-bright">Load files</button>
|
||||
<button type="button" id="splash-upload-dir-button" class="button-big button-bright">Load directory</button>
|
||||
</div>
|
||||
<ul class="played-pack-list" id="splash-other-pack-list">
|
||||
<!-- populated by js -->
|
||||
@ -213,8 +227,8 @@
|
||||
<section id="splash-your-levels">
|
||||
<h2>Create</h2>
|
||||
<div class="button-row">
|
||||
<button type="button" id="splash-create-pack" class="button-big">New pack</button>
|
||||
<button type="button" id="splash-create-level" class="button-big">New scratch level<br>(won't be saved!)</button>
|
||||
<button type="button" id="splash-create-pack" class="button-big button-bright">New pack</button>
|
||||
<button type="button" id="splash-create-level" class="button-big button-bright">New scratch level<br>(won't be saved!)</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@ -226,8 +240,7 @@
|
||||
<span class="-optional-label">pause</span> <span class="keyhint"><kbd>p</kbd></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"></path></svg>
|
||||
<span class="-optional-label">retry</span>
|
||||
</button>
|
||||
<span class="-optional-label">retry</span> <span class="keyhint"><kbd>r</kbd></span></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"></path></svg>
|
||||
<span class="-optional-label">undo</span> <span class="keyhint"><kbd>u</kbd></span></button>
|
||||
@ -252,6 +265,7 @@
|
||||
<section id="player-game-area">
|
||||
<div class="level"><!-- level canvas and any overlays go here --></div>
|
||||
<div class="player-overlay-message"></div>
|
||||
<div class="player-overlay-captions"></div>
|
||||
<div class="player-hint-wrapper">
|
||||
<div class="player-hint"></div>
|
||||
<svg class="player-hint-bg-icon svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-hint"></use></svg>
|
||||
|
||||
181
js/algorithms.js
@ -1,9 +1,95 @@
|
||||
import { DIRECTIONS, DIRECTION_ORDER } from './defs.js';
|
||||
import { DIRECTIONS, LAYERS } from './defs.js';
|
||||
|
||||
export function trace_floor_circuit(level, start_cell, start_edge, on_wire, on_dead_end) {
|
||||
// Iterates over every terrain tile in the grid that has one of the given types (a Set of type
|
||||
// names), in linear order, optionally in reverse. The starting cell is checked last.
|
||||
// Yields [tile, cell].
|
||||
export function* find_terrain_linear(levelish, start_cell, type_names, reverse = false) {
|
||||
let i = levelish.coords_to_scalar(start_cell.x, start_cell.y);
|
||||
while (true) {
|
||||
if (reverse) {
|
||||
i -= 1;
|
||||
if (i < 0) {
|
||||
i += levelish.size_x * levelish.size_y;
|
||||
}
|
||||
}
|
||||
else {
|
||||
i += 1;
|
||||
i %= levelish.size_x * levelish.size_y;
|
||||
}
|
||||
|
||||
let cell = levelish.linear_cells[i];
|
||||
let tile = cell[LAYERS.terrain];
|
||||
if (tile && type_names.has(tile.type.name)) {
|
||||
yield [tile, cell];
|
||||
}
|
||||
|
||||
if (cell === start_cell)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Iterates over every terrain tile in the grid that has one of the given types (a Set of type
|
||||
// names), spreading outward in a diamond pattern. The starting cell is not included.
|
||||
// Only used by orange buttons.
|
||||
// Yields [tile, cell].
|
||||
export function* find_terrain_diamond(levelish, start_cell, type_names) {
|
||||
// Note that this won't search the entire level in all cases, but it does match CC2 behavior.
|
||||
// Not worth a compat flag since it only affects level design, and fairly perversely
|
||||
let max_search_radius = Math.max(levelish.size_x, levelish.size_y) + 1;
|
||||
for (let dist = 1; dist <= max_search_radius; dist++) {
|
||||
// Start east and move counterclockwise
|
||||
let sx = start_cell.x + dist;
|
||||
let sy = start_cell.y;
|
||||
for (let direction of [[-1, -1], [-1, 1], [1, 1], [1, -1]]) {
|
||||
for (let i = 0; i < dist; i++) {
|
||||
let cell = levelish.cell(sx, sy);
|
||||
sx += direction[0];
|
||||
sy += direction[1];
|
||||
|
||||
if (! cell)
|
||||
continue;
|
||||
let terrain = cell[LAYERS.terrain];
|
||||
if (type_names.has(terrain.type.name)) {
|
||||
yield [terrain, cell];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CONNECTION_FUNCTIONS = {
|
||||
forward: find_terrain_linear,
|
||||
diamond: find_terrain_diamond,
|
||||
};
|
||||
|
||||
|
||||
export class Circuit {
|
||||
constructor() {
|
||||
this.is_powered = null;
|
||||
this.tiles = new Map;
|
||||
this.inputs = new Map;
|
||||
}
|
||||
|
||||
add_tile_edge(tile, edgebits) {
|
||||
this.tiles.set(tile, (this.tiles.get(tile) ?? 0) | edgebits);
|
||||
}
|
||||
|
||||
add_input_edge(tile, edgebits) {
|
||||
this.inputs.set(tile, (this.inputs.get(tile) ?? 0) | edgebits);
|
||||
}
|
||||
}
|
||||
|
||||
// Traces a wire circuit and calls the given callbacks when finding either a new wire or an ending.
|
||||
// actor_mode describes how to handle circuit blocks:
|
||||
// - still: Actor wires are examined only for actors with a zero cooldown. (Normal behavior.)
|
||||
// - always: Actor wires are always examined. (compat.tiles_react_instantly behavior.)
|
||||
// - ignore: Skip actors entirely. (Editor behavior.)
|
||||
// Returns a Circuit.
|
||||
export function trace_floor_circuit(levelish, actor_mode, start_cell, start_edge, on_wire, on_dead_end) {
|
||||
let is_first = true;
|
||||
let pending = [[start_cell, start_edge]];
|
||||
let seen_cells = new Map;
|
||||
let circuit = new Circuit;
|
||||
while (pending.length > 0) {
|
||||
let next = [];
|
||||
for (let [cell, edge] of pending) {
|
||||
@ -15,45 +101,65 @@ export function trace_floor_circuit(level, start_cell, start_edge, on_wire, on_d
|
||||
let seen_edges = seen_cells.get(cell) ?? 0;
|
||||
if (seen_edges & edgeinfo.bit)
|
||||
continue;
|
||||
|
||||
|
||||
let tile = terrain;
|
||||
let actor = cell.get_actor();
|
||||
let wire_directions = terrain.wire_directions;
|
||||
if ((actor?.wire_directions ?? null !== null) && (actor.movement_cooldown === 0 || level.compat.tiles_react_instantly))
|
||||
if (actor && actor.type.contains_wire && (
|
||||
(actor_mode === 'still' && actor.movement_cooldown === 0) || actor_mode === 'always'))
|
||||
{
|
||||
wire_directions = actor.wire_directions;
|
||||
tile = actor;
|
||||
}
|
||||
|
||||
// The wire comes in from this edge towards the center; see how it connects within this
|
||||
// cell, then check for any neighbors
|
||||
let connections = edgeinfo.bit;
|
||||
if (! is_first && ((wire_directions ?? 0) & edgeinfo.bit) === 0) {
|
||||
// There's not actually a wire here (but not if this is our starting cell, in which
|
||||
// case we trust the caller)
|
||||
if (on_dead_end) {
|
||||
on_dead_end(terrain.cell, edge);
|
||||
let mode = tile.wire_propagation_mode ?? tile.type.wire_propagation_mode;
|
||||
if (! is_first && ((tile.wire_directions ?? 0) & edgeinfo.bit) === 0) {
|
||||
// There's not actually a wire here, so check for things that respond to receiving
|
||||
// power... but if this is the starting cell, we trust the caller and skip it (XXX why)
|
||||
for (let tile2 of cell) {
|
||||
if (! tile2)
|
||||
continue;
|
||||
|
||||
if (tile2.type.name === 'logic_gate') {
|
||||
// Logic gates are technically not wired, but still attached to
|
||||
// circuits, mostly so blue teleporters can follow them
|
||||
let wire = tile2.type._gate_types[tile2.gate_type][
|
||||
(DIRECTIONS[edge].index - DIRECTIONS[tile2.direction].index + 4) % 4];
|
||||
if (! wire)
|
||||
continue;
|
||||
circuit.add_tile_edge(tile2, DIRECTIONS[edge].bit);
|
||||
if (wire.match(/^out/)) {
|
||||
circuit.add_input_edge(tile2, DIRECTIONS[edge].bit);
|
||||
}
|
||||
}
|
||||
else if (tile2.type.on_power) {
|
||||
circuit.add_tile_edge(tile2, DIRECTIONS[edge].bit);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
else if (terrain.type.wire_propagation_mode === 'none') {
|
||||
else if (mode === 'none') {
|
||||
// The wires in this tile never connect to each other
|
||||
}
|
||||
else if (terrain.type.wire_propagation_mode === 'cross' ||
|
||||
(wire_directions === 0x0f && terrain.type.wire_propagation_mode !== 'all'))
|
||||
{
|
||||
else if (mode === 'cross' || (mode === 'autocross' && tile.wire_directions === 0x0f)) {
|
||||
// This is a cross pattern, so only opposite edges connect
|
||||
if (wire_directions & edgeinfo.opposite_bit) {
|
||||
if (tile.wire_directions & edgeinfo.opposite_bit) {
|
||||
connections |= edgeinfo.opposite_bit;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Everything connects
|
||||
connections |= wire_directions;
|
||||
connections |= tile.wire_directions;
|
||||
}
|
||||
|
||||
seen_cells.set(cell, seen_edges | connections);
|
||||
|
||||
if (on_wire) {
|
||||
on_wire(terrain, connections);
|
||||
circuit.add_tile_edge(tile, connections);
|
||||
|
||||
if (tile.type.update_power_emission) {
|
||||
// TODO could just do this in a pass afterwards?
|
||||
circuit.add_input_edge(tile, connections);
|
||||
}
|
||||
|
||||
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
|
||||
@ -67,10 +173,12 @@ export function trace_floor_circuit(level, start_cell, start_edge, on_wire, on_d
|
||||
let neighbor;
|
||||
if ((terrain.wire_tunnel_directions ?? 0) & dirinfo.bit) {
|
||||
// Search in this direction for a matching tunnel
|
||||
neighbor = find_matching_wire_tunnel(level, cell.x, cell.y, direction);
|
||||
// Note that while actors (the fuckin circuit block) can be wired, tunnels ONLY
|
||||
// appear on terrain, and are NOT affected by actors on top
|
||||
neighbor = find_matching_wire_tunnel(levelish, cell.x, cell.y, direction);
|
||||
}
|
||||
else {
|
||||
neighbor = level.get_neighboring_cell(cell, direction);
|
||||
neighbor = levelish.get_neighboring_cell(cell, direction);
|
||||
}
|
||||
|
||||
/*
|
||||
@ -88,16 +196,18 @@ export function trace_floor_circuit(level, start_cell, start_edge, on_wire, on_d
|
||||
pending = next;
|
||||
is_first = false;
|
||||
}
|
||||
|
||||
return circuit;
|
||||
}
|
||||
|
||||
export function find_matching_wire_tunnel(level, x, y, direction) {
|
||||
export function find_matching_wire_tunnel(levelish, x, y, direction) {
|
||||
let dirinfo = DIRECTIONS[direction];
|
||||
let [dx, dy] = dirinfo.movement;
|
||||
let nesting = 0;
|
||||
while (true) {
|
||||
x += dx;
|
||||
y += dy;
|
||||
let candidate = level.cell(x, y);
|
||||
let candidate = levelish.cell(x, y);
|
||||
if (! candidate)
|
||||
return null;
|
||||
|
||||
@ -118,28 +228,3 @@ export function find_matching_wire_tunnel(level, x, y, direction) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO make this guy work generically for orange, red, brown buttons? others...?
|
||||
export function find_implicit_connection() {
|
||||
}
|
||||
|
||||
// Iterates over a grid in a diamond pattern, spreading out from the given start cell (but not
|
||||
// including it). Only used for connecting orange buttons.
|
||||
export function* iter_cells_in_diamond(levelish, x0, y0) {
|
||||
let max_search_radius = Math.max(levelish.size_x, levelish.size_y) + 1;
|
||||
for (let dist = 1; dist <= max_search_radius; dist++) {
|
||||
// Start east and move counterclockwise
|
||||
let sx = x0 + dist;
|
||||
let sy = y0;
|
||||
for (let direction of [[-1, -1], [-1, 1], [1, 1], [1, -1]]) {
|
||||
for (let i = 0; i < dist; i++) {
|
||||
let cell = levelish.cell(sx, sy);
|
||||
if (cell) {
|
||||
yield cell;
|
||||
}
|
||||
sx += direction[0];
|
||||
sy += direction[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
338
js/defs.js
@ -135,178 +135,198 @@ export const COMPAT_RULESET_LABELS = {
|
||||
};
|
||||
export const COMPAT_RULESET_ORDER = ['lexy', 'steam', 'steam-strict', 'lynx', 'ms', 'custom'];
|
||||
// FIXME some of the names of the flags themselves kinda suck
|
||||
export const COMPAT_FLAGS = [
|
||||
// Level loading
|
||||
// TODO? /strictly/ speaking, these should be turned on for lynx+ms/lynx respectively, but then i'd
|
||||
// have to also alter the behavior of the corresponding terrain, which seems kind of silly
|
||||
{
|
||||
key: 'no_auto_convert_ccl_popwalls',
|
||||
label: "Recessed walls under actors in CCL levels are left alone",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
// TODO some ms compat things that wouldn't be too hard to add:
|
||||
// - walkers choose a random /unblocked/ direction, not just a random direction
|
||||
// - (boosting) player cooldown is /zero/ after ending a slide
|
||||
// - cleats allow walking through ice corner walls while standing on them
|
||||
// - blocks can be pushed through thin walls + ice corners
|
||||
export const COMPAT_FLAG_CATEGORIES = [{
|
||||
title: "Level loading",
|
||||
flags: [{
|
||||
key: 'no_auto_convert_ccl_popwalls',
|
||||
label: "Recessed walls under actors are not auto-converted in CCL levels",
|
||||
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
|
||||
}, {
|
||||
key: 'no_auto_convert_ccl_blue_walls',
|
||||
label: "Blue walls under blocks are not auto-converted in CCL levels",
|
||||
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
|
||||
}, {
|
||||
key: 'no_auto_convert_ccl_bombs',
|
||||
label: "Mines under actors are not auto-converted in CCL levels",
|
||||
rulesets: new Set(['steam-strict', 'lynx', 'ms']),
|
||||
}],
|
||||
}, {
|
||||
key: 'no_auto_convert_ccl_blue_walls',
|
||||
label: "Blue walls under blocks in CCL levels are left alone",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
},
|
||||
|
||||
// Core
|
||||
{
|
||||
key: 'allow_double_cooldowns',
|
||||
label: "Actors may cooldown twice in one tic",
|
||||
rulesets: new Set(['steam', 'steam-strict', 'lynx']),
|
||||
title: "Actor behavior",
|
||||
flags: [{
|
||||
key: 'emulate_60fps',
|
||||
label: "Actors update at 60 FPS",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
key: 'no_separate_idle_phase',
|
||||
label: "Actors teleport immediately after moving",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
key: 'allow_double_cooldowns',
|
||||
label: "Actors may move forwards twice in one tic",
|
||||
rulesets: new Set(['steam', 'steam-strict', 'lynx']),
|
||||
}, {
|
||||
key: 'player_moves_last',
|
||||
label: "Players always update last",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'reuse_actor_slots',
|
||||
label: "New actors reuse slots in the actor list",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'player_protected_by_items',
|
||||
label: "Players can't be trampled while standing on items",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'force_lynx_animation_lengths',
|
||||
label: "Animations play at their slower Lynx duration",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
// Note that this requires no_early_push as well
|
||||
key: 'player_safe_at_decision_time',
|
||||
label: "Players can't be trampled at decision time",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'bonking_isnt_instant',
|
||||
label: "Bonking while sliding doesn't apply instantly",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'actors_move_instantly',
|
||||
label: "Movement is instant",
|
||||
rulesets: new Set(['ms']),
|
||||
}],
|
||||
}, {
|
||||
key: 'no_separate_idle_phase',
|
||||
label: "Actors teleport immediately after moving",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
title: "Monsters",
|
||||
flags: [{
|
||||
// TODO ms needs "player doesn't block monsters", but tbh that's kind of how it should work
|
||||
// anyway, especially in combination with the ankh
|
||||
// TODO? in lynx they ignore the button while in motion too
|
||||
// TODO what about in a trap, in every game??
|
||||
// TODO what does ms do when a tank is on ice or a ff? wiki's description is wacky
|
||||
// TODO yellow tanks seem to have memory too??
|
||||
key: 'tanks_always_obey_button',
|
||||
label: "Blue tanks obey blue buttons even on clone machines",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
key: 'tanks_ignore_button_while_moving',
|
||||
label: "Blue tanks ignore blue buttons while moving",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'blobs_use_tw_prng',
|
||||
label: "Blobs use the Lynx RNG",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'teeth_target_internal_position',
|
||||
label: "Teeth pursue the cell the player is moving into",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'rff_blocks_monsters',
|
||||
label: "Monsters cannot step on random force floors",
|
||||
rulesets: new Set(['ms']),
|
||||
}, {
|
||||
key: 'fire_allows_most_monsters',
|
||||
label: "Monsters can walk into fire, except for bugs and walkers",
|
||||
rulesets: new Set(['ms']),
|
||||
}],
|
||||
}, {
|
||||
key: 'player_moves_last',
|
||||
label: "Player always moves last",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'player_protected_by_items',
|
||||
label: "Players can't be trampled when standing on items",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
// Note that this requires no_early_push as well
|
||||
key: 'player_safe_at_decision_time',
|
||||
label: "Players can't be trampled at decision time",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'emulate_60fps',
|
||||
label: "Game runs at 60 FPS",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
key: 'reuse_actor_slots',
|
||||
label: "Game reuses slots in the actor list",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'force_lynx_animation_lengths',
|
||||
label: "Animations use Lynx duration",
|
||||
rulesets: new Set(['lynx']),
|
||||
},
|
||||
|
||||
// Tiles
|
||||
{
|
||||
// XXX this is goofy
|
||||
key: 'tiles_react_instantly',
|
||||
label: "Tiles react when approached",
|
||||
rulesets: new Set(['ms']),
|
||||
}, {
|
||||
key: 'rff_actually_random',
|
||||
label: "Random force floors are actually random",
|
||||
rulesets: new Set(['ms']),
|
||||
}, {
|
||||
key: 'no_backwards_override',
|
||||
label: "Player can't override backwards on a force floor",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'force_floors_inert_on_first_tic',
|
||||
label: "Force floors don't trigger on the first tic",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'traps_like_lynx',
|
||||
label: "Traps eject faster, and even when already open",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'blue_floors_vanish_on_arrive',
|
||||
label: "Fake blue walls vanish on arrival",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'green_teleports_can_fail',
|
||||
label: "Green teleporters sometimes fail",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
},
|
||||
|
||||
// Items
|
||||
{
|
||||
key: 'no_immediate_detonate_bombs',
|
||||
label: "Mines under non-player actors don't explode at level start",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'detonate_bombs_under_players',
|
||||
label: "Mines under players explode at level start",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
key: 'cloned_bowling_balls_can_be_lost',
|
||||
label: "Bowling balls on cloners are destroyed when fired at point blank",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
key: 'monsters_ignore_keys',
|
||||
label: "Monsters completely ignore keys",
|
||||
rulesets: new Set(['ms']),
|
||||
},
|
||||
|
||||
// Blocks
|
||||
{
|
||||
key: 'no_early_push',
|
||||
label: "Pushing blocks happens at move time",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'use_legacy_hooking',
|
||||
label: "Pulling blocks with the hook happens at decision time",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
// FIXME this is kind of annoying, there are some collision rules too
|
||||
key: 'tanks_teeth_push_ice_blocks',
|
||||
label: "Ice blocks emulate pgchip rules",
|
||||
rulesets: new Set(['ms']),
|
||||
}, {
|
||||
key: 'allow_pushing_blocks_off_faux_walls',
|
||||
label: "Blocks may be pushed off of blue (fake), invisible, and revealing walls",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'emulate_spring_mining',
|
||||
label: "Spring mining is possible",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
title: "Blocks",
|
||||
flags: [{
|
||||
key: 'use_legacy_hooking',
|
||||
label: "Pulling blocks with the hook happens earlier, and may prevent moving",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
key: 'no_directly_pushing_sliding_blocks',
|
||||
label: "Pushing sliding blocks queues a move, rather than moving them right away",
|
||||
rulesets: new Set(['steam', 'steam-strict']),
|
||||
}, {
|
||||
key: 'emulate_spring_mining',
|
||||
label: "Pushing a block off a recessed wall may cause you to move into the resulting wall",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
key: 'no_early_push',
|
||||
label: "Pushing blocks happens at move time (block slapping is disabled)",
|
||||
// XXX wait but the DEFAULT behavior allows block slapping, which lynx has, so why is lynx listed here?
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'use_pgchip_ice_blocks',
|
||||
label: "Ice blocks use pgchip rules",
|
||||
rulesets: new Set(['ms']),
|
||||
}, {
|
||||
key: 'allow_pushing_blocks_off_faux_walls',
|
||||
label: "Blocks may be pushed off of blue (fake), invisible, and revealing walls",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'block_splashes_dont_block',
|
||||
label: "Block splashes don't block the player",
|
||||
rulesets: new Set(['ms']),
|
||||
/* XXX not implemented
|
||||
}, {
|
||||
key: 'emulate_flicking',
|
||||
label: "Flicking is possible",
|
||||
rulesets: new Set(['ms']),
|
||||
*/
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
title: "Terrain",
|
||||
flags: [{
|
||||
key: 'green_teleports_can_fail',
|
||||
label: "Green teleporters sometimes fail",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
key: 'no_backwards_override',
|
||||
label: "Players can't override backwards on a force floor",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'traps_like_lynx',
|
||||
label: "Traps eject faster, and eject when already open",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'blue_floors_vanish_on_arrive',
|
||||
label: "Fake blue walls vanish when stepped on",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'popwalls_pop_on_arrive',
|
||||
label: "Recessed walls activate when stepped on",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'rff_actually_random',
|
||||
label: "Random force floors are actually random",
|
||||
rulesets: new Set(['ms']),
|
||||
}],
|
||||
}, {
|
||||
title: "Items",
|
||||
flags: [{
|
||||
key: 'cloned_bowling_balls_can_be_lost',
|
||||
label: "Bowling balls on cloners are destroyed when fired at point blank",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
// XXX is this necessary, with the addition of the dormant bomb?
|
||||
key: 'bombs_immediately_detonate_under_players',
|
||||
label: "Mines under players detonate when the level starts",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
key: 'bombs_detonate_on_arrive',
|
||||
label: "Mines detonate only when stepped on",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'monsters_ignore_keys',
|
||||
label: "Monsters completely ignore keys",
|
||||
rulesets: new Set(['ms']),
|
||||
}],
|
||||
}];
|
||||
|
||||
// Monsters
|
||||
{
|
||||
// TODO? in lynx they ignore the button while in motion too
|
||||
// TODO what about in a trap, in every game??
|
||||
// TODO what does ms do when a tank is on ice or a ff? wiki's description is wacky
|
||||
// TODO yellow tanks seem to have memory too??
|
||||
key: 'tanks_always_obey_button',
|
||||
label: "Blue tanks always obey blue buttons",
|
||||
rulesets: new Set(['steam-strict']),
|
||||
}, {
|
||||
key: 'tanks_ignore_button_while_moving',
|
||||
label: "Blue tanks ignore blue buttons while moving",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'blobs_use_tw_prng',
|
||||
label: "Blobs use the Tile World RNG",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'teeth_target_internal_position',
|
||||
label: "Teeth target the player's internal position",
|
||||
rulesets: new Set(['lynx']),
|
||||
}, {
|
||||
key: 'rff_blocks_monsters',
|
||||
label: "Random force floors block monsters",
|
||||
rulesets: new Set(['ms']),
|
||||
}, {
|
||||
key: 'bonking_isnt_instant',
|
||||
label: "Bonking while sliding doesn't apply instantly",
|
||||
rulesets: new Set(['lynx', 'ms']),
|
||||
}, {
|
||||
key: 'fire_allows_monsters',
|
||||
label: "Fire doesn't block monsters",
|
||||
rulesets: new Set(['ms']),
|
||||
},
|
||||
];
|
||||
|
||||
export function compat_flags_for_ruleset(ruleset) {
|
||||
let compat = {};
|
||||
for (let compatdef of COMPAT_FLAGS) {
|
||||
if (compatdef.rulesets.has(ruleset)) {
|
||||
compat[compatdef.key] = true;
|
||||
for (let category of COMPAT_FLAG_CATEGORIES) {
|
||||
for (let compatdef of category.flags) {
|
||||
if (compatdef.rulesets.has(ruleset)) {
|
||||
compat[compatdef.key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return compat;
|
||||
|
||||
@ -197,7 +197,7 @@ export class EditorLevelMetaOverlay extends DialogOverlay {
|
||||
let size_x = Math.max(1, Math.min(100, parseInt(els.size_x.value, 10)));
|
||||
let size_y = Math.max(1, Math.min(100, parseInt(els.size_y.value, 10)));
|
||||
if (size_x !== stored_level.size_x || size_y !== stored_level.size_y) {
|
||||
this.conductor.editor.resize_level(size_x, size_y);
|
||||
this.conductor.editor.crop_level(0, 0, size_x, size_y);
|
||||
}
|
||||
|
||||
stored_level.blob_behavior = parseInt(els.blob_behavior.value, 10);
|
||||
|
||||
@ -7,8 +7,21 @@ export const TOOLS = {
|
||||
pencil: {
|
||||
icon: 'icons/tool-pencil.png',
|
||||
name: "Pencil",
|
||||
desc: "Place, erase, and select tiles.\nLeft click: draw\nCtrl: erase\nShift: replace all layers\nRight click: pick foreground tile\nCtrl-right click: pick background tile",
|
||||
desc: [
|
||||
"Place, erase, and select tiles.",
|
||||
"Picks the top-most tile by default.",
|
||||
"Use the layer selector to pick specific tiles.",
|
||||
"",
|
||||
"[mouse1] Draw",
|
||||
"[shift] [mouse1] Draw, replacing entire cell",
|
||||
"[ctrl] [mouse1] Erase (terrain becomes background)",
|
||||
"[ctrl] [shift] [mouse1] Erase entire cell",
|
||||
"",
|
||||
"[mouse2] Pick foreground tile",
|
||||
"[ctrl] [mouse2] Pick background tile",
|
||||
].join("\n"),
|
||||
uses_palette: true,
|
||||
uses_layers: true,
|
||||
op1: mouseops.PencilOperation,
|
||||
op2: mouseops.EyedropOperation,
|
||||
shortcut: 'b',
|
||||
@ -19,7 +32,10 @@ export const TOOLS = {
|
||||
name: "Line",
|
||||
desc: "Draw straight lines",
|
||||
uses_palette: true,
|
||||
uses_layers: undefined,
|
||||
shortcut: 'l',
|
||||
op1: mouseops.LineOperation,
|
||||
op2: mouseops.EyedropOperation,
|
||||
},
|
||||
box: {
|
||||
// TODO not implemented
|
||||
@ -27,13 +43,27 @@ export const TOOLS = {
|
||||
name: "Box",
|
||||
desc: "Fill a rectangular area with tiles",
|
||||
uses_palette: true,
|
||||
uses_layers: undefined,
|
||||
shortcut: 'u',
|
||||
},
|
||||
fill: {
|
||||
icon: 'icons/tool-fill.png',
|
||||
name: "Fill",
|
||||
desc: "Flood-fill an area with tiles",
|
||||
desc: [
|
||||
"Flood-fill an area with the current tile.",
|
||||
"By default, fills the traversable region within the same layer.",
|
||||
"Use the layer selector to floodfill within a specific layer.",
|
||||
"",
|
||||
"[mouse1] Floodfill",
|
||||
"[ctrl] [mouse1] Floodfill, ignoring traversability",
|
||||
"[shift] [mouse1] Fill all matching tiles in the entire level",
|
||||
"",
|
||||
"[mouse2] Pick foreground tile",
|
||||
// TODO override the traversable part? ctrl?
|
||||
// TODO fill all similar tiles instead? shift?
|
||||
].join("\n"),
|
||||
uses_palette: true,
|
||||
uses_layers: undefined,
|
||||
op1: mouseops.FillOperation,
|
||||
op2: mouseops.EyedropOperation,
|
||||
shortcut: 'g',
|
||||
@ -41,28 +71,140 @@ export const TOOLS = {
|
||||
select_box: {
|
||||
icon: 'icons/tool-select-box.png',
|
||||
name: "Box select",
|
||||
desc: "Select and manipulate rectangles.",
|
||||
desc: [
|
||||
"Select and manipulate rectangles.",
|
||||
"",
|
||||
"[mouse1] Select rectangle",
|
||||
"[shift] [mouse1] Add to selection",
|
||||
"[ctrl] [mouse1] Remove from selection",
|
||||
"",
|
||||
"[mouse1] Move selection",
|
||||
"[ctrl] [mouse1] Clone selection",
|
||||
].join("\n"),
|
||||
affects_selection: true,
|
||||
uses_layers: undefined,
|
||||
op1: mouseops.SelectOperation,
|
||||
shortcut: 'm',
|
||||
},
|
||||
select_wand: {
|
||||
icon: 'icons/tool-select-wand.png',
|
||||
name: "Wand select",
|
||||
desc: [
|
||||
"Select regions of similar tiles.",
|
||||
"",
|
||||
"[mouse1] Select contiguous similar tiles",
|
||||
"[mouse2] Select all similar tiles",
|
||||
"[shift] Add to selection",
|
||||
"[ctrl] Remove from selection",
|
||||
"",
|
||||
"[mouse1] Move selection",
|
||||
"[ctrl] [mouse1] Clone selection",
|
||||
].join("\n"),
|
||||
affects_selection: true,
|
||||
uses_layers: true,
|
||||
op1: mouseops.WandSelectOperation,
|
||||
shortcut: 'w',
|
||||
},
|
||||
'force-floors': {
|
||||
icon: 'icons/tool-force-floors.png',
|
||||
name: "Force floors",
|
||||
desc: "Draw force floors following the cursor.",
|
||||
uses_layers: false,
|
||||
min_version: 'cc1',
|
||||
op1: mouseops.ForceFloorOperation,
|
||||
},
|
||||
ice: {
|
||||
icon: 'icons/tool-ice.png',
|
||||
name: "Ice",
|
||||
desc: [
|
||||
"Draw ice following the cursor.",
|
||||
"",
|
||||
"[mouse1] Lay ice",
|
||||
].join("\n"),
|
||||
uses_layers: false,
|
||||
min_version: 'cc1',
|
||||
op1: mouseops.IceOperation,
|
||||
op2: mouseops.IceOperation,
|
||||
},
|
||||
tracks: {
|
||||
icon: 'icons/tool-tracks.png',
|
||||
name: "Tracks",
|
||||
desc: "Draw tracks following the cursor.\nLeft click: Lay tracks\nCtrl-click: Erase tracks\nRight click: Toggle track switch",
|
||||
desc: [
|
||||
"Draw tracks following the cursor.",
|
||||
"",
|
||||
"[mouse1] Lay tracks",
|
||||
"[ctrl] [mouse1] Erase tracks",
|
||||
"[mouse2] Toggle track switch",
|
||||
].join("\n"),
|
||||
uses_layers: false,
|
||||
min_version: 'cc2',
|
||||
op1: mouseops.TrackOperation,
|
||||
op2: mouseops.TrackOperation,
|
||||
},
|
||||
text: {
|
||||
icon: 'icons/tool-text.png',
|
||||
name: "Text",
|
||||
desc: [
|
||||
"Type text directly onto the floor.",
|
||||
"",
|
||||
"[mouse1] Move cursor",
|
||||
].join("\n"),
|
||||
uses_layers: false,
|
||||
min_version: 'cc2',
|
||||
op1: mouseops.TextOperation,
|
||||
op2: mouseops.TextOperation,
|
||||
},
|
||||
thin_walls: {
|
||||
icon: 'icons/tool-thin-walls.png',
|
||||
name: "Thin walls",
|
||||
desc: [
|
||||
"Draw thin walls by dragging along the edges of cells.",
|
||||
"",
|
||||
"[mouse1] Draw thin walls",
|
||||
"[mouse2] Draw one-way walls (LL only)",
|
||||
"[ctrl] Erase",
|
||||
].join("\n"),
|
||||
uses_layers: false,
|
||||
op1: mouseops.ThinWallOperation,
|
||||
op2: mouseops.ThinWallOperation,
|
||||
},
|
||||
// TODO this is so clumsy. maybe right-click to cycle target, like pencil? i don't know. that
|
||||
// seems annoying for piercing through a lot of thin walls
|
||||
// TODO you can't shift-mouse2 in firefox also, it brings up the real context menu
|
||||
rotate: {
|
||||
icon: 'icons/tool-rotate.png',
|
||||
name: "Rotate",
|
||||
desc: [
|
||||
"Rotate existing tiles.",
|
||||
"Works on both actors and orientable terrain.",
|
||||
"Affects the top-most rotatable tile by default.",
|
||||
"Use the layer selector to affect specific tiles.",
|
||||
"",
|
||||
"[mouse1] Rotate clockwise",
|
||||
"[mouse2] Rotate counter-clockwise",
|
||||
].join("\n"),
|
||||
uses_layers: new Set(['terrain', 'actor']),
|
||||
op1: mouseops.RotateOperation,
|
||||
op2: mouseops.RotateOperation,
|
||||
shortcut: 'r',
|
||||
},
|
||||
adjust: {
|
||||
icon: 'icons/tool-adjust.png',
|
||||
name: "Adjust",
|
||||
desc: "Edit existing tiles.\nLeft click: rotate actor or toggle terrain\nRight click: rotate or toggle in reverse\nShift: always target terrain\nCtrl-click: edit properties of complex tiles\n(wires, railroads, hints, etc.)",
|
||||
desc: [
|
||||
"Inspect and alter tiles in a variety of ways:",
|
||||
"• Transmogrify tiles (including terrain)",
|
||||
"• Edit letter tiles or hint text",
|
||||
"• Change frame block arrows, track directions",
|
||||
"• Preview or press buttons",
|
||||
"• Edit thin walls",
|
||||
"Affects the top-most adjustable tile by default.",
|
||||
"Use the layer selector to affect specific tiles.",
|
||||
"",
|
||||
"[mouse1] Adjust tile",
|
||||
//"[mouse2] Adjust tile backwards",
|
||||
].join("\n"),
|
||||
uses_layers: true,
|
||||
op1: mouseops.AdjustOperation,
|
||||
op2: mouseops.AdjustOperation,
|
||||
shortcut: 'a',
|
||||
@ -70,31 +212,48 @@ export const TOOLS = {
|
||||
connect: {
|
||||
icon: 'icons/tool-connect.png',
|
||||
name: "Connect",
|
||||
// XXX shouldn't you be able to drag the destination?
|
||||
// TODO mod + right click for RRO or diamond alg? ah but we only have ctrl available
|
||||
// ok lemme think then
|
||||
// left drag: create a new connection (supported connections only)
|
||||
// ctrl-click: erase all connections
|
||||
// shift-drag: create a new connection (arbitrary cells)
|
||||
// right drag: move a connection endpoint
|
||||
// ctrl-right drag: move the other endpoint (if a cell is both source and dest)
|
||||
desc: "Set up CC1-style clone and trap connections.\n(WIP)\nNOTE: Not supported in CC2!\nRight click: auto link using Lynx rules",
|
||||
//desc: "Set up CC1-style clone and trap connections.\nNOTE: Not supported in CC2!\nLeft drag: link button with valid target\nCtrl-click: erase link\nRight click: auto link using Lynx rules",
|
||||
desc: [
|
||||
"Set up CC1-style clone and trap connections.",
|
||||
"(Supported in CC1 and LL, but not CC2!)",
|
||||
"",
|
||||
"[mouse1] Connect a button to a mechanism",
|
||||
"[mouse1] Move existing connections",
|
||||
"[ctrl] [mouse1] Delete connection",
|
||||
"[shift] [mouse1] Allow connecting to any cell",
|
||||
"(not recommended)",
|
||||
"[mouse2] Auto link a button using Lynx/CC2 rules",
|
||||
].join("\n"),
|
||||
uses_layers: false,
|
||||
op1: mouseops.ConnectOperation,
|
||||
op2: mouseops.ConnectOperation,
|
||||
},
|
||||
wire: {
|
||||
icon: 'icons/tool-wire.png',
|
||||
name: "Wire",
|
||||
desc: "Edit CC2 wiring.\nLeft click: draw wires\nCtrl-click: erase wires\nRight click: toggle tunnels (floor only)",
|
||||
desc: [
|
||||
"Edit CC2 wiring.",
|
||||
"",
|
||||
"[mouse1] Draw wire",
|
||||
"[ctrl] [mouse1] Erase wire",
|
||||
"",
|
||||
"[mouse2] Toggle tunnels (floor only)",
|
||||
].join("\n"),
|
||||
uses_layers: true,
|
||||
op1: mouseops.WireOperation,
|
||||
op2: mouseops.WireOperation,
|
||||
},
|
||||
camera: {
|
||||
icon: 'icons/tool-camera.png',
|
||||
name: "Camera",
|
||||
desc: "Draw and edit custom camera regions",
|
||||
help: "Draw and edit camera regions.\n(LL only. When the player is within a camera region,\nthe camera stays locked inside it.)\nLeft click: create or edit a region\nRight click: erase a region",
|
||||
desc: [
|
||||
"Draw and edit camera regions.",
|
||||
"(LL only. When the player is within a camera region,",
|
||||
"the camera stays locked inside it.)",
|
||||
"",
|
||||
"[mouse1] Create or edit a region",
|
||||
"[mouse2] Delete a region",
|
||||
].join("\n"),
|
||||
uses_layers: false,
|
||||
op1: mouseops.CameraOperation,
|
||||
op2: mouseops.CameraEraseOperation,
|
||||
},
|
||||
@ -103,7 +262,14 @@ export const TOOLS = {
|
||||
// slade when you have some selected?
|
||||
// TODO ah, railroads...
|
||||
};
|
||||
export const TOOL_ORDER = ['pencil', 'select_box', 'fill', 'adjust', 'force-floors', 'tracks', 'connect', 'wire', 'camera'];
|
||||
export const TOOL_ORDER = [
|
||||
'pencil', 'line', 'box', 'fill',
|
||||
'select_box', 'select_wand',
|
||||
'rotate', 'adjust',
|
||||
'force-floors', 'ice', 'tracks', 'text', 'thin_walls',
|
||||
'wire', 'connect',
|
||||
'camera',
|
||||
];
|
||||
export const TOOL_SHORTCUTS = {};
|
||||
for (let [tool, tooldef] of Object.entries(TOOLS)) {
|
||||
if (tooldef.shortcut) {
|
||||
@ -111,6 +277,9 @@ for (let [tool, tooldef] of Object.entries(TOOLS)) {
|
||||
}
|
||||
}
|
||||
|
||||
export const SELECTABLE_LAYERS = [null, 'terrain', 'item', 'item_mod', 'actor', 'swivel', 'thin_wall', 'canopy'];
|
||||
export const SELECTABLE_LAYER_NAMES = ["auto", "terrain", "items", "item mods", "actors", "swivels", "thin walls", "canopies"];
|
||||
|
||||
// TODO this MUST use a LL tileset!
|
||||
export const PALETTE = [{
|
||||
title: "Basics",
|
||||
@ -140,10 +309,10 @@ export const PALETTE = [{
|
||||
'no_player1_sign',
|
||||
'no_player2_sign',
|
||||
|
||||
'floor_custom_green', 'floor_custom_pink', 'floor_custom_yellow', 'floor_custom_blue',
|
||||
'wall_custom_green', 'wall_custom_pink', 'wall_custom_yellow', 'wall_custom_blue',
|
||||
'floor_custom_pink', 'floor_custom_blue', 'floor_custom_yellow', 'floor_custom_green',
|
||||
'wall_custom_pink', 'wall_custom_blue', 'wall_custom_yellow', 'wall_custom_green',
|
||||
|
||||
'door_blue', 'door_red', 'door_yellow', 'door_green',
|
||||
'door_red', 'door_blue', 'door_yellow', 'door_green',
|
||||
'swivel_nw',
|
||||
'railroad/straight',
|
||||
'railroad/curve',
|
||||
@ -157,8 +326,8 @@ export const PALETTE = [{
|
||||
}, {
|
||||
title: "Items",
|
||||
tiles: [
|
||||
'key_blue', 'key_red', 'key_yellow', 'key_green',
|
||||
'flippers', 'fire_boots', 'cleats', 'suction_boots',
|
||||
'key_red', 'key_blue', 'key_yellow', 'key_green',
|
||||
'cleats', 'suction_boots', 'fire_boots', 'flippers',
|
||||
'hiking_boots', 'speed_boots', 'lightning_bolt', 'railroad_sign',
|
||||
'helmet', 'foil', 'hook', 'xray_eye',
|
||||
'bribe', 'bowling_ball', 'dynamite', 'no_sign',
|
||||
@ -173,8 +342,8 @@ export const PALETTE = [{
|
||||
'walker',
|
||||
'fireball',
|
||||
'glider',
|
||||
'bug',
|
||||
'paramecium',
|
||||
'bug',
|
||||
|
||||
'doppelganger1',
|
||||
'doppelganger2',
|
||||
@ -211,10 +380,10 @@ export const PALETTE = [{
|
||||
'button_orange', 'flame_jet_off', 'flame_jet_on',
|
||||
'transmogrifier',
|
||||
|
||||
'teleport_blue',
|
||||
'teleport_red',
|
||||
'teleport_green',
|
||||
'teleport_blue',
|
||||
'teleport_yellow',
|
||||
'teleport_green',
|
||||
'stopwatch_bonus',
|
||||
'stopwatch_penalty',
|
||||
'stopwatch_toggle',
|
||||
@ -235,6 +404,7 @@ export const PALETTE = [{
|
||||
'logic_gate/latch-cw',
|
||||
'logic_gate/latch-ccw',
|
||||
'logic_gate/counter',
|
||||
|
||||
'button_pink',
|
||||
'button_black',
|
||||
'light_switch_off',
|
||||
@ -246,41 +416,46 @@ export const PALETTE = [{
|
||||
}, {
|
||||
title: "Experimental",
|
||||
tiles: [
|
||||
'circuit_block/xxx',
|
||||
'gift_bow',
|
||||
'skeleton_key',
|
||||
'sokoban_block/red',
|
||||
'sokoban_block/blue',
|
||||
'sokoban_block/yellow',
|
||||
'sokoban_block/green',
|
||||
'sokoban_button/red',
|
||||
'sokoban_button/blue',
|
||||
'sokoban_button/yellow',
|
||||
'sokoban_button/green',
|
||||
|
||||
'sokoban_wall/red',
|
||||
'sokoban_wall/blue',
|
||||
'sokoban_wall/yellow',
|
||||
'sokoban_wall/green',
|
||||
'gate_red',
|
||||
'gate_blue',
|
||||
'gate_yellow',
|
||||
'gate_green',
|
||||
'sand',
|
||||
|
||||
'one_way_walls/south',
|
||||
'dash_floor',
|
||||
'spikes',
|
||||
'sand',
|
||||
'grass',
|
||||
'cracked_ice',
|
||||
'hole',
|
||||
'cracked_floor',
|
||||
'hole',
|
||||
|
||||
'turntable_cw',
|
||||
'turntable_ccw',
|
||||
'teleport_blue_exit',
|
||||
'electrified_floor',
|
||||
'ankh',
|
||||
'score_5x',
|
||||
'boulder',
|
||||
'circuit_block/xxx',
|
||||
'glass_block',
|
||||
'logic_gate/diode',
|
||||
'sokoban_block/red',
|
||||
'sokoban_button/red',
|
||||
'sokoban_wall/red',
|
||||
'sokoban_block/blue',
|
||||
'sokoban_button/blue',
|
||||
'sokoban_wall/blue',
|
||||
'sokoban_block/green',
|
||||
'sokoban_button/green',
|
||||
'sokoban_wall/green',
|
||||
'sokoban_block/yellow',
|
||||
'sokoban_button/yellow',
|
||||
'sokoban_wall/yellow',
|
||||
'one_way_walls/south',
|
||||
'boulder',
|
||||
|
||||
'gift_bow',
|
||||
'skeleton_key',
|
||||
'ankh',
|
||||
'score_5x',
|
||||
],
|
||||
}];
|
||||
|
||||
@ -289,8 +464,8 @@ export const PALETTE = [{
|
||||
export const SPECIAL_PALETTE_ENTRIES = {
|
||||
'thin_walls/south': { name: 'thin_walls', edges: DIRECTIONS['south'].bit },
|
||||
'frame_block/0': { name: 'frame_block', direction: 'south', arrows: new Set },
|
||||
'frame_block/1': { name: 'frame_block', direction: 'north', arrows: new Set(['north']) },
|
||||
'frame_block/2a': { name: 'frame_block', direction: 'north', arrows: new Set(['north', 'east']) },
|
||||
'frame_block/1': { name: 'frame_block', direction: 'south', arrows: new Set(['south']) },
|
||||
'frame_block/2a': { name: 'frame_block', direction: 'south', arrows: new Set(['south', 'west']) },
|
||||
'frame_block/2o': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'south']) },
|
||||
'frame_block/3': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'east', 'south']) },
|
||||
'frame_block/4': { name: 'frame_block', direction: 'south', arrows: new Set(['north', 'east', 'south', 'west']) },
|
||||
@ -515,7 +690,7 @@ export const TILE_DESCRIPTIONS = {
|
||||
name: "Ice corner",
|
||||
desc: "Acts like ice, but turns anything sliding on it around the corner. Edges act like thin walls.",
|
||||
},
|
||||
force_floor_n: {
|
||||
force_floor_s: {
|
||||
name: "Force floor",
|
||||
desc: "Slides anything on it in the indicated direction, unless it has suction boots. Players may attempt to step off, but not on their first slide. No effect on ghosts.",
|
||||
},
|
||||
@ -706,15 +881,15 @@ export const TILE_DESCRIPTIONS = {
|
||||
// Mechanisms
|
||||
dirt_block: {
|
||||
name: "Dirt block",
|
||||
desc: "Can be pushed, but only one at a time. Resists fire. Turns to dirt in water.",
|
||||
desc: "A single-push block (i.e., cannot push other blocks ahead of it). Fireproof. Turns to dirt in water.",
|
||||
},
|
||||
ice_block: {
|
||||
name: "Ice block",
|
||||
desc: "Can be pushed. Pushes any ice block or frame block ahead of it. Turns to water in fire. Turns to ice in water.",
|
||||
desc: "A multi-push block (i.e., can push other blocks ahead of it). Cannot push dirt blocks directly. Turns to water in fire. Turns to ice in water.",
|
||||
},
|
||||
frame_block: {
|
||||
name: "Frame block",
|
||||
desc: "Can be pushed, but only in the directions given by the arrows. Pushes any other kind of block ahead of it. Can be moved in other directions by ice, force floors, etc. Rotates when moved along a curved railroad track.",
|
||||
desc: "A multi-push block. Can only be pushed in the directions given by the arrows. Can be moved in other directions by ice, force floors, etc. Rotates when moved along a curved railroad track.",
|
||||
},
|
||||
green_floor: {
|
||||
name: "Toggle floor",
|
||||
@ -818,10 +993,6 @@ export const TILE_DESCRIPTIONS = {
|
||||
name: "NOT gate",
|
||||
desc: "Emits power only when not receiving power.",
|
||||
},
|
||||
'logic_gate/diode': {
|
||||
name: "Diode",
|
||||
desc: "Emits power only when receiving power. (Effectively, this delays power by one frame.)",
|
||||
},
|
||||
'logic_gate/and': {
|
||||
name: "AND gate",
|
||||
desc: "Emits power while both inputs are receiving power.",
|
||||
@ -880,81 +1051,9 @@ export const TILE_DESCRIPTIONS = {
|
||||
},
|
||||
|
||||
// Experimental
|
||||
circuit_block: {
|
||||
name: "Circuit block",
|
||||
desc: "May contain wires, which will connect to any adjacent wires and conduct power as normal. When pushed into water, turns into floor with the same wires.",
|
||||
},
|
||||
gift_bow: {
|
||||
name: "Gift bow",
|
||||
desc: "When placed atop an item, anything may step on the item and will pick it up, even if it normally could not do so. When placed alone, has no effect, but an item may be dropped beneath it.",
|
||||
},
|
||||
skeleton_key: {
|
||||
name: "Skeleton key",
|
||||
desc: "Counts as a tool, not a key. Opens any color lock if the owner lacks a matching key.",
|
||||
},
|
||||
gate_red: {
|
||||
name: "Red gate",
|
||||
desc: "Requires a red key. Unlike doors, may be placed on top of other terrain.",
|
||||
},
|
||||
sand: {
|
||||
name: "Sand",
|
||||
desc: "Anything walking on it moves at half speed. Stops all blocks.",
|
||||
},
|
||||
ankh: {
|
||||
name: "Ankh",
|
||||
desc: "When dropped on empty floor by a player, inscribes a sacred symbol which will save a player's life once.",
|
||||
},
|
||||
turntable_cw: {
|
||||
name: "Turntable (clockwise)",
|
||||
desc: "Rotates anything entering this tile clockwise. Frame blocks are rotated too. If connected to wire, only functions while receiving power.",
|
||||
},
|
||||
turntable_ccw: {
|
||||
name: "Turntable (counterclockwise)",
|
||||
desc: "Rotates anything entering this tile counterclockwise. Frame blocks are rotated too. If connected to wire, only functions while receiving power.",
|
||||
},
|
||||
electrified_floor: {
|
||||
name: "Electrified floor",
|
||||
desc: "Conducts power (like a 4-way wire). While powered, destroys anything not wearing lightning boots (except dirt blocks).",
|
||||
},
|
||||
hole: {
|
||||
name: "Hole",
|
||||
desc: "A bottomless pit. Destroys everything (except ghosts).",
|
||||
},
|
||||
cracked_floor: {
|
||||
name: "Cracked floor",
|
||||
desc: "Turns into a hole when something steps off of it (except ghosts).",
|
||||
},
|
||||
cracked_ice: {
|
||||
name: "Cracked ice",
|
||||
desc: "Turns into water when something steps off of it (except ghosts).",
|
||||
},
|
||||
score_5x: {
|
||||
name: "×5 bonus",
|
||||
desc: "Quintuples the player's current bonus points. Can be collected by doppelgangers, rovers, and bowling balls, but will not grant bonus points.",
|
||||
},
|
||||
spikes: {
|
||||
name: "Spikes",
|
||||
desc: "Stops players (and doppelgangers) unless they have hiking boots. Everything else can pass.",
|
||||
},
|
||||
boulder: {
|
||||
name: "Boulder",
|
||||
desc: "Similar to a dirt block, but rolls when pushed. Boulders transfer momentum to each other. Has ice block/frame block collision. Turns into gravel in water. Spreads slime.",
|
||||
},
|
||||
dash_floor: {
|
||||
name: "Dash floor",
|
||||
desc: "Anything walking on it moves at double speed. Stacks with speed shoes!",
|
||||
},
|
||||
teleport_blue_exit: {
|
||||
name: "Blue teleporter exit",
|
||||
desc: "A blue teleporter for all intents and purposes except it can only be exited, not entered.",
|
||||
},
|
||||
glass_block: {
|
||||
name: "Glass block",
|
||||
desc: "Similar to a dirt block, but stores the first item it moves over, dropping it when destroyed and cloning it in a cloning machine. Has ice block/frame block collision. Turns into floor in water. Doesn't have dirt block immunities.",
|
||||
},
|
||||
sokoban_block: {
|
||||
name: "Sokoban block",
|
||||
desc: "Similar to a dirt block. Turns to colored floor in water. Can't pass over colored floor of a different color. Has no effect on sokoban buttons of a different color.",
|
||||
desc: "A single-push block. Can't pass over colored floor of a different color. Has no effect on sokoban buttons of a different color. Turns to colored floor in water.",
|
||||
},
|
||||
sokoban_button: {
|
||||
name: "Sokoban button",
|
||||
@ -964,6 +1063,147 @@ export const TILE_DESCRIPTIONS = {
|
||||
name: "Sokoban wall",
|
||||
desc: "Acts like wall. Turns to floor while all sokoban buttons of the same color are pressed.",
|
||||
},
|
||||
gate_red: {
|
||||
name: "Red gate",
|
||||
desc: "Requires a red key. Unlike doors, may be placed on top of other terrain, and any actor with the key may unlock it.",
|
||||
},
|
||||
gate_blue: {
|
||||
name: "Blue gate",
|
||||
desc: "Requires a blue key. Unlike doors, may be placed on top of other terrain, and any actor with the key may unlock it.",
|
||||
},
|
||||
gate_yellow: {
|
||||
name: "Yellow gate",
|
||||
desc: "Requires a yellow key. Unlike doors, may be placed on top of other terrain, and any actor with the key may unlock it.",
|
||||
},
|
||||
gate_green: {
|
||||
name: "Green gate",
|
||||
desc: "Requires a green key. Unlike doors, may be placed on top of other terrain, and any actor with the key may unlock it.",
|
||||
},
|
||||
|
||||
one_way_walls: {
|
||||
name: "One-way wall",
|
||||
desc: "Similar to a thin wall, but can be passed through one side only.",
|
||||
},
|
||||
dash_floor: {
|
||||
name: "Dash floor",
|
||||
desc: "Anything walking on it moves at double speed. Stacks with speed boots.",
|
||||
},
|
||||
spikes: {
|
||||
name: "Spikes",
|
||||
desc: "Stops players (and doppelgangers) unless they have hiking boots. Everything else can pass.",
|
||||
},
|
||||
sand: {
|
||||
name: "Sand",
|
||||
desc: "Anything walking on it moves 50% slower. Stops all blocks.",
|
||||
},
|
||||
grass: {
|
||||
name: "Grass",
|
||||
desc: "Stops all blocks, tanks, and rovers. Turns to fire when a fireball touches it. Both types of bug refuse to leave once they enter.",
|
||||
},
|
||||
cracked_ice: {
|
||||
name: "Cracked ice",
|
||||
desc: "Turns into water when something steps off of it (except ghosts and Cerise).",
|
||||
},
|
||||
cracked_floor: {
|
||||
name: "Cracked floor",
|
||||
desc: "Turns into a hole when something steps off of it (except ghosts and Cerise).",
|
||||
},
|
||||
hole: {
|
||||
name: "Hole",
|
||||
desc: "A bottomless pit. Destroys everything (except ghosts).",
|
||||
},
|
||||
|
||||
turntable_cw: {
|
||||
name: "Turntable (clockwise)",
|
||||
desc: "Rotates anything entering this tile clockwise. Frame blocks rotate as if on tracks. If connected to wire, only functions while receiving power.",
|
||||
},
|
||||
turntable_ccw: {
|
||||
name: "Turntable (counterclockwise)",
|
||||
desc: "Rotates anything entering this tile counterclockwise. Frame blocks rotate as if on tracks. If connected to wire, only functions while receiving power.",
|
||||
},
|
||||
teleport_blue_exit: {
|
||||
name: "Blue teleporter exit",
|
||||
desc: "A blue teleporter for all intents and purposes except it can only be exited, not entered.",
|
||||
},
|
||||
electrified_floor: {
|
||||
name: "Electrified floor",
|
||||
desc: "Conducts power (like a 4-way wire). While powered, destroys anything not wearing lightning boots (except dirt blocks).",
|
||||
},
|
||||
circuit_block: {
|
||||
name: "Circuit block",
|
||||
desc: "A single-push block. May contain wires, which will connect to any adjacent wires and conduct power as normal, replacing anything on the floor below. When pushed into water, turns into floor with the same wires.",
|
||||
},
|
||||
glass_block: {
|
||||
name: "Glass block",
|
||||
desc: "A single-push block. Can pick up one item it moves over, which may then be cloned via a clone machine. Drops the item when destroyed. Turns to floor in water.",
|
||||
},
|
||||
'logic_gate/diode': {
|
||||
name: "Diode",
|
||||
desc: "Only transmits power in one direction.",
|
||||
},
|
||||
boulder: {
|
||||
name: "Boulder",
|
||||
desc: "Rolls when pushed. Transfers momentum to another boulder it hits. Fireproof. Turns into gravel in water. Spreads slime.",
|
||||
},
|
||||
|
||||
gift_bow: {
|
||||
name: "Gift bow",
|
||||
desc: "When placed atop an item, anything may step on the item and will pick it up, even if it normally could not do so. When placed alone, has no effect, but an item may be dropped beneath it.",
|
||||
},
|
||||
skeleton_key: {
|
||||
name: "Skeleton key",
|
||||
desc: "Counts as a tool, not a key. Opens any lock when the owner lacks a matching key, but only once.",
|
||||
},
|
||||
ankh: {
|
||||
name: "Ankh",
|
||||
desc: "When dropped on empty floor by a player, inscribes a sacred symbol which will resurrect a player on that tile, then vanish. Only one symbol may exist at a time, and only the player may step on the symbol.",
|
||||
},
|
||||
score_5x: {
|
||||
name: "×5 bonus",
|
||||
desc: "Quintuples the player's current bonus points. Can be collected by doppelgangers, rovers, and bowling balls, but will not grant bonus points.",
|
||||
},
|
||||
// TODO on the chopping block
|
||||
raft: {
|
||||
name: "Raft",
|
||||
desc: "A vehicle for crossing water safely. Follows its passenger until they reach land. Can be pushed around (like a single-push block) while swimming.",
|
||||
},
|
||||
iceberg: {
|
||||
name: "Iceberg",
|
||||
desc: "Behaves like ice when stepped on. Can be pushed around (like a multi-push block) while swimming.",
|
||||
},
|
||||
cart: {
|
||||
name: "Cart",
|
||||
desc: "A vehicle for moving in both directions on tracks. Automatically pushes and pulls any number of adjacent carts.",
|
||||
},
|
||||
// XXX extremely aspirational lmao
|
||||
laser: {
|
||||
name: "Laser",
|
||||
desc: "Fires a powerful beam in a straight line, which destroys anything that touches it.",
|
||||
},
|
||||
prism_main: {
|
||||
name: "Prism",
|
||||
desc: "A multi-push block. Reflects any laser hitting it. Turns to ice in water.",
|
||||
},
|
||||
crystal: {
|
||||
name: "Crystal",
|
||||
desc: "Emits power while struck by a laser.",
|
||||
},
|
||||
fan: {
|
||||
name: "Fan",
|
||||
desc: "Blows strong wind in a straight line, pushing anything in its path. Only active while powered.",
|
||||
},
|
||||
toll: {
|
||||
name: "Toll",
|
||||
desc: "When placed atop an item, that item becomes a toll required to pass. One of the item will be taken away every time an actor passes. When placed alone, has no effect, but an item may be dropped beneath it.",
|
||||
},
|
||||
phantom_ring: {
|
||||
name: "Phantom ring",
|
||||
desc: "Allows its wearer to walk through all varieties of solid walls, but mysteriously vanishes once they exit into normal space.",
|
||||
},
|
||||
log: {
|
||||
name: "Log",
|
||||
desc: "May be rolled in one direction, in which case it acts like a boulder. May be stood up in the other direction, in which case it stands up on end and acts like a single-push block. Turns to grass in water.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1310,16 +1550,16 @@ function add_special_tile_cycle(rotation_order, mirror_mapping, flip_mapping) {
|
||||
}
|
||||
|
||||
if (name in mirror_mapping) {
|
||||
let mirror = mirror_mapping[name];
|
||||
let mirrored = mirror_mapping[name];
|
||||
behavior.mirror = function mirror(tile) {
|
||||
tile.type = TILE_TYPES[mirror];
|
||||
tile.type = TILE_TYPES[mirrored];
|
||||
};
|
||||
}
|
||||
|
||||
if (name in flip_mapping) {
|
||||
let flip = flip_mapping[name];
|
||||
let flipped = flip_mapping[name];
|
||||
behavior.flip = function flip(tile) {
|
||||
tile.type = TILE_TYPES[flip];
|
||||
tile.type = TILE_TYPES[flipped];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
// Small helper classes used by the editor, often with their own UI for the SVG overlay.
|
||||
import { mk, mk_svg } from '../util.js';
|
||||
import { DIRECTIONS } from '../defs.js';
|
||||
import { BitVector, mk, mk_svg } from '../util.js';
|
||||
|
||||
export class SVGConnection {
|
||||
constructor(sx, sy, dx, dy) {
|
||||
this.source = mk_svg('circle.-source', {r: 0.5});
|
||||
this.source = mk_svg('circle.-source', {r: 0.5, cx: sx + 0.5, cy: sy + 0.5});
|
||||
this.line = mk_svg('line.-arrow', {});
|
||||
this.dest = mk_svg('rect.-dest', {width: 1, height: 1});
|
||||
this.dest = mk_svg('rect.-dest', {x: dx, y: dy, width: 1, height: 1});
|
||||
this.element = mk_svg('g.overlay-connection', this.source, this.line, this.dest);
|
||||
this.set_source(sx, sy);
|
||||
this.set_dest(dx, dy);
|
||||
this.sx = sx;
|
||||
this.sy = sy;
|
||||
this.dx = dx;
|
||||
this.dy = dy;
|
||||
this._update_line_endpoints();
|
||||
}
|
||||
|
||||
set_source(sx, sy) {
|
||||
@ -16,27 +20,54 @@ export class SVGConnection {
|
||||
this.sy = sy;
|
||||
this.source.setAttribute('cx', sx + 0.5);
|
||||
this.source.setAttribute('cy', sy + 0.5);
|
||||
this.line.setAttribute('x1', sx + 0.5);
|
||||
this.line.setAttribute('y1', sy + 0.5);
|
||||
this._update_line_endpoints();
|
||||
}
|
||||
|
||||
set_dest(dx, dy) {
|
||||
this.dx = dx;
|
||||
this.dy = dy;
|
||||
this.line.setAttribute('x2', dx + 0.5);
|
||||
this.line.setAttribute('y2', dy + 0.5);
|
||||
this.dest.setAttribute('x', dx);
|
||||
this.dest.setAttribute('y', dy);
|
||||
this._update_line_endpoints();
|
||||
}
|
||||
|
||||
_update_line_endpoints() {
|
||||
// Start the line at the edge of the circle, so, add 0.5 in the direction of the line
|
||||
let vx = this.dx - this.sx;
|
||||
let vy = this.dy - this.sy;
|
||||
let line_length = Math.sqrt(vx*vx + vy*vy);
|
||||
let trim_x = 0;
|
||||
let trim_y = 0;
|
||||
if (line_length >= 1) {
|
||||
trim_x = 0.5 * vx / line_length;
|
||||
trim_y = 0.5 * vy / line_length;
|
||||
}
|
||||
this.line.setAttribute('x1', this.sx + 0.5 + trim_x);
|
||||
this.line.setAttribute('y1', this.sy + 0.5 + trim_y);
|
||||
// Technically this isn't quite right, since the ending is a square and the arrowhead will
|
||||
// poke into it a bit from angles near 45°, but that requires a bit more trig than seems
|
||||
// worth it, and it looks kinda neat anyway.
|
||||
// Also, one nicety: if the cells are adjacent, don't trim the endpoint, or we won't have
|
||||
// an arrow at all.
|
||||
if (line_length < 2) {
|
||||
this.line.setAttribute('x2', this.dx + 0.5);
|
||||
this.line.setAttribute('y2', this.dy + 0.5);
|
||||
}
|
||||
else {
|
||||
this.line.setAttribute('x2', this.dx + 0.5 - trim_x);
|
||||
this.line.setAttribute('y2', this.dy + 0.5 - trim_y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TODO probably need to combine this with Selection somehow since it IS one, just not committed yet
|
||||
export class PendingSelection {
|
||||
constructor(owner) {
|
||||
export class PendingRectangularSelection {
|
||||
constructor(owner, mode) {
|
||||
this.owner = owner;
|
||||
this.mode = mode ?? 'new'; // new, add, subtract
|
||||
this.element = mk_svg('rect.overlay-pending-selection');
|
||||
this.owner.svg_group.append(this.element);
|
||||
this.size_text = mk_svg('text.overlay-edit-tip');
|
||||
this.owner.svg_group.append(this.element, this.size_text);
|
||||
this.rect = null;
|
||||
}
|
||||
|
||||
@ -47,15 +78,29 @@ export class PendingSelection {
|
||||
this.element.setAttribute('y', this.rect.y);
|
||||
this.element.setAttribute('width', this.rect.width);
|
||||
this.element.setAttribute('height', this.rect.height);
|
||||
this.size_text.textContent = `${this.rect.width} × ${this.rect.height}`;
|
||||
this.size_text.setAttribute('x', this.rect.x + this.rect.width / 2);
|
||||
this.size_text.setAttribute('y', this.rect.y + this.rect.height / 2);
|
||||
}
|
||||
|
||||
commit() {
|
||||
this.owner.set_from_rect(this.rect);
|
||||
if (this.mode === 'new') {
|
||||
this.owner.clear();
|
||||
this.owner.add_rect(this.rect);
|
||||
}
|
||||
else if (this.mode === 'add') {
|
||||
this.owner.add_rect(this.rect);
|
||||
}
|
||||
else if (this.mode === 'subtract') {
|
||||
this.owner.subtract_rect(this.rect);
|
||||
}
|
||||
this.element.remove();
|
||||
this.size_text.remove();
|
||||
}
|
||||
|
||||
discard() {
|
||||
this.element.remove();
|
||||
this.size_text.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,206 +110,524 @@ export class Selection {
|
||||
|
||||
this.svg_group = mk_svg('g');
|
||||
this.editor.svg_overlay.append(this.svg_group);
|
||||
// Used for the floating preview and selection rings, which should all move together
|
||||
this.selection_group = mk_svg('g');
|
||||
this.svg_group.append(this.selection_group);
|
||||
|
||||
this.rect = null;
|
||||
this.element = mk_svg('rect.overlay-selection.overlay-transient');
|
||||
this.svg_group.append(this.element);
|
||||
// Note that this is a set of the ORIGINAL coordinates of the selected cells. Moving a
|
||||
// floated selection doesn't change this; instead it updates floated_offset
|
||||
this.cells = new Set;
|
||||
this.bbox = null;
|
||||
// I want a black-and-white outline ring so it shows against any background, but the only
|
||||
// way to do that in SVG is apparently to just duplicate the path
|
||||
this.ring_bg_element = mk_svg('path.overlay-selection-background.overlay-transient');
|
||||
this.ring_element = mk_svg('path.overlay-selection.overlay-transient');
|
||||
this.selection_group.append(this.ring_bg_element, this.ring_element);
|
||||
|
||||
this.floated_cells = null;
|
||||
this.floated_element = null;
|
||||
this.floated_canvas = null;
|
||||
this.floated_offset = null;
|
||||
}
|
||||
|
||||
get is_empty() {
|
||||
return this.rect === null;
|
||||
return this.cells.size === 0;
|
||||
}
|
||||
|
||||
get is_floating() {
|
||||
return !! this.floated_cells;
|
||||
}
|
||||
|
||||
get has_moved() {
|
||||
return !! (this.floated_offset && (this.floated_offset[0] || this.floated_offset[0]));
|
||||
}
|
||||
|
||||
contains(x, y) {
|
||||
// Empty selection means everything is selected?
|
||||
if (this.rect === null)
|
||||
if (this.is_empty)
|
||||
return true;
|
||||
|
||||
return this.rect.left <= x && x < this.rect.right && this.rect.top <= y && y < this.rect.bottom;
|
||||
if (this.floated_offset) {
|
||||
x -= this.floated_offset[0];
|
||||
y -= this.floated_offset[1];
|
||||
}
|
||||
|
||||
return this.cells.has(this.editor.stored_level.coords_to_scalar(x, y));
|
||||
}
|
||||
|
||||
create_pending() {
|
||||
return new PendingSelection(this);
|
||||
create_pending(mode) {
|
||||
return new PendingRectangularSelection(this, mode);
|
||||
}
|
||||
|
||||
set_from_rect(rect) {
|
||||
let old_rect = this.rect;
|
||||
add_rect(rect) {
|
||||
let old_cells = this.cells;
|
||||
// TODO would be nice to only store the difference between the old/new sets of cells?
|
||||
this.cells = new Set(this.cells);
|
||||
|
||||
this.editor._do(
|
||||
() => this._set_from_rect(rect),
|
||||
() => this._add_rect(rect),
|
||||
() => {
|
||||
if (old_rect) {
|
||||
this._set_from_rect(old_rect);
|
||||
}
|
||||
else {
|
||||
this._clear();
|
||||
}
|
||||
this._set_from_set(old_cells);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
_set_from_rect(rect) {
|
||||
this.rect = rect;
|
||||
this.element.classList.add('--visible');
|
||||
this.element.setAttribute('x', this.rect.x);
|
||||
this.element.setAttribute('y', this.rect.y);
|
||||
this.element.setAttribute('width', this.rect.width);
|
||||
this.element.setAttribute('height', this.rect.height);
|
||||
|
||||
if (this.floated_element) {
|
||||
let tileset = this.editor.renderer.tileset;
|
||||
this.floated_canvas.width = rect.width * tileset.size_x;
|
||||
this.floated_canvas.height = rect.height * tileset.size_y;
|
||||
let foreign_obj = this.floated_element.querySelector('foreignObject');
|
||||
foreign_obj.setAttribute('width', this.floated_canvas.width);
|
||||
foreign_obj.setAttribute('height', this.floated_canvas.height);
|
||||
_add_rect(rect) {
|
||||
let stored_level = this.editor.stored_level;
|
||||
for (let y = rect.top; y < rect.bottom; y++) {
|
||||
for (let x = rect.left; x < rect.right; x++) {
|
||||
this.cells.add(stored_level.coords_to_scalar(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
if (! this.bbox) {
|
||||
this.bbox = rect;
|
||||
}
|
||||
else {
|
||||
// Just recreate it from scratch to avoid mixing old and new properties
|
||||
let new_x = Math.min(this.bbox.x, rect.x);
|
||||
let new_y = Math.min(this.bbox.y, rect.y);
|
||||
this.bbox = new DOMRect(
|
||||
new_x, new_y,
|
||||
Math.max(this.bbox.right, rect.right) - new_x,
|
||||
Math.max(this.bbox.bottom, rect.bottom) - new_y);
|
||||
}
|
||||
|
||||
this._update_outline();
|
||||
}
|
||||
|
||||
subtract_rect(rect) {
|
||||
let old_cells = this.cells;
|
||||
this.cells = new Set(this.cells);
|
||||
|
||||
this.editor._do(
|
||||
() => this._subtract_rect(rect),
|
||||
() => {
|
||||
this._set_from_set(old_cells);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
_subtract_rect(rect) {
|
||||
if (this.is_empty)
|
||||
// Nothing to do
|
||||
return;
|
||||
|
||||
let stored_level = this.editor.stored_level;
|
||||
for (let y = rect.top; y < rect.bottom; y++) {
|
||||
for (let x = rect.left; x < rect.right; x++) {
|
||||
this.cells.delete(stored_level.coords_to_scalar(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO shrink bbox? i guess i only have to check along the edges that the rect intersects?
|
||||
|
||||
this._update_outline();
|
||||
}
|
||||
|
||||
_set_from_set(cells) {
|
||||
this.cells = cells;
|
||||
|
||||
// Recompute bbox
|
||||
if (cells.size === 0) {
|
||||
this.bbox = null;
|
||||
}
|
||||
else {
|
||||
let min_x = null;
|
||||
let min_y = null;
|
||||
let max_x = null;
|
||||
let max_y = null;
|
||||
for (let n of cells) {
|
||||
let [x, y] = this.editor.stored_level.scalar_to_coords(n);
|
||||
if (min_x === null) {
|
||||
min_x = x;
|
||||
min_y = y;
|
||||
max_x = x;
|
||||
max_y = y;
|
||||
}
|
||||
else {
|
||||
min_x = Math.min(min_x, x);
|
||||
max_x = Math.max(max_x, x);
|
||||
min_y = Math.min(min_y, y);
|
||||
max_y = Math.max(max_y, y);
|
||||
}
|
||||
}
|
||||
|
||||
this.bbox = new DOMRect(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1);
|
||||
}
|
||||
|
||||
// XXX ??? if (this.floated_element) {
|
||||
|
||||
this._update_outline();
|
||||
}
|
||||
|
||||
// Faster internal version of contains() that ignores the floating offset
|
||||
_contains(x, y) {
|
||||
let stored_level = this.editor.stored_level;
|
||||
return stored_level.is_point_within_bounds(x, y) &&
|
||||
this.cells.has(stored_level.coords_to_scalar(x, y));
|
||||
}
|
||||
|
||||
_update_outline() {
|
||||
if (this.is_empty) {
|
||||
this.ring_bg_element.classList.remove('--visible');
|
||||
this.ring_element.classList.remove('--visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the borders between cells to an SVG path.
|
||||
// I don't know an especially clever way to do this so I guess I'll just make it up. The
|
||||
// basic idea is to start with the top-left highlighted cell, start tracing from its top
|
||||
// left corner towards the right (which must be a border, because this is the top left
|
||||
// selected cell, so nothing above it is selected), then just keep going until we get back
|
||||
// to where we started. Then we... repeat.
|
||||
// But how do we repeat? My tiny insight is that every island (including holes) must cross
|
||||
// the top of at least one cell; the only alternatives are for it to be zero width or only
|
||||
// exist in the bottom row, and either way that makes it zero area, which isn't allowed. So
|
||||
// we only have to track and check the top edges of cells, and run through every cell in the
|
||||
// grid in order, stopping to draw a new outline when we find a cell whose top edge we
|
||||
// haven't yet examined (and whose top edge is in fact a border). We unfortunately need to
|
||||
// examine cells outside the selection, too, so that we can identify holes. But we can
|
||||
// restrict all of this to within the bbox, so that's nice.
|
||||
// Also, note that we concern ourselves with /grid points/ here, which are intersections of
|
||||
// grid lines, whereas the grid cells are the spaces between grid lines.
|
||||
// TODO might be more efficient to store a list of horizontal spans instead of just cells,
|
||||
// but of course this would be more complicated
|
||||
let seen_tops = new BitVector(this.bbox.width * this.bbox.height);
|
||||
// In clockwise order for ease of rotation, starting with right
|
||||
let directions = [
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[-1, 0],
|
||||
[0, -1],
|
||||
];
|
||||
|
||||
let segments = [];
|
||||
for (let y = this.bbox.top; y < this.bbox.bottom; y++) {
|
||||
for (let x = this.bbox.left; x < this.bbox.right; x++) {
|
||||
if (seen_tops.get((x - this.bbox.left) + this.bbox.width * (y - this.bbox.top)))
|
||||
// Already traced
|
||||
continue;
|
||||
if (this._contains(x, y) === this._contains(x, y - 1))
|
||||
// Not a top border
|
||||
continue;
|
||||
|
||||
// Start a new segment!
|
||||
let gx = x;
|
||||
let gy = y;
|
||||
let dx = 1;
|
||||
let dy = 0;
|
||||
let d = 0;
|
||||
|
||||
let segment = [];
|
||||
segments.push(segment);
|
||||
segment.push([gx, gy]);
|
||||
while (segment.length < 100) {
|
||||
// At this point we know that d is a valid direction and we've just traced it
|
||||
if (dx === 1) {
|
||||
seen_tops.set((gx - this.bbox.left) + this.bbox.width * (gy - this.bbox.top));
|
||||
}
|
||||
else if (dx === -1) {
|
||||
seen_tops.set((gx - 1 - this.bbox.left) + this.bbox.width * (gy - this.bbox.top));
|
||||
}
|
||||
gx += dx;
|
||||
gy += dy;
|
||||
|
||||
if (gx === x && gy === y)
|
||||
break;
|
||||
|
||||
// Now we're at a new point, so search for the next direction, starting from the left
|
||||
// Again, this is clockwise order (tr, br, bl, tl), arranged so that direction D goes
|
||||
// between cells D and D + 1
|
||||
let neighbors = [
|
||||
this._contains(gx, gy - 1),
|
||||
this._contains(gx, gy),
|
||||
this._contains(gx - 1, gy),
|
||||
this._contains(gx - 1, gy - 1),
|
||||
];
|
||||
let new_d = (d + 1) % 4;
|
||||
for (let i = 3; i <= 4; i++) {
|
||||
let sd = (d + i) % 4;
|
||||
if (neighbors[sd] !== neighbors[(sd + 1) % 4]) {
|
||||
new_d = sd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (new_d !== d) {
|
||||
// We're turning, so this is a new point
|
||||
segment.push([gx, gy]);
|
||||
d = new_d;
|
||||
[dx, dy] = directions[d];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO do it again for the next region... but how do i tell where the next region is?
|
||||
|
||||
let pathdata = [];
|
||||
for (let subpath of segments) {
|
||||
let first = true;
|
||||
for (let [x, y] of subpath) {
|
||||
if (first) {
|
||||
first = false;
|
||||
pathdata.push(`M${x},${y}`);
|
||||
}
|
||||
else {
|
||||
pathdata.push(`L${x},${y}`);
|
||||
}
|
||||
}
|
||||
pathdata.push('z');
|
||||
}
|
||||
this.ring_bg_element.classList.add('--visible');
|
||||
this.ring_bg_element.setAttribute('d', pathdata.join(' '));
|
||||
this.ring_element.classList.add('--visible');
|
||||
this.ring_element.setAttribute('d', pathdata.join(' '));
|
||||
}
|
||||
|
||||
move_by(dx, dy) {
|
||||
if (! this.rect)
|
||||
if (this.is_empty)
|
||||
return;
|
||||
|
||||
this.rect.x += dx;
|
||||
this.rect.y += dy;
|
||||
this.element.setAttribute('x', this.rect.x);
|
||||
this.element.setAttribute('y', this.rect.y);
|
||||
|
||||
if (! this.floated_element)
|
||||
if (! this.floated_cells) {
|
||||
console.error("Can't move a non-floating selection");
|
||||
return;
|
||||
}
|
||||
|
||||
let bbox = this.rect;
|
||||
this.floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
|
||||
this.floated_offset[0] += dx;
|
||||
this.floated_offset[1] += dy;
|
||||
this._update_floating_transform();
|
||||
}
|
||||
|
||||
_update_floating_transform() {
|
||||
let transform = `translate(${this.floated_offset[0]} ${this.floated_offset[1]})`;
|
||||
this.selection_group.setAttribute('transform', transform);
|
||||
}
|
||||
|
||||
clear() {
|
||||
let rect = this.rect;
|
||||
if (! rect)
|
||||
// FIXME behavior when floating is undefined
|
||||
if (this.is_empty)
|
||||
return;
|
||||
|
||||
let old_cells = this.cells;
|
||||
|
||||
this.editor._do(
|
||||
() => this._clear(),
|
||||
() => this._set_from_rect(rect),
|
||||
() => {
|
||||
this._set_from_set(old_cells);
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
_clear() {
|
||||
this.rect = null;
|
||||
this.element.classList.remove('--visible');
|
||||
}
|
||||
|
||||
*iter_coords() {
|
||||
if (! this.rect)
|
||||
return;
|
||||
|
||||
let stored_level = this.editor.stored_level;
|
||||
for (let y = this.rect.top; y < this.rect.bottom; y++) {
|
||||
for (let x = this.rect.left; x < this.rect.right; x++) {
|
||||
let n = stored_level.coords_to_scalar(x, y);
|
||||
yield [x, y, n];
|
||||
}
|
||||
}
|
||||
this.cells = new Set;
|
||||
this.bbox = null;
|
||||
this.ring_bg_element.classList.remove('--visible');
|
||||
this.ring_element.classList.remove('--visible');
|
||||
}
|
||||
|
||||
// Convert this selection into a floating selection, plucking all the selected cells from the
|
||||
// level and replacing them with blank cells.
|
||||
enfloat(copy = false) {
|
||||
if (this.floated_cells)
|
||||
if (this.floated_cells) {
|
||||
console.error("Trying to float a selection that's already floating");
|
||||
return;
|
||||
}
|
||||
|
||||
let floated_cells = [];
|
||||
let tileset = this.editor.renderer.tileset;
|
||||
let floated_cells = new Map;
|
||||
let stored_level = this.editor.stored_level;
|
||||
let bbox = this.rect;
|
||||
let canvas = mk('canvas', {width: bbox.width * tileset.size_x, height: bbox.height * tileset.size_y});
|
||||
let ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(
|
||||
this.editor.renderer.canvas,
|
||||
bbox.x * tileset.size_x, bbox.y * tileset.size_y, bbox.width * tileset.size_x, bbox.height * tileset.size_y,
|
||||
0, 0, bbox.width * tileset.size_x, bbox.height * tileset.size_y);
|
||||
for (let [x, y, n] of this.iter_coords()) {
|
||||
for (let n of this.cells) {
|
||||
let [x, y] = stored_level.scalar_to_coords(n);
|
||||
let cell = stored_level.linear_cells[n];
|
||||
if (copy) {
|
||||
floated_cells.push(cell.map(tile => tile ? {...tile} : null));
|
||||
floated_cells.set(n, cell.map(tile => tile ? {...tile} : null));
|
||||
}
|
||||
else {
|
||||
floated_cells.push(cell);
|
||||
floated_cells.set(n, cell);
|
||||
this.editor.replace_cell(cell, this.editor.make_blank_cell(x, y));
|
||||
}
|
||||
}
|
||||
let floated_element = mk_svg('g', mk_svg('foreignObject', {
|
||||
x: 0, y: 0,
|
||||
width: canvas.width, height: canvas.height,
|
||||
transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`,
|
||||
}, canvas));
|
||||
floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
|
||||
|
||||
// FIXME far more memory efficient to recreate the canvas in the redo, rather than hold onto
|
||||
// it forever
|
||||
this.editor._do(
|
||||
() => {
|
||||
this.floated_canvas = canvas;
|
||||
this.floated_element = floated_element;
|
||||
this.floated_cells = floated_cells;
|
||||
this.svg_group.append(floated_element);
|
||||
this.floated_offset = [0, 0];
|
||||
this._init_floated_canvas();
|
||||
this.ring_element.classList.add('--floating');
|
||||
},
|
||||
() => this._defloat(),
|
||||
() => this._delete_floating(),
|
||||
);
|
||||
}
|
||||
|
||||
// Create floated_canvas and floated_element, based on floated_cells, or update them if they
|
||||
// already exist
|
||||
_init_floated_canvas() {
|
||||
let tileset = this.editor.renderer.tileset;
|
||||
if (! this.floated_canvas) {
|
||||
this.floated_canvas = mk('canvas');
|
||||
}
|
||||
this.floated_canvas.width = this.bbox.width * tileset.size_x;
|
||||
this.floated_canvas.height = this.bbox.height * tileset.size_y;
|
||||
this.redraw();
|
||||
|
||||
if (! this.floated_element) {
|
||||
this.floated_element = mk_svg('g', mk_svg('foreignObject', {
|
||||
x: 0,
|
||||
y: 0,
|
||||
transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`,
|
||||
}, this.floated_canvas));
|
||||
// This goes first, so the selection ring still appears on top
|
||||
this.selection_group.prepend(this.floated_element);
|
||||
}
|
||||
let foreign = this.floated_element.querySelector('foreignObject');
|
||||
foreign.setAttribute('width', this.floated_canvas.width);
|
||||
foreign.setAttribute('height', this.floated_canvas.height);
|
||||
|
||||
// The canvas only covers our bbox, so it needs to start where the bbox does
|
||||
this.floated_element.setAttribute('transform', `translate(${this.bbox.x} ${this.bbox.y})`);
|
||||
}
|
||||
|
||||
stamp_float(copy = false) {
|
||||
if (! this.floated_element)
|
||||
return;
|
||||
|
||||
let stored_level = this.editor.stored_level;
|
||||
let i = 0;
|
||||
for (let [x, y, n] of this.iter_coords()) {
|
||||
let cell = this.floated_cells[i];
|
||||
for (let n of this.cells) {
|
||||
let [x, y] = stored_level.scalar_to_coords(n);
|
||||
x += this.floated_offset[0];
|
||||
y += this.floated_offset[1];
|
||||
// If the selection is moved so that part of it is outside the level, skip that bit
|
||||
if (! stored_level.is_point_within_bounds(x, y))
|
||||
continue;
|
||||
|
||||
let cell = this.floated_cells.get(n);
|
||||
if (copy) {
|
||||
cell = cell.map(tile => tile ? {...tile} : null);
|
||||
}
|
||||
cell.x = x;
|
||||
cell.y = y;
|
||||
this.editor.replace_cell(stored_level.linear_cells[n], cell);
|
||||
i += 1;
|
||||
|
||||
let n2 = stored_level.coords_to_scalar(x, y);
|
||||
this.editor.replace_cell(stored_level.linear_cells[n2], cell);
|
||||
}
|
||||
}
|
||||
|
||||
defloat() {
|
||||
// Converts a floating selection back to a regular selection, including stamping it in place
|
||||
commit_floating() {
|
||||
// This is OK; we're idempotent
|
||||
if (! this.floated_element)
|
||||
return;
|
||||
|
||||
this.stamp_float();
|
||||
|
||||
let element = this.floated_element;
|
||||
let canvas = this.floated_canvas;
|
||||
let cells = this.floated_cells;
|
||||
// Actually apply the offset, so we can be a regular selection again
|
||||
let old_cells = this.cells;
|
||||
let old_bbox = DOMRect.fromRect(this.bbox);
|
||||
let new_cells = new Set;
|
||||
let stored_level = this.editor.stored_level;
|
||||
for (let n of old_cells) {
|
||||
let [x, y] = stored_level.scalar_to_coords(n);
|
||||
x += this.floated_offset[0];
|
||||
y += this.floated_offset[1];
|
||||
|
||||
if (stored_level.is_point_within_bounds(x, y)) {
|
||||
new_cells.add(stored_level.coords_to_scalar(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
let old_floated_cells = this.floated_cells;
|
||||
let old_floated_offset = this.floated_offset;
|
||||
this.editor._do(
|
||||
() => this._defloat(),
|
||||
() => {
|
||||
this.floated_cells = cells;
|
||||
this.floated_canvas = canvas;
|
||||
this.floated_element = element;
|
||||
this.svg_group.append(element);
|
||||
this._delete_floating();
|
||||
this._set_from_set(new_cells);
|
||||
},
|
||||
() => {
|
||||
// Don't use _set_from_set here; it's not designed for an offset float
|
||||
this.cells = old_cells;
|
||||
this.bbox = old_bbox;
|
||||
this._update_outline();
|
||||
|
||||
this.floated_cells = old_floated_cells;
|
||||
this.floated_offset = old_floated_offset;
|
||||
this._init_floated_canvas();
|
||||
this._update_floating_transform();
|
||||
this.ring_element.classList.add('--floating');
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
_defloat() {
|
||||
// Modifies the cells (and their arrangement) within a floating selection
|
||||
_rearrange_cells(original_width, convert_coords, upgrade_tile) {
|
||||
if (! this.floated_cells)
|
||||
return;
|
||||
|
||||
let new_cells = new Set;
|
||||
let new_floated_cells = new Map;
|
||||
let w = this.editor.stored_level.size_x;
|
||||
let h = this.editor.stored_level.size_y;
|
||||
for (let n of this.cells) {
|
||||
// Alas this needs manually computing since the level may have changed size
|
||||
let x = n % original_width;
|
||||
let y = Math.floor(n / original_width);
|
||||
let [x2, y2] = convert_coords(x, y, w, h);
|
||||
let n2 = x2 + w * y2;
|
||||
let cell = this.floated_cells.get(n);
|
||||
cell.x = x2;
|
||||
cell.y = y2;
|
||||
for (let tile of cell) {
|
||||
if (tile) {
|
||||
upgrade_tile(tile);
|
||||
}
|
||||
}
|
||||
new_cells.add(n2);
|
||||
new_floated_cells.set(n2, cell);
|
||||
}
|
||||
|
||||
// Track the old and new centers of the bboxes so the transform can be center-relative
|
||||
let [cx0, cy0] = convert_coords(
|
||||
Math.floor(this.bbox.x + this.bbox.width / 2),
|
||||
Math.floor(this.bbox.y + this.bbox.height / 2),
|
||||
w, h);
|
||||
|
||||
// Alter the bbox by just transforming two opposite corners
|
||||
let [x1, y1] = convert_coords(this.bbox.left, this.bbox.top, w, h);
|
||||
let [x2, y2] = convert_coords(this.bbox.right - 1, this.bbox.bottom - 1, w, h);
|
||||
let xs = [x1, x2];
|
||||
let ys = [y1, y2];
|
||||
xs.sort((a, b) => a - b);
|
||||
ys.sort((a, b) => a - b);
|
||||
this.bbox = new DOMRect(xs[0], ys[0], xs[1] - xs[0] + 1, ys[1] - ys[0] + 1);
|
||||
|
||||
// Now make it center-relative by shifting the offsets
|
||||
let [cx1, cy1] = convert_coords(
|
||||
Math.floor(this.bbox.x + this.bbox.width / 2),
|
||||
Math.floor(this.bbox.y + this.bbox.height / 2),
|
||||
w, h);
|
||||
this.floated_offset[0] += cx1 - cx0;
|
||||
this.floated_offset[1] += cy1 - cy0;
|
||||
this._update_floating_transform();
|
||||
|
||||
// No need for undo; this is undone by performing the reverse operation
|
||||
this.cells = new_cells;
|
||||
this.floated_cells = new_floated_cells;
|
||||
this._init_floated_canvas();
|
||||
|
||||
this._update_outline();
|
||||
}
|
||||
|
||||
_delete_floating() {
|
||||
this.selection_group.removeAttribute('transform');
|
||||
this.ring_element.classList.remove('--floating');
|
||||
this.floated_element.remove();
|
||||
|
||||
this.floated_cells = null;
|
||||
this.floated_offset = null;
|
||||
this.floated_element = null;
|
||||
this.floated_canvas = null;
|
||||
this.floated_cells = null;
|
||||
}
|
||||
|
||||
// Redraw the selection canvas from scratch
|
||||
@ -272,19 +635,22 @@ export class Selection {
|
||||
if (! this.floated_canvas)
|
||||
return;
|
||||
|
||||
// FIXME uhoh, how do i actually do this? we have no renderer of our own, we have a
|
||||
// separate canvas, and all the renderer stuff expects to get ahold of a level. i guess
|
||||
// refactor it to draw a block of cells?
|
||||
this.editor.renderer.draw_static_generic({
|
||||
x0: 0, y0: 0,
|
||||
x1: this.rect.width, y1: this.rect.height,
|
||||
cells: this.floated_cells,
|
||||
width: this.rect.width,
|
||||
ctx: this.floated_canvas.getContext('2d'),
|
||||
});
|
||||
let ctx = this.floated_canvas.getContext('2d');
|
||||
for (let n of this.cells) {
|
||||
let [x, y] = this.editor.stored_level.scalar_to_coords(n);
|
||||
this.editor.renderer.draw_static_generic({
|
||||
// Incredibly stupid hack for just drawing one cell
|
||||
x0: 0, x1: 0,
|
||||
y0: 0, y1: 0,
|
||||
width: 1,
|
||||
cells: [this.floated_cells.get(n)],
|
||||
ctx: ctx,
|
||||
destx: x - this.bbox.left,
|
||||
desty: y - this.bbox.top,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO allow floating/dragging, ctrl-dragging to copy, anchoring...
|
||||
// TODO make more stuff respect this (more things should go through Editor for undo reasons anyway)
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
import { LAYERS } from './defs.js';
|
||||
import { DIRECTIONS, LAYERS } from './defs.js';
|
||||
import * as util from './util.js';
|
||||
|
||||
export class StoredCell extends Array {
|
||||
constructor() {
|
||||
super(LAYERS.MAX);
|
||||
}
|
||||
|
||||
get_terrain() {
|
||||
return this[LAYERS.terrain] ?? null;
|
||||
}
|
||||
|
||||
get_actor() {
|
||||
return this[LAYERS.actor] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export class Replay {
|
||||
@ -77,6 +85,10 @@ export class LevelInterface {
|
||||
return x + y * this.size_x;
|
||||
}
|
||||
|
||||
cell_to_scalar(cell) {
|
||||
return this.coords_to_scalar(cell.x, cell.y);
|
||||
}
|
||||
|
||||
is_point_within_bounds(x, y) {
|
||||
return (x >= 0 && x < this.size_x && y >= 0 && y < this.size_y);
|
||||
}
|
||||
@ -89,6 +101,11 @@ export class LevelInterface {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get_neighboring_cell(cell, direction) {
|
||||
let move = DIRECTIONS[direction].movement;
|
||||
return this.cell(cell.x + move[0], cell.y + move[1]);
|
||||
}
|
||||
}
|
||||
|
||||
export class StoredLevel extends LevelInterface {
|
||||
@ -128,8 +145,10 @@ export class StoredLevel extends LevelInterface {
|
||||
this.linear_cells = [];
|
||||
|
||||
// Maps of button positions to trap/cloner positions, as scalars
|
||||
this.has_custom_connections = false;
|
||||
this.custom_connections = {};
|
||||
// Not supported by Steam CC2, but supported by Tile World even in Lynx mode
|
||||
this.custom_connections = new Map;
|
||||
// If true, Lynx-style implicit connections don't work at all
|
||||
this.only_custom_connections = false;
|
||||
|
||||
// New LL feature: custom camera regions, as lists of {x, y, width, height}
|
||||
this.camera_regions = [];
|
||||
@ -152,6 +171,7 @@ export class StoredLevel extends LevelInterface {
|
||||
|
||||
export class StoredPack {
|
||||
constructor(identifier, level_loader) {
|
||||
// This isn't very strongly defined, but it's used to distinguish scores for packs and URLs
|
||||
this.identifier = identifier;
|
||||
this.title = "";
|
||||
this._level_loader = level_loader;
|
||||
|
||||
@ -894,6 +894,10 @@ const TILE_ENCODING = {
|
||||
name: 'sand',
|
||||
is_extension: true,
|
||||
},
|
||||
0xe8: {
|
||||
name: 'grass',
|
||||
is_extension: true,
|
||||
},
|
||||
0xed: {
|
||||
name: 'ankh',
|
||||
has_next: true,
|
||||
@ -926,6 +930,22 @@ const TILE_ENCODING = {
|
||||
modifier: modifier_color,
|
||||
is_extension: true,
|
||||
},
|
||||
0xf4: {
|
||||
name: 'one_way_walls',
|
||||
has_next: true,
|
||||
is_extension: true,
|
||||
extra_args: [
|
||||
{
|
||||
size: 1,
|
||||
decode(tile, mask) {
|
||||
tile.edges = mask;
|
||||
},
|
||||
encode(tile) {
|
||||
return tile.edges;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const REVERSE_TILE_ENCODING = {};
|
||||
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
|
||||
@ -1312,12 +1332,11 @@ export function parse_level(buf, number = 1) {
|
||||
if (bytes.length % 4 !== 0)
|
||||
throw new Error(`Expected LXCX chunk to be a multiple of 4 bytes; got ${bytes.length}`);
|
||||
|
||||
level.has_custom_connections = true;
|
||||
let p = 0;
|
||||
while (p < bytes.length) {
|
||||
let src = view.getUint16(p, true);
|
||||
let dest = view.getUint16(p + 2, true);
|
||||
level.custom_connections[src] = dest;
|
||||
level.custom_connections.set(src, dest);
|
||||
p += 4;
|
||||
}
|
||||
}
|
||||
@ -1552,12 +1571,12 @@ export function synthesize_level(stored_level) {
|
||||
|
||||
// Store MSCC-like custom connections
|
||||
// TODO LL feature, should be distinguished somehow
|
||||
let num_connections = Object.keys(stored_level.custom_connections).length;
|
||||
let num_connections = stored_level.custom_connections.size;
|
||||
if (num_connections > 0) {
|
||||
let buf = new ArrayBuffer(4 * num_connections);
|
||||
let view = new DataView(buf);
|
||||
let p = 0;
|
||||
for (let [src, dest] of Object.entries(stored_level.custom_connections)) {
|
||||
for (let [src, dest] of stored_level.custom_connections) {
|
||||
view.setUint16(p + 0, src, true);
|
||||
view.setUint16(p + 2, dest, true);
|
||||
p += 4;
|
||||
@ -1756,7 +1775,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+)' +
|
||||
@ -1771,7 +1790,7 @@ const TOKENIZE_RX = RegExp(
|
||||
'|([a-zA-Z]\\S*)' +
|
||||
// 8: Anything else is an error
|
||||
'|(\\S+)' +
|
||||
')', 'g');
|
||||
')', 'gm');
|
||||
const DIRECTIVES = {
|
||||
// Important stuff
|
||||
'chdir': ['string'],
|
||||
@ -1875,6 +1894,7 @@ class ParseError extends Error {
|
||||
super(`${message} at line ${parser.lineno}`);
|
||||
}
|
||||
}
|
||||
ParseError.prototype.name = 'ParseError';
|
||||
|
||||
class Parser {
|
||||
constructor(string) {
|
||||
@ -2156,7 +2176,7 @@ const MAX_SIMULTANEOUS_REQUESTS = 5;
|
||||
|
||||
_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);
|
||||
@ -2172,8 +2192,9 @@ const MAX_SIMULTANEOUS_REQUESTS = 5;
|
||||
if (stmt.kind === 'directive' && stmt.name === 'map') {
|
||||
let path = stmt.args[0].value;
|
||||
path = path.replace(/\\/, '/');
|
||||
// FIXME can we get away with not downloading all of them eagerly?
|
||||
fetch_map(path, level_number);
|
||||
level_number++;
|
||||
level_number += 1;
|
||||
}
|
||||
else if (stmt.kind === 'directive' && stmt.name === 'game') {
|
||||
// TODO apparently cc2 lets you change this mid-game and will then use a different save
|
||||
|
||||
@ -209,7 +209,7 @@ export function parse_level_metadata(bytes) {
|
||||
|
||||
function parse_level(bytes, number) {
|
||||
let level = new format_base.StoredLevel(number);
|
||||
level.has_custom_connections = true;
|
||||
level.only_custom_connections = true;
|
||||
level.format = 'ccl';
|
||||
level.uses_ll_extensions = false;
|
||||
level.chips_required = 0;
|
||||
@ -353,7 +353,7 @@ function parse_level(bytes, number) {
|
||||
let s = level.coords_to_scalar(button_x, button_y);
|
||||
let d = level.coords_to_scalar(trap_x, trap_y);
|
||||
if (level.linear_cells[s][LAYERS.terrain].type.name === 'button_brown') {
|
||||
level.custom_connections[s] = d;
|
||||
level.custom_connections.set(s, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -372,7 +372,7 @@ function parse_level(bytes, number) {
|
||||
let s = level.coords_to_scalar(button_x, button_y);
|
||||
let d = level.coords_to_scalar(cloner_x, cloner_y);
|
||||
if (level.linear_cells[s][LAYERS.terrain].type.name === 'button_red') {
|
||||
level.custom_connections[s] = d;
|
||||
level.custom_connections.set(s, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -561,9 +561,10 @@ export function synthesize_level(stored_level) {
|
||||
else if (other.type.name === 'button_brown') {
|
||||
cxn_target = 'trap';
|
||||
}
|
||||
if (cxn_target && i in stored_level.custom_connections) {
|
||||
let dest = stored_level.custom_connections[i];
|
||||
if (cxn_target && stored_level.custom_connections.has(i)) {
|
||||
let dest = stored_level.custom_connections.get(i);
|
||||
let dest_cell = stored_level.linear_cells[dest];
|
||||
// FIXME these need to be sorted by destination actually
|
||||
if (dest_cell && dest_cell[LAYERS.terrain].type.name === cxn_target) {
|
||||
if (other.type.name === 'button_red') {
|
||||
cloner_cxns.push(x, y, ...stored_level.scalar_to_coords(dest));
|
||||
@ -620,7 +621,7 @@ export function synthesize_level(stored_level) {
|
||||
// TODO do something with not-ascii; does TW support utf8 or latin1 or anything?
|
||||
add_block(3, util.bytestring_to_buffer(stored_level.title.substring(0, 63) + "\0"));
|
||||
// Trap and cloner connections
|
||||
function to_words(cxns) {
|
||||
function encode_connections(cxns) {
|
||||
let words = new ArrayBuffer(cxns.length * 2);
|
||||
let view = new DataView(words);
|
||||
for (let [i, val] of cxns.entries()) {
|
||||
@ -629,10 +630,10 @@ export function synthesize_level(stored_level) {
|
||||
return words;
|
||||
}
|
||||
if (trap_cxns.length > 0) {
|
||||
add_block(4, to_words(trap_cxns));
|
||||
add_block(4, encode_connections(trap_cxns));
|
||||
}
|
||||
if (cloner_cxns.length > 0) {
|
||||
add_block(5, to_words(cloner_cxns));
|
||||
add_block(5, encode_connections(cloner_cxns));
|
||||
}
|
||||
// Password
|
||||
// TODO support this for real lol
|
||||
|
||||
@ -13,6 +13,7 @@ const TW_DIRECTION_TO_INPUT_BITS = [
|
||||
INPUT_BITS.down | INPUT_BITS.right,
|
||||
];
|
||||
|
||||
// doc: http://www.muppetlabs.com/~breadbox/software/tworld/tworldff.html#3
|
||||
export function parse_solutions(bytes) {
|
||||
let buf;
|
||||
if (bytes.buffer) {
|
||||
@ -82,10 +83,12 @@ export function parse_solutions(bytes) {
|
||||
let q = p + 16;
|
||||
while (q < p + len) {
|
||||
// There are four formats for packing solutions, identified by the lowest two bits,
|
||||
// except that format 3 is actually two formats, don't ask
|
||||
// except that format 3 is actually two formats. Be aware that the documentation
|
||||
// refers to them in a different order than suggested by the identifying nybble.
|
||||
let fmt = bytes[q] & 0x3;
|
||||
let fmt2 = (bytes[q] >> 4) & 0x1;
|
||||
if (fmt === 0) {
|
||||
// "Third format": three consecutive moves packed into one byte
|
||||
let val = bytes[q];
|
||||
q += 1;
|
||||
let input1 = TW_DIRECTION_TO_INPUT_BITS[(val >> 2) & 0x3];
|
||||
@ -98,6 +101,8 @@ export function parse_solutions(bytes) {
|
||||
);
|
||||
}
|
||||
else if (fmt === 1 || fmt === 2 || (fmt === 3 && fmt2 === 0)) {
|
||||
// "First format" and "second format": one, two, or four bytes containing a
|
||||
// direction and a number of tics
|
||||
let val;
|
||||
if (fmt === 1) {
|
||||
val = bytes[q];
|
||||
@ -118,9 +123,34 @@ export function parse_solutions(bytes) {
|
||||
}
|
||||
inputs.push(input);
|
||||
}
|
||||
else { // 3-1
|
||||
// variable-size and only needed for ms so let's just hope not
|
||||
throw new Error;
|
||||
else { // low nybble is 3, and bit 4 is set
|
||||
// "Fourth format": 2 to 5 bytes, containing an exceptionally long direction
|
||||
// field and time field, mostly used for MSCC mouse moves
|
||||
let n = ((bytes[q] >> 2) & 0x3) + 2;
|
||||
if (q + n - 1 >= bytes.length)
|
||||
throw new Error(`Malformed TWS file: expected ${n} bytes starting at ${q}, but only found ${bytes.length - q}`);
|
||||
|
||||
// Up to 5 bytes is an annoying amount, but we can cut it down to 1-4 by
|
||||
// extracting the direction first
|
||||
let input = (bytes[q] >> 5) | ((bytes[q + 1] & 0x3f) << 3);
|
||||
let duration = bytes[q + 1] >> 6;
|
||||
for (let i = 3; i <= n; i++) {
|
||||
duration |= bytes[q + i - 1] << (2 + (i - 3) * 8);
|
||||
}
|
||||
|
||||
// Mouse moves are encoded as 16 + ((y + 9) * 19) + (x + 9), but I extremely do
|
||||
// not support them at the moment (and may never), so replace them with blank
|
||||
// input for now (and possibly forever)
|
||||
if (input >= 16) {
|
||||
input = 0;
|
||||
}
|
||||
|
||||
// And now queue it up
|
||||
for (let i = 0; i < duration; i++) {
|
||||
inputs.push(input);
|
||||
}
|
||||
|
||||
q += n;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1532
js/game.js
@ -1,13 +1,15 @@
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
import { performance } from 'perf_hooks';
|
||||
import { argv, exit, stderr, stdout } from 'process';
|
||||
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
|
||||
|
||||
import { compat_flags_for_ruleset } from '../defs.js';
|
||||
import { Level } from '../game.js';
|
||||
import * as format_c2g from '../format-c2g.js';
|
||||
import * as format_dat from '../format-dat.js';
|
||||
import * as format_tws from '../format-tws.js';
|
||||
import * as util from '../util.js';
|
||||
|
||||
import { argv, exit, stderr, stdout } from 'process';
|
||||
import { opendir, readFile, stat } from 'fs/promises';
|
||||
import { performance } from 'perf_hooks';
|
||||
import { LocalDirectorySource } from './lib.js';
|
||||
|
||||
// TODO arguments:
|
||||
// - custom pack to test, possibly its solutions, possibly its ruleset (or default to steam-strict/lynx)
|
||||
@ -17,45 +19,17 @@ import { performance } from 'perf_hooks';
|
||||
// - support for xfails somehow?
|
||||
// TODO use this for a test suite
|
||||
|
||||
export class LocalDirectorySource extends util.FileSource {
|
||||
constructor(root) {
|
||||
super();
|
||||
this.root = root;
|
||||
this.files = {};
|
||||
this._loaded_promise = this._scan_dir('/');
|
||||
}
|
||||
|
||||
async _scan_dir(path) {
|
||||
let dir = await opendir(this.root + path);
|
||||
for await (let dirent of dir) {
|
||||
if (dirent.isDirectory()) {
|
||||
await this._scan_dir(path + dirent.name + '/');
|
||||
}
|
||||
else {
|
||||
let filepath = path + dirent.name;
|
||||
this.files[filepath.toLowerCase()] = filepath;
|
||||
if (this.files.size > 2000)
|
||||
throw `way, way too many files in local directory source ${this.root}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
let realpath = this.files[path.toLowerCase()];
|
||||
if (realpath) {
|
||||
return (await readFile(this.root + realpath)).buffer;
|
||||
}
|
||||
else {
|
||||
throw new Error(`No such file: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pad(s, n) {
|
||||
return s.substring(0, n).padEnd(n, " ");
|
||||
}
|
||||
|
||||
const RESULT_TYPES = {
|
||||
pending: {
|
||||
// not a real result type, but used for the initial display
|
||||
color: "\x1b[90m",
|
||||
symbol: "?",
|
||||
},
|
||||
skipped: {
|
||||
color: "\x1b[90m",
|
||||
symbol: "-",
|
||||
@ -86,198 +60,150 @@ const RESULT_TYPES = {
|
||||
},
|
||||
};
|
||||
const ANSI_RESET = "\x1b[39m";
|
||||
function ansi_cursor_move(dx, dy) {
|
||||
if (dx > 0) {
|
||||
stdout.write(`\x1b[${dx}C`);
|
||||
}
|
||||
else if (dx < 0) {
|
||||
stdout.write(`\x1b[${-dx}D`);
|
||||
}
|
||||
|
||||
async function test_pack(pack, ruleset, level_filter = null) {
|
||||
let dummy_sfx = {
|
||||
set_player_position() {},
|
||||
play() {},
|
||||
play_once() {},
|
||||
};
|
||||
let compat = compat_flags_for_ruleset(ruleset);
|
||||
if (dy > 0) {
|
||||
stdout.write(`\x1b[${dy}B`);
|
||||
}
|
||||
else if (dy < 0) {
|
||||
stdout.write(`\x1b[${-dy}A`);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO factor out the common parts maybe?
|
||||
stdout.write(pad(`${pack.title} (${ruleset})`, 20) + " ");
|
||||
let num_levels = pack.level_metadata.length;
|
||||
let num_passed = 0;
|
||||
let num_missing = 0;
|
||||
let total_tics = 0;
|
||||
let t0 = performance.now();
|
||||
let last_pause = t0;
|
||||
let failures = [];
|
||||
for (let i = 0; i < num_levels; i++) {
|
||||
let stored_level, level;
|
||||
let level_start_time = performance.now();
|
||||
let record_result = (token, short_status, include_canvas, comment) => {
|
||||
let result_stuff = RESULT_TYPES[token];
|
||||
stdout.write(result_stuff.color + result_stuff.symbol);
|
||||
if (token === 'failure' || token === 'short' || token === 'error') {
|
||||
failures.push({
|
||||
token,
|
||||
short_status,
|
||||
comment,
|
||||
level,
|
||||
stored_level,
|
||||
index: i,
|
||||
fail_reason: level ? level.fail_reason : null,
|
||||
time_elapsed: performance.now() - level_start_time,
|
||||
time_expected: stored_level ? stored_level.replay.duration / 20 : null,
|
||||
title: stored_level ? stored_level.title : "[error]",
|
||||
time_simulated: level ? level.tic_counter / 20 : null,
|
||||
const dummy_sfx = {
|
||||
play() {},
|
||||
play_once() {},
|
||||
};
|
||||
|
||||
function test_level(stored_level, compat) {
|
||||
let level;
|
||||
let level_start_time = performance.now();
|
||||
let make_result = (type, short_status, include_canvas) => {
|
||||
//let result_stuff = RESULT_TYPES[type];
|
||||
// XXX stdout.write(result_stuff.color + result_stuff.symbol);
|
||||
return {
|
||||
type,
|
||||
short_status,
|
||||
fail_reason: level ? level.fail_reason : null,
|
||||
time_elapsed: performance.now() - level_start_time,
|
||||
time_simulated: level ? level.tic_counter / 20 : null,
|
||||
tics_simulated: level ? level.tic_counter : null,
|
||||
};
|
||||
|
||||
// FIXME allegedly it's possible to get a canvas working in node...
|
||||
/*
|
||||
if (include_canvas && level) {
|
||||
try {
|
||||
let tileset = this.conductor.choose_tileset_for_level(level.stored_level);
|
||||
this.renderer.set_tileset(tileset);
|
||||
let canvas = mk('canvas', {
|
||||
width: Math.min(this.renderer.canvas.width, level.size_x * tileset.size_x),
|
||||
height: Math.min(this.renderer.canvas.height, level.size_y * tileset.size_y),
|
||||
});
|
||||
this.renderer.set_level(level);
|
||||
this.renderer.set_active_player(level.player);
|
||||
this.renderer.draw();
|
||||
canvas.getContext('2d').drawImage(
|
||||
this.renderer.canvas, 0, 0,
|
||||
this.renderer.canvas.width, this.renderer.canvas.height);
|
||||
tbody.append(mk('tr', mk('td.-full', {colspan: 5}, canvas)));
|
||||
}
|
||||
if (level) {
|
||||
/*
|
||||
mk('td.-clock', util.format_duration(level.tic_counter / TICS_PER_SECOND)),
|
||||
mk('td.-delta', util.format_duration((level.tic_counter - stored_level.replay.duration) / TICS_PER_SECOND, 2)),
|
||||
mk('td.-speed', ((level.tic_counter / TICS_PER_SECOND) / (performance.now() - level_start_time) * 1000).toFixed(2) + '×'),
|
||||
*/
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
tbody.append(mk('tr', mk('td.-full', {colspan: 5},
|
||||
`Internal error while trying to capture screenshot: ${e}`)));
|
||||
}
|
||||
}
|
||||
*/
|
||||
};
|
||||
|
||||
let replay = stored_level.replay;
|
||||
level = new Level(stored_level, compat);
|
||||
level.sfx = dummy_sfx;
|
||||
level.undo_enabled = false; // slight performance boost
|
||||
replay.configure_level(level);
|
||||
|
||||
while (true) {
|
||||
let input = replay.get(level.tic_counter);
|
||||
level.advance_tic(input);
|
||||
|
||||
if (level.state === 'success') {
|
||||
if (level.tic_counter < replay.duration - 10) {
|
||||
// Early exit is dubious (e.g. this happened sometimes before multiple
|
||||
// players were implemented correctly)
|
||||
return make_result('early', "Won early", true);
|
||||
}
|
||||
else {
|
||||
return make_result('success', "Won");
|
||||
}
|
||||
}
|
||||
else if (level.state === 'failure') {
|
||||
return make_result('failure', "Lost", true);
|
||||
}
|
||||
else if (level.tic_counter >= replay.duration + 220) {
|
||||
// This threshold of 11 seconds was scientifically calculated by noticing that
|
||||
// the TWS of Southpole runs 11 seconds past its last input
|
||||
return make_result('short', "Out of input", true);
|
||||
}
|
||||
|
||||
// FIXME allegedly it's possible to get a canvas working in node...
|
||||
if (level.tic_counter % 20 === 1) {
|
||||
// XXX
|
||||
/*
|
||||
if (include_canvas && level) {
|
||||
try {
|
||||
let tileset = this.conductor.choose_tileset_for_level(level.stored_level);
|
||||
this.renderer.set_tileset(tileset);
|
||||
let canvas = mk('canvas', {
|
||||
width: Math.min(this.renderer.canvas.width, level.size_x * tileset.size_x),
|
||||
height: Math.min(this.renderer.canvas.height, level.size_y * tileset.size_y),
|
||||
});
|
||||
this.renderer.set_level(level);
|
||||
this.renderer.set_active_player(level.player);
|
||||
this.renderer.draw();
|
||||
canvas.getContext('2d').drawImage(
|
||||
this.renderer.canvas, 0, 0,
|
||||
this.renderer.canvas.width, this.renderer.canvas.height);
|
||||
tbody.append(mk('tr', mk('td.-full', {colspan: 5}, canvas)));
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
tbody.append(mk('tr', mk('td.-full', {colspan: 5},
|
||||
`Internal error while trying to capture screenshot: ${e}`)));
|
||||
}
|
||||
if (handle.cancel) {
|
||||
return make_result('interrupted', "Interrupted");
|
||||
this.current_status.textContent = `Interrupted on level ${i + 1}/${num_levels}; ${num_passed} passed`;
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
if (level) {
|
||||
total_tics += level.tic_counter;
|
||||
// Don't run for more than 100ms at a time, to avoid janking the browser...
|
||||
// TOO much. I mean, we still want it to reflow the stuff we've added, but
|
||||
// we also want to be pretty aggressive so this finishes quickly
|
||||
// XXX unnecessary headless
|
||||
/*
|
||||
let now = performance.now();
|
||||
if (now - last_pause > 100) {
|
||||
await util.sleep(4);
|
||||
last_pause = now;
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stuff that's related to testing a level, but is not actually testing a level
|
||||
function test_level_wrapper(pack, level_index, compat) {
|
||||
let result;
|
||||
let stored_level;
|
||||
try {
|
||||
stored_level = pack.load_level(level_index);
|
||||
if (! stored_level.has_replay) {
|
||||
result = { type: 'no-replay', short_status: "No replay" };
|
||||
}
|
||||
else {
|
||||
result = test_level(stored_level, compat);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
result = {
|
||||
type: 'error',
|
||||
short_status: "Error",
|
||||
time_simulated: null,
|
||||
tics_simulated: null,
|
||||
exception: e,
|
||||
};
|
||||
|
||||
if (level_filter && ! level_filter.has(i + 1)) {
|
||||
record_result('skipped', "Skipped");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
stored_level = pack.load_level(i);
|
||||
if (! stored_level.has_replay) {
|
||||
record_result('no-replay', "No replay");
|
||||
num_missing += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO? this.current_status.textContent = `Testing level ${i + 1}/${num_levels} ${stored_level.title}...`;
|
||||
|
||||
let replay = stored_level.replay;
|
||||
level = new Level(stored_level, compat);
|
||||
level.sfx = dummy_sfx;
|
||||
level.undo_enabled = false; // slight performance boost
|
||||
replay.configure_level(level);
|
||||
|
||||
while (true) {
|
||||
let input = replay.get(level.tic_counter);
|
||||
level.advance_tic(input);
|
||||
|
||||
if (level.state === 'success') {
|
||||
if (level.tic_counter < replay.duration - 10) {
|
||||
// Early exit is dubious (e.g. this happened sometimes before multiple
|
||||
// players were implemented correctly)
|
||||
record_result('early', "Won early", true);
|
||||
}
|
||||
else {
|
||||
record_result('success', "Won");
|
||||
}
|
||||
num_passed += 1;
|
||||
break;
|
||||
}
|
||||
else if (level.state === 'failure') {
|
||||
record_result('failure', "Lost", true);
|
||||
break;
|
||||
}
|
||||
else if (level.tic_counter >= replay.duration + 220) {
|
||||
// This threshold of 11 seconds was scientifically calculated by noticing that
|
||||
// the TWS of Southpole runs 11 seconds past its last input
|
||||
record_result('short', "Out of input", true);
|
||||
break;
|
||||
}
|
||||
|
||||
if (level.tic_counter % 20 === 1) {
|
||||
// XXX
|
||||
/*
|
||||
if (handle.cancel) {
|
||||
record_result('interrupted', "Interrupted");
|
||||
this.current_status.textContent = `Interrupted on level ${i + 1}/${num_levels}; ${num_passed} passed`;
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
// Don't run for more than 100ms at a time, to avoid janking the browser...
|
||||
// TOO much. I mean, we still want it to reflow the stuff we've added, but
|
||||
// we also want to be pretty aggressive so this finishes quickly
|
||||
// XXX unnecessary headless
|
||||
/*
|
||||
let now = performance.now();
|
||||
if (now - last_pause > 100) {
|
||||
await util.sleep(4);
|
||||
last_pause = now;
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
// FIXME this does not seem to work
|
||||
record_result(
|
||||
'error', "Error", true,
|
||||
`Replay failed due to internal error (see console for traceback): ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
let total_real_elapsed = (performance.now() - t0) / 1000;
|
||||
|
||||
stdout.write(`${ANSI_RESET} ${num_passed}/${num_levels - num_missing}\n`);
|
||||
for (let failure of failures) {
|
||||
let short_status = failure.short_status;
|
||||
if (failure.token === 'failure') {
|
||||
short_status += ": ";
|
||||
short_status += failure.fail_reason;
|
||||
}
|
||||
|
||||
let parts = [
|
||||
String(failure.index + 1).padStart(5),
|
||||
pad(failure.title.replace(/[\r\n]+/, " "), 32),
|
||||
RESULT_TYPES[failure.token].color + pad(short_status, 20) + ANSI_RESET,
|
||||
];
|
||||
if (failure.time_simulated !== null) {
|
||||
parts.push("ran for" + util.format_duration(failure.time_simulated).padStart(6, " "));
|
||||
}
|
||||
if (failure.token === 'failure') {
|
||||
parts.push(" with" + util.format_duration(failure.time_expected - failure.time_simulated).padStart(6, " ") + " still to go");
|
||||
}
|
||||
stdout.write(parts.join(" ") + "\n");
|
||||
}
|
||||
|
||||
return {
|
||||
num_passed,
|
||||
num_missing,
|
||||
num_failed: num_levels - num_passed - num_missing,
|
||||
time_elapsed: total_real_elapsed,
|
||||
time_simulated: total_tics / 20,
|
||||
};
|
||||
result.level_index = level_index;
|
||||
result.time_expected = stored_level && stored_level.has_replay ? stored_level.replay.duration / 20 : null;
|
||||
result.title = stored_level ? stored_level.title : "[load error]";
|
||||
return result;
|
||||
}
|
||||
|
||||
async function _scan_source(source) {
|
||||
@ -315,6 +241,222 @@ async function _scan_source(source) {
|
||||
// TODO else...? complain we couldn't find anything? list what we did find?? idk
|
||||
}
|
||||
|
||||
async function load_pack(testdef) {
|
||||
let pack;
|
||||
if ((await stat(testdef.pack_path)).isDirectory()) {
|
||||
let source = new LocalDirectorySource(testdef.pack_path);
|
||||
pack = await _scan_source(source);
|
||||
}
|
||||
else {
|
||||
let pack_data = await readFile(testdef.pack_path);
|
||||
if (testdef.pack_path.match(/[.]zip$/)) {
|
||||
let source = new util.ZipFileSource(pack_data.buffer);
|
||||
pack = await _scan_source(source);
|
||||
}
|
||||
else {
|
||||
pack = format_dat.parse_game(pack_data.buffer);
|
||||
|
||||
let solutions_data = await readFile(testdef.solutions_path);
|
||||
let solutions = format_tws.parse_solutions(solutions_data.buffer);
|
||||
pack.level_replays = solutions.levels;
|
||||
}
|
||||
}
|
||||
|
||||
if (! pack.title) {
|
||||
let match = testdef.pack_path.match(/(?:^|\/)([^/.]+)(?:\..*)?\/?$/);
|
||||
if (match) {
|
||||
pack.title = match[1];
|
||||
}
|
||||
else {
|
||||
pack.title = testdef.pack_path;
|
||||
}
|
||||
}
|
||||
|
||||
return pack;
|
||||
}
|
||||
|
||||
async function main_worker(testdef) {
|
||||
// We have to load the pack separately in every thread
|
||||
let pack = await load_pack(testdef);
|
||||
let ruleset = testdef.ruleset;
|
||||
let compat = compat_flags_for_ruleset(ruleset);
|
||||
|
||||
let t = performance.now();
|
||||
parentPort.on('message', level_index => {
|
||||
//console.log("idled for", (performance.now() - t) / 1000);
|
||||
parentPort.postMessage(test_level_wrapper(pack, level_index, compat));
|
||||
t = performance.now();
|
||||
});
|
||||
}
|
||||
|
||||
// the simplest pool in the world
|
||||
async function* run_in_thread_pool(num_workers, worker_data, items) {
|
||||
let next_index = 0;
|
||||
let workers = [];
|
||||
let result_available_resolve;
|
||||
let result_available = new Promise(resolve => {
|
||||
result_available_resolve = resolve;
|
||||
});
|
||||
for (let i = 0; i < num_workers; i++) {
|
||||
let worker = new Worker(new URL(import.meta.url), {
|
||||
workerData: worker_data,
|
||||
});
|
||||
|
||||
let waiting_on_index = null;
|
||||
let process_next = () => {
|
||||
if (next_index < items.length) {
|
||||
let item = items[next_index];
|
||||
next_index += 1;
|
||||
worker.postMessage(item);
|
||||
}
|
||||
};
|
||||
worker.on('message', result => {
|
||||
result_available_resolve(result);
|
||||
process_next();
|
||||
});
|
||||
process_next();
|
||||
|
||||
workers.push(worker);
|
||||
}
|
||||
|
||||
try {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let result = await result_available;
|
||||
result_available = new Promise(resolve => {
|
||||
result_available_resolve = resolve;
|
||||
});
|
||||
|
||||
yield result;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
for (let worker of workers) {
|
||||
worker.terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// well maybe this is simpler
|
||||
async function* dont_run_in_thread_pool(num_workers, testdef, items) {
|
||||
let pack = await load_pack(testdef);
|
||||
let ruleset = testdef.ruleset;
|
||||
let compat = compat_flags_for_ruleset(ruleset);
|
||||
|
||||
for (let level_index of items) {
|
||||
yield test_level_wrapper(pack, level_index, compat);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function test_pack(testdef) {
|
||||
let pack = await load_pack(testdef);
|
||||
let ruleset = testdef.ruleset;
|
||||
let level_filter = testdef.level_filter;
|
||||
|
||||
let num_levels = pack.level_metadata.length;
|
||||
let columns = stdout.columns || 80;
|
||||
// 20 for title, 1 for space, the dots, 1 for space, 9 for succeeded/total, 1 for padding
|
||||
let title_width = 20;
|
||||
let dots_per_row = columns - title_width - 1 - 1 - 9 - 1;
|
||||
// TODO factor out the common parts maybe?
|
||||
stdout.write(pad(`${pack.title} (${ruleset})`, title_width) + " ");
|
||||
let indices = [];
|
||||
let num_dot_lines = 1;
|
||||
let previous_type = null;
|
||||
for (let i = 0; i < num_levels; i++) {
|
||||
if (i > 0 && i % dots_per_row === 0) {
|
||||
stdout.write("\n");
|
||||
stdout.write(" ".repeat(title_width + 1));
|
||||
num_dot_lines += 1;
|
||||
}
|
||||
|
||||
let type = (level_filter && ! level_filter.has(i + 1)) ? 'skipped' : 'pending';
|
||||
if (type !== previous_type) {
|
||||
stdout.write(RESULT_TYPES[type].color);
|
||||
}
|
||||
stdout.write(RESULT_TYPES[type].symbol);
|
||||
previous_type = type;
|
||||
|
||||
if (type === 'pending') {
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
ansi_cursor_move(0, -(num_dot_lines - 1));
|
||||
stdout.write(`\x1b[${title_width + 2}G`);
|
||||
|
||||
// We really really don't want to have only a single thread left running at the end on a single
|
||||
// remaining especially-long replay, so it would be nice to run the levels in reverse order of
|
||||
// complexity. But that sounds hard so instead just run them backwards, since the earlier
|
||||
// levels in any given pack tend to be easier.
|
||||
indices.reverse();
|
||||
|
||||
let num_passed = 0;
|
||||
let num_missing = 0;
|
||||
let total_tics = 0;
|
||||
let t0 = performance.now();
|
||||
let last_pause = t0;
|
||||
let failures = [];
|
||||
for await (let result of run_in_thread_pool(4, testdef, indices)) {
|
||||
let result_stuff = RESULT_TYPES[result.type];
|
||||
let col = result.level_index % dots_per_row;
|
||||
let row = Math.floor(result.level_index / dots_per_row);
|
||||
ansi_cursor_move(col, row);
|
||||
stdout.write(result_stuff.color + result_stuff.symbol);
|
||||
ansi_cursor_move(-(col + 1), -row);
|
||||
|
||||
if (result.tics_simulated) {
|
||||
total_tics += result.tics_simulated;
|
||||
}
|
||||
|
||||
if (result.type === 'no-replay') {
|
||||
num_missing += 1;
|
||||
}
|
||||
else if (result.type === 'success' || result.type === 'early') {
|
||||
num_passed += 1;
|
||||
}
|
||||
else {
|
||||
failures.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
let total_real_elapsed = (performance.now() - t0) / 1000;
|
||||
|
||||
ansi_cursor_move(dots_per_row + 1, 0);
|
||||
stdout.write(`${ANSI_RESET} ${num_passed}/${num_levels - num_missing}`);
|
||||
ansi_cursor_move(0, num_dot_lines - 1);
|
||||
stdout.write("\n");
|
||||
failures.sort((a, b) => a.level_index - b.level_index);
|
||||
for (let failure of failures) {
|
||||
let short_status = failure.short_status;
|
||||
if (failure.type === 'failure') {
|
||||
short_status += ": ";
|
||||
short_status += failure.fail_reason;
|
||||
}
|
||||
|
||||
let parts = [
|
||||
String(failure.level_index + 1).padStart(5),
|
||||
pad(failure.title.replace(/[\r\n]+/, " "), 32),
|
||||
RESULT_TYPES[failure.type].color + pad(short_status, 20) + ANSI_RESET,
|
||||
];
|
||||
if (failure.time_simulated !== null) {
|
||||
parts.push("ran for" + util.format_duration(failure.time_simulated).padStart(6, " "));
|
||||
}
|
||||
if (failure.type === 'failure') {
|
||||
parts.push(" with" + util.format_duration(failure.time_expected - failure.time_simulated).padStart(6, " ") + " still to go");
|
||||
}
|
||||
stdout.write(parts.join(" ") + "\n");
|
||||
}
|
||||
|
||||
return {
|
||||
num_passed,
|
||||
num_missing,
|
||||
num_failed: failures.length,
|
||||
// FIXME should maybe count the thread time if we care about actual game speedup
|
||||
time_elapsed: total_real_elapsed,
|
||||
time_simulated: total_tics / 20,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
const USAGE = `\
|
||||
@ -330,7 +472,7 @@ may be run with different compat modes.
|
||||
don't support built-in replays, this must be a TWS file
|
||||
-l level range to play back; either 'all' or a string like '1-4,10'
|
||||
-f force the next argument to be interpreted as a file path, if for
|
||||
some perverse reason you have a level file named '-c'
|
||||
some perverse reason you have a level file named '-c'
|
||||
-h, --help ignore other arguments and show this message
|
||||
|
||||
Supports the same filetypes as Lexy's Labyrinth: DAT/CCL, C2M, or a directory
|
||||
@ -429,37 +571,7 @@ async function main() {
|
||||
time_simulated: 0,
|
||||
};
|
||||
for (let testdef of tests) {
|
||||
let pack;
|
||||
if ((await stat(testdef.pack_path)).isDirectory()) {
|
||||
let source = new LocalDirectorySource(testdef.pack_path);
|
||||
pack = await _scan_source(source);
|
||||
}
|
||||
else {
|
||||
let pack_data = await readFile(testdef.pack_path);
|
||||
if (testdef.pack_path.match(/[.]zip$/)) {
|
||||
let source = new util.ZipFileSource(pack_data.buffer);
|
||||
pack = await _scan_source(source);
|
||||
}
|
||||
else {
|
||||
pack = format_dat.parse_game(pack_data.buffer);
|
||||
|
||||
let solutions_data = await readFile(testdef.solutions_path);
|
||||
let solutions = format_tws.parse_solutions(solutions_data.buffer);
|
||||
pack.level_replays = solutions.levels;
|
||||
}
|
||||
}
|
||||
|
||||
if (! pack.title) {
|
||||
let match = testdef.pack_path.match(/(?:^|\/)([^/.]+)(?:\..*)?\/?$/);
|
||||
if (match) {
|
||||
pack.title = match[1];
|
||||
}
|
||||
else {
|
||||
pack.title = testdef.pack_path;
|
||||
}
|
||||
}
|
||||
|
||||
let result = await test_pack(pack, testdef.ruleset, testdef.level_filter);
|
||||
let result = await test_pack(testdef);
|
||||
for (let key of Object.keys(overall)) {
|
||||
overall[key] += result[key];
|
||||
}
|
||||
@ -471,4 +583,10 @@ async function main() {
|
||||
stdout.write(`Simulated ${util.format_duration(overall.time_simulated)} of game time in ${util.format_duration(overall.time_elapsed)}, speed of ${(overall.time_simulated / overall.time_elapsed).toFixed(1)}×\n`);
|
||||
|
||||
}
|
||||
main();
|
||||
|
||||
if (isMainThread) {
|
||||
main();
|
||||
}
|
||||
else {
|
||||
main_worker(workerData);
|
||||
}
|
||||
|
||||
50
js/headless/lib.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { opendir, readFile } from 'fs/promises';
|
||||
|
||||
//import canvas from 'canvas';
|
||||
|
||||
//import CanvasRenderer from '../renderer-canvas.js';
|
||||
import * as util from '../util.js';
|
||||
|
||||
/*
|
||||
export class NodeCanvasRenderer extends CanvasRenderer {
|
||||
static make_canvas(w, h) {
|
||||
return canvas.createCanvas(w, h);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
export class LocalDirectorySource extends util.FileSource {
|
||||
constructor(root) {
|
||||
super();
|
||||
this.root = root;
|
||||
this.files = {};
|
||||
this._loaded_promise = this._scan_dir('/');
|
||||
}
|
||||
|
||||
async _scan_dir(path) {
|
||||
let dir = await opendir(this.root + path);
|
||||
for await (let dirent of dir) {
|
||||
if (dirent.isDirectory()) {
|
||||
await this._scan_dir(path + dirent.name + '/');
|
||||
}
|
||||
else {
|
||||
let filepath = path + dirent.name;
|
||||
this.files[filepath.toLowerCase()] = filepath;
|
||||
if (this.files.size > 2000)
|
||||
throw `way, way too many files in local directory source ${this.root}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
let realpath = this.files[path.toLowerCase()];
|
||||
if (realpath) {
|
||||
return (await readFile(this.root + realpath)).buffer;
|
||||
}
|
||||
else {
|
||||
throw new Error(`No such file: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
70
js/headless/render.mjs
Normal file
@ -0,0 +1,70 @@
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import * as process from 'process';
|
||||
|
||||
import canvas from 'canvas';
|
||||
import minimist from 'minimist';
|
||||
|
||||
import * as format_c2g from '../format-c2g.js';
|
||||
import { infer_tileset_from_image } from '../tileset.js';
|
||||
import { NodeCanvasRenderer } from './lib.js';
|
||||
|
||||
|
||||
const USAGE = `\
|
||||
Usage: render.mjs [OPTION]... LEVELFILE OUTFILE
|
||||
Renders the level contained in LEVELFILE to a PNG and saves it to OUTFILE.
|
||||
|
||||
Arguments:
|
||||
-t FILE path to a tileset to use
|
||||
-e render in editor mode: use the revealed forms of tiles and
|
||||
show facing directions
|
||||
-l NUM choose the level number to render, if LEVELFILE is a pack
|
||||
[default: 1]
|
||||
-r REGION specify the region to render; see below
|
||||
|
||||
REGION may be one of:
|
||||
initial an area the size of the level's viewport, centered on the
|
||||
player's initial position
|
||||
all the entire level
|
||||
WxH an area W by H, centered on the player's initial position
|
||||
...etc...
|
||||
`;
|
||||
async function main() {
|
||||
let args = minimist(process.argv.slice(2), {
|
||||
alias: {
|
||||
tileset: ['t'],
|
||||
},
|
||||
});
|
||||
// assert _.length is 2
|
||||
let [pack_path, dest_path] = args._;
|
||||
|
||||
// TODO i need a more consistent and coherent way to turn a path into a level pack, currently
|
||||
// this is only a single c2m
|
||||
let pack_data = await readFile(pack_path);
|
||||
let stored_level = format_c2g.parse_level(pack_data.buffer);
|
||||
|
||||
let img = await canvas.loadImage(args.tileset ?? 'tileset-lexy.png');
|
||||
let tileset = infer_tileset_from_image(img);
|
||||
let renderer = new NodeCanvasRenderer(tileset);
|
||||
renderer.set_level(stored_level);
|
||||
|
||||
let i = stored_level.linear_cells.findIndex(cell => cell.some(tile => tile && tile.type.is_real_player));
|
||||
if (i < 0) {
|
||||
console.log("???");
|
||||
process.stderr.write("error: no players in this level\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let [x, y] = stored_level.scalar_to_coords(i);
|
||||
let w = stored_level.viewport_size;
|
||||
let h = w;
|
||||
|
||||
// TODO this is probably duplicated from the renderer, and could also be reused in the editor
|
||||
// TODO handle a map smaller than the viewport
|
||||
let x0 = Math.max(0, x - w / 2);
|
||||
let y0 = Math.max(0, y - h / 2);
|
||||
renderer.draw_static_region(x0, y0, x0 + w, y0 + h);
|
||||
|
||||
await writeFile(dest_path, renderer.canvas.toBuffer());
|
||||
}
|
||||
|
||||
main();
|
||||
@ -234,8 +234,11 @@ export class DialogOverlay extends Overlay {
|
||||
this.header.append(mk('h1', {}, title));
|
||||
}
|
||||
|
||||
add_button(label, onclick) {
|
||||
add_button(label, onclick, is_default) {
|
||||
let button = mk('button', {type: 'button'}, label);
|
||||
if (is_default) {
|
||||
button.classList.add('button-bright');
|
||||
}
|
||||
button.addEventListener('click', onclick);
|
||||
this.footer.append(button);
|
||||
return button;
|
||||
@ -252,9 +255,9 @@ export class AlertOverlay extends DialogOverlay {
|
||||
super(conductor);
|
||||
this.set_title(title);
|
||||
this.main.append(mk('p', {}, message));
|
||||
this.add_button("a'ight", ev => {
|
||||
this.add_button("a'ight", () => {
|
||||
this.close();
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -267,7 +270,7 @@ export class ConfirmOverlay extends DialogOverlay {
|
||||
this.add_button("yep", ev => {
|
||||
this.close();
|
||||
what();
|
||||
});
|
||||
}, true);
|
||||
this.add_button("nope", ev => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
1352
js/main.js
@ -1,12 +1,12 @@
|
||||
import { DIRECTIONS, LAYERS } from './defs.js';
|
||||
import { mk } from './util.js';
|
||||
import * as util from './util.js';
|
||||
import { DrawPacket } from './tileset.js';
|
||||
import TILE_TYPES from './tiletypes.js';
|
||||
|
||||
class CanvasRendererDrawPacket extends DrawPacket {
|
||||
constructor(renderer, ctx, perception, clock, update_progress, update_rate) {
|
||||
super(perception, renderer.hide_logic, clock, update_progress, update_rate);
|
||||
this.renderer = renderer;
|
||||
export class CanvasDrawPacket extends DrawPacket {
|
||||
constructor(tileset, ctx, perception, hide_logic, clock, update_progress, update_rate) {
|
||||
super(perception, hide_logic, clock, update_progress, update_rate);
|
||||
this.tileset = tileset;
|
||||
this.ctx = ctx;
|
||||
// Canvas position of the cell being drawn
|
||||
this.x = 0;
|
||||
@ -14,20 +14,17 @@ class CanvasRendererDrawPacket extends DrawPacket {
|
||||
// Offset within the cell, for actors in motion
|
||||
this.offsetx = 0;
|
||||
this.offsety = 0;
|
||||
// Compatibility settings
|
||||
this.use_cc2_anim_speed = renderer.use_cc2_anim_speed;
|
||||
this.show_facing = renderer.show_facing;
|
||||
}
|
||||
|
||||
blit(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) {
|
||||
this.renderer.blit(this.ctx,
|
||||
this.tileset.blit_to_canvas(this.ctx,
|
||||
tx + mx, ty + my,
|
||||
this.x + this.offsetx + mdx, this.y + this.offsety + mdy,
|
||||
mw, mh);
|
||||
}
|
||||
|
||||
blit_aligned(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) {
|
||||
this.renderer.blit(this.ctx,
|
||||
this.tileset.blit_to_canvas(this.ctx,
|
||||
tx + mx, ty + my,
|
||||
this.x + mdx, this.y + mdy,
|
||||
mw, mh);
|
||||
@ -76,7 +73,42 @@ export class CanvasRenderer {
|
||||
|
||||
// This is here so command-line Node stuff can swap it out for the canvas package
|
||||
static make_canvas(w, h) {
|
||||
return mk('canvas', {width: w, height: h});
|
||||
return util.mk('canvas', {width: w, height: h});
|
||||
}
|
||||
|
||||
// Draw a single tile, or even the name of a tile type. Either a canvas or a context may be given.
|
||||
// If neither is given, a new canvas is returned.
|
||||
static draw_single_tile(tileset, name_or_tile, canvas = null, x = 0, y = 0) {
|
||||
let ctx;
|
||||
if (! canvas) {
|
||||
canvas = this.make_canvas(tileset.size_x, tileset.size_y);
|
||||
ctx = canvas.getContext('2d');
|
||||
}
|
||||
else if (canvas instanceof CanvasRenderingContext2D) {
|
||||
ctx = canvas;
|
||||
canvas = ctx.canvas;
|
||||
}
|
||||
else {
|
||||
ctx = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
let name, tile;
|
||||
if (typeof name_or_tile === 'string' || name_or_tile instanceof String) {
|
||||
name = name_or_tile;
|
||||
tile = null;
|
||||
}
|
||||
else {
|
||||
tile = name_or_tile;
|
||||
name = tile.type.name;
|
||||
}
|
||||
|
||||
// Individual tile types always reveal what they are
|
||||
let packet = new CanvasDrawPacket(tileset, ctx, 'palette');
|
||||
packet.x = x;
|
||||
packet.y = y;
|
||||
tileset.draw_type(name, tile, packet);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
set_level(level) {
|
||||
@ -148,16 +180,6 @@ export class CanvasRenderer {
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
// Draw to a canvas using tile coordinates
|
||||
blit(ctx, sx, sy, dx, dy, w = 1, h = w) {
|
||||
let tw = this.tileset.size_x;
|
||||
let th = this.tileset.size_y;
|
||||
ctx.drawImage(
|
||||
this.tileset.image,
|
||||
sx * tw, sy * th, w * tw, h * th,
|
||||
dx * tw, dy * th, w * tw, h * th);
|
||||
}
|
||||
|
||||
_adjust_viewport_if_dirty() {
|
||||
if (! this.viewport_dirty)
|
||||
return;
|
||||
@ -185,8 +207,11 @@ export class CanvasRenderer {
|
||||
// game starts, because we're trying to interpolate backwards from 0, hence the Math.max()
|
||||
let clock = (this.level.tic_counter ?? 0) + (
|
||||
(this.level.frame_offset ?? 0) + (update_progress - 1) * this.update_rate) / 3;
|
||||
let packet = new CanvasRendererDrawPacket(
|
||||
this, this.ctx, this.perception, Math.max(0, clock), update_progress, this.update_rate);
|
||||
let packet = new CanvasDrawPacket(
|
||||
this.tileset, this.ctx, this.perception, this.hide_logic,
|
||||
Math.max(0, clock), update_progress, this.update_rate);
|
||||
packet.use_cc2_anim_speed = this.use_cc2_anim_speed;
|
||||
packet.show_facing = this.show_facing;
|
||||
|
||||
let tw = this.tileset.size_x;
|
||||
let th = this.tileset.size_y;
|
||||
@ -355,6 +380,8 @@ export class CanvasRenderer {
|
||||
draw_rewind_effect(clock) {
|
||||
// Shift several rows over in a recurring pattern, like a VHS, whatever that is
|
||||
let rewind_start = clock / 20 % 1;
|
||||
// Draw noisy white stripes in there too
|
||||
this.ctx.save();
|
||||
for (let chunk = 0; chunk < 4; chunk++) {
|
||||
let y = Math.floor(this.canvas.height * (chunk + rewind_start) / 4);
|
||||
for (let dy = 1; dy < 5; dy++) {
|
||||
@ -362,8 +389,22 @@ export class CanvasRenderer {
|
||||
this.canvas,
|
||||
0, y + dy, this.canvas.width, 1,
|
||||
-dy * dy, y + dy, this.canvas.width, 1);
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, y + dy + 0.5);
|
||||
this.ctx.lineTo(this.canvas.width, y + dy + 0.5);
|
||||
let alpha = (0.9 - y / this.canvas.height * 0.25) * ((dy - 1) / 3);
|
||||
this.ctx.strokeStyle = `rgba(100%, 100%, 100%, ${alpha})`;
|
||||
this.ctx.setLineDash([
|
||||
util.random_range(4, 20),
|
||||
util.random_range(2, 6),
|
||||
util.random_range(4, 20),
|
||||
util.random_range(2, 6),
|
||||
]);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
}
|
||||
this.ctx.restore();
|
||||
}
|
||||
|
||||
// Used by the editor and map previews. Draws a region of the level (probably a StoredLevel),
|
||||
@ -386,7 +427,8 @@ export class CanvasRenderer {
|
||||
width = width ?? this.level.size_x;
|
||||
cells = cells ?? this.level.linear_cells;
|
||||
|
||||
let packet = new CanvasRendererDrawPacket(this, ctx, perception);
|
||||
let packet = new CanvasDrawPacket(this.tileset, ctx, perception);
|
||||
packet.show_facing = show_facing;
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
let cell = cells[y * width + x];
|
||||
@ -441,7 +483,8 @@ export class CanvasRenderer {
|
||||
let ctx = canvas.getContext('2d');
|
||||
|
||||
// Individual tile types always reveal what they are
|
||||
let packet = new CanvasRendererDrawPacket(this, ctx, 'palette');
|
||||
let packet = new CanvasDrawPacket(this.tileset, ctx, 'palette');
|
||||
packet.show_facing = this.show_facing;
|
||||
packet.x = x;
|
||||
packet.y = y;
|
||||
this.tileset.draw_type(name, tile, packet);
|
||||
|
||||
990
js/tileset.js
1073
js/tiletypes.js
184
js/util.js
@ -4,10 +4,37 @@ import * as fflate from './vendor/fflate.js';
|
||||
export class LLError extends Error {}
|
||||
|
||||
// Random choice
|
||||
export function random_range(a, b = null) {
|
||||
if (b === null) {
|
||||
b = a;
|
||||
a = 0;
|
||||
}
|
||||
return a + Math.floor(Math.random() * (b - a));
|
||||
}
|
||||
|
||||
export function random_choice(list) {
|
||||
return list[Math.floor(Math.random() * list.length)];
|
||||
}
|
||||
|
||||
export function random_shuffle(list) {
|
||||
// Knuth–Fisher–Yates, of course
|
||||
for (let i = list.length - 1; i > 0; i--) {
|
||||
let j = Math.floor(Math.random() * (i + 1));
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
}
|
||||
}
|
||||
|
||||
export function setdefault(map, key, defaulter) {
|
||||
if (map.has(key)) {
|
||||
return map.get(key);
|
||||
}
|
||||
else {
|
||||
let value = defaulter();
|
||||
map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// DOM stuff
|
||||
function _mk(el, children) {
|
||||
@ -167,11 +194,11 @@ export function promise_event(element, success_event, failure_event) {
|
||||
}
|
||||
|
||||
|
||||
export async function fetch(url) {
|
||||
export async function fetch(url, response_type = 'arraybuffer') {
|
||||
let xhr = new XMLHttpRequest;
|
||||
let promise = promise_event(xhr, 'load', 'error');
|
||||
xhr.open('GET', url);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.responseType = response_type;
|
||||
xhr.send();
|
||||
await promise;
|
||||
if (xhr.status !== 200)
|
||||
@ -258,52 +285,56 @@ export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
|
||||
let goal_x = Math.floor(x1);
|
||||
let goal_y = Math.floor(y1);
|
||||
|
||||
// Use a modified Bresenham. Use mirroring to move everything into the
|
||||
// first quadrant, then split it into two octants depending on whether dx
|
||||
// or dy increases faster, and call that the main axis. Track an "error"
|
||||
// value, which is the (negative) distance between the ray and the next
|
||||
// grid line parallel to the main axis, but scaled up by dx. Every
|
||||
// iteration, we move one cell along the main axis and increase the error
|
||||
// value by dy (the ray's slope, scaled up by dx); when it becomes
|
||||
// positive, we can subtract dx (1) and move one cell along the minor axis
|
||||
// as well. Since the main axis is the faster one, we'll never traverse
|
||||
// more than one cell on the minor axis for one cell on the main axis, and
|
||||
// this readily provides every cell the ray hits in order.
|
||||
// Use a modified Bresenham. Use mirroring to move everything into the first quadrant, then
|
||||
// split it into two octants depending on whether dx or dy increases faster, and call that the
|
||||
// main axis. Track an "error" value, which is the (negative) distance between the ray and the
|
||||
// next grid line parallel to the main axis, but scaled up by dx. Every iteration, we move one
|
||||
// cell along the main axis and increase the error value by dy (the ray's slope, scaled up by
|
||||
// dx); when it becomes positive, we can subtract dx (1) and move one cell along the minor axis
|
||||
// as well. Since the main axis is the faster one, we'll never traverse more than one cell on
|
||||
// the minor axis for one cell on the main axis, and this readily provides every cell the ray
|
||||
// hits in order.
|
||||
// Based on: http://www.idav.ucdavis.edu/education/GraphicsNotes/Bresenhams-Algorithm/Bresenhams-Algorithm.html
|
||||
|
||||
// Setup: map to the first quadrant. The "offsets" are the distance
|
||||
// between the starting point and the next grid point.
|
||||
// Setup: map to the first quadrant. The "offsets" are the distance between the starting point
|
||||
// and the next grid point.
|
||||
let step_a = 1;
|
||||
let offset_x = 1 - (x0 - a);
|
||||
if (offset_x === 0) {
|
||||
// Zero offset means we're on a grid line, so we're a full cell away from the next grid line
|
||||
offset_x = 1;
|
||||
}
|
||||
if (dx < 0) {
|
||||
dx = -dx;
|
||||
step_a = -step_a;
|
||||
offset_x = 1 - offset_x;
|
||||
}
|
||||
else if (offset_x === 0) {
|
||||
// Zero offset means we're on a grid line, so we're a full cell away from the next grid line
|
||||
// (if we're moving forward; if we're moving backward, the next cell really is 0 away)
|
||||
offset_x = 1;
|
||||
}
|
||||
let step_b = 1;
|
||||
let offset_y = 1 - (y0 - b);
|
||||
if (offset_y === 0) {
|
||||
offset_y = 1;
|
||||
}
|
||||
if (dy < 0) {
|
||||
dy = -dy;
|
||||
step_b = -step_b;
|
||||
offset_y = 1 - offset_y;
|
||||
}
|
||||
else if (offset_y === 0) {
|
||||
offset_y = 1;
|
||||
}
|
||||
|
||||
let err = dy * offset_x - dx * offset_y;
|
||||
|
||||
if (dx > dy) {
|
||||
// Main axis is x/a
|
||||
while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) {
|
||||
yield [a, b];
|
||||
if (a === goal_x && b === goal_y)
|
||||
if (a === goal_x && b === goal_y) {
|
||||
yield [a, b];
|
||||
return;
|
||||
}
|
||||
// When we go exactly through a corner, we cross two grid lines, but between them we
|
||||
// enter a cell the line doesn't actually pass through. That happens here, when err ===
|
||||
// dx, because it was 0 last loop
|
||||
if (err !== dy) {
|
||||
yield [a, b];
|
||||
}
|
||||
|
||||
if (err > 0) {
|
||||
err -= dx;
|
||||
@ -320,9 +351,13 @@ export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
|
||||
err = -err;
|
||||
// Main axis is y/b
|
||||
while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) {
|
||||
yield [a, b];
|
||||
if (a === goal_x && b === goal_y)
|
||||
if (a === goal_x && b === goal_y) {
|
||||
yield [a, b];
|
||||
return;
|
||||
}
|
||||
if (err !== dx) {
|
||||
yield [a, b];
|
||||
}
|
||||
|
||||
if (err > 0) {
|
||||
err -= dy;
|
||||
@ -337,6 +372,33 @@ export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Baby's first bit vector
|
||||
export class BitVector {
|
||||
constructor(size) {
|
||||
this.array = new Uint32Array(Math.ceil(size / 32));
|
||||
}
|
||||
|
||||
get(bit) {
|
||||
let i = Math.floor(bit / 32);
|
||||
let b = bit % 32;
|
||||
return (this.array[i] & (1 << b)) !== 0;
|
||||
}
|
||||
|
||||
set(bit) {
|
||||
let i = Math.floor(bit / 32);
|
||||
let b = bit % 32;
|
||||
this.array[i] |= (1 << b);
|
||||
}
|
||||
|
||||
clear(bit) {
|
||||
let i = Math.floor(bit / 32);
|
||||
let b = bit % 32;
|
||||
this.array[i] &= ~(1 << b);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Root class to indirect over where we might get files from
|
||||
// - a pool of uploaded in-memory files
|
||||
// - a single uploaded zip file
|
||||
@ -349,6 +411,9 @@ export class FileSource {
|
||||
|
||||
// Get a file's contents as an ArrayBuffer
|
||||
async get(path) {}
|
||||
|
||||
// Get a list of all files under here, recursively
|
||||
// async *iter_all_files() {}
|
||||
}
|
||||
// Files we have had uploaded one at a time (note that each upload becomes its own source)
|
||||
export class FileFileSource extends FileSource {
|
||||
@ -369,6 +434,10 @@ export class FileFileSource extends FileSource {
|
||||
return Promise.reject(new Error(`No such file was provided: ${path}`));
|
||||
}
|
||||
}
|
||||
|
||||
iter_all_files() {
|
||||
return Object.keys(this.files);
|
||||
}
|
||||
}
|
||||
// Regular HTTP fetch
|
||||
export class HTTPFileSource extends FileSource {
|
||||
@ -383,6 +452,58 @@ export class HTTPFileSource extends FileSource {
|
||||
return fetch(url);
|
||||
}
|
||||
}
|
||||
// Regular HTTP fetch, but for a directory structure from nginx's index module
|
||||
export class HTTPNginxDirectorySource extends FileSource {
|
||||
// Should be given a URL object as a root
|
||||
constructor(root) {
|
||||
super();
|
||||
this.root = root;
|
||||
if (! this.root.pathname.endsWith('/')) {
|
||||
this.root.pathname += '/';
|
||||
}
|
||||
}
|
||||
|
||||
get(path) {
|
||||
// TODO should strip off multiple of these
|
||||
// TODO and canonicalize, and disallow going upwards
|
||||
if (path.startsWith('/')) {
|
||||
path = path.substring(1);
|
||||
}
|
||||
let url = new URL(path, this.root);
|
||||
return fetch(url);
|
||||
}
|
||||
|
||||
async *iter_all_files() {
|
||||
let fetch_count = 0;
|
||||
let paths = [''];
|
||||
while (paths.length > 0) {
|
||||
let next_paths = [];
|
||||
for (let path of paths) {
|
||||
if (fetch_count >= 50) {
|
||||
throw new Error("Too many subdirectories to fetch one at a time; is this really a single CC2 set?");
|
||||
}
|
||||
let response = await fetch(new URL(path, this.root), 'text');
|
||||
fetch_count += 1;
|
||||
let doc = document.implementation.createHTMLDocument();
|
||||
doc.write(response);
|
||||
doc.close();
|
||||
for (let link of doc.querySelectorAll('a')) {
|
||||
let subpath = link.getAttribute('href');
|
||||
if (subpath === '../') {
|
||||
continue;
|
||||
}
|
||||
else if (subpath.endsWith('/')) {
|
||||
next_paths.push(path + subpath);
|
||||
}
|
||||
else {
|
||||
yield path + subpath;
|
||||
}
|
||||
}
|
||||
}
|
||||
paths = next_paths;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@ -433,6 +554,11 @@ export class EntryFileSource extends FileSource {
|
||||
let file = await new Promise((res, rej) => entry.file(res, rej));
|
||||
return await file.arrayBuffer();
|
||||
}
|
||||
|
||||
async iter_all_files() {
|
||||
await this._loaded_promise;
|
||||
return Object.keys(this.files);
|
||||
}
|
||||
}
|
||||
// Zip files, using fflate
|
||||
// TODO somewhat unfortunately fflate only supports unzipping the whole thing at once, not
|
||||
@ -455,4 +581,8 @@ export class ZipFileSource extends FileSource {
|
||||
|
||||
return file.buffer;
|
||||
}
|
||||
|
||||
iter_all_files() {
|
||||
return Object.keys(this.files);
|
||||
}
|
||||
}
|
||||
|
||||
BIN
levels/CCLP5.ccl
Normal file
BIN
levels/previews/cclp5.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
og-preview.png
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
BIN
sfx/get-tool.ogg
BIN
sfx/mmf2.ogg
Normal file
BIN
sfx/step-dirt.ogg
Normal file
BIN
sfx/step-floor1.ogg
Normal file
BIN
sfx/step-floor2.ogg
Normal file
480
style.css
@ -12,18 +12,22 @@ body {
|
||||
|
||||
font-family: Ubuntu, Source Sans Pro, DejaVu Sans, sans-serif;
|
||||
line-height: 1.33;
|
||||
background: hsl(220, 5%, 5%);
|
||||
background: hsl(var(--main-hue), 5%, 5%);
|
||||
background-image: url(background.svg);
|
||||
background-size: 12em;
|
||||
color: #ececec;
|
||||
|
||||
--panel-bg-color: hsl(220, 10%, 15%);
|
||||
--button-bg-color: hsl(220, 20%, 25%);
|
||||
--main-hue: 340;
|
||||
--hover-hue: 15;
|
||||
--selected-hue: 320;
|
||||
--panel-bg-color: hsl(var(--main-hue), 30%, 12.5%);
|
||||
--button-bg-color: hsl(var(--main-hue), 60%, 25%);
|
||||
--button-bg-gradient: linear-gradient(to bottom, var(--button-bg-shadow-color), transparent 75%);
|
||||
--button-bg-shadow-color: #fff1;
|
||||
--button-bg-hover-color: hsl(220, 30%, 30%);
|
||||
--generic-bg-hover-on-white: hsl(220, 60%, 90%);
|
||||
--generic-bg-selected-on-white: hsl(220, 60%, 85%);
|
||||
--generic-border-selected-on-white: hsl(220, 60%, 75%);
|
||||
--button-bg-hover-color: hsl(var(--hover-hue), 70%, 35%);
|
||||
--generic-bg-hover-on-white: hsl(var(--hover-hue), 90%, 85%);
|
||||
--generic-bg-selected-on-white: hsl(var(--selected-hue), 50%, 85%);
|
||||
--generic-border-selected-on-white: hsl(var(--selected-hue), 60%, 75%);
|
||||
}
|
||||
|
||||
/* Generic element styling */
|
||||
@ -33,6 +37,7 @@ main[hidden] {
|
||||
input[type=radio],
|
||||
input[type=checkbox],
|
||||
input[type=range] {
|
||||
font-size: inherit;
|
||||
margin: 0.125em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@ -49,12 +54,13 @@ button,
|
||||
font-family: inherit;
|
||||
color: white;
|
||||
background-color: var(--button-bg-color);
|
||||
background-image: linear-gradient(to bottom, var(--button-bg-shadow-color), transparent 75%);
|
||||
border: 1px solid hsl(220, 10%, 7.5%);
|
||||
background-image: var(--button-bg-gradient);
|
||||
border: 1px solid hsl(var(--main-hue), 10%, 2.5%);
|
||||
box-shadow:
|
||||
inset 0 0 1px 1px #fff2,
|
||||
0 1px 1px hsl(220, 10%, 7.5%);
|
||||
inset 0 0 0 1px #ffffff18,
|
||||
0 1px 1px hsl(var(--main-hue), 10%, 7.5%);
|
||||
border-radius: 0.25em;
|
||||
text-shadow: 0 1px 0 #0006;
|
||||
text-transform: lowercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
@ -69,13 +75,13 @@ button:active,
|
||||
z-index: 1;
|
||||
}
|
||||
button:enabled.button-bright {
|
||||
background-color: hsl(220, 50%, 25%);
|
||||
background-color: hsl(var(--main-hue), 60%, 40%);
|
||||
}
|
||||
button:enabled.button-bright:hover {
|
||||
background-color: hsl(220, 70%, 30%);
|
||||
background-color: hsl(var(--hover-hue), 65%, 50%);
|
||||
}
|
||||
button:disabled {
|
||||
color: #606060;
|
||||
color: #808080;
|
||||
background-color: #202020;
|
||||
cursor: auto;
|
||||
}
|
||||
@ -86,7 +92,7 @@ button.button-big {
|
||||
padding: 1em;
|
||||
}
|
||||
button.--button-glow-ok {
|
||||
background: hsl(220, 100%, 50%);
|
||||
background: hsl(var(--main-hue), 100%, 50%);
|
||||
}
|
||||
button.--button-glow {
|
||||
transition: background-color 0.5s ease-out;
|
||||
@ -100,6 +106,9 @@ button.--image {
|
||||
button.--image img {
|
||||
display: block;
|
||||
}
|
||||
select {
|
||||
font-size: inherit;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
@ -149,17 +158,17 @@ a:visited {
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
a:link {
|
||||
color: hsl(220, 50%, 75%);
|
||||
color: hsl(var(--main-hue), 80%, 75%);
|
||||
}
|
||||
a:visited {
|
||||
color: hsl(255, 50%, 75%);
|
||||
color: hsl(270, 80%, 75%);
|
||||
}
|
||||
a:link:hover,
|
||||
a:visited:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:active {
|
||||
color: hsl(0, 50%, 60%);
|
||||
color: hsl(0, 90%, 75%);
|
||||
}
|
||||
|
||||
svg#svg-iconsheet {
|
||||
@ -207,11 +216,11 @@ svg.svg-icon {
|
||||
}
|
||||
button.--pressed,
|
||||
.radio-faux-button-set > label > input:checked + span {
|
||||
background: hsl(220, 80%, 50%);
|
||||
background: hsl(var(--main-hue), 80%, 50%);
|
||||
box-shadow:
|
||||
inset 0 1px 3px 1px hsl(220, 50%, 15%),
|
||||
inset 0 0.25em 1em 0.5em hsl(220, 50%, 30%),
|
||||
0 1px 1px hsl(220, 10%, 10%)
|
||||
inset 0 1px 3px 1px hsl(var(--main-hue), 50%, 15%),
|
||||
inset 0 0.25em 1em 0.5em hsl(var(--main-hue), 50%, 30%),
|
||||
0 1px 1px hsl(var(--main-hue), 10%, 10%)
|
||||
}
|
||||
|
||||
.button-row {
|
||||
@ -251,7 +260,7 @@ button.--pressed,
|
||||
min-width: 10vw;
|
||||
border: 1px solid #444;
|
||||
color: black;
|
||||
background: hsl(220, 20%, 95%);
|
||||
background: hsl(var(--main-hue), 20%, 95%);
|
||||
box-shadow: 0 1px 3px 1px #0009;
|
||||
}
|
||||
.popup-menu > li {
|
||||
@ -259,8 +268,8 @@ button.--pressed,
|
||||
cursor: pointer;
|
||||
}
|
||||
.popup-menu > li:hover {
|
||||
color: hsl(220, 90%, 10%);
|
||||
background: hsl(220, 90%, 75%);
|
||||
color: hsl(var(--hover-hue), 60%, 10%);
|
||||
background: var(--generic-bg-hover-on-white);
|
||||
}
|
||||
.dialog {
|
||||
display: flex;
|
||||
@ -272,16 +281,20 @@ button.--pressed,
|
||||
border: 1px solid black;
|
||||
color: black;
|
||||
background: #f4f4f4;
|
||||
box-shadow: 0 1px 3px #000c;
|
||||
box-shadow: 0 1px 6px #000c;
|
||||
}
|
||||
.dialog > header {
|
||||
padding: 0.5em;
|
||||
line-height: 1;
|
||||
background: hsl(220, 20%, 40%);
|
||||
background: linear-gradient(
|
||||
hsl(var(--main-hue), 40%, 50%),
|
||||
hsl(var(--main-hue), 50%, 45%));
|
||||
border-bottom: 1px solid hsl(var(--main-hue), 60%, 30%);
|
||||
color: white;
|
||||
text-shadow: 0 2px 0 #0006;
|
||||
}
|
||||
.dialog > header h1 {
|
||||
font-size: 1em;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
.dialog > footer {
|
||||
display: flex;
|
||||
@ -309,11 +322,21 @@ button.--pressed,
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
.dialog a:link {
|
||||
color: hsl(220, 50%, 50%);
|
||||
color: hsl(var(--main-hue), 50%, 50%);
|
||||
}
|
||||
.dialog a:visited {
|
||||
color: hsl(255, 50%, 50%);
|
||||
}
|
||||
.dialog code {
|
||||
color: hsl(var(--main-hue), 50%, 30%);
|
||||
}
|
||||
.dialog h2 {
|
||||
color: hsl(var(--main-hue), 75%, 25%);
|
||||
border-bottom: 1px dotted hsl(var(--main-hue), 50%, 40%);
|
||||
}
|
||||
.dialog h2:nth-child(n+1) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
dl.formgrid {
|
||||
display: grid;
|
||||
grid: auto-flow min-content / 1fr 4fr;
|
||||
@ -324,7 +347,7 @@ dl.formgrid {
|
||||
dl.formgrid > dt {
|
||||
grid-column: 1;
|
||||
text-align: right;
|
||||
color: hsl(220, 50%, 25%);
|
||||
color: hsl(var(--main-hue), 50%, 25%);
|
||||
}
|
||||
dl.formgrid > dd {
|
||||
grid-column: 2;
|
||||
@ -366,7 +389,7 @@ table.level-browser thead {
|
||||
background: #f4f4f4; /* match dialog background */
|
||||
}
|
||||
table.level-browser thead tr th {
|
||||
border-bottom: 2px solid hsl(220, 20%, 60%);
|
||||
border-bottom: 2px solid hsl(var(--main-hue), 20%, 60%);
|
||||
}
|
||||
table.level-browser tfoot {
|
||||
position: sticky;
|
||||
@ -374,7 +397,7 @@ table.level-browser tfoot {
|
||||
background: #f4f4f4; /* match dialog background */
|
||||
}
|
||||
table.level-browser tfoot tr th {
|
||||
border-top: 2px solid hsl(220, 20%, 60%);
|
||||
border-top: 2px solid hsl(var(--main-hue), 20%, 60%);
|
||||
text-align: right;
|
||||
}
|
||||
table.level-browser th,
|
||||
@ -413,7 +436,7 @@ table.level-browser tbody tr:hover {
|
||||
background: var(--generic-bg-hover-on-white);
|
||||
}
|
||||
table.level-browser tbody tr:nth-child(10n) td {
|
||||
border-bottom: 2px solid hsl(220, 20%, 80%);
|
||||
border-bottom: 2px solid hsl(var(--main-hue), 20%, 80%);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
/* Unique media query: this is only necessary for VERY narrow screens */
|
||||
@ -478,10 +501,10 @@ table.level-browser tbody tr:nth-child(10n) td {
|
||||
border: none;
|
||||
}
|
||||
table.level-browser thead tr {
|
||||
border-bottom: 2px solid hsl(220, 20%, 60%);
|
||||
border-bottom: 2px solid hsl(var(--main-hue), 20%, 60%);
|
||||
}
|
||||
table.level-browser tfoot tr {
|
||||
border-top: 2px solid hsl(220, 20%, 60%);
|
||||
border-top: 2px solid hsl(var(--main-hue), 20%, 60%);
|
||||
}
|
||||
table.level-browser tbody tr {
|
||||
border-bottom: 1px solid #ddd;
|
||||
@ -490,7 +513,7 @@ table.level-browser tbody tr:nth-child(10n) td {
|
||||
border: none;
|
||||
}
|
||||
table.level-browser tbody tr:nth-child(10n) {
|
||||
border-bottom: 2px solid hsl(220, 20%, 80%);
|
||||
border-bottom: 2px solid hsl(var(--main-hue), 20%, 80%);
|
||||
}
|
||||
}
|
||||
|
||||
@ -512,6 +535,9 @@ ul.compat-flags > li > label {
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
}
|
||||
ul.compat-flags > li > label > input[type=check] {
|
||||
margin: 0.25em;
|
||||
}
|
||||
ul.compat-flags > li > label > span.-desc {
|
||||
flex: 1;
|
||||
}
|
||||
@ -574,7 +600,26 @@ img.compat-icon,
|
||||
.option-volume > input[type=range] {
|
||||
flex: auto;
|
||||
}
|
||||
.option-tileset canvas {
|
||||
table.option-tilesets th,
|
||||
table.option-tilesets td {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
table.option-tilesets > tr > .-format {
|
||||
font-size: 0.75em;
|
||||
text-align: center;
|
||||
}
|
||||
table.option-tilesets > tr > .-slot {
|
||||
padding: 0;
|
||||
}
|
||||
table.option-tilesets > tr > .-slot > label {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
padding: 0.5em 1em;
|
||||
place-items: center;
|
||||
}
|
||||
table.option-tilesets canvas {
|
||||
vertical-align: middle;
|
||||
}
|
||||
label.option {
|
||||
@ -597,6 +642,9 @@ label.option .option-label {
|
||||
.option-help.--visible {
|
||||
/* TODO */
|
||||
}
|
||||
.dialog-options input[type=file][name=custom-tileset] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.dialog {
|
||||
@ -714,6 +762,11 @@ pre.stack-trace {
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
#main-compat > img {
|
||||
height: 16px;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
@media (orientation: portrait) and (max-width: 800px), (orientation: landscape) and (max-height: 600px) {
|
||||
body > header {
|
||||
@ -831,9 +884,11 @@ pre.stack-trace {
|
||||
height: auto;
|
||||
}
|
||||
#splash h2 {
|
||||
border-bottom: 1px solid #404040;
|
||||
color: #909090;
|
||||
text-shadow: 0 1px #0004;
|
||||
color: hsl(var(--main-hue), 40%, 90%);
|
||||
text-shadow: 0 1px #000c;
|
||||
background: hsl(var(--main-hue), 40%, 25%);
|
||||
margin: -1rem -1rem 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
#splash * + h2 {
|
||||
margin-top: 1rem;
|
||||
@ -970,7 +1025,9 @@ pre.stack-trace {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0.25em;
|
||||
border: 1px solid hsl(220, 25%, 40%);
|
||||
border: 1px solid hsl(var(--main-hue), 40%, 40%);
|
||||
background: hsl(var(--main-hue), 30%, 5%);
|
||||
box-shadow: 0 0 1px 1px #0009;
|
||||
text-shadow: 0 1px 1px black;
|
||||
text-align: center;
|
||||
}
|
||||
@ -985,11 +1042,11 @@ pre.stack-trace {
|
||||
}
|
||||
.played-pack-list .-progress > .-levels::before {
|
||||
width: calc(var(--cleared) * 100%);
|
||||
background: hsl(220, 25%, 30%);
|
||||
background: hsl(var(--main-hue), 50%, 25%);
|
||||
}
|
||||
.played-pack-list .-progress > .-levels::after {
|
||||
width: calc(var(--aidless) * 100%);
|
||||
background: hsl(220, 25%, 40%);
|
||||
background: hsl(var(--main-hue), 40%, 40%);
|
||||
}
|
||||
.played-pack-list .-progress > .-score {
|
||||
grid-area: score;
|
||||
@ -997,16 +1054,15 @@ pre.stack-trace {
|
||||
.played-pack-list .-progress > .-time {
|
||||
grid-area: time;
|
||||
}
|
||||
.played-pack-list .-progress > .-levels {
|
||||
grid-area: levels;
|
||||
}
|
||||
.played-pack-list .-progress > .-score::before {
|
||||
content: "Score: ";
|
||||
color: #909090;
|
||||
}
|
||||
.played-pack-list .-progress > .-time::before {
|
||||
content: "Time: ";
|
||||
color: #909090;
|
||||
}
|
||||
.played-pack-list .-progress > .-score::before,
|
||||
.played-pack-list .-progress > .-time::before {
|
||||
color: hsl(var(--main-hue), 20%, 50%);
|
||||
}
|
||||
.played-pack-list .-editor-status {
|
||||
display: flex;
|
||||
@ -1129,7 +1185,7 @@ ol.packtest-summary > li {
|
||||
font-weight: bold;
|
||||
}
|
||||
.packtest-dialog .grade-B {
|
||||
color: hsl(220, 60%, 45%);
|
||||
color: hsl(var(--main-hue), 60%, 45%);
|
||||
font-weight: bold;
|
||||
}
|
||||
.packtest-dialog .grade-C {
|
||||
@ -1234,6 +1290,13 @@ ol.packtest-summary > li {
|
||||
#player button:disabled .keyhint {
|
||||
display: none;
|
||||
}
|
||||
#player-controls button:enabled.control-restart {
|
||||
/* Special shenanigans for holding R to restart */
|
||||
--restart-progress: 0;
|
||||
background-image: var(--button-bg-gradient), conic-gradient(
|
||||
hsl(345, 60%, 40%) 0deg calc(var(--restart-progress) * 360deg),
|
||||
transparent calc(var(--restart-progress) * 360deg) 360deg)
|
||||
}
|
||||
@media (orientation: portrait) {
|
||||
/* On a portrait screen, put the controls on top */
|
||||
#player-main {
|
||||
@ -1256,17 +1319,9 @@ ol.packtest-summary > li {
|
||||
padding: 0.25em 0.5em;
|
||||
line-height: 1.33;
|
||||
}
|
||||
/* Hackily remove the <br>s in "turn based mode" */
|
||||
#player-controls .radio-faux-button-set br {
|
||||
display: none;
|
||||
}
|
||||
#player-actions {
|
||||
justify-content: end;
|
||||
}
|
||||
#player-actions button svg {
|
||||
display: inline-block;
|
||||
margin: 0.125em;
|
||||
}
|
||||
#player button .keyhint {
|
||||
top: -2em;
|
||||
left: 0;
|
||||
@ -1301,6 +1356,10 @@ ol.packtest-summary > li {
|
||||
/* Hide key hints; there's nowhere to put them and they take up surprisingly a lot of space */
|
||||
display: none;
|
||||
}
|
||||
#player-controls .radio-faux-button-set span {
|
||||
/* "step mode" is real big */
|
||||
font-size: 0.75em;
|
||||
}
|
||||
}
|
||||
@media (orientation: landscape) and (max-height: 600px) {
|
||||
/* On a small landscape screen, remove the music row (it matters!) */
|
||||
@ -1347,7 +1406,7 @@ ol.packtest-summary > li {
|
||||
row-gap: calc(var(--tile-height) * var(--scale) / 4);
|
||||
|
||||
padding: calc(var(--tile-height) * var(--scale) / 4) calc(var(--tile-width) * var(--scale) / 4);
|
||||
background: hsl(220, 10%, 15%);
|
||||
background: hsl(var(--main-hue), 10%, 15%);
|
||||
box-shadow: 0 0.25em 1em black;
|
||||
}
|
||||
|
||||
@ -1355,7 +1414,7 @@ ol.packtest-summary > li {
|
||||
grid-area: level;
|
||||
|
||||
position: relative;
|
||||
outline: 1px solid hsl(220, 10%, 5%);
|
||||
outline: 1px solid hsl(var(--main-hue), 10%, 5%);
|
||||
}
|
||||
.level canvas {
|
||||
display: block;
|
||||
@ -1372,10 +1431,11 @@ ol.packtest-summary > li {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid:
|
||||
"pack" calc(1.25em * 1.25 * 1)
|
||||
"pack" calc(1em * 1.25 * 1)
|
||||
"level" calc(1.333em * 1.25 * 2)
|
||||
"author" calc(1em * 1.25 * 1)
|
||||
"space" 1fr
|
||||
"score" 1.5em
|
||||
"controls" 1.5em
|
||||
;
|
||||
align-items: center;
|
||||
@ -1405,11 +1465,11 @@ body.--debug .player-overlay-message {
|
||||
.player-overlay-message h1 {
|
||||
/* Pack title, doesn't need to be too big */
|
||||
grid-area: pack;
|
||||
font-size: 1em;
|
||||
font-size: 0.833em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: hsl(220, 25%, 60%);
|
||||
color: hsl(var(--main-hue), 25%, 75%);
|
||||
}
|
||||
.player-overlay-message > h2 {
|
||||
grid-area: level;
|
||||
@ -1425,10 +1485,14 @@ body.--debug .player-overlay-message {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: hsl(220, 10%, 90%);
|
||||
color: hsl(var(--main-hue), 10%, 90%);
|
||||
}
|
||||
.player-overlay-message > .-best-score {
|
||||
grid-area: score;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.player-overlay-message > .scoreboard {
|
||||
grid-row: author / space;
|
||||
grid-row: author / score;
|
||||
}
|
||||
.player-overlay-message .-controls-hint {
|
||||
grid-area: controls;
|
||||
@ -1490,10 +1554,10 @@ body.--debug .player-overlay-message {
|
||||
}
|
||||
.player-overlay-message[data-reason=failure] {
|
||||
background: hsla(330, 20%, 10%, 0.5);
|
||||
background: radial-gradient(#0004, hsla(330, 10%, 10%, 0.5) 40%, hsl(330, 20%, 10%));
|
||||
background: radial-gradient(hsla(330, 10%, 10%, 0.75) 40%, hsl(330, 20%, 10%));
|
||||
}
|
||||
.player-overlay-message[data-reason=success] {
|
||||
background: radial-gradient(hsla(220, 60%, 5%, 0.75), 60%, hsla(220, 60%, 25%, 0.75));
|
||||
background: radial-gradient(hsla(40, 80%, 10%, 0.75), hsla(40, 80%, 20%, 0.875) 80%, hsla(40, 80%, 30%, 0.875));
|
||||
}
|
||||
.player-overlay-message[data-reason=ended] {
|
||||
/* Rearrange this entirely, to fit the ending image in */
|
||||
@ -1506,7 +1570,7 @@ body.--debug .player-overlay-message {
|
||||
;
|
||||
overflow: hidden;
|
||||
background: url(ending.png) no-repeat center center / cover;
|
||||
box-shadow: inset 0 0 calc(4 * var(--tile-width)) hsl(220, 50%, 25%);
|
||||
box-shadow: inset 0 0 calc(4 * var(--tile-width)) hsl(var(--main-hue), 50%, 25%);
|
||||
}
|
||||
.player-overlay-message[data-reason=ended] .mobile-pause-menu {
|
||||
grid-area: menu;
|
||||
@ -1561,6 +1625,9 @@ body.--debug .player-overlay-message {
|
||||
margin: auto 5%;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
/* this is a lot of stuff crammed into a small space, so prefer having more space between rows
|
||||
* and less space between labels+values (which makes them more clearly related anyway) */
|
||||
line-height: 1.1;
|
||||
}
|
||||
.scoreboard .-subscore {
|
||||
grid-column: span 2;
|
||||
@ -1592,11 +1659,11 @@ body.--debug .player-overlay-message {
|
||||
}
|
||||
.scoreboard .-total-score {
|
||||
grid-column: span 3;
|
||||
color: hsl(45, 50%, 75%);
|
||||
color: hsl(45, 100%, 75%);
|
||||
}
|
||||
.scoreboard h4 {
|
||||
font-size: 0.833em;
|
||||
color: hsl(220, 10%, 80%);
|
||||
font-size: 0.75em;
|
||||
color: hsl(var(--main-hue), 10%, 60%);
|
||||
}
|
||||
.scoreboard .-total-score h4 {
|
||||
color: hsl(30, 50%, 60%);
|
||||
@ -1608,6 +1675,51 @@ body.--debug .player-overlay-message {
|
||||
font-size: 1.333em;
|
||||
}
|
||||
|
||||
/* Transparent container for displaying captions for captions */
|
||||
.player-overlay-captions {
|
||||
grid-area: level;
|
||||
place-self: stretch;
|
||||
position: relative;
|
||||
/* above the message layer */
|
||||
z-index: 3;
|
||||
|
||||
font-size: calc(0.75em * var(--scale));
|
||||
pointer-events: none;
|
||||
}
|
||||
.player-overlay-captions > span.-caption {
|
||||
position: absolute;
|
||||
left: calc(var(--x-offset) * var(--tile-width) * var(--scale));
|
||||
top: calc(var(--y-offset) * var(--tile-height) * var(--scale));
|
||||
animation: 1s ease-in 1 forwards caption-fade;
|
||||
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
/* Lol this sucks, please save me Tab */
|
||||
/* TODO use an svg element for these instead? would also avoid overflow issues */
|
||||
text-shadow:
|
||||
-1px -1px black,
|
||||
1px -1px black,
|
||||
-1px 2px black,
|
||||
1px 2px black,
|
||||
/* one more to fix lowercase k! */
|
||||
1px 0 black;
|
||||
|
||||
white-space: nowrap;
|
||||
/* Anchor these to their absolute centers */
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
@keyframes caption-fade {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.player-level-number {
|
||||
grid-area: number;
|
||||
/* This is only for portrait, and mostly to fill space */
|
||||
@ -1638,7 +1750,7 @@ body.--debug .player-overlay-message {
|
||||
order: 2;
|
||||
font-size: 0.75em;
|
||||
line-height: 1;
|
||||
color: hsl(220, 20%, 80%);
|
||||
color: hsl(var(--main-hue), 20%, 80%);
|
||||
}
|
||||
.chips output,
|
||||
.time output,
|
||||
@ -1649,7 +1761,7 @@ body.--debug .player-overlay-message {
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
color: hsl(220, 20%, 60%);
|
||||
color: hsl(var(--main-hue), 20%, 60%);
|
||||
}
|
||||
/* nb: the hex colors are all taken from the lexy palette */
|
||||
.chips output {
|
||||
@ -1677,7 +1789,7 @@ body.--debug .player-overlay-message {
|
||||
.chips output.--done,
|
||||
.time output.--frozen,
|
||||
.bonus output {
|
||||
color: hsl(220, 10%, 30%);
|
||||
color: hsl(var(--main-hue), 10%, 30%);
|
||||
}
|
||||
#player.--bonus-visible .bonus output {
|
||||
color: #e2c9ff;
|
||||
@ -1689,7 +1801,7 @@ body.--debug .player-overlay-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
color: hsl(220, 20%, 80%);
|
||||
color: hsl(var(--main-hue), 20%, 80%);
|
||||
}
|
||||
.player-rules p {
|
||||
display: none;
|
||||
@ -1713,9 +1825,9 @@ body.--debug .player-overlay-message {
|
||||
overflow: hidden;
|
||||
font-size: calc(var(--tile-height) * var(--scale) / 4);
|
||||
font-family: serif;
|
||||
color: hsl(220, 20%, 80%);
|
||||
background: url(#svg-icon-hint) hsl(220, 10%, 10%);
|
||||
border: 3px double hsl(220, 10%, 15%);
|
||||
color: hsl(var(--main-hue), 20%, 80%);
|
||||
background: url(#svg-icon-hint) hsl(var(--main-hue), 10%, 10%);
|
||||
border: 3px double hsl(var(--main-hue), 10%, 15%);
|
||||
}
|
||||
#player-game-area > .player-hint-wrapper > .player-hint-bg-icon {
|
||||
position: absolute;
|
||||
@ -1918,7 +2030,7 @@ body.--debug #player-debug {
|
||||
display: block;
|
||||
}
|
||||
#player-debug > .-inventory > button.-wide {
|
||||
grid-column: span 5;
|
||||
grid-column: span 3;
|
||||
padding: 0.25em;
|
||||
}
|
||||
#player-debug .-buttons {
|
||||
@ -2030,7 +2142,7 @@ body.--debug #player-debug {
|
||||
padding: 0.33em 0.75em;
|
||||
border: 1px solid black;
|
||||
color: #d8d8d8;
|
||||
background: hsl(220, 10%, 20%);
|
||||
background: hsl(var(--main-hue), 10%, 20%);
|
||||
box-shadow: 0 1px 2px 1px #0004;
|
||||
|
||||
opacity: 0.9;
|
||||
@ -2129,7 +2241,7 @@ body.--debug #player-debug {
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
#editor .editor-canvas canvas {
|
||||
#editor .editor-canvas canvas.editor-renderer-canvas {
|
||||
display: block;
|
||||
width: calc(var(--viewport-width) * var(--tile-width) * var(--scale));
|
||||
--viewport-width: 9;
|
||||
@ -2139,15 +2251,16 @@ body.--debug #player-debug {
|
||||
/* SVG overlays */
|
||||
svg.level-editor-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset: calc(-1 * var(--tile-width) * var(--scale)) calc(-1 * var(--tile-height) * var(--scale));
|
||||
/* allow clicks to go through us! */
|
||||
pointer-events: none;
|
||||
|
||||
/* not used to shrink us (absolute positioning does that), just to make the stroke width a
|
||||
* consistent size at any zoom level */
|
||||
--scale: 1;
|
||||
/* default svg properties */
|
||||
stroke-width: 0.0625;
|
||||
--stroke-width: calc(0.125 / var(--scale));
|
||||
stroke-width: calc(1px * var(--stroke-width));
|
||||
fill: none;
|
||||
}
|
||||
svg.level-editor-overlay .overlay-transient {
|
||||
@ -2156,25 +2269,39 @@ svg.level-editor-overlay .overlay-transient {
|
||||
svg.level-editor-overlay .overlay-transient.--visible {
|
||||
display: initial;
|
||||
}
|
||||
svg.level-editor-overlay rect.overlay-cursor {
|
||||
x-stroke: hsla(220, 100%, 60%, 0.5);
|
||||
fill: hsla(220, 100%, 75%, 0.25);
|
||||
svg.level-editor-overlay rect.overlay-pencil-cursor {
|
||||
stroke: hsla(var(--main-hue), 80%, 40%, 0.9);
|
||||
fill: hsla(var(--main-hue), 100%, 75%, 0.25);
|
||||
/* Automatically scale the cursor up just enough that the outline appears outside the cell,
|
||||
* rather than straddling it */
|
||||
transform: scale(calc(100% * (1 + var(--stroke-width))));
|
||||
transform-origin: 0.5px 0.5px;
|
||||
}
|
||||
svg.level-editor-overlay rect.overlay-pending-selection {
|
||||
stroke: hsla(220, 100%, 60%, 0.5);
|
||||
fill: hsla(220, 100%, 75%, 0.25);
|
||||
stroke: hsla(var(--selected-hue), 100%, 60%, 0.5);
|
||||
fill: hsla(var(--selected-hue), 100%, 75%, 0.25);
|
||||
}
|
||||
svg.level-editor-overlay rect.overlay-selection {
|
||||
stroke: #000c;
|
||||
fill: hsla(220, 0%, 75%, 0.25);
|
||||
stroke-dasharray: 0.125, 0.125;
|
||||
animation: marching-ants 1s linear infinite;
|
||||
svg.level-editor-overlay path.overlay-selection-background {
|
||||
stroke: hsla(var(--selected-hue), 10%, 90%, 0.9);
|
||||
fill: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
svg.level-editor-overlay path.overlay-selection {
|
||||
stroke: hsla(var(--selected-hue), 10%, 10%, 0.75);
|
||||
fill: hsla(var(--selected-hue), 50%, 75%, 0.375);
|
||||
fill-rule: evenodd;
|
||||
stroke-width: calc(0.125px / var(--scale));
|
||||
stroke-dasharray: calc(0.125px / var(--scale)), calc(0.125px / var(--scale));
|
||||
animation: marching-ants 0.5s linear infinite;
|
||||
pointer-events: auto;
|
||||
cursor: move;
|
||||
}
|
||||
svg.level-editor-overlay path.overlay-selection.--floating {
|
||||
stroke: hsla(var(--selected-hue), 80%, 50%, 0.75);
|
||||
}
|
||||
@keyframes marching-ants {
|
||||
0% {
|
||||
stroke-dashoffset: 0.25;
|
||||
stroke-dashoffset: calc(0.25px / var(--scale));
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
@ -2182,14 +2309,83 @@ svg.level-editor-overlay rect.overlay-selection {
|
||||
}
|
||||
#overlay-arrowhead {
|
||||
fill: white;
|
||||
fill: context-stroke;
|
||||
}
|
||||
svg.level-editor-overlay g.overlay-connection {
|
||||
stroke: white;
|
||||
stroke: #e4e4e4;
|
||||
filter: url(#overlay-filter-outline);
|
||||
}
|
||||
svg.level-editor-overlay g.overlay-connection.--cursor {
|
||||
stroke: hsl(90, 90%, 40%);
|
||||
}
|
||||
svg.level-editor-overlay g.overlay-connection[data-source=button_red] {
|
||||
stroke: hsl(0, 90%, 60%);
|
||||
}
|
||||
svg.level-editor-overlay g.overlay-connection[data-source=button_brown] {
|
||||
stroke: hsl(50, 90%, 50%);
|
||||
}
|
||||
svg.level-editor-overlay g.overlay-connection[data-source=button_orange] {
|
||||
stroke: hsl(30, 90%, 60%);
|
||||
}
|
||||
svg.level-editor-overlay g.overlay-connection.--implicit line.-arrow {
|
||||
stroke-dasharray: calc(0.25px / var(--scale)), calc(0.25px / var(--scale));
|
||||
}
|
||||
svg.level-editor-overlay g.overlay-connection line.-arrow {
|
||||
marker-end: url(#overlay-arrowhead);
|
||||
}
|
||||
svg.level-editor-overlay g.overlay-circuitry {
|
||||
filter: url(#overlay-filter-outline);
|
||||
opacity: 0.75;
|
||||
}
|
||||
svg.level-editor-overlay .overlay-circuit-wires {
|
||||
stroke: hsl(180, 100%, 50%);
|
||||
stroke-linecap: square; /* so a corner meets nicely in the middle of a tile */
|
||||
}
|
||||
svg.level-editor-overlay .overlay-circuit-tunnels {
|
||||
stroke: hsl(180, 100%, 50%);
|
||||
stroke-dasharray: 0.0625, 0.0625;
|
||||
}
|
||||
svg.level-editor-overlay circle.overlay-circuit-input {
|
||||
stroke: hsl(180, 100%, 50%);
|
||||
fill: hsl(180, 100%, 10%);
|
||||
}
|
||||
svg.level-editor-overlay circle.overlay-circuit-output {
|
||||
stroke: hsl(180, 100%, 50%);
|
||||
fill: hsl(180, 100%, 50%);
|
||||
}
|
||||
svg.level-editor-overlay .overlay-adjust-cursor {
|
||||
/* shared between rotate+adjust tools, though they use different elements/shapes */
|
||||
stroke: #444;
|
||||
fill: #fff4;
|
||||
}
|
||||
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=terrain] {
|
||||
stroke: hsl(150deg, 80%, 20%, 0.8);
|
||||
fill: hsl(150deg, 80%, 60%, 0.4);
|
||||
}
|
||||
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=item] {
|
||||
stroke: hsl(50deg, 80%, 20%, 0.8);
|
||||
fill: hsl(50deg, 80%, 60%, 0.4);
|
||||
}
|
||||
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=item-mod] {
|
||||
}
|
||||
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=actor] {
|
||||
stroke: hsl(215deg, 80%, 20%, 0.8);
|
||||
fill: hsl(215deg, 80%, 60%, 0.4);
|
||||
}
|
||||
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=swivel] {
|
||||
}
|
||||
svg.level-editor-overlay .overlay-adjust-cursor[data-layer=thin-wall] {
|
||||
stroke: hsl(330deg, 80%, 20%, 0.8);
|
||||
fill: hsl(330deg, 80%, 60%, 0.4);
|
||||
}
|
||||
svg.level-editor-overlay .overlay-adjust-gray-button-radius {
|
||||
stroke: #f4f4f4;
|
||||
fill: hsla(10, 10%, 80%, 0.125);
|
||||
}
|
||||
svg.level-editor-overlay .overlay-adjust-gray-button-shroud {
|
||||
stroke: none;
|
||||
fill: hsla(10, 10%, 30%, 0.6);
|
||||
}
|
||||
svg.level-editor-overlay rect.overlay-camera {
|
||||
stroke: #808080;
|
||||
fill: #80808040;
|
||||
@ -2200,8 +2396,24 @@ svg.level-editor-overlay text {
|
||||
font-size: 1px;
|
||||
}
|
||||
svg.level-editor-overlay text.overlay-edit-tip {
|
||||
/* Used for showing e.g. the size of a pending selection. Centered around its anchor */
|
||||
stroke: none;
|
||||
fill: black;
|
||||
fill: hsl(var(--selected-hue), 80%, 30%);
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
}
|
||||
svg.level-editor-overlay text.overlay-adjust-hint {
|
||||
font-size: calc(0.5px / var(--scale));
|
||||
font-weight: bold;
|
||||
stroke: black;
|
||||
fill: white;
|
||||
paint-order: stroke;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: auto;
|
||||
}
|
||||
svg.level-editor-overlay .overlay-text-cursor {
|
||||
stroke: hsla(50, 90%, 60%, 0.75);
|
||||
fill: hsla(50, 90%, 60%, 0.25);
|
||||
}
|
||||
|
||||
.editor-big-tooltip {
|
||||
@ -2224,7 +2436,7 @@ svg.level-editor-overlay text.overlay-edit-tip {
|
||||
text-transform: none;
|
||||
text-align: left;
|
||||
color: #d8d8d8;
|
||||
background: hsl(220, 10%, 20%);
|
||||
background: hsl(var(--main-hue), 10%, 20%);
|
||||
box-shadow: 0 1px 2px 1px #0004;
|
||||
}
|
||||
.editor-big-tooltip h3 {
|
||||
@ -2233,6 +2445,28 @@ svg.level-editor-overlay text.overlay-edit-tip {
|
||||
border-bottom: 1px solid currentColor;
|
||||
color: white;
|
||||
}
|
||||
.editor-big-tooltip kbd {
|
||||
font-size: 0.75em;
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
padding: 1px 2px;
|
||||
border: 1px solid #d8d8d8;
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
vertical-align: 0.25em;
|
||||
background: #d8d8d8;
|
||||
box-shadow: 0 2px #999;
|
||||
color: hsl(var(--main-hue), 10%, 20%);
|
||||
letter-spacing: -1px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.editor-big-tooltip svg {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
margin: 0 -0.25em; /* these are mouse buttons; shave off some of the extra space */
|
||||
vertical-align: -0.375em;
|
||||
}
|
||||
#editor .controls {
|
||||
/* TODO with the hint area gone i don't think this needs to be a grid? could just flex */
|
||||
grid-area: controls;
|
||||
@ -2269,9 +2503,40 @@ svg.level-editor-overlay text.overlay-edit-tip {
|
||||
transition-delay: 0.5s;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
#editor .controls #editor-layer-selector {
|
||||
display: grid;
|
||||
grid:
|
||||
"icon header" auto
|
||||
"icon name" 1fr
|
||||
/ auto 6em
|
||||
;
|
||||
align-items: center;
|
||||
gap: 0 0.5em;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
#editor .controls #editor-layer-selector > img {
|
||||
grid-area: icon;
|
||||
}
|
||||
#editor .controls #editor-layer-selector > h3 {
|
||||
grid-area: header;
|
||||
margin: 0;
|
||||
font-size: 0.75em;
|
||||
font-weight: normal;
|
||||
color: #606060;
|
||||
}
|
||||
#editor .controls #editor-layer-selector > output {
|
||||
grid-area: name;
|
||||
}
|
||||
#editor .controls .-buttons {
|
||||
grid-area: menu;
|
||||
}
|
||||
#editor .controls .-toolbar-section {
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--selected-hue), 10%, 10%);
|
||||
border: 1px solid hsl(var(--selected-hue), 10%, 20%);
|
||||
}
|
||||
.icon-button-set {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -2282,13 +2547,16 @@ svg.level-editor-overlay text.overlay-edit-tip {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
background: url(icons/tool-bg-unselected.png) no-repeat;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.icon-button-set button:hover {
|
||||
background: hsl(var(--hover-hue), 50%, 40%);
|
||||
}
|
||||
.icon-button-set button.-selected {
|
||||
background-image: url(icons/tool-bg-selected.png);
|
||||
background: hsl(var(--selected-hue), 90%, 75%);
|
||||
}
|
||||
.icon-button-set button img {
|
||||
display: block;
|
||||
@ -2322,7 +2590,7 @@ svg.level-editor-overlay text.overlay-edit-tip {
|
||||
.palette-entry {
|
||||
}
|
||||
.palette-entry:hover {
|
||||
box-shadow: 0 0 0 1px black, 0 0 0 3px hsl(220, 100%, 75%);
|
||||
box-shadow: 0 0 0 1px black, 0 0 0 3px hsl(var(--main-hue), 100%, 75%);
|
||||
}
|
||||
.palette-entry.--selected {
|
||||
z-index: 1;
|
||||
@ -2488,8 +2756,8 @@ ol.editor-letter-tile-picker input[type=radio] {
|
||||
display: none;
|
||||
}
|
||||
ol.editor-letter-tile-picker input[type=radio]:checked + .-glyph {
|
||||
background: hsl(220, 75%, 90%);
|
||||
outline: 2px solid hsl(220, 75%, 80%);
|
||||
background: hsl(var(--main-hue), 75%, 90%);
|
||||
outline: 2px solid hsl(var(--main-hue), 75%, 80%);
|
||||
}
|
||||
/* Hint tiles accept prose */
|
||||
textarea.editor-hint-tile-text {
|
||||
@ -2514,7 +2782,7 @@ textarea.editor-hint-tile-text {
|
||||
stroke-width: 2;
|
||||
}
|
||||
.editor-tile-editor-svg-parts input:checked + svg {
|
||||
stroke: hsl(220, 90%, 50%);
|
||||
stroke: hsl(var(--main-hue), 90%, 50%);
|
||||
}
|
||||
/* Directional blocks have arrows */
|
||||
ol.editor-directional-block-tile-arrows {
|
||||
|
||||
BIN
tileset-lexy.png
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 94 KiB |