58 Commits
Author SHA1 Message Date
Eevee (Evelyn Woods) 86764612d3 [WIP] Switch to a more accurate frame-based model
This seems to match how CC2 actually works, and it fixes the replays for
the CC1 levels BLOCK BUSTER, THE PRISONER, CATACOMBS, and GOLDKEY.

Unfortunately, it also regresses ON THE ROCKS, GRAIL, and ALPHABET SOUP,
and I do not know why.  I'm not even sure why it fixes CATACOMBS and
GOLDKEY.  It makes a mess of force floor handling with mixed results.
It's also some 40% slower, which is not ideal.

I doubt I'll revisit this particular branch, since it's the sort of mass
code rearrangement that breaks everything, but I'm keeping it for future
reference in case I try to revisit this idea.
2020-12-15 21:07:47 -07:00
Eevee (Evelyn Woods) eec7ab2e1a Fix rover behavior at last; lesson 5 now syncs 2020-12-15 17:50:13 -07:00
Eevee (Evelyn Woods) 25b4b32f94 Add teleport overriding and seriously clean up teleport code 2020-12-15 16:44:37 -07:00
Eevee (Evelyn Woods) 7c82a4cdf9 Only do CC2 actions per press, not per held tic 2020-12-15 01:07:02 -07:00
Eevee (Evelyn Woods) 0f02e270f2 Catch level parse errors in the bulk test dialog 2020-12-15 00:55:40 -07:00
Eevee (Evelyn Woods) 07c9a83f75 Fix crash with teeth and Cerise 2020-12-15 00:48:28 -07:00
Eevee (Evelyn Woods) bf81738e19 Partly implement helmet; get rover very close to correct (?) 2020-12-15 00:44:07 -07:00
Eevee (Evelyn Woods) 55abe8b53a Allow the bowling ball to slide normally 2020-12-15 00:26:00 -07:00
Eevee (Evelyn Woods) f858668ca8 Clumsily teach bowling balls to destroy actors 2020-12-15 00:10:23 -07:00
Eevee (Evelyn Woods) 2d7df413ee Allow ghosts to pass through thin walls 2020-12-14 23:59:38 -07:00
Eevee (Evelyn Woods) dae66de160 Thieves will, however, take bribes from anyone 2020-12-14 23:49:47 -07:00
Eevee (Evelyn Woods) 8b60a44b09 Fix loading of the initial entry direction for railroads 2020-12-14 23:39:07 -07:00
Eevee (Evelyn Woods) 2103c649f6 Implement a few quirks of ghost movement 2020-12-14 23:35:11 -07:00
Eevee (Evelyn Woods) 6d4326fe5b Thieves only steal from the player 2020-12-14 23:29:05 -07:00
Eevee (Evelyn Woods) 3e18e38f15 Add rough implementations of dynamite and bowling ball 2020-12-14 23:25:48 -07:00
Eevee (Evelyn Woods) 9ade84c6fe Generally only lose if the real player dies, not a doppelganger 2020-12-14 23:20:46 -07:00
Eevee (Evelyn Woods) bf3c501353 Fix yellow tank behavior to be faux simultaneous 2020-12-14 23:14:31 -07:00
Eevee (Evelyn Woods) a529414e42 Cycle an RFF after a failed override; move slide turnaround to move phase 2020-12-14 22:06:42 -07:00
Eevee (Evelyn Woods) e4ab7dde86 Fix loading custom floors with junk in their modifier's high nybble 2020-12-14 22:06:01 -07:00
Eevee (Evelyn Woods) a865647eae Non-players move instantly while sliding 2020-12-14 17:52:59 -07:00
Eevee (Evelyn Woods) f02fa1a9bb Split up the wiring updates 2020-12-14 17:42:31 -07:00
Eevee (Evelyn Woods) fea93aa9ec Fix wire tool only working in the upper-left quadrant 2020-12-14 17:32:48 -07:00
Eevee (Evelyn Woods) 00ac94ac8c Add a further hack atop the cooldown delay hack to fix adjacent trap release 2020-12-14 17:25:15 -07:00
Eevee (Evelyn Woods) c34aaadf06 Attempt, unsuccessfully, to implement ice block melting 2020-12-14 17:07:35 -07:00
Eevee (Evelyn Woods) efd25294ac Restore the notion of an out-of-turn move
This fixes a lot of replay sync issues with cloners; in CC2, actors
advance only one frame (1/3 tic) at a time, so when a cloned object
happens to get a turn later in the same tic that it was cloned, it only
ends up 1 frame ahead of everything else.  Since actors can only begin
moves on tic-aligned frames, even though it does get where it was going
sooner, it has to wait for a frame before moving, so the advantage
doesn't change anything.

The problem is that LL counts movement in tics, not frames, so that kind
of bonus turn puts the clone an entire tic ahead which can gum things
up.

This is still not perfect, but it's much closer.
2020-12-14 17:05:01 -07:00
Eevee (Evelyn Woods) beb5a5c743 Change the debug mode trigger to a regular click 2020-12-14 17:02:14 -07:00
Eevee (Evelyn Woods) f3f73a5e41 Move input handling into Level and clean it up a ton; add a bulk test gizmo 2020-12-14 17:02:14 -07:00
Eevee (Evelyn Woods) 189ab96e3c C2G strings can be empty 2020-12-13 23:56:13 -07:00
Eevee (Evelyn Woods) 16f11f3a9b Exits block CC1 blocks 2020-12-13 22:22:22 -07:00
Eevee (Evelyn Woods) c8ed4b9fba Fix the spelling of Chuck Sommerville's name lol whoops 2020-12-13 20:43:01 -07:00
Eevee (Evelyn Woods) 2fa231a6cd Oops; include HTML/CSS changes for replays too 2020-12-13 20:39:55 -07:00
Eevee (Evelyn Woods) 1c9dee1213 Add support for recording replays, with a bunch of refactoring along the way 2020-12-13 20:36:12 -07:00
Eevee (Evelyn Woods) 85a81878cc Add a button to download a level from the editor 2020-12-13 16:23:45 -07:00
Eevee (Evelyn Woods) df14b62b94 Auto-grow the map save buffer when necessary; do less slicing 2020-12-13 16:09:04 -07:00
Eevee (Evelyn Woods) 5e6784c235 Fix encoding and decoding of swivels 2020-12-13 15:56:24 -07:00
Eevee (Evelyn Woods) 0f5b8098f6 Split teleporting into its own pass, like Lynx 2020-12-13 02:23:14 -07:00
Eevee (Evelyn Woods) 93954135d2 Set splash delay back to 6; is correct after all I guess 2020-12-13 01:54:52 -07:00
Eevee (Evelyn Woods) 90008c3a89 Make the player push blocks at decision time
It turns out the player explores all their decisions in a very physical
way, which is the real source of block slapping and also means the
player can push blocks before anything else can move, regardless of
actor order.

This fixes at least half a dozen CC1 replays, which is just
mindboggling.
2020-12-13 00:39:36 -07:00
Eevee (Evelyn Woods) bd4c04c1d8 Fix wiring order; relax player/monster collision 2020-12-12 21:00:28 -07:00
Eevee (Evelyn Woods) f95913b6d8 Teeth chase the player's apparent position 2020-12-12 20:24:28 -07:00
Eevee (Evelyn Woods) 3a04b6276c Go back to toggling green objects instantly 2020-12-12 19:22:04 -07:00
Eevee (Evelyn Woods) 4d23acb27e Add on_approach; use it to erase animations and fix popdown floors 2020-12-12 19:00:58 -07:00
Eevee (Evelyn Woods) 077ac65650 Restore turn-based mode 2020-12-12 18:16:52 -07:00
Eevee (Evelyn Woods) 299b1578a7 Mostly revert actor loop reorg
I was right the first time, and I've proven it to myself now.  I
originally made the change because I couldn't see any other way to fix
the ICEBERG replay from Steam CC1, but now, I do!
2020-12-12 17:57:47 -07:00
Eevee (Evelyn Woods) 413fdce590 Rejigger input parsing to be stateless and better match CC2; syncs SCAVENGER HUNT! 2020-12-12 01:08:08 -07:00
Eevee (Evelyn Woods) cfdbe0705a Add even faster playback options; fix some demo decoding bugs 2020-12-12 00:22:51 -07:00
Eevee (Evelyn Woods) 769d424dde Fix rendering breakage caused by a couple more out-of-turn moves 2020-12-11 22:54:01 -07:00
Eevee (Evelyn Woods) 8671bee08b Run wiring thrice per tic; recognize some tiles have odd propagation rules 2020-12-11 22:49:23 -07:00
Eevee (Evelyn Woods) c17169f49d Rearranged debug panel a bit; added progress bar for replay playback 2020-12-11 22:15:39 -07:00
Eevee (Evelyn Woods) fde7d9a11c Remove some old stuff; stub out replay progress bar 2020-12-11 21:40:40 -07:00
Eevee (Evelyn Woods) fec09c03ba Support running the game at ludicrous speed 2020-12-11 21:31:07 -07:00
Eevee (Evelyn Woods) 0f0c7437a6 Allow rewinding a replay without desyncing it 2020-12-11 21:14:19 -07:00
Eevee (Evelyn Woods) 410af788fc Fix teleporters, and more generally out-of-turn movement 2020-12-11 20:58:50 -07:00
Eevee (Evelyn Woods) a96c089d7f Move the debug panel to a sidebar 2020-12-11 19:43:22 -07:00
Eevee (Evelyn Woods) b8dd0ae41e Add a delay before restarting a failed level by tapping the viewport 2020-12-11 14:39:14 -07:00
Eevee (Evelyn Woods) 75e981335f Restore the input viewer 2020-12-11 14:25:02 -07:00
Eevee (Evelyn Woods) 6c2afd7e32 Fix flicker when pushing a block across multiple cells 2020-12-11 13:59:23 -07:00
Eevee (Evelyn Woods) 2f130861d6 Remove animation_{progress,speed} and fix interpolation 2020-12-11 13:56:41 -07:00
13 changed files with 2353 additions and 838 deletions

No files matched your search

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