Compare commits

..

1 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
122 changed files with 6812 additions and 22552 deletions

View File

@ -1,16 +1,8 @@
# Lexy's Labyrinth
This is a reimplementation of [**Chip's Challenge®**](https://wiki.bitbusters.club/Chip%27s_Challenge), that puzzle game you might remember from the early 90s (and its long-awaited [sequel](https://wiki.bitbusters.club/Chip%27s_Challenge_2)).
This is a web implementation of a puzzle game that bears a _striking_ similarity to [Chip's Challenge](https://wiki.bitbusters.club/Chip%27s_Challenge) and its [sequel](https://wiki.bitbusters.club/Chip%27s_Challenge_2), but is legally distinct, and also free!
It's free; runs in a browser; has completely new artwork, sounds, and music; comes with hundreds of quality fan-made levels built in; and can load the original levels from a copy of the commercial game!
Documentation is underway on the [wiki](https://github.com/eevee/lexys-labyrinth/wiki).
## My lawyer is telling me to say this
To be absolutely clear: this is a ***fan project*** and is not affiliated with, sponsored by, endorsed by, or in any way approved of by Bridgestone Multimedia Group LLC. **Chip's Challenge** is a registered trademark of Bridgestone Multimedia Group LLC, and is used here for identification purposes only.
Despite the names, the built-in "Chip's Challenge Level Packs" are community creations and have no relation to the commercial games or their levels.
It is a work in progress and also might be abandoned and forgotten at any time.
## Play online
@ -18,35 +10,31 @@ Give it a try, I guess! [https://c.eev.ee/lexys-labyrinth/](https://c.eev.ee/le
## Current status
- Fully compatible with Chip's Challenge 1 levels... barring a few obscure rule changes
- Fully compatible with Chip's Challenge 2 levels... barring a few obscure bugs
- Supports 99% of Chip's Challenge 1
- Supports 75% of Chip's Challenge 2
- Completely original tileset, sound effects, and music
- Compatible with MS Chip's Challenge DAT/CCL files, Steam Chip's Challenge C2G/C2M files, and ZIP files
- Can load one of its built-in level packs, the original levels, or anything you've got lying around
- Able to record and play back demos (replays) from Steam-format levels
- Lets you rewind your mistakes, up to 30 seconds back
- Lets you take the pressure off by switching from real-time to turn-based mode, where nothing moves until you do
- Contains a completely usable level editor with support for every tile in Chip's Challenge 2
- Works on touchscreens too
- Has compatibility settings for opting into behavior (or bugs) from particular implementations
- Debug mode (click the logo in the lower left)
- Can load MS Chip's Challenge DAT/CCL files and Steam Chip's Challenge C2M files
- Can load levels from your hard drive
- Can play back replays (demos) from C2M files, though some may desync
- Allows undoing moves, with moderate success
- Has the beginning bits of a level editor
### Planned features
- Save your score, and compare it to the BBC leaderboards
- Load levels directly from the BBC set list
- Mouse support
- Support for all of the nonsense in Chip's Challenge 2
- Allow playing the original commercial levels by dragging the data files in from your own computer
- Support various sets of bugs from various implementations
- Play the game turn-based instead of realtime (i.e., nothing moves until Chip does)
- Record demos
- Mouse and touchscreen support
- Bunches of debug features
- Outright cheat in a variety of ways
## For developers
### Noble aspirations
It's all static JS; there's no build system. If you want to run it locally, just throw your favorite HTTP server at a checkout and open a browser. (Browsers won't allow XHR from `file:///` URLs, alas. If you don't have a favorite HTTP server, try `python -m http.server`.)
If you have Node installed, you can test the solutions included with the bundled level packs without needing a web browser:
```
node js/headless/bulktest.mjs
```
Note that solution playback is still not perfect, so don't be alarmed if you don't get 100% — only if you make a change and something regresses.
- New exclusive puzzle elements?? Embrace extend extinguish baby
## Special thanks
@ -56,3 +44,5 @@ Note that solution playback is still not perfect, so don't be alarmed if you don
- Everyone who worked on [Chip's Challenge Level Pack 1](https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_1), the default set of levels
- [Tile World](https://wiki.bitbusters.club/Tile_World) for being an incredible reference on Lynx mechanics
- Everyone who contributed music — see [`js/soundtrack.js`](js/soundtrack.js) for a list!
Not associated with or blessed by Chuck Sommerville, Niffler, or AOP.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 B

After

Width:  |  Height:  |  Size: 508 B

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 B

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 B

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 B

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 B

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

After

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 B

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 B

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 B

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 B

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 257 B

View File

@ -5,80 +5,16 @@
<title>Lexy's Labyrinth</title>
<link rel="stylesheet" type="text/css" href="style.css">
<link rel="shortcut icon" type="image/png" href="icon.png">
<script>
"use strict";
{
let domloaded = false;
window.addEventListener('DOMContentLoaded', ev => domloaded = true);
let _ll_log_fatal_error = (err, ev) => {
document.getElementById('loading').setAttribute('hidden', '');
let failed = document.getElementById('failed');
failed.removeAttribute('hidden');
document.body.setAttribute('data-mode', 'failed');
failed.classList.add('--got-error');
let stack = '(origin unknown)';
if (err.stack && err.stack.match(/\n/)) {
// Chrome sometimes gives us a stack that's actually just the message without
// any filenames, in which case skip it
stack = err.stack.replace(/^/mg, " ");
}
else if (err.fileName) {
stack = `in ${err.fileName} at ${err.lineNumber}:${err.columnNumber}`;
}
else if (ev) {
stack = `in ${ev.filename} at ${ev.lineno}:${ev.colno}`;
}
failed.querySelector('pre').textContent = `${err.toString()}\n\n${stack}`;
};
window.ll_log_fatal_error = function(err, ev) {
if (domloaded) {
_ll_log_fatal_error(err, ev);
}
else {
window.addEventListener('DOMContentLoaded', () => _ll_log_fatal_error(err, ev));
}
};
let error_listener = ev => {
if (! ev.error)
// Not a script error
return;
try {
ll_log_fatal_error(ev.error, ev);
}
catch (err) {}
};
window.addEventListener('error', error_listener, true);
// Once we've loaded successfully, drop the handler
window.ll_successfully_loaded = function() {
window.removeEventListener('error', error_listener, true);
};
}
</script>
<!-- FIXME it would be super swell if i could load this lazily -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script type="module" src="js/main.js"></script>
<meta name="og:type" content="website">
<meta name="og:image" content="https://c.eev.ee/lexys-labyrinth/og-preview.png">
<meta name="og:title" content="Lexy's Labyrinth">
<meta name="og:description" content="Free online puzzle game that emulates Chip's Challenge. Play hundreds of community curated levels, load the levels from the commercial games, or make your own with the built-in editor.">
<meta name="description" content="Free online puzzle game that emulates Chip's Challenge. Play hundreds of community curated levels, load the levels from the commercial games, or make your own with the built-in editor.">
<meta name="og:description" content="A (work in progress) reimplementation of Chip's Challenge 1 and 2, using entirely free assets.">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
</head>
<body data-mode="failed">
<script>document.body.setAttribute('data-mode', 'loading');</script>
<body data-mode="splash">
<svg id="svg-iconsheet">
<defs>
<g id="svg-icon-menu-chevron">
<path d="M2,4 l6,6 l6,-6 v3 l-6,6 l-6,-6 z"></path>
</g>
<g id="svg-icon-prev">
<path d="M14,1 2,8 14,14 z">
</g>
<g id="svg-icon-next">
<path d="M2,1 14,8 2,14 z">
</g>
<!-- Actions -->
<g id="svg-icon-up">
<path d="M0,12 l8,-8 l8,8 z"></path>
@ -104,39 +40,16 @@
<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>
<!-- Hint background -->
<g id="svg-icon-hint">
<path d="M1,8 a7,7 0 1,1 14,0 7,7 0 1,1 -14,0 M2,8 a6,6 0 1,0 12,0 6,6 0 1,0 -12,0"></path>
<path d="M5,6 a1,1 0 0,0 2,0 a1,1 0 1,1 1,1 a1,1 0 0,0 -1,1 v1 a1,1 0 1,0 2,0 v-0.17 A3,3 0 1,0 5,6"></path>
<circle cx="8" cy="12" r="1"></circle>
</g>
<!-- Editor stuff -->
<g id="svg-icon-zoom">
<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">
<img id="header-icon" src="icon.png" alt="">
<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>
<h1>Lexy's Labyrinth</h1>
<p>— a game by <a href="https://eev.ee/">eevee</a></p>
<nav>
<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>
<button id="main-about" type="button">about</button>
<button id="main-help" type="button" disabled>help</button>
<button id="main-options" type="button" disabled>options</button>
</nav>
</header>
<header id="header-pack">
@ -152,178 +65,117 @@
<h3 id="level-name">Level 1 — Key Pyramid</h3>
<nav>
<button id="main-prev-level" type="button">
<svg class="svg-icon" viewBox="0 0 16 16" title="previous"><use href="#svg-icon-prev"></svg>
<svg class="svg-icon" viewBox="0 0 16 16" title="previous"><path d="M14,1 2,8 14,14 z"></svg>
</button>
<button id="main-choose-level" type="button">Level select</button>
<button id="main-next-level" type="button">
<svg class="svg-icon" viewBox="0 0 16 16" title="next"><use href="#svg-icon-next"></svg>
<svg class="svg-icon" viewBox="0 0 16 16" title="next"><path d="M2,1 14,8 2,14 z"></svg>
</button>
</nav>
</header>
<main id="failed">
<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>
<main id="loading" hidden>
<p>...loading...</p>
<div class="scrolling-sidewalk">
<img src="loading.gif" alt="Lexy walking">
</div>
</main>
<script>
document.querySelector('#failed').setAttribute('hidden', '');
document.querySelector('#loading').removeAttribute('hidden');
</script>
<main id="splash" hidden>
<main id="splash">
<div class="drag-overlay"></div>
<header>
<img src="og-preview.png" alt="">
<h1>Lexy's Labyrinth</h1>
<p>an unofficial <strong>Chip's Challenge</strong>® emulator</p>
<button id="splash-fullscreen" type="button" title="Toggle fullscreen">
<svg class="svg-icon" viewBox="0 0 16 16">
<path d="m 11,1 h 4 V 5 L 14,4 12,6 10,4 12,2 Z"></path>
<path d="m 11,15 h 4 v -4 l -1,1 -2,-2 -2,2 2,2 z"></path>
<path d="M 5,1 H 1 V 5 L 2,4 4,6 6,4 4,2 Z"></path>
<path d="M 5,15 H 1 v -4 l 1,1 2,-2 2,2 -2,2 z"></path>
</svg>
</button>
<h1><img src="og-preview.png" alt="">Lexy's Labyrinth</h1>
<p>an unapproved Chip's Challenge emulator</p>
</header>
<div id="splash-links">
<a href="https://github.com/eevee/lexys-labyrinth/wiki">About</a>
<a href="https://github.com/eevee/lexys-labyrinth/wiki/How-To-Play">How to play</a>
<a href="https://github.com/eevee/lexys-labyrinth">Source code and more</a>
<a href="https://patreon.com/eevee">Support on Patreon</a>
</div>
<p id="splash-disclaimer"><strong>Chip's Challenge</strong> is a registered trademark of Bridgestone Media Group LLC, used here for identification purposes only. Not affiliated with, sponsored, or endorsed by Bridgestone Media Group LLC.</p>
<section id="splash-intro">
<p><strong>Welcome</strong> to Lexy's Labyrinth, an open source puzzle game that is curiously similar to — but legally distinct from — the Atari classic <a href="https://en.wikipedia.org/wiki/Chip%27s_Challenge">Chip's Challenge</a>!</p>
<p>Pick a level pack to get started! You can also load and play any levels you've got lying around, or brave the level editor and make one of your own.</p>
<p>If you're not familiar with the game, read up on <a href="https://github.com/eevee/lexys-labyrinth/wiki/How-To-Play">how to play</a>. You can also get more technical details or report bugs on <a href="https://github.com/eevee/lexys-labyrinth">GitHub</a>, find out more about Chip's Challenge via the <a href="https://bitbusters.club/">Bit Busters Club</a> fansite, or support this endeavor (and other things I do) on <a href="https://www.patreon.com/eevee">Patreon</a>.</p>
</section>
<section id="splash-stock-levels">
<h2>Play</h2>
<ul class="played-pack-list" id="splash-stock-pack-list">
<!-- populated by js -->
</ul>
<div class="button-row">
<button type="button" class="button-big" disabled>More levels</button>
<button type="button" class="button-big" disabled>Other saved scores</button>
</div>
<h2>Play community levels</h2>
<!-- populated by js -->
</section>
<h2>More levels</h2>
<p>Supports CCL/DAT, C2G, C2M, and ZIP; drag and drop; and both custom and official levels. <a href="https://github.com/eevee/lexys-labyrinth/wiki/Loading-Levels">More details</a></p>
<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 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 -->
</ul>
<section id="splash-upload-levels">
<h2>Load other levels</h2>
<p>Load and play any levels you have on hand, including the original levels. Supports CCL/DAT, C2G, and individual C2Ms (though scores aren't saved for those). Find more on the <a href="https://sets.bitbusters.club/">Bit Busters Club set list</a>.</p>
<!-- TODO zip files! -->
<p>You can also drag and drop files or directories into this window.</p>
<input id="splash-upload-file" type="file" accept=".dat,.ccl,.c2m,.ccs" 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>
<p>If you still have the original Microsoft "BOWEP" game lying around, you can play the Chip's Challenge 1 levels by loading <code>CHIPS.DAT</code>.</p>
<p>If you own the Steam versions of <a href="https://store.steampowered.com/app/346850/Chips_Challenge_1/">Chip's Challenge 1</a> (<em>free!</em>) or <a href="https://store.steampowered.com/app/348300/Chips_Challenge_2/">Chip's Challenge 2</a> ($5 last I checked), you can play those too, even on Linux or Mac:</p>
<ol class="normal-list">
<li>Right-click the game in Steam and choose <em>Properties</em>. On the <em>Local Files</em> tab, click <em>Browse local files</em>.</li>
<li>Open the <code>data</code> folder, then <code>games</code>.</li>
<li>You should see either a <code>cc1</code> or <code>cc2</code> folder. Drag it into this window, or load it with the button above.</li>
</ol>
</section>
<section id="splash-your-levels">
<h2>Create</h2>
<div class="button-row">
<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>
<h2>Make your own (WIP lol)</h2>
<p>Please note that the level editor is <strong>very</strong> unfinished, and doesn't even have undo yet. It may eat your work! Beware!</p>
<p><button type="button" id="splash-create-pack" class="button-big">New pack</button></p>
<p><button type="button" id="splash-create-level" class="button-big">New scratch level<br>(won't be saved!)</button></p>
</section>
</main>
<main id="player" hidden>
<div id="player-main">
<div id="player-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"></path></svg>
<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> <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>
<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"></path></svg>
<span class="-optional-label">rewind</span> <span class="keyhint"><kbd>z</kbd></span></button>
<div class="radio-faux-button-set">
<label><input class="control-turn-based" type="checkbox"> <span>Step <br>mode</span></label>
<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>
<div id="player-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"><kbd>q</kbd></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"><kbd>e</kbd></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"><kbd>c</kbd></span></button>
</div>
<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>
</div>
<div class="player-level-number">
Level
<output></output>
<div class="overlay-message">
<h1 class="-top"></h1>
<div class="-middle"></div>
<p class="-bottom"></p>
<p class="-keyhint"></p>
</div>
<div class="message"></div>
<div class="chips">
<h3>
<svg class="svg-icon" viewBox="0 0 16 16" title="Hearts">
<path d="M4,2 C 2,2 1,4 1,6 C 1,8 2,10 4,12 C 6,14 8,15 8,15 C 8,15 10,14 12,12 C 14,10 15,8 15,6 C 15,4 14,2 12,2 C 10,2 8,5 8,5 C 8,5 6,2 4,2 z M12,4 C 12,5 13,6 14,6 C 13,6 12,7 12,8 C 12,7 11,6 10,6 C 11,6 12,5 12,4 z"></path>
</svg>
</h3>
<h3>Hearts</h3>
<output></output>
</div>
<div class="time">
<h3>
<svg class="svg-icon" viewBox="0 0 16 16" title="Time">
<path d="M 7,3 A -6,6 0 0 1 13,9 -6,6 0 0 1 7,15 -6,6 0 0 1 1,9 -6,6 0 0 1 7,3 Z M 7,4 A -5,5 0 0 0 2,9 -5,5 0 0 0 7,14 -5,5 0 0 0 12,9 -5,5 0 0 0 7,4 Z"></path>
<!-- cap -->
<path d="M 15,4 12,1 c -1,0 -1,0 -1,1 l 1,1 -2,2 1,1 2,-2 1,1 c 1,0 1,0 1,-1 z"></path>
<!-- arrow -->
<path d="M 8,9 10,6 7,8 Z"></path>
<!-- center -->
<circle cx="7" cy="9" r="1"></circle>
</svg>
</h3>
<h3>Time</h3>
<output></output>
</div>
<div class="bonus">
<h3>
<svg class="svg-icon" viewBox="0 0 16 16" title="Bonus">
<circle cx="8" cy="8" r="4"></circle>
<path d="m9,7 2,-6 c 2,0 1,1 2,2 1,1 2,0 2,2 z"></path>
<path d="M7,9 5,15 C 3,15 4,14 3,13 2,12 1,13 1,11 z"></path>
</svg>
</h3>
<h3>Bonus</h3>
<output></output>
</div>
<div class="player-rules">
<p id="player-rule-compat-lynx" title="This level is known to have compatibility issues with the default Lexy rules, but is playable with the rules it was designed for.">
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M 5,1 C 4,1 3,2 3,3 v 9 c 0,1 0,1 -1,1 -1,0 -1,2 0,2 h 9 c 1,0 2,-1 2,-2 V 4 c 0,-1 0,-1 1,-1 1,0 1,-2 0,-2 z m 0,2 h 6 V 4 H 5 Z m 0,3 h 6 V 7 H 5 Z m 0,3 h 6 v 1 H 5 Z m 0,3 h 6 v 1 H 5 Z"></path></svg>
<span>May require Lynx rules</span>
</p>
<p id="player-rule-logic-hidden" title="In this level, wires and logic gates are invisible. X-ray glasses will reveal them.">
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M1,8 a7,7 0 0,1 14,0 7,7 0 0,1 -14,0 M3,8 a5,5 0 0,1 10,0 5,5 0 0,1 -10,0"></path><path d="M2,7 h5 v-5 h2 v5 h5 v2 h-5 v5 h-2 v-5 h-5 v-2"></path><path d="M1,14 L14,1 l1,1 L2,15 l-1,-1"></path></svg>
<span>Logic hidden</span>
</p>
<p id="player-rule-cc1-boots" title="In this level, tools cannot be dropped, and only the tools available in CC1 (cleats, suction boots, fire boots, and flippers) can be picked up.">
<svg class="svg-icon" viewBox="0 0 16 16"><path d="m 1,12 v 3 h 3 l 5,-5 h 3 L 15,7 V 4 L 12,7 H 11 L 9,5 V 4 L 12,1 H 9 L 6,4 v 3 z"></path></svg>
<span>Retro tool mode</span>
</p>
</div>
<div class="inventory"></div>
</section>
<div id="player-music">
🎵 <a id="player-music-title" target="_blank">title</a> by <a id="player-music-author" target="_blank">author</a>
<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>
@ -352,18 +204,22 @@
<div class="-buttons" id="player-debug-time-buttons">
<!-- populated in js -->
</div>
<div id="player-debug-speed" class="radio-faux-button-set">
<label><input type="radio" name="speed" value="1/4"><span class="-button">¼</span></label>
<label><input type="radio" name="speed" value="1/3"><span class="-button"></span></label>
<label><input type="radio" name="speed" value="1/2"><span class="-button">½</span></label>
<label><input type="radio" name="speed" value="1"><span class="-button"><strong>1×</strong></span></label>
<label><input type="radio" name="speed" value="2"><span class="-button">2</span></label>
<label><input type="radio" name="speed" value="3"><span class="-button">3</span></label>
<label><input type="radio" name="speed" value="5"><span class="-button">5</span></label>
<label><input type="radio" name="speed" value="10"><span class="-button">10</span></label>
<label><input type="radio" name="speed" value="25"><span class="-button">25</span></label>
<label><input type="radio" name="speed" value="100"><span class="-button">100</span></label>
</div>
<p>Game speed:
<select name="speed">
<option value="100">100× faster</option>
<option value="50">50× faster</option>
<option value="20">20× faster</option>
<option value="10">10× faster</option>
<option value="5">5× faster</option>
<option value="3">3× faster</option>
<option value="2">2× faster</option>
<option value="3/2">× faster</option>
<option value="1" selected>Normal speed</option>
<option value="1/2">2× slower</option>
<option value="1/3">3× slower</option>
<option value="1/4">4× slower</option>
</select>
</p>
<h3>Inventory</h3>
<div class="-inventory">
@ -407,11 +263,10 @@
</select>
</p>
<ul>
<li><label><input type="checkbox" name="disable_interpolation"> Disable interpolation</label></li>
<li><label><input type="checkbox" name="show_actor_bboxes"> Show actor bounding boxes</label></li>
<li><label><input type="checkbox" name="show_actor_order"> Show actor order</label></li>
<li><label><input type="checkbox" name="show_actor_tooltips"> Show actor tooltips</label></li>
<li><label><input type="checkbox" name="disable_interpolation"> Disable interpolation</label></li>
<!--
<li><label><input type="checkbox" disabled> Show actor info</label></li>
<li><label><input type="checkbox" disabled> Freeze time for everything else</label></li>
<li><label><input type="checkbox" disabled> Player is immortal</label></li>
<li><label><input type="checkbox" disabled> Player ignores collision</label></li>
@ -422,6 +277,7 @@
<!-- populated in js -->
</div>
<p>Tip: Middle-click to teleport.</p>
<!-- TODO?
- inspect with mouse
- list of actors, or currently pointed-to actor?
@ -439,16 +295,72 @@
</form>
</main>
<main id="editor" hidden>
<header></header>
<header>
<!-- TODO
- close
- export
- delete??
- zoom
also deal with levels vs level /packs/ somehow, not sure how that'll work (including downloading them, yeargh?)
-->
</header>
<div class="editor-canvas">
<div class="-container">
<!-- level canvas and any overlays go here -->
<!-- the container is to allow them to scroll as a single unit -->
</div>
</div>
<nav class="controls"></nav>
<nav class="controls">
<div id="editor-tile">
</div>
<!--
<p style>
Tip: Right click to color drop.<br>
Tip: Ctrl-click with terrain to replace only the current tile's terrain, rather than overwriting the whole tile.
</p>
<p>Layer: [all/auto] [terrain] [item] [actor] [overlay]</p>
<p>Actor direction: [north] [south] [east] [west]</p>
<p>[ ] Show connections</p>
<p>[ ] Toggle green objects</p>
<p>[ ] Show monster pathing</p>
<p>[ ] Show circuits???</p>
<pre>
Metadata:
xxx / yyy chips required
Time limit: [____]
Title: [__________]
Author: [__________]
map size
</pre>
-->
</nav>
<div class="palette"></div>
<div id="editor-statusbar"></div>
<!-- TODO:
controls
- play!
- object palette
- choose direction
- choose layer to /modify/: terrain, item, creature, overlay
- stack (place item atop whatever terrain), or replace (placing a tile overwrites the whole cell)
[XXX mode that allows arbitrary stacking of objects?]
- level metadata
- change size
XXX how do i handle thin walls? treat specially, allow drawing/erasing them along edges instead of tiles? ehh then you can't control which tile they're in though... but the game seems to prefer south+east so maybe that works...
hotkeys
- mod a tile on the board: rotate a creature, alter thin walls??
- "pick up" a tile
cool stuff
- set chip count by hand, set extra ones automatically
-->
</main>
</body>
</html>

View File

@ -1,230 +0,0 @@
import { DIRECTIONS, LAYERS } from './defs.js';
// 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) {
let terrain = cell.get_terrain();
if (! terrain)
continue;
let edgeinfo = DIRECTIONS[edge];
let seen_edges = seen_cells.get(cell) ?? 0;
if (seen_edges & edgeinfo.bit)
continue;
let tile = terrain;
let actor = cell.get_actor();
if (actor && actor.type.contains_wire && (
(actor_mode === 'still' && actor.movement_cooldown === 0) || actor_mode === 'always'))
{
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;
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 (mode === 'none') {
// The wires in this tile never connect to each other
}
else if (mode === 'cross' || (mode === 'autocross' && tile.wire_directions === 0x0f)) {
// This is a cross pattern, so only opposite edges connect
if (tile.wire_directions & edgeinfo.opposite_bit) {
connections |= edgeinfo.opposite_bit;
}
}
else {
// Everything connects
connections |= tile.wire_directions;
}
seen_cells.set(cell, seen_edges | 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)) {
// Obviously don't go backwards, but that doesn't apply if this is our first pass
if (direction === edge && ! is_first)
continue;
if ((connections & dirinfo.bit) === 0)
continue;
let neighbor;
if ((terrain.wire_tunnel_directions ?? 0) & dirinfo.bit) {
// Search in this direction for a matching tunnel
// 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 = levelish.get_neighboring_cell(cell, direction);
}
/*
if (! neighbor || (((neighbor.get_terrain().wire_directions ?? 0) & dirinfo.opposite_bit) === 0)) {
console.log("bailing here", neighbor, direction);
continue;
}
*/
if (! neighbor)
continue;
next.push([neighbor, dirinfo.opposite]);
}
}
pending = next;
is_first = false;
}
return circuit;
}
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 = levelish.cell(x, y);
if (! candidate)
return null;
let neighbor = candidate.get_terrain();
if (! neighbor)
continue;
if ((neighbor.wire_tunnel_directions ?? 0) & dirinfo.opposite_bit) {
if (nesting === 0) {
return candidate;
}
else {
nesting -= 1;
}
}
if ((neighbor.wire_tunnel_directions ?? 0) & dirinfo.bit) {
nesting += 1;
}
}
}

View File

@ -4,50 +4,38 @@ export const DIRECTIONS = {
north: {
movement: [0, -1],
bit: 0x01,
opposite_bit: 0x04,
index: 0,
action: 'up',
left: 'west',
right: 'east',
opposite: 'south',
mirrored: 'north',
flipped: 'south',
},
south: {
movement: [0, 1],
bit: 0x04,
opposite_bit: 0x01,
index: 2,
action: 'down',
left: 'east',
right: 'west',
opposite: 'north',
mirrored: 'south',
flipped: 'north',
},
west: {
movement: [-1, 0],
bit: 0x08,
opposite_bit: 0x02,
index: 3,
action: 'left',
left: 'south',
right: 'north',
opposite: 'east',
mirrored: 'east',
flipped: 'west',
},
east: {
movement: [1, 0],
bit: 0x02,
opposite_bit: 0x08,
index: 1,
action: 'right',
left: 'north',
right: 'south',
opposite: 'west',
mirrored: 'west',
flipped: 'east',
},
};
// Should match the bit ordering above, and CC2's order
@ -65,33 +53,23 @@ export const INPUT_BITS = {
wait: 0x8000,
};
export const LAYERS = {
// TODO cc2 order is: swivel, thinwalls, canopy (and yes you can have them all in the same tile)
export const DRAW_LAYERS = {
terrain: 0,
item: 1,
item_mod: 2,
actor: 3,
vfx: 4,
swivel: 5,
thin_wall: 6,
canopy: 7,
MAX: 8,
overlay: 4,
MAX: 5,
};
export const COLLISION = {
real_player1: 0x0001,
real_player2: 0x0002,
real_player: 0x0003,
doppel1: 0x0004,
doppel2: 0x0008,
doppel: 0x000c,
playerlike1: 0x0005,
playerlike2: 0x000a,
playerlike: 0x000f,
player1: 0x0001,
player2: 0x0002,
player: 0x0003,
block_cc1: 0x0010,
block_cc2: 0x0020, // ice + frame (+ circuit, etc)
bowling_ball: 0x0040, // rolling ball, dynamite
block_cc1: 0x0004,
block_cc2: 0x0008, // ice + directional
// Monsters are a little complicated, because some of them have special rules, e.g. fireballs
// aren't blocked by fire.
@ -100,234 +78,14 @@ export const COLLISION = {
monster_generic: 0x0100,
fireball: 0x0200,
bug: 0x0400,
yellow_tank: 0x0800,
rover: 0x1000,
ghost: 0x8000,
// For a tile's COLLISION, use one of these bit combinations
monster_typical: 0x6f00, // everything but ghost and rover
monster_any: 0xff00, // everything including ghost (only used for monster/fire compat flag)
monster_solid: 0x7f00, // everything but ghost
monster_any: 0xff00, // everything including ghost
// Combo masks used for matching
all_but_ghost: 0xffff & ~0x8000,
all_but_real_player: 0xffff & ~0x0003,
all: 0xffff,
all_but_ghost: 0xffff & ~0x8000,
all_but_player: 0xffff & ~0x0003,
all: 0xffff,
};
// Item pickup priority, which both actors and items have. An actor will pick up an item if the
// item's priority is greater than or equal to the actor's.
export const PICKUP_PRIORITIES = {
never: 4, // cc2 blocks, never pick anything up
always: 3, // all actors; blue keys, yellow teleporters (everything picks up except cc2 blocks)
// TODO is this even necessary? in cc2 the general rule seems to be that anything stepping on
// an item picks it up, and collision is used to avoid that most of the time
normal: 3, // actors with inventories; most items
player: 1, // players and doppelgangers; red keys (ignored by everything else)
real_player: 0,
};
export const COMPAT_RULESET_LABELS = {
lexy: "Lexy",
steam: "Steam",
'steam-strict': "Steam (strict)",
lynx: "Lynx",
ms: "Microsoft",
custom: "Custom",
};
export const COMPAT_RULESET_ORDER = ['lexy', 'steam', 'steam-strict', 'lynx', 'ms', 'custom'];
// FIXME some of the names of the flags themselves kinda suck
// 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']),
}],
}, {
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']),
}],
}, {
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']),
}],
}, {
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']),
}],
}];
export function compat_flags_for_ruleset(ruleset) {
let compat = {};
for (let category of COMPAT_FLAG_CATEGORIES) {
for (let compatdef of category.flags) {
if (compatdef.rulesets.has(ruleset)) {
compat[compatdef.key] = true;
}
}
}
return compat;
}

View File

@ -1,5 +1,5 @@
import { TransientOverlay } from '../main-base.js';
import { mk, mk_svg } from '../util.js';
import { TransientOverlay } from './main-base.js';
import { mk, mk_svg } from './util.js';
// FIXME could very much stand to have a little animation when appearing
class TileEditorOverlay extends TransientOverlay {
@ -10,43 +10,8 @@ class TileEditorOverlay extends TransientOverlay {
this.tile = null;
}
edit_tile(tile, cell) {
edit_tile(tile) {
this.tile = tile;
this.cell = cell;
this.needs_undo_entry = false;
}
// Please call this BEFORE actually modifying the tile; it's important for undo!
mark_dirty() {
if (this.cell) {
if (! this.needs_undo_entry) {
// We are ABOUT to mutate this tile for the first time; swap it out with a clone in
// preparation for making an undo entry when this overlay closes
this.pristine_tile = this.tile;
this.tile = {...this.tile};
this.cell[this.tile.type.layer] = this.tile;
this.needs_undo_entry = true;
}
this.editor.mark_cell_dirty(this.cell);
}
else if (this.tile === this.editor.fg_tile) {
// The change hasn't happened yet! Don't redraw until we return to the event loop
setTimeout(() => this.editor.redraw_foreground_tile(), 0);
}
else if (this.tile === this.editor.bg_tile) {
setTimeout(() => this.editor.redraw_background_tile(), 0);
}
}
close() {
if (this.needs_undo_entry) {
// This will be a no-op the first time since the tile was already swapped, but it's
// important for redo
this.editor._assign_tile(this.cell, this.tile.type.layer, this.tile, this.pristine_tile);
this.editor.commit_undo();
}
super.close();
}
static configure_tile_defaults(tile) {
@ -80,14 +45,14 @@ class LetterTileEditor extends TileEditorOverlay {
list.addEventListener('change', ev => {
if (this.tile) {
this.mark_dirty();
this.tile.overlaid_glyph = this.root.elements['glyph'].value;
this.editor.mark_tile_dirty(this.tile);
}
});
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
edit_tile(tile) {
super.edit_tile(tile);
this.root.elements['glyph'].value = tile.overlaid_glyph;
}
@ -103,16 +68,15 @@ class HintTileEditor extends TileEditorOverlay {
this.root.append(mk('h3', "Hint text"));
this.text = mk('textarea.editor-hint-tile-text');
this.root.append(this.text);
this.text.addEventListener('change', ev => {
if (this.tile && this.text.value !== this.tile.hint_text) {
this.mark_dirty();
this.text.addEventListener('input', ev => {
if (this.tile) {
this.tile.hint_text = this.text.value;
}
});
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
edit_tile(tile) {
super.edit_tile(tile);
this.text.value = tile.hint_text ?? "";
}
@ -174,19 +138,19 @@ class FrameBlockTileEditor extends TileEditorOverlay {
if (! this.tile)
return;
this.mark_dirty();
if (ev.target.checked) {
this.tile.arrows.add(ev.target.value);
}
else {
this.tile.arrows.delete(ev.target.value);
}
this.editor.mark_tile_dirty(this.tile);
});
this.root.append(arrow_list);
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
edit_tile(tile) {
super.edit_tile(tile);
for (let input of this.root.elements['direction']) {
input.checked = tile.arrows.has(input.value);
@ -225,22 +189,21 @@ class RailroadTileEditor extends TileEditorOverlay {
track_list.append(mk('li', mk('label', input, svg_icons[i])));
}
track_list.addEventListener('change', ev => {
if (! this.tile)
return;
this.mark_dirty();
let bit = 1 << ev.target.value;
if (ev.target.checked) {
this.tile.tracks |= bit;
}
else {
this.tile.tracks &= ~bit;
if (this.tile) {
let bit = 1 << ev.target.value;
if (ev.target.checked) {
this.tile.tracks |= bit;
}
else {
this.tile.tracks &= ~bit;
}
this.editor.mark_tile_dirty(this.tile);
}
});
this.root.append(track_list);
this.root.append(mk('h3', "Switch"));
let switch_list = mk('ul.editor-railroad-tile-tracks.--switch.editor-tile-editor-svg-parts');
let switch_list = mk('ul.editor-railroad-tile-tracks.--switch');
for (let i of track_order) {
let input = mk('input', {type: 'radio', name: 'switch', value: i});
switch_list.append(mk('li', mk('label', input, svg_icons[i].cloneNode(true))));
@ -249,8 +212,8 @@ class RailroadTileEditor extends TileEditorOverlay {
// TODO if they pick a track that's missing it should add it
switch_list.addEventListener('change', ev => {
if (this.tile) {
this.mark_dirty();
this.tile.track_switch = parseInt(ev.target.value, 10);
this.tile.track_switch = ev.target.value;
this.editor.mark_tile_dirty(this.tile);
}
});
this.root.append(switch_list);
@ -259,8 +222,8 @@ class RailroadTileEditor extends TileEditorOverlay {
// TODO initial actor facing (maybe only if there's an actor in the cell)
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
edit_tile(tile) {
super.edit_tile(tile);
for (let input of this.root.elements['track']) {
input.checked = !! (tile.tracks & (1 << input.value));
@ -285,6 +248,6 @@ export const TILES_WITH_PROPS = {
railroad: RailroadTileEditor,
// TODO various wireable tiles (hmm not sure how that ui works)
// TODO initial value of counter
// TODO cloner arrows (should this be automatic unless you set them explicitly?)
// TODO cloner arrows
// TODO later, custom floor/wall selection
};

View File

@ -1,547 +0,0 @@
import * as c2g from '../format-c2g.js';
import { DialogOverlay, AlertOverlay, flash_button } from '../main-base.js';
import CanvasRenderer from '../renderer-canvas.js';
import { mk, mk_button } from '../util.js';
import * as util from '../util.js';
export class EditorPackMetaOverlay extends DialogOverlay {
constructor(conductor, stored_pack) {
super(conductor);
this.set_title("pack properties");
let dl = mk('dl.formgrid');
this.main.append(dl);
dl.append(
mk('dt', "Title"),
mk('dd', mk('input', {name: 'title', type: 'text', value: stored_pack.title})),
);
// TODO...? what else is a property of the pack itself
this.add_button("save", () => {
let els = this.root.elements;
let title = els.title.value;
if (title !== stored_pack.title) {
stored_pack.title = title;
this.conductor.update_level_title();
}
this.close();
});
this.add_button("nevermind", () => {
this.close();
});
}
}
export class EditorLevelMetaOverlay extends DialogOverlay {
constructor(conductor, stored_level) {
super(conductor);
this.set_title("level properties");
let dl = mk('dl.formgrid');
this.main.append(dl);
let time_limit_input = mk('input', {name: 'time_limit', type: 'number', min: 0, max: 65535, value: stored_level.time_limit});
let time_limit_output = mk('output');
let update_time_limit = () => {
let time_limit = parseInt(time_limit_input.value, 10);
// FIXME need a change event for this tbh?
// FIXME handle NaN; maybe block keydown of not-numbers
time_limit = Math.max(0, Math.min(65535, time_limit));
time_limit_input.value = time_limit;
let text;
if (time_limit === 0) {
text = "No time limit";
}
else {
text = util.format_duration(time_limit);
}
time_limit_output.textContent = text;
};
update_time_limit();
time_limit_input.addEventListener('input', update_time_limit);
let make_size_input = (name) => {
let input = mk('input', {name: name, type: 'number', min: 10, max: 100, value: stored_level[name]});
// TODO maybe block keydown of non-numbers too?
// Note that this is a change event, not an input event, so we don't prevent them from
// erasing the whole value to type a new one
input.addEventListener('change', ev => {
let value = parseInt(ev.target.value, 10);
if (isNaN(value)) {
ev.target.value = stored_level[name];
}
else if (value < 1) {
// Smaller than 10×10 isn't supported by CC2, but LL doesn't mind, so let it
// through if they try it manually
ev.target.value = 1;
}
else if (value > 100) {
ev.target.value = 100;
}
});
return input;
};
dl.append(
mk('dt', "Title"),
mk('dd.-one-field', mk('input', {name: 'title', type: 'text', value: stored_level.title})),
mk('dt', "Author"),
mk('dd.-one-field', mk('input', {name: 'author', type: 'text', value: stored_level.author})),
mk('dt', "Comment"),
mk('dd.-textarea', mk('textarea', {name: 'comment', rows: 4, cols: 20}, stored_level.comment)),
mk('dt', "Time limit"),
mk('dd.-with-buttons',
mk('div.-left',
time_limit_input,
" ",
time_limit_output,
),
mk('div.-right',
mk_button("None", () => {
this.root.elements['time_limit'].value = 0;
update_time_limit();
}),
mk_button("30s", () => {
this.root.elements['time_limit'].value = Math.max(0,
parseInt(this.root.elements['time_limit'].value, 10) - 30);
update_time_limit();
}),
mk_button("+30s", () => {
this.root.elements['time_limit'].value = Math.min(999,
parseInt(this.root.elements['time_limit'].value, 10) + 30);
update_time_limit();
}),
mk_button("Max", () => {
this.root.elements['time_limit'].value = 999;
update_time_limit();
}),
),
),
mk('dt', "Size"),
mk('dd.-with-buttons',
mk('div.-left', make_size_input('size_x'), " × ", make_size_input('size_y')),
mk('div.-right', ...[10, 32, 50, 100].map(size =>
mk_button(`${size}²`, () => {
this.root.elements['size_x'].value = size;
this.root.elements['size_y'].value = size;
}),
)),
),
mk('dt', "Viewport"),
mk('dd',
mk('label',
mk('input', {name: 'viewport', type: 'radio', value: '10'}),
" 10×10 (Chip's Challenge 2 size)"),
mk('br'),
mk('label',
mk('input', {name: 'viewport', type: 'radio', value: '9'}),
" 9×9 (Chip's Challenge 1 size)"),
mk('br'),
mk('label',
mk('input', {name: 'viewport', type: 'radio', value: '', disabled: 'disabled'}),
" Split 10×10 (not yet supported)"),
),
mk('dt', "Blob behavior"),
mk('dd',
mk('label',
mk('input', {name: 'blob_behavior', type: 'radio', value: '0'}),
" Deterministic (PRNG + simple convolution)"),
mk('br'),
mk('label',
mk('input', {name: 'blob_behavior', type: 'radio', value: '1'}),
" 4 patterns (CC2 default; PRNG + rotating offset)"),
mk('br'),
mk('label',
mk('input', {name: 'blob_behavior', type: 'radio', value: '2'}),
" Extra random (LL default; initial seed is truly random)"),
),
mk('dt', "Options"),
mk('dd', mk('label',
mk('input', {name: 'hide_logic', type: 'checkbox'}),
" Hide wires and logic gates (warning: CC2 also hides pink/black buttons!)")),
mk('dd', mk('label',
mk('input', {name: 'use_cc1_boots', type: 'checkbox'}),
" Use CC1-style inventory (can only pick up the four classic boots; can't drop or cycle)")),
);
this.root.elements['viewport'].value = stored_level.viewport_size;
this.root.elements['blob_behavior'].value = stored_level.blob_behavior;
this.root.elements['hide_logic'].checked = stored_level.hide_logic;
this.root.elements['use_cc1_boots'].checked = stored_level.use_cc1_boots;
// TODO:
// - chips?
// - password???
// - comment
// - use CC1 tools
// - hide logic
// - "unviewable", "read only"
this.add_button("save", () => {
let els = this.root.elements;
let title = els.title.value;
if (title !== stored_level.title) {
stored_level.title = title;
this.conductor.stored_game.level_metadata[this.conductor.level_index].title = title;
this.conductor.update_level_title();
}
let author = els.author.value;
if (author !== stored_level.author) {
stored_level.author = author;
}
// FIXME gotta deal with NaNs here too, sigh, might just need a teeny tiny form library
stored_level.time_limit = Math.max(0, Math.min(65535, parseInt(els.time_limit.value, 10)));
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.crop_level(0, 0, size_x, size_y);
}
stored_level.blob_behavior = parseInt(els.blob_behavior.value, 10);
stored_level.hide_logic = els.hide_logic.checked;
stored_level.use_cc1_boots = els.use_cc1_boots.checked;
let viewport_size = parseInt(els.viewport.value, 10);
if (viewport_size !== 9 && viewport_size !== 10) {
viewport_size = 10;
}
stored_level.viewport_size = viewport_size;
this.conductor.player.update_viewport_size();
this.close();
});
this.add_button("nevermind", () => {
this.close();
});
}
}
// List of levels, used in the player
export class EditorLevelBrowserOverlay extends DialogOverlay {
constructor(conductor) {
super(conductor);
this.set_title("choose a level");
// Set up some infrastructure to lazily display level renders
// FIXME should this use the tileset appropriate for the particular level?
this.renderer = new CanvasRenderer(this.conductor.tilesets['ll'], 32);
this.awaiting_renders = [];
this.observer = new IntersectionObserver((entries, _observer) => {
let any_new = false;
let to_remove = new Set;
for (let entry of entries) {
if (entry.target.classList.contains('--rendered'))
continue;
let index = this._get_index(entry.target);
if (entry.isIntersecting) {
this.awaiting_renders.push(index);
any_new = true;
}
else {
to_remove.add(index);
}
}
this.awaiting_renders = this.awaiting_renders.filter(index => ! to_remove.has(index));
if (any_new) {
this.schedule_level_render();
}
},
{ root: this.main },
);
this.list = mk('ol.editor-level-browser');
this.selection = this.conductor.level_index;
for (let [i, meta] of conductor.stored_game.level_metadata.entries()) {
this.list.append(this._make_list_item(i, meta));
}
this.list.childNodes[this.selection].classList.add('--selected');
this.main.append(
mk('p', "Drag to rearrange. Changes are immediate!"),
this.list,
);
this.list.addEventListener('click', ev => {
let index = this._get_index(ev.target);
if (index === null)
return;
this._select(index);
});
this.list.addEventListener('dblclick', ev => {
let index = this._get_index(ev.target);
if (index !== null && this.conductor.change_level(index)) {
this.close();
}
});
this.sortable = new Sortable(this.list, {
group: 'editor-levels',
onEnd: ev => {
if (ev.oldIndex === ev.newIndex)
return;
this._move_level(ev.oldIndex, ev.newIndex);
this.undo_stack.push(() => {
this.list.insertBefore(
this.list.childNodes[ev.newIndex],
this.list.childNodes[ev.oldIndex + (ev.oldIndex < ev.newIndex ? 0 : 1)]);
this._move_level(ev.newIndex, ev.oldIndex);
});
this.undo_button.disabled = false;
},
});
// FIXME ring buffer?
this.undo_stack = [];
// Left buttons
this.undo_button = this.add_button("undo", () => {
if (! this.undo_stack.length)
return;
let undo = this.undo_stack.pop();
undo();
this.undo_button.disabled = ! this.undo_stack.length;
});
this.undo_button.disabled = true;
this.add_button("create", () => {
let index = this.selection + 1;
let stored_level = this.conductor.editor._make_empty_level(index + 1, 32, 32);
this.conductor.editor.move_level(stored_level, index);
this._after_insert_level(stored_level, index);
this.undo_stack.push(() => {
this._delete_level(index);
});
this.undo_button.disabled = false;
});
this.add_button("duplicate", () => {
let index = this.selection + 1;
let stored_level = this.conductor.editor.duplicate_level(this.selection);
this._after_insert_level(stored_level, index);
this.undo_stack.push(() => {
this._delete_level(index);
});
this.undo_button.disabled = false;
});
this.delete_button = this.add_button("delete", () => {
let index = this.selection;
if (index === this.conductor.level_index) {
new AlertOverlay(this.conductor, "You can't delete the level you have open.").open();
return;
}
// Snag a copy of the serialized level for undo purposes
// FIXME can't undo deleting a corrupt level
let meta = this.conductor.stored_game.level_metadata[index];
let serialized_level = window.localStorage.getItem(meta.key);
this._delete_level(index);
this.undo_stack.push(() => {
let stored_level = meta.stored_level ?? c2g.parse_level(
util.bytestring_to_buffer(serialized_level), index + 1);
this.conductor.editor.move_level(stored_level, index);
if (this.selection >= index) {
this.selection += 1;
}
this._after_insert_level(stored_level, index);
});
this.undo_button.disabled = false;
});
this._update_delete_button();
// Right buttons
this.add_button_gap();
this.add_button("open", () => {
if (this.selection === this.conductor.level_index || this.conductor.change_level(this.selection)) {
this.close();
}
});
this.add_button("nevermind", () => {
this.close();
});
}
_make_list_item(index, meta) {
let li = mk('li',
{'data-index': index},
mk('div.-preview'),
mk('div.-number', {}, meta.number),
mk('div.-title', {}, meta.error ? "(error!)" : meta.title),
);
if (meta.error) {
li.classList.add('--error');
}
else {
this.observer.observe(li);
}
return li;
}
renumber_levels(start_index, end_index = null) {
end_index = end_index ?? this.conductor.stored_game.level_metadata.length - 1;
for (let i = start_index; i <= end_index; i++) {
let li = this.list.childNodes[i];
let meta = this.conductor.stored_game.level_metadata[i];
li.setAttribute('data-index', i);
li.querySelector('.-number').textContent = meta.number;
}
}
_get_index(element) {
let li = element.closest('li');
if (! li)
return null;
return parseInt(li.getAttribute('data-index'), 10);
}
_select(index) {
this.list.childNodes[this.selection].classList.remove('--selected');
this.selection = index;
this.list.childNodes[this.selection].classList.add('--selected');
this._update_delete_button();
}
_update_delete_button() {
this.delete_button.disabled = !! (this.selection === this.conductor.level_index);
}
schedule_level_render() {
if (this._handle)
return;
this._handle = setTimeout(() => { this.render_level() }, 50);
}
render_level() {
this._handle = null;
let t0 = performance.now();
while (true) {
if (this.awaiting_renders.length === 0)
return;
let index = this.awaiting_renders.shift();
let element = this.list.childNodes[index];
// FIXME levels may have been renumbered since this was queued, whoops
let stored_level = this.conductor.stored_game.load_level(index);
this.renderer.set_level(stored_level);
this.renderer.set_viewport_size(stored_level.size_x, stored_level.size_y);
this.renderer.draw_static_region(0, 0, stored_level.size_x, stored_level.size_y);
let canvas = mk('canvas', {
width: stored_level.size_x * this.renderer.tileset.size_x / 4,
height: stored_level.size_y * this.renderer.tileset.size_y / 4,
});
canvas.getContext('2d').drawImage(this.renderer.canvas, 0, 0, canvas.width, canvas.height);
element.querySelector('.-preview').append(canvas);
element.classList.add('--rendered');
if (performance.now() - t0 > 10)
break;
}
this.schedule_level_render();
}
expire(index) {
let li = this.list.childNodes[index];
li.classList.remove('--rendered');
li.querySelector('.-preview').textContent = '';
}
_after_insert_level(stored_level, index) {
this.list.insertBefore(
this._make_list_item(index, this.conductor.stored_game.level_metadata[index]),
this.list.childNodes[index]);
this._select(index);
this.renumber_levels(index + 1);
}
_delete_level(index) {
let num_levels = this.conductor.stored_game.level_metadata.length;
this.conductor.editor.move_level(index, null);
this.list.childNodes[this.selection].classList.remove('--selected');
this.list.childNodes[index].remove();
if (index === num_levels - 1) {
this.selection -= 1;
}
else {
this.renumber_levels(index);
}
this.list.childNodes[this.selection].classList.add('--selected');
}
_move_level(from_index, to_index) {
this.conductor.editor.move_level(from_index, to_index);
let selection = this.selection;
if (from_index < to_index) {
this.renumber_levels(from_index, to_index);
if (from_index < selection && selection <= to_index) {
selection -= 1;
}
}
else {
this.renumber_levels(to_index, from_index);
if (to_index <= selection && selection < from_index) {
selection += 1;
}
}
if (this.selection === from_index) {
this.selection = to_index;
}
else {
this.selection = selection;
}
this._update_delete_button();
}
}
export class EditorShareOverlay extends DialogOverlay {
constructor(conductor, url) {
super(conductor);
this.set_title("give this to friends");
this.main.append(mk('p', "Give this URL out to let others try your level:"));
this.main.append(mk('p.editor-share-url', {}, url));
let copy_button = mk('button', {type: 'button'}, "Copy to clipboard");
copy_button.addEventListener('click', ev => {
flash_button(ev.target);
navigator.clipboard.writeText(url);
});
this.main.append(copy_button);
let ok = mk('button', {type: 'button'}, "neato");
ok.addEventListener('click', () => {
this.close();
});
this.footer.append(ok);
}
}
export class EditorExportFailedOverlay extends DialogOverlay {
constructor(conductor, errors, _warnings) {
// TODO support warnings i guess
super(conductor);
this.set_title("export didn't go so well");
this.main.append(mk('p', "Whoops! I tried very hard to export your level, but it didn't work out. Sorry."));
let ul = mk('ul.editor-export-errors');
// TODO structure the errors better and give them names out here, also reduce duplication,
// also be clear about which are recoverable or not
for (let error of errors) {
ul.append(mk('li', error));
}
this.main.append(ul);
this.add_button("oh well", () => {
this.close();
});
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,656 +0,0 @@
// Small helper classes used by the editor, often with their own UI for the SVG overlay.
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, cx: sx + 0.5, cy: sy + 0.5});
this.line = mk_svg('line.-arrow', {});
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.sx = sx;
this.sy = sy;
this.dx = dx;
this.dy = dy;
this._update_line_endpoints();
}
set_source(sx, sy) {
this.sx = sx;
this.sy = sy;
this.source.setAttribute('cx', sx + 0.5);
this.source.setAttribute('cy', sy + 0.5);
this._update_line_endpoints();
}
set_dest(dx, dy) {
this.dx = dx;
this.dy = dy;
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);
}
}
}
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.size_text = mk_svg('text.overlay-edit-tip');
this.owner.svg_group.append(this.element, this.size_text);
this.rect = null;
}
set_extrema(x0, y0, x1, y1) {
this.rect = new DOMRect(Math.min(x0, x1), Math.min(y0, y1), Math.abs(x0 - x1) + 1, Math.abs(y0 - y1) + 1);
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);
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() {
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();
}
}
export class Selection {
constructor(editor) {
this.editor = editor;
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);
// 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.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.is_empty)
return true;
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(mode) {
return new PendingRectangularSelection(this, mode);
}
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._add_rect(rect),
() => {
this._set_from_set(old_cells);
},
false,
);
}
_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.is_empty)
return;
if (! this.floated_cells) {
console.error("Can't move a non-floating selection");
return;
}
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() {
// FIXME behavior when floating is undefined
if (this.is_empty)
return;
let old_cells = this.cells;
this.editor._do(
() => this._clear(),
() => {
this._set_from_set(old_cells);
},
false,
);
}
_clear() {
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) {
console.error("Trying to float a selection that's already floating");
return;
}
let floated_cells = new Map;
let stored_level = this.editor.stored_level;
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.set(n, cell.map(tile => tile ? {...tile} : null));
}
else {
floated_cells.set(n, cell);
this.editor.replace_cell(cell, this.editor.make_blank_cell(x, y));
}
}
this.editor._do(
() => {
this.floated_cells = floated_cells;
this.floated_offset = [0, 0];
this._init_floated_canvas();
this.ring_element.classList.add('--floating');
},
() => 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;
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;
let n2 = stored_level.coords_to_scalar(x, y);
this.editor.replace_cell(stored_level.linear_cells[n2], cell);
}
}
// 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();
// 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._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,
);
}
// 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;
}
// Redraw the selection canvas from scratch
redraw() {
if (! this.floated_canvas)
return;
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 make more stuff respect this (more things should go through Editor for undo reasons anyway)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,40 +1,17 @@
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 {
constructor(initial_force_floor_direction, blob_seed, inputs = null, step_parity = null, tw_seed = 0) {
constructor(initial_force_floor_direction, blob_seed, inputs = null) {
this.initial_force_floor_direction = initial_force_floor_direction;
this.blob_seed = blob_seed;
this.step_parity = step_parity;
this.tw_seed = tw_seed;
this.inputs = inputs ?? new Uint8Array;
this.duration = this.inputs.length;
this.cursor = 0;
}
configure_level(level) {
level.force_floor_direction = this.initial_force_floor_direction;
level._blob_modifier = this.blob_seed;
level._tw_rng = this.tw_seed;
if (this.step_parity !== null) {
level.step_parity = this.step_parity;
}
}
get(t) {
if (this.duration <= 0) {
return 0;
@ -71,68 +48,24 @@ export class Replay {
}
}
// Small shared helper methods for navigating a StoredLevel or Level
export class LevelInterface {
// Expected attributes:
// .size_x
// .size_y
// .linear_cells
scalar_to_coords(n) {
return [n % this.size_x, Math.floor(n / this.size_x)];
}
coords_to_scalar(x, y) {
return x + y * this.size_x;
}
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);
}
cell(x, y) {
if (this.is_point_within_bounds(x, y)) {
return this.linear_cells[this.coords_to_scalar(x, y)];
}
else {
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 {
export class StoredLevel {
constructor(number) {
super();
// TODO still not sure this belongs here
this.number = number; // one-based
this.title = '';
this.author = '';
this.password = null;
this.comment = '';
// A number is a specified count; the default of null means that the chips are counted on
// level init, as in CC2
this.chips_required = null;
this.hint = '';
this.chips_required = 0;
this.time_limit = 0;
this.viewport_size = 9;
this.extra_chunks = [];
this.use_cc1_boots = false;
// What we were parsed from: 'ccl', 'c2m', or null
this.format = null;
// Whether we use LL features that don't exist in CC2; null means we don't know
this.uses_ll_extensions = null;
this.use_ccl_compat = false;
// 0 - deterministic (PRNG + simple convolution)
// 1 - 4 patterns (default; PRNG + rotating through 0-3)
// 2 - extra random (like deterministic, but initial seed is "actually" random)
this.blob_behavior = 1;
this.hide_logic = false;
// Lazy-loading that allows for checking existence (see methods below)
// TODO this needs a better interface, these get accessed too much atm
@ -144,16 +77,25 @@ export class StoredLevel extends LevelInterface {
this.size_y = 0;
this.linear_cells = [];
// Maps of button positions to trap/cloner positions, as scalars
// 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;
// Maps of button positions to trap/cloner positions, as scalar indexes
// in the linear cell list
// TODO merge these imo
this.has_custom_connections = false;
this.custom_trap_wiring = {};
this.custom_cloner_wiring = {};
// New LL feature: custom camera regions, as lists of {x, y, width, height}
this.camera_regions = [];
}
scalar_to_coords(n) {
return [n % this.size_x, Math.floor(n / this.size_x)];
}
coords_to_scalar(x, y) {
return x + y * this.size_x;
}
check() {
}
@ -171,7 +113,6 @@ 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;
@ -183,10 +124,6 @@ export class StoredPack {
// error: any error received while loading the level
// bytes: Uint8Array of the encoded level data
this.level_metadata = [];
// Sparse/optional array of Replays, generally from an ancillary file like a TWS
// TODO unclear if this is a good API for this
this.level_replays = [];
}
// TODO this may or may not work sensibly when correctly following a c2g
@ -201,13 +138,10 @@ export class StoredPack {
// The editor stores inflated levels at times, so respect that
return meta.stored_level;
}
// Otherwise, attempt to load the level
let stored_level = this._level_loader(meta);
if (! stored_level.has_replay && this.level_replays[index]) {
stored_level._replay = this.level_replays[index];
else {
// Otherwise, attempt to load the level
return this._level_loader(meta);
}
return stored_level;
}
}

View File

@ -1,4 +1,4 @@
import { DIRECTIONS, DIRECTION_ORDER, LAYERS } from './defs.js';
import { DIRECTIONS, DIRECTION_ORDER } from './defs.js';
import * as format_base from './format-base.js';
import TILE_TYPES from './tiletypes.js';
import * as util from './util.js';
@ -79,7 +79,7 @@ export function encode_replay(replay, stored_level = null) {
}
let input = replay.inputs[i];
if (input !== prev_input || count >= 252 - 2) {
if (input !== prev_input || count >= 252) {
out[p] = count;
out[p + 1] = input;
p += 2;
@ -92,8 +92,6 @@ export function encode_replay(replay, stored_level = null) {
}
out[p] = 0xff;
p += 1;
out[p] = 0x00;
p += 1;
out = out.subarray(0, p);
// TODO stick it on the level if given?
return out;
@ -111,16 +109,6 @@ let modifier_wire = {
},
};
let modifier_color = {
_order: ['red', 'blue', 'yellow', 'green'],
decode(tile, modifier) {
tile.color = this._order[modifier % 4];
},
encode(tile) {
return this._order.indexOf(tile.color);
},
};
let arg_direction = {
size: 1,
decode(tile, dirbyte) {
@ -226,46 +214,16 @@ const TILE_ENCODING = {
extra_args: [arg_direction],
},
0x1b: {
// CC1 south thin wall
name: 'thin_walls',
name: 'thinwall_s',
has_next: true,
modifier: {
dummy: true,
decode(tile, mod) {
tile.edges = DIRECTIONS['south'].bit;
},
encode(tile) {
return 0;
},
},
},
0x1c: {
// CC1 east thin wall
name: 'thin_walls',
name: 'thinwall_e',
has_next: true,
modifier: {
dummy: true,
decode(tile, mod) {
tile.edges = DIRECTIONS['east'].bit;
},
encode(tile) {
return 0;
},
},
},
0x1d: {
// CC1 southeast thin wall
name: 'thin_walls',
name: 'thinwall_se',
has_next: true,
modifier: {
dummy: true,
decode(tile, mod) {
tile.edges = DIRECTIONS['south'].bit | DIRECTIONS['east'].bit;
},
encode(tile) {
return 0;
},
},
},
0x1e: {
name: 'gravel',
@ -398,18 +356,8 @@ const TILE_ENCODING = {
has_next: true,
},
0x41: {
name: 'trap',
// Not actually a modifier, just using this for hax
// FIXME round-trip this, maybe expose it in the editor (sigh)
modifier: {
dummy: true,
decode(tile, mod) {
tile._initially_open = true;
},
encode(tile) {
return 0;
},
},
// FIXME cc2lp1 uses this, i don't know what it actually does
error: "Open trap is not yet implemented!",
},
0x42: {
name: 'trap',
@ -419,14 +367,8 @@ const TILE_ENCODING = {
},
0x44: {
name: 'cloner',
modifier: {
decode(tile, mod) {
tile.arrows = mod;
},
encode(tile) {
return tile.arrows;
},
},
// TODO visual directions bitmask, no gameplay impact, possible editor impact
modifier: null,
},
0x45: {
name: 'hint',
@ -546,8 +488,8 @@ const TILE_ENCODING = {
else {
tile.direction = DIRECTION_ORDER[modifier & 0x03];
let type = modifier >> 2;
if (type < 7) {
tile.gate_type = ['not', 'and', 'or', 'xor', 'latch-cw', 'nand', 'diode'][type];
if (type < 6) {
tile.gate_type = ['not', 'and', 'or', 'xor', 'latch-cw', 'nand'][type];
}
else if (type === 16) {
tile.gate_type = 'latch-ccw';
@ -577,9 +519,6 @@ const TILE_ENCODING = {
else if (tile.gate_type === 'nand') {
return 20 + direction_offset;
}
else if (tile.gate_type === 'diode') {
return 24 + direction_offset;
}
else if (tile.gate_type === 'counter') {
return 30 + tile.memory;
}
@ -805,146 +744,16 @@ const TILE_ENCODING = {
has_next: true,
},
// ------------------------------------------------------------------------------------------------
// LL-specific tiles
0xd0: {
name: 'electrified_floor',
is_extension: true,
},
0xd1: {
name: 'hole',
is_extension: true,
},
0xd2: {
name: 'cracked_floor',
is_extension: true,
},
0xd3: {
name: 'cracked_ice',
is_extension: true,
},
0xd4: {
name: 'score_5x',
has_next: true,
is_extension: true,
},
0xd5: {
name: 'spikes',
is_extension: true,
},
0xd6: {
name: 'boulder',
has_next: true,
extra_args: [arg_direction],
},
// 0xd7
0xd8: {
name: 'dash_floor',
is_extension: true,
},
0xd9: {
name: 'teleport_blue_exit',
modifier: modifier_wire,
is_extension: true,
},
0xda: {
name: 'glass_block',
has_next: true,
extra_args: [arg_direction],
is_extension: true,
},
0xe0: {
name: 'gift_bow',
has_next: true,
is_extension: true,
},
0xe1: {
name: 'circuit_block',
has_next: true,
modifier: modifier_wire,
extra_args: [arg_direction],
is_extension: true,
},
0xe2: {
name: 'skeleton_key',
has_next: true,
is_extension: true,
},
0xe3: {
name: 'gate_red',
has_next: true,
is_extension: true,
},
0xe4: {
name: 'gate_blue',
has_next: true,
is_extension: true,
},
0xe5: {
name: 'gate_yellow',
has_next: true,
is_extension: true,
},
0xe6: {
name: 'gate_green',
has_next: true,
is_extension: true,
},
0xe7: {
name: 'sand',
is_extension: true,
},
0xe8: {
name: 'grass',
is_extension: true,
},
0xed: {
name: 'ankh',
has_next: true,
is_extension: true,
},
0xef: {
name: 'turntable_cw',
modifier: modifier_wire,
is_extension: true,
},
0xf0: {
name: 'turntable_ccw',
modifier: modifier_wire,
is_extension: true,
},
0xf1: {
name: 'sokoban_block',
has_next: true,
modifier: modifier_color,
extra_args: [arg_direction],
is_extension: true,
},
0xf2: {
name: 'sokoban_button',
modifier: modifier_color,
is_extension: true,
},
0xf3: {
name: 'sokoban_wall',
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 = {};
@ -1063,9 +872,6 @@ export function parse_level(buf, number = 1) {
}
let level = new format_base.StoredLevel(number);
level.format = 'c2m';
level.uses_ll_extensions = false; // we'll update this if it changes
let default_hint = '';
let extra_hints = [];
let hint_tiles = [];
for (let [type, bytes] of read_c2m_sections(buf)) {
@ -1074,7 +880,7 @@ export function parse_level(buf, number = 1) {
type === 'CLUE' || type === 'NOTE')
{
// These are all singular strings (with a terminating NUL, for some reason)
// XXX character encoding?? seems to be latin1, ugh
// XXX character encoding??
let str = util.string_from_buffer_ascii(bytes, 0, bytes.length - 1).replace(/\r\n/g, "\n");
// TODO store more of this, at least for idempotence, maybe
@ -1097,26 +903,13 @@ export function parse_level(buf, number = 1) {
}
else if (type === 'CLUE') {
// Level hint
default_hint = str;
level.hint = str;
}
else if (type === 'NOTE') {
// Author's comments... but might also include tags delimiting special blocks, most
// notably for storing multiple hints. Note that this parsing might lose data in
// two cases: if other text is in the same line as the tag (which still counts!),
// it's silently ignored; if there are more [CLUE] blocks than the level has hints,
// the extras are silently dropped, because hint text is a tile prop in LL.
let parts = str.split(/\n?^.*\[(CLUE|JETLIFE|COM)\].*$\n?/mg);
level.comment = parts[0];
extra_hints = [];
for (let i = 1; i < parts.length; i += 2) {
let type = parts[i];
let text = parts[i + 1];
if (type === 'CLUE') {
extra_hints.push(text);
}
// TODO do something with COM (c2g commands) and JETLIFE (easter egg, make flame
// jets propagate like game of life)
}
// Author's comments... but might also include multiple hints for levels with
// multiple hint tiles, delineated by [CLUE] (anywhere in the line (!)).
// LL treats extra hints as tile properties, so store them for later
[level.comment, ...extra_hints] = str.split(/\n?^.*\[CLUE\].*$\n?/mg);
}
continue;
}
@ -1164,11 +957,11 @@ export function parse_level(buf, number = 1) {
if (view.byteLength <= 22)
continue;
level.hide_logic = !! view.getUint8(22, true);
//options.hide_logic = view.getUint8(22, true);
if (view.byteLength <= 23)
continue;
level.use_cc1_boots = !! view.getUint8(23, true);
level.use_cc1_boots = view.getUint8(23, true);
if (view.byteLength <= 24)
continue;
@ -1228,10 +1021,6 @@ export function parse_level(buf, number = 1) {
}
}
if (spec.is_extension) {
level.uses_ll_extensions = true;
}
let name = spec.name;
// Make a tile template, possibly dealing with some special cases
@ -1241,13 +1030,21 @@ export function parse_level(buf, number = 1) {
// bitmask
let mask = bytes[p];
p++;
// This order is important; this is the order CC2 draws them in
if (mask & 0x10) {
let type = TILE_TYPES['canopy'];
cell[type.layer] = {type};
cell.push({type: TILE_TYPES['canopy']});
}
if (mask & 0x0f) {
let type = TILE_TYPES['thin_walls'];
cell[type.layer] = {type, edges: mask & 0x0f};
if (mask & 0x08) {
cell.push({type: TILE_TYPES['thinwall_w']});
}
if (mask & 0x04) {
cell.push({type: TILE_TYPES['thinwall_s']});
}
if (mask & 0x02) {
cell.push({type: TILE_TYPES['thinwall_e']});
}
if (mask & 0x01) {
cell.push({type: TILE_TYPES['thinwall_n']});
}
// Skip the rest of the loop. That means we don't handle any of the other
// special behavior below, but neither thin walls nor canopies should use
@ -1262,7 +1059,7 @@ export function parse_level(buf, number = 1) {
let type = TILE_TYPES[name];
if (!type) console.error(name, spec);
let tile = {type};
cell[type.layer] = tile;
cell.push(tile);
if (spec.modifier) {
spec.modifier.decode(tile, modifier);
}
@ -1271,10 +1068,12 @@ export function parse_level(buf, number = 1) {
// TODO this should go on the bottom
// TODO we should sort and also only allow one thing per layer
if (spec.dummy_terrain) {
let type = TILE_TYPES[spec.dummy_terrain];
cell[type.layer] = {type};
cell.push({type: TILE_TYPES[spec.dummy_terrain]});
}
if (type.is_required_chip) {
level.chips_required++;
}
if (type.is_hint) {
// Remember all the hint tiles (in reading order) so we can map extra hints
// to them later. Don't do it now, since the format doesn't technically
@ -1294,6 +1093,7 @@ export function parse_level(buf, number = 1) {
if (! spec.has_next)
break;
}
cell.reverse();
level.linear_cells.push(cell);
}
}
@ -1327,21 +1127,8 @@ export function parse_level(buf, number = 1) {
p += 4;
}
}
else if (type === 'LXCX') {
// Custom connections, like MSCC (but more! maybe)
if (bytes.length % 4 !== 0)
throw new Error(`Expected LXCX chunk to be a multiple of 4 bytes; got ${bytes.length}`);
let p = 0;
while (p < bytes.length) {
let src = view.getUint16(p, true);
let dest = view.getUint16(p + 2, true);
level.custom_connections.set(src, dest);
p += 4;
}
}
else {
// console.warn(`Unrecognized section type '${type}' at offset ${bytes.byteOffset}`, view);
console.warn(`Unrecognized section type '${type}' at offset ${bytes.byteOffset}`);
// TODO save it, persist when editing level
}
}
@ -1353,7 +1140,7 @@ export function parse_level(buf, number = 1) {
}
else {
// Fall back to regular hint
tile.hint_text = default_hint;
tile.hint_text = null;
}
}
@ -1472,7 +1259,6 @@ class C2M {
}
if (typeof buf === 'string' || buf instanceof String) {
// FIXME encode as latin1, maybe with some kludge for anything that doesn't fit
let str = buf;
// C2M also includes the trailing NUL
buf = new ArrayBuffer(str.length + 1);
@ -1515,9 +1301,7 @@ class C2M {
export function synthesize_level(stored_level) {
let c2m = new C2M;
c2m.add_section('CC2M', '7'); // latest version
// TODO add in a VERS (editor version) section? some other indication LL produced it? not for
// url sharing though, should make that as small as possible
c2m.add_section('CC2M', '133');
if (stored_level.title) {
c2m.add_section('TITL', stored_level.title);
@ -1527,32 +1311,17 @@ export function synthesize_level(stored_level) {
}
// Options block
let options = new Uint8Array(25); // max possible size
let options_length = 0;
let options = new Uint8Array(3);
new DataView(options.buffer).setUint16(0, stored_level.time_limit, true);
if (stored_level.viewport_size === 10) {
options[2] = 0;
}
else if (stored_level.viewport_size === 9) {
options[2] = 1;
options_length = 3;
}
if (stored_level.hide_logic) {
options[22] = 1;
options_length = 23;
}
if (stored_level.use_cc1_boots) {
options[23] = 1;
options_length = 24;
}
if (stored_level.blob_behavior !== 0) {
options[24] = stored_level.blob_behavior;
options_length = 25;
}
// TODO split
if (options_length > 0) {
c2m.add_section('OPTN', options.slice(0, options_length));
}
// TODO for size purposes, omit the block entirely if all options are defaults?
c2m.add_section('OPTN', options);
// Store camera regions
// TODO LL feature, should be distinguished somehow
@ -1569,21 +1338,6 @@ export function synthesize_level(stored_level) {
c2m.add_section('LXCM', bytes.buffer);
}
// Store MSCC-like custom connections
// TODO LL feature, should be distinguished somehow
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 stored_level.custom_connections) {
view.setUint16(p + 0, src, true);
view.setUint16(p + 2, dest, true);
p += 4;
}
c2m.add_section('LXCX', buf);
}
let map_bytes = new Uint8Array(1024);
let map_view = new DataView(map_bytes.buffer);
map_bytes[0] = stored_level.size_x;
@ -1601,44 +1355,11 @@ export function synthesize_level(stored_level) {
map_view = new DataView(map_bytes.buffer);
}
// TODO complain if duplicates on a layer
let dummy_terrain_tile = null;
let handled_thin_walls = false;
for (let i = LAYERS.MAX - 1; i >= 0; i--) {
for (let i = cell.length - 1; i >= 0; i--) {
let tile = cell[i];
if (! tile)
continue;
if (tile.type.name === 'canopy' || tile.type.name === 'thin_walls') {
// These two tiles are encoded together despite being on different layers. If we
// see the canopy first, then find the thin wall tile (if any) and set a flag so we
// don't try to encode it again
if (handled_thin_walls)
continue;
handled_thin_walls = true;
let canopy, thin_walls;
if (tile.type.name === 'canopy') {
canopy = tile;
thin_walls = cell[LAYERS.thin_wall];
}
else {
thin_walls = tile;
}
let arg = 0;
if (canopy) {
arg |= 0x10;
}
if (thin_walls) {
arg |= thin_walls.edges;
}
map_bytes[p] = REVERSE_TILE_ENCODING['#thinwall/canopy'].tile_byte;
map_bytes[p + 1] = arg;
p += 2;
continue;
}
// FIXME does not yet support canopy or thin walls >:S
let spec = REVERSE_TILE_ENCODING[tile.type.name];
// Handle the swivel, a tile that draws as an overlay but is stored like terrain. In a
@ -1647,7 +1368,7 @@ export function synthesize_level(stored_level) {
// 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.layer === LAYERS.terrain && dummy_terrain_tile) {
if (tile.type.draw_layer === 0 && dummy_terrain_tile) {
tile = dummy_terrain_tile;
spec = REVERSE_TILE_ENCODING[tile.type.name];
}
@ -1703,18 +1424,12 @@ export function synthesize_level(stored_level) {
}
map_bytes = map_bytes.subarray(0, p);
let comment = stored_level.comment;
if (hints.length) {
// 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.
hints = hints.map(hint => hint ?? '');
hints.push('');
hints.unshift('');
// Must use Windows linebreaks here 🙄
comment += hints.join('\r\n[CLUE]\r\n');
}
// TODO support COM and JETLIFE
c2m.add_section('NOTE', comment);
// 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.
hints = hints.map(hint => hint ?? '');
hints.push('');
hints.unshift('');
c2m.add_section('NOTE', hints.join('\n[CLUE]\n'));
let compressed_map = compress(map_bytes);
if (compressed_map) {
@ -1775,7 +1490,7 @@ const TOKENIZE_RX = RegExp(
// 2: Comments are preceded by ; or // for some reason and run to the end of the line
'|(?:;|//)(.*)' +
// 3: Strings are double-quoted (only!) and contain no escapes
'|"([^"]*?)(?:"|$)' +
'|"([^"]*?)"' +
// 4: Labels are indicated by a #, including when used with 'goto'
// (the exact set of allowed characters is unclear and i'm fudging it here)
'|#(\\w+)' +
@ -1790,7 +1505,7 @@ const TOKENIZE_RX = RegExp(
'|([a-zA-Z]\\S*)' +
// 8: Anything else is an error
'|(\\S+)' +
')', 'gm');
')', 'g');
const DIRECTIVES = {
// Important stuff
'chdir': ['string'],
@ -1894,7 +1609,6 @@ class ParseError extends Error {
super(`${message} at line ${parser.lineno}`);
}
}
ParseError.prototype.name = 'ParseError';
class Parser {
constructor(string) {
@ -2176,7 +1890,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);
@ -2192,19 +1906,8 @@ 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 += 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
// slot (?!), but i can't even consider that until i actually execute these things in
// order
if (game.identifier === undefined) {
let title = stmt.args[0].value;
game.identifier = title;
game.title = title;
}
level_number++;
}
statements.push(stmt);
}

View File

@ -1,4 +1,3 @@
import { DIRECTIONS, LAYERS } from './defs.js';
import * as format_base from './format-base.js';
import TILE_TYPES from './tiletypes.js';
import * as util from './util.js';
@ -10,10 +9,10 @@ const TILE_ENCODING = {
0x03: 'water',
0x04: 'fire',
0x05: 'wall_invisible',
0x06: ['thin_walls', {edges: DIRECTIONS['north'].bit}],
0x07: ['thin_walls', {edges: DIRECTIONS['west'].bit}],
0x08: ['thin_walls', {edges: DIRECTIONS['south'].bit}],
0x09: ['thin_walls', {edges: DIRECTIONS['east'].bit}],
0x06: 'thinwall_n',
0x07: 'thinwall_w',
0x08: 'thinwall_s',
0x09: 'thinwall_e',
// This is MSCC's incomprehensible non-directional dirt block, which needs a direction for Lynx
// purposes; Tile World defaults it to north
0x0a: ['dirt_block', 'north'],
@ -55,7 +54,7 @@ const TILE_ENCODING = {
0x2d: 'gravel',
0x2e: 'popwall',
0x2f: 'hint',
0x30: ['thin_walls', {edges: DIRECTIONS['south'].bit | DIRECTIONS['east'].bit}],
0x30: 'thinwall_se',
0x31: 'cloner',
0x32: 'force_floor_all',
0x33: 'bogus_player_drowned',
@ -63,7 +62,7 @@ const TILE_ENCODING = {
0x35: 'bogus_player_burned',
0x36: 'wall_invisible', // unused
0x37: 'wall_invisible', // unused
0x38: 'ice_block', // unused, but co-opted by pgchip
0x38: 'wall_invisible', // unused
0x39: 'bogus_player_win',
0x3a: 'bogus_player_win',
0x3b: 'bogus_player_win',
@ -121,45 +120,6 @@ const TILE_ENCODING = {
0x6f: ['player', 'east'],
};
const REVERSE_TILE_ENCODING = {};
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
tile_byte = parseInt(tile_byte, 10); // these are keys so they get stringified ugh
if (0x36 <= tile_byte && tile_byte <= 0x37) {
// These are unused tiles which get turned into invisible walls; don't encode invisible
// walls as them! (0x38 is also "unused", but pgchip turns it into ice block.)
continue;
}
let name, arg;
if (spec instanceof Array) {
[name, arg] = spec;
}
else {
name = spec;
arg = null;
}
let rev_spec = REVERSE_TILE_ENCODING[name];
if (! rev_spec) {
rev_spec = {};
REVERSE_TILE_ENCODING[name] = rev_spec;
}
if (arg === null || tile_byte === 0x0a) {
// Special case: 0x0a is MSCC's undirected dirt block, which needs to coexist with the
// directed "clone" blocks
rev_spec['all'] = tile_byte;
}
else if (typeof arg === 'string') {
// This is a direction
rev_spec[arg] = tile_byte;
}
else {
// This is the thin_walls argument structure
rev_spec[arg.edges] = tile_byte;
}
}
function decode_password(bytes, start, len) {
let password = [];
for (let i = 0; i < len; i++) {
@ -201,6 +161,10 @@ export function parse_level_metadata(bytes) {
// Password, with trailing NUL, and XORed with 0x99 (???)
meta.password = decode_password(bytes, p, field_length - 1);
}
else if (field_type === 0x07) {
// Hint, including trailing NUL, of course
meta.hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
}
p += field_length;
}
@ -209,10 +173,8 @@ export function parse_level_metadata(bytes) {
function parse_level(bytes, number) {
let level = new format_base.StoredLevel(number);
level.only_custom_connections = true;
level.format = 'ccl';
level.uses_ll_extensions = false;
level.chips_required = 0;
level.has_custom_connections = true;
level.use_ccl_compat = true;
// Map size is always fixed as 32x32 in CC1
level.size_x = 32;
level.size_y = 32;
@ -232,7 +194,6 @@ function parse_level(bytes, number) {
let unknown = view.getUint16(6, true);
// Same structure twice, for the two layers
let p = 8;
let hint_tiles = [];
for (let l = 0; l < 2; l++) {
let layer_length = view.getUint16(p, true);
p += 2;
@ -256,18 +217,14 @@ function parse_level(bytes, number) {
throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y})`);
}
let name, extra;
let name, direction;
if (spec instanceof Array) {
[name, extra] = spec;
if (typeof extra === 'string') {
extra = {direction: extra};
}
[name, direction] = spec;
}
else {
name = spec;
extra = {};
}
let tile = {type: TILE_TYPES[name], ...extra};
let type = TILE_TYPES[name];
for (let i = 0; i < count; i++) {
if (c >= 1024)
@ -285,22 +242,7 @@ function parse_level(bytes, number) {
continue;
}
// pgchip grants directions to ice blocks on cloners by putting a clone block
// beneath them instead
if (l === 1 && 0x0e <= tile_byte && tile_byte <= 0x11 &&
cell[LAYERS.actor] && cell[LAYERS.actor].type.name === 'ice_block')
{
cell[LAYERS.actor].direction = extra.direction;
let type = TILE_TYPES['cloner'];
cell[type.layer] = {type};
continue;
}
let new_tile = {...tile};
cell[tile.type.layer] = new_tile;
if (new_tile.type.name === 'hint') {
hint_tiles.push(new_tile);
}
cell.unshift({type, direction});
}
}
if (c !== 1024)
@ -309,8 +251,9 @@ function parse_level(bytes, number) {
// Fix the "floor/empty" nonsense here by adding floor to any cell with no terrain on bottom
for (let cell of level.linear_cells) {
if (! cell[LAYERS.terrain]) {
cell[LAYERS.terrain] = { type: TILE_TYPES['floor'] };
if (cell.length === 0 || cell[0].type.draw_layer !== 0) {
// No terrain; insert a floor
cell.unshift({ type: TILE_TYPES['floor'] });
}
// TODO we could also deal with weird cases where there's terrain /on top of/ something
// else: things underwater, the quirk where a glider will erase the item underneath...
@ -348,13 +291,7 @@ function parse_level(bytes, number) {
let trap_y = field_view.getUint16(q + 6, true);
// Fifth u16 is always zero, possibly live game state
q += 10;
// Connections are ignored if they're on the wrong tiles anyway, and we use a single
// mapping that's a bit more flexible, so only store valid connections
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.set(s, d);
}
level.custom_trap_wiring[button_x + button_y * level.size_x] = trap_x + trap_y * level.size_x;
}
}
else if (field_type === 0x05) {
@ -367,13 +304,7 @@ function parse_level(bytes, number) {
let cloner_x = field_view.getUint16(q + 4, true);
let cloner_y = field_view.getUint16(q + 6, true);
q += 8;
// Connections are ignored if they're on the wrong tiles anyway, and we use a single
// mapping that's a bit more flexible, so only store valid connections
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.set(s, d);
}
level.custom_cloner_wiring[button_x + button_y * level.size_x] = cloner_x + cloner_y * level.size_x;
}
}
else if (field_type === 0x06) {
@ -382,19 +313,12 @@ function parse_level(bytes, number) {
}
else if (field_type === 0x07) {
// Hint, including trailing NUL, of course
let hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
for (let tile of hint_tiles) {
tile.hint_text = hint;
}
level.hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
}
else if (field_type === 0x08) {
// Password, but not encoded
// TODO ???
}
else if (field_type === 0x09) {
// EXTENSION: Author, including trailing NUL
level.author = util.string_from_buffer_ascii(bytes, p, field_length - 1);
}
else if (field_type === 0x0a) {
// Initial actor order
// TODO ??? should i... trust this...
@ -423,10 +347,6 @@ export function parse_game(buf) {
// OK
// TODO tile world convention, use lynx rules
}
else if (magic === 0x0003aaac) {
// OK
// TODO add in ice block i guess???
}
else {
throw new Error(`Unrecognized magic number ${magic.toString(16)}`);
}
@ -454,257 +374,3 @@ export function parse_game(buf) {
return game;
}
export class CCLEncodingErrors extends util.LLError {
constructor(errors) {
super("Failed to encode level as CCL");
this.errors = errors;
}
}
export function synthesize_level(stored_level) {
let errors = [];
if (stored_level.size_x !== 32) {
errors.push(`Level width must be 32, not ${stored_level.size_x}`);
}
if (stored_level.size_y !== 32) {
errors.push(`Level width must be 32, not ${stored_level.size_y}`);
}
// TODO might also want the tile world "lynx mode" magic number, or pgchip's ice block rules
let magic = 0x0002aaac;
let top_layer = [];
let bottom_layer = [];
let hint_text = null;
let trap_cxns = [];
let cloner_cxns = [];
let monster_coords = [];
let error_found_wires = false;
// TODO i could be a little kinder and support, say, items on terrain; do those work in mscc? tw lynx?
for (let [i, cell] of stored_level.linear_cells.entries()) {
let [x, y] = stored_level.scalar_to_coords(i);
let actor = null;
let other = null;
for (let tile of cell) {
if (! tile)
continue;
if (tile.wire_directions || tile.wire_tunnel_directions) {
error_found_wires = true;
}
if (tile.type.layer === LAYERS.actor) {
actor = tile;
}
else if (tile.type.name === 'floor') {
// This is the default anyway, so don't count it against the number of tiles
continue;
}
else if (other) {
errors.push(`A cell can only contain one static tile, but cell (${x}, ${y}) has both ${other.type.name} and ${tile.type.name}`);
}
else {
other = tile;
}
if (tile.type.is_monster) {
monster_coords.push(x, y);
}
}
let actor_byte = null;
let other_byte = null;
if (actor) {
let rev_spec = REVERSE_TILE_ENCODING[actor.type.name];
if (rev_spec) {
// Special case: dirt blocks only have a direction when on a cloner
if (actor.type.name === 'dirt_block' && ! (other && other.type.name === 'cloner')) {
actor_byte = rev_spec['all'];
}
else {
actor_byte = rev_spec[actor.direction];
}
}
else {
errors.push(`Can't encode tile: ${actor.type.name}`);
}
}
if (other) {
let rev_spec = REVERSE_TILE_ENCODING[other.type.name];
if (rev_spec) {
// Special case: thin walls only come in one of a few configurations
if (other.type.name === 'thin_walls') {
if (other.edges in rev_spec) {
other_byte = rev_spec[other.edges];
}
else {
errors.push(`Thin walls may only have one edge, or be a lower-right corner`);
}
}
else {
other_byte = rev_spec['all'];
}
if (other.type.name === 'hint') {
if (hint_text === null) {
hint_text = other.hint_text;
}
else if (hint_text !== other.hint_text) {
errors.push(`All hints must contain the same text`);
}
}
let cxn_target;
// FIXME one begins to wonder if the lady doth repeat herself
if (other.type.name === 'button_red') {
cxn_target = 'cloner';
}
else if (other.type.name === 'button_brown') {
cxn_target = 'trap';
}
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));
}
else {
// Traps have an extra zero!
trap_cxns.push(x, y, ...stored_level.scalar_to_coords(dest), 0);
}
}
}
}
else {
errors.push(`Can't encode tile: ${other.type.name}`);
}
}
if (other_byte === null) {
other_byte = 0x00; // floor
}
if (actor_byte === null) {
top_layer.push(other_byte);
bottom_layer.push(0x00);
}
else {
top_layer.push(actor_byte);
bottom_layer.push(other_byte);
}
}
if (error_found_wires) {
errors.push(`Wires are not supported`);
}
// TODO RLE
let top_layer_bytes = top_layer;
let bottom_layer_bytes = bottom_layer;
// Assemble metadata fields. You'd think this would deserve a little wrapper like I have for
// the C2M sections, but you're wrong!
let metadata_blocks = [];
let metadata_length = 0;
function add_block(type, contents) {
let len = 2 + contents.byteLength;
let bytes = new Uint8Array(len);
// TODO this copy is annoying
bytes[0] = type;
bytes[1] = contents.byteLength;
bytes.set(new Uint8Array(contents), 2);
metadata_blocks.push(bytes);
metadata_length += len;
}
// Level name
// 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 encode_connections(cxns) {
let words = new ArrayBuffer(cxns.length * 2);
let view = new DataView(words);
for (let [i, val] of cxns.entries()) {
view.setUint16(i * 2, val, true);
}
return words;
}
if (trap_cxns.length > 0) {
add_block(4, encode_connections(trap_cxns));
}
if (cloner_cxns.length > 0) {
add_block(5, encode_connections(cloner_cxns));
}
// Password
// TODO support this for real lol
add_block(6, util.bytestring_to_buffer("XXXX\0"));
// Hint
// TODO tile world seems to do latin-1 (just sort of, inherently); this will do modulo on
// anything outside it (yyyyikes!), probably should sub with ? or something
if (hint_text !== null) {
add_block(7, util.bytestring_to_buffer(hint_text.substring(0, 127) + "\0"));
}
// EXTENSION: Author's name, if present
if (stored_level.author) {
add_block(9, util.bytestring_to_buffer(stored_level.author.substring(0, 255) + "\0"));
}
// Monster positions (dumb as hell and only used in MS mode)
if (monster_coords.length > 0) {
if (monster_coords.length > 256) {
errors.push(`Level has ${monster_coords.length >> 1} monsters, but MS only supports up to 128`);
monster_coords.length = 256;
}
add_block(10, new Uint8Array(monster_coords).buffer);
}
if (errors.length > 0) {
throw new CCLEncodingErrors(errors);
}
// OK, almost done, serialize for real
let level_length = (
10 + // level header
2 + top_layer_bytes.length +
2 + bottom_layer_bytes.length +
2 + metadata_length
);
let total_length = (
6 + // game header
level_length
);
let ret = new ArrayBuffer(total_length);
let array = new Uint8Array(ret);
let view = new DataView(ret);
view.setUint32(0, magic, true);
view.setUint16(4, 1, true); // level count, teehee
let p = 6;
view.setUint16(p, level_length - 2, true); // doesn't include this field
view.setUint16(p + 2, 1, true); // level number
view.setUint16(p + 4, stored_level.time_limit, true);
view.setUint16(p + 6, stored_level.chips_required || 0, true); // FIXME
view.setUint16(p + 8, 1, true); // always 1? indicates compressed map data?
p += 10;
// Map data
view.setUint16(p, top_layer_bytes.length, true);
array.set(new Uint8Array(top_layer_bytes), p + 2);
p += 2 + top_layer_bytes.length;
view.setUint16(p, bottom_layer_bytes.length, true);
array.set(new Uint8Array(bottom_layer_bytes), p + 2);
p += 2 + bottom_layer_bytes.length;
// Metadata
view.setUint16(p, metadata_length, true);
p += 2;
for (let block of metadata_blocks) {
array.set(block, p);
p += block.byteLength;
}
if (p !== total_length) {
console.error("Something has gone very awry:", total_length, p);
}
return ret;
}

View File

@ -1,164 +0,0 @@
import { DIRECTIONS, INPUT_BITS } from './defs.js';
import * as format_base from './format-base.js';
const TW_DIRECTION_TO_INPUT_BITS = [
INPUT_BITS.up,
INPUT_BITS.left,
INPUT_BITS.down,
INPUT_BITS.right,
INPUT_BITS.up | INPUT_BITS.left,
INPUT_BITS.down | INPUT_BITS.left,
INPUT_BITS.up | INPUT_BITS.right,
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) {
buf = bytes.buffer;
}
else {
buf = bytes;
bytes = new Uint8Array(buf);
}
let view = new DataView(buf);
let magic = view.getUint32(0, true);
if (magic !== 0x999b3335)
return;
// 1 for lynx, 2 for ms; also extended to 3 for cc2, 4 for ll
let ruleset = bytes[4];
let extra_bytes = bytes[7];
let ret = {
ruleset: ruleset,
levels: [],
};
let p = 8 + extra_bytes;
let is_first = true;
while (p < buf.byteLength) {
let len = view.getUint32(p, true);
p += 4;
if (len === 0xffffffff)
break;
if (len === 0) {
// Empty, do nothing
}
else if (len < 6) {
// This should never happen
// TODO gripe?
}
else if (bytes[p] === 0 && bytes[p + 1] === 0 && bytes[p + 2] === 0 &&
bytes[p + 3] === 0 && bytes[p + 5] === 0 && bytes[p + 6] === 0)
{
// This record is special and contains the name of the set; it's optional but, if present, must be first
if (! is_first) {
// TODO gripe?
}
}
else if (len === 6) {
// Short record; password only, no replay
}
else {
// Long record
let number = view.getUint16(p, true);
// 2-5: password, don't care
// 6: flags, always zero
let initial_state = bytes[p + 7];
let step_parity = initial_state >> 3;
let initial_rff = ['north', 'west', 'south', 'east'][initial_state & 0x7];
// In CC2 replays, the initial RFF direction is the one you'll actually start with;
// however, in Lynx, the direction is rotated BEFORE it takes effect, so to compensate
// we have to rotate this once ahead of time
initial_rff = DIRECTIONS[initial_rff].right;
let initial_rng = view.getUint32(p + 8, true);
let total_duration = view.getUint32(p + 12, true);
// TODO split this off though
let inputs = [];
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. 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];
let input2 = TW_DIRECTION_TO_INPUT_BITS[(val >> 4) & 0x3];
let input3 = TW_DIRECTION_TO_INPUT_BITS[(val >> 6) & 0x3];
inputs.push(
0, 0, 0, input1,
0, 0, 0, input2,
0, 0, 0, input3,
);
}
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];
q += 1;
}
else if (fmt === 2) {
val = view.getUint16(q, true);
q += 2;
}
else {
val = view.getUint32(q, true);
q += 4;
}
let input = TW_DIRECTION_TO_INPUT_BITS[(val >> 2) & 0x7];
let duration = val >> 5;
for (let i = 0; i < duration; i++) {
inputs.push(0);
}
inputs.push(input);
}
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;
}
}
ret.levels[number - 1] = new format_base.Replay(initial_rff, 0, inputs, step_parity, initial_rng);
}
is_first = false;
p += len;
}
return ret;
}

3623
js/game.js

File diff suppressed because it is too large Load Diff

View File

@ -1,592 +0,0 @@
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 { LocalDirectorySource } from './lib.js';
// TODO arguments:
// - custom pack to test, possibly its solutions, possibly its ruleset (or default to steam-strict/lynx)
// - filter existing packs
// - verbose: ?
// - quiet: hide failure reasons
// - support for xfails somehow?
// TODO use this for a test suite
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: "-",
},
'no-replay': {
color: "\x1b[90m",
symbol: "0",
},
success: {
color: "\x1b[92m",
symbol: ".",
},
early: {
color: "\x1b[96m",
symbol: "?",
},
failure: {
color: "\x1b[91m",
symbol: "#",
},
'short': {
color: "\x1b[93m",
symbol: "#",
},
error: {
color: "\x1b[95m",
symbol: "X",
},
};
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`);
}
if (dy > 0) {
stdout.write(`\x1b[${dy}B`);
}
else if (dy < 0) {
stdout.write(`\x1b[${-dy}A`);
}
}
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)));
}
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);
}
if (level.tic_counter % 20 === 1) {
// XXX
/*
if (handle.cancel) {
return make_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;
}
*/
}
}
}
// 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,
};
}
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) {
// FIXME copied wholesale from Splash.search_multi_source; need a real filesystem + searching api!
// TODO not entiiirely kosher, but not sure if we should have an api for this or what
if (source._loaded_promise) {
await source._loaded_promise;
}
let paths = Object.keys(source.files);
// TODO should handle having multiple candidates, but this is good enough for now
paths.sort((a, b) => a.length - b.length);
for (let path of paths) {
let m = path.match(/[.]([^./]+)$/);
if (! m)
continue;
let ext = m[1];
// TODO this can't load an individual c2m, hmmm
if (ext === 'c2g') {
let buf = await source.get(path);
//await this.conductor.parse_and_load_game(buf, source, path);
// FIXME and this is from parse_and_load_game!!
let dir;
if (! path.match(/[/]/)) {
dir = '';
}
else {
dir = path.replace(/[/][^/]+$/, '');
}
return await format_c2g.parse_game(buf, source, dir);
}
}
// 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 = `\
Usage: bulktest.mjs [OPTION]... [FILE]...
Runs replays for the given level packs and report results.
With no FILE given, default to the built-in copy of CC2LP1.
Arguments may be repeated, and apply to any subsequent pack, so different packs
may be run with different compat modes.
-c compatibility mode; one of
lexy (default), steam, steam-strict, lynx, ms
-r path to a file containing replays; for CCL/DAT packs, which
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'
-h, --help ignore other arguments and show this message
Supports the same filetypes as Lexy's Labyrinth: DAT/CCL, C2M, or a directory
containing a C2G.
`;
class ArgParseError extends Error {}
function parse_level_range(string) {
if (string === 'all') {
return null;
}
let res = new Set;
let parts = string.split(/,/);
for (let part of parts) {
let endpoints = part.match(/^(\d+)(?:-(\d+))?$/);
if (endpoints === null)
throw new ArgParseError(`Bad syntax in level range: ${part}`);
let a = parseInt(endpoints[1], 10);
let b = endpoints[2] === undefined ? a : parseInt(endpoints[2], 10);
if (a > b)
throw new ArgParseError(`Backwards span in level range: ${part}`);
for (let n = a; n <= b; n++) {
res.add(n);
}
}
return res;
}
function parse_args() {
// Parse arguments
let test_template = {
ruleset: 'lexy',
solutions_path: null,
level_filter: null,
};
let tests = [];
try {
let i;
let next_arg = () => {
i += 1;
if (i >= argv.length)
throw new ArgParseError(`Missing argument after ${argv[i - 1]}`);
return argv[i];
};
for (i = 2; i < argv.length; i++) {
let arg = argv[i];
if (arg === '-h' || arg === '--help') {
stdout.write(USAGE);
exit(0);
}
if (arg === '-c') {
let ruleset = next_arg();
if (['lexy', 'steam', 'steam-strict', 'lynx', 'ms'].indexOf(ruleset) === -1)
throw new ArgParseError(`Unrecognized compat mode: ${ruleset}`);
test_template.ruleset = ruleset;
}
else if (arg === '-r') {
test_template.solutions_path = next_arg();
}
else if (arg === '-l') {
test_template.level_filter = parse_level_range(next_arg());
}
else if (arg === '-f') {
tests.push({ pack_path: next_arg(), ...test_template });
}
else {
tests.push({ pack_path: arg, ...test_template });
}
}
}
catch (e) {
if (e instanceof ArgParseError) {
stderr.write(e.message);
stderr.write("\n");
exit(2);
}
}
if (tests.length === 0) {
tests.push({ pack_path: 'levels/CC2LP1.zip', ...test_template });
}
return tests;
}
async function main() {
let tests = parse_args();
let overall = {
num_passed: 0,
num_missing: 0,
num_failed: 0,
time_elapsed: 0,
time_simulated: 0,
};
for (let testdef of tests) {
let result = await test_pack(testdef);
for (let key of Object.keys(overall)) {
overall[key] += result[key];
}
}
let num_levels = overall.num_passed + overall.num_failed + overall.num_missing;
stdout.write("\n");
stdout.write(`${overall.num_passed}/${num_levels} = ${(overall.num_passed / num_levels * 100).toFixed(1)}% passed (${overall.num_failed} failed, ${overall.num_missing} missing replay)\n`);
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`);
}
if (isMainThread) {
main();
}
else {
main_worker(workerData);
}

View File

@ -1,50 +0,0 @@
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}`);
}
}
}

View File

@ -1,70 +0,0 @@
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();

View File

@ -1,4 +1,4 @@
import { mk, mk_svg } from './util.js';
import { mk, mk_svg, walk_grid } from './util.js';
// Superclass for the main display modes: the player, the editor, and the splash screen
export class PrimaryView {
@ -24,8 +24,6 @@ export class PrimaryView {
this.root.setAttribute('hidden', '');
this.active = false;
}
reload_options(options) {}
}
// Stackable modal overlay of some kind, usually a dialog
@ -33,31 +31,22 @@ export class Overlay {
constructor(conductor, root) {
this.conductor = conductor;
this.root = root;
// Make the dialog itself focusable; this makes a lot of stuff easier, like ensuring that
// pressing Esc always has a viable target
this.root.tabIndex = 0;
// Don't propagate clicks on the root element, so they won't trigger a parent overlay's
// automatic dismissal
// Don't propagate clicks on the root element, so they won't trigger a
// parent overlay's automatic dismissal
this.root.addEventListener('click', ev => {
ev.stopPropagation();
});
// Don't propagate keys, either. This is only a partial solution (for when something within
// the dialog has focus), but open() adds another handler to block keys more aggressively
// Block any window-level key handlers from firing when we type
this.root.addEventListener('keydown', ev => {
ev.stopPropagation();
if (ev.key === 'Escape') {
this.close();
}
});
}
open() {
if (this.root.isConnected) {
this.close();
}
// FIXME ah, but keystrokes can still go to the game, including
// spacebar to begin it if it was waiting. how do i completely disable
// an entire chunk of the page?
if (this.conductor.player.state === 'playing') {
this.conductor.player.set_state('paused');
}
@ -70,87 +59,11 @@ export class Overlay {
this.close();
});
// Start with the overlay itself focused
this.root.focus();
// While this dialog is open, keys should not reach the rest of the document, and you should
// not be able to tab your way out of it. This is a rough implementation of that.
// Note that focusin bubbles, but focus doesn't. Also, focusin happens /just before/ an
// element receives focus, not afterwards, but that doesn't seem to affect this.
this.focusin_handler = ev => {
// If we're no longer visible at all, remove this event handler
if (! this.root.isConnected) {
this._remove_global_event_handlers();
return;
}
// If we're not the topmost overlay, do nothing
if (this.root.parentNode.nextElementSibling)
return;
// No problem if the focus is within the dialog, OR on the root <html> element
if (ev.target === document.documentElement || this.root.contains(ev.target)) {
this.last_focused = ev.target;
return;
}
// Otherwise, focus is trying to escape! Put a stop to that.
// Focus was probably moved with tab or shift-tab. We should be the last element in the
// document, so tabbing off the end of us should go to browser UI. Shift-tabbing back
// beyond the start of a document usually goes to the root (and after that, browser UI
// again). Thus, there are only two common cases here: if the last valid focus was on
// the document root, they must be tabbing forwards, so focus our first element; if the
// last valid focus was within us, they must be tabbing backwards, so focus the root.
if (this.last_focused === document.documentElement) {
this.root.focus();
this.last_focused = this.root;
}
else {
document.documentElement.focus();
this.last_focused = document.documentElement;
}
};
window.addEventListener('focusin', this.focusin_handler);
// Block any keypresses attempting to go to an element outside the dialog
this.keydown_handler = ev => {
// If we're no longer visible at all, remove this event handler
if (! this.root.isConnected) {
this._remove_global_event_handlers();
return;
}
// If we're not the topmost overlay, do nothing
if (this.root.parentNode.nextElementSibling)
return;
// Note that if the target is the window itself, contains() will explode
if (! (ev.target instanceof Node && this.root.contains(ev.target))) {
ev.stopPropagation();
}
};
// Use capture, which runs before any other event handler
window.addEventListener('keydown', this.keydown_handler, true);
// Block mouse movement as well
overlay.addEventListener('mousemove', ev => {
ev.stopPropagation();
});
return overlay;
}
_remove_global_event_handlers() {
window.removeEventListener('focusin', this.focusin_handler);
window.removeEventListener('keydown', this.keydown_handler, true);
}
close() {
this._remove_global_event_handlers();
this.root.closest('.overlay').remove();
if (document.activeElement) {
// The active element is almost certainly either the dialog or a control within it,
// which is useless as a focus target, so blur it and let the page have focus
document.activeElement.blur();
}
}
}
@ -164,59 +77,6 @@ export class TransientOverlay extends Overlay {
}
}
export class MenuOverlay extends TransientOverlay {
constructor(conductor, items, make_label, onclick) {
super(conductor, mk('ol.popup-menu'));
for (let [i, item] of items.entries()) {
this.root.append(mk('li', {'data-index': i}, make_label(item)));
}
this.root.addEventListener('click', ev => {
let li = ev.target.closest('li');
if (! li || ! this.root.contains(li))
return;
let i = parseInt(li.getAttribute('data-index'), 10);
let item = items[i];
onclick(item);
this.close();
});
}
open(relto) {
super.open();
let anchor = relto.getBoundingClientRect();
let rect = this.root.getBoundingClientRect();
// Prefer left anchoring, but use right if that would go off the screen
if (anchor.left + rect.width > document.body.clientWidth) {
this.root.style.right = `${document.body.clientWidth - anchor.right}px`;
}
else {
this.root.style.left = `${anchor.left}px`;
}
// Open vertically in whichever direction has more space (with a slight bias towards opening
// downwards). If we would then run off the screen, also set the other anchor to constrain
// the height.
let top_space = anchor.top - 0;
let bottom_space = document.body.clientHeight - anchor.bottom;
if (top_space > bottom_space) {
this.root.style.bottom = `${document.body.clientHeight - anchor.top}px`;
if (rect.height > top_space) {
this.root.style.top = `${0}px`;
}
}
else {
this.root.style.top = `${anchor.bottom}px`;
if (rect.height > bottom_space) {
this.root.style.bottom = `${0}px`;
}
}
}
}
// Overlay styled like a dialog box
export class DialogOverlay extends Overlay {
constructor(conductor) {
@ -234,30 +94,10 @@ export class DialogOverlay extends Overlay {
this.header.append(mk('h1', {}, title));
}
add_button(label, onclick, is_default) {
add_button(label, onclick) {
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;
}
add_button_gap() {
this.footer.append(mk('div.-spacer'));
}
}
// Informational popup dialog
export class AlertOverlay extends DialogOverlay {
constructor(conductor, message, title = "heads up") {
super(conductor);
this.set_title(title);
this.main.append(mk('p', {}, message));
this.add_button("a'ight", () => {
this.close();
}, true);
}
}
@ -267,13 +107,16 @@ export class ConfirmOverlay extends DialogOverlay {
super(conductor);
this.set_title("just checking");
this.main.append(mk('p', {}, message));
this.add_button("yep", ev => {
let yes = mk('button', {type: 'button'}, "yep");
let no = mk('button', {type: 'button'}, "nope");
yes.addEventListener('click', ev => {
this.close();
what();
}, true);
this.add_button("nope", ev => {
});
no.addEventListener('click', ev => {
this.close();
});
this.footer.append(yes, no);
}
}
@ -288,13 +131,6 @@ export function flash_button(button) {
}, 500);
}
export function svg_icon(name) {
return mk_svg(
'svg.svg-icon',
{viewBox: '0 0 16 16'},
mk_svg('use', {href: `#svg-icon-${name}`}));
}
export function load_json_from_storage(key) {
return JSON.parse(window.localStorage.getItem(key));
}

2109
js/main-editor.js Normal file

File diff suppressed because it is too large Load Diff

3567
js/main.js

File diff suppressed because it is too large Load Diff

View File

@ -1,36 +1,7 @@
import { DIRECTIONS, LAYERS } from './defs.js';
import * as util from './util.js';
import { DrawPacket } from './tileset.js';
import { DIRECTIONS, DRAW_LAYERS } from './defs.js';
import { mk } from './util.js';
import TILE_TYPES from './tiletypes.js';
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;
this.y = 0;
// Offset within the cell, for actors in motion
this.offsetx = 0;
this.offsety = 0;
}
blit(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) {
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.tileset.blit_to_canvas(this.ctx,
tx + mx, ty + my,
this.x + mdx, this.y + mdy,
mw, mh);
}
}
export class CanvasRenderer {
constructor(tileset, fixed_size = null) {
this.tileset = tileset;
@ -47,68 +18,16 @@ export class CanvasRenderer {
this.viewport_size_x = 9;
this.viewport_size_y = 9;
}
this.canvas = this.constructor.make_canvas(
tileset.size_x * this.viewport_size_x,
tileset.size_y * this.viewport_size_y);
if (this.canvas.style) {
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
this.canvas.style.setProperty('--tile-width', `${tileset.size_x}px`);
this.canvas.style.setProperty('--tile-height', `${tileset.size_y}px`);
}
this.canvas = mk('canvas', {width: tileset.size_x * this.viewport_size_x, height: tileset.size_y * this.viewport_size_y});
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
this.ctx = this.canvas.getContext('2d');
this.viewport_x = 0;
this.viewport_y = 0;
this.viewport_dirty = false;
this.show_actor_bboxes = false;
this.show_actor_order = false;
this.show_facing = false;
this.use_rewind_effect = false;
this.perception = 'normal'; // normal, xray, editor, palette
this.hide_logic = false;
this.update_rate = 3;
this.use_cc2_anim_speed = false;
this.active_player = null;
}
// This is here so command-line Node stuff can swap it out for the canvas package
static make_canvas(w, 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;
this.perception = 0; // 0 normal, 1 secret eye, 2 editor
}
set_level(level) {
@ -116,10 +35,6 @@ export class CanvasRenderer {
// TODO update viewport size... or maybe Game should do that since you might be cheating
}
set_active_player(actor) {
this.active_player = actor;
}
// Change the viewport size. DOES NOT take effect until the next redraw!
set_viewport_size(x, y) {
this.viewport_size_x = x;
@ -127,23 +42,6 @@ export class CanvasRenderer {
this.viewport_dirty = true;
}
set_tileset(tileset) {
this.tileset = tileset;
this.viewport_dirty = true;
}
get_cell_rect(x, y) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
let scale_y = rect.height / this.canvas.height;
let tile_w = scale_x * this.tileset.size_x;
let tile_h = scale_y * this.tileset.size_y;
return new DOMRect(
rect.x + (x - this.viewport_x) * tile_w,
rect.y + (y - this.viewport_y) * tile_h,
tile_w, tile_h);
}
cell_coords_from_event(ev) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
@ -153,15 +51,6 @@ export class CanvasRenderer {
return [x, y];
}
point_to_cell_coords(client_x, client_y) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
let scale_y = rect.height / this.canvas.height;
let x = Math.floor((client_x - rect.x) / scale_x / this.tileset.size_x + this.viewport_x);
let y = Math.floor((client_y - rect.y) / scale_y / this.tileset.size_y + this.viewport_y);
return [x, y];
}
real_cell_coords_from_event(ev) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
@ -171,48 +60,31 @@ export class CanvasRenderer {
return [x, y];
}
point_to_real_cell_coords(client_x, client_y) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
let scale_y = rect.height / this.canvas.height;
let x = (client_x - rect.x) / scale_x / this.tileset.size_x + this.viewport_x;
let y = (client_y - rect.y) / scale_y / this.tileset.size_y + this.viewport_y;
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;
this.viewport_dirty = false;
this.canvas.width = this.tileset.size_x * this.viewport_size_x;
this.canvas.height = this.tileset.size_y * this.viewport_size_y;
if (this.canvas.style) {
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
this.canvas.style.setProperty('--tile-width', `${this.tileset.size_x}px`);
this.canvas.style.setProperty('--tile-height', `${this.tileset.size_y}px`);
}
}
draw(update_progress = 0) {
draw(tic_offset = 0) {
if (! this.level) {
console.warn("CanvasRenderer.draw: No level to render");
return;
}
this._adjust_viewport_if_dirty();
// Compute the effective current time. Note that this might come out negative before the
// 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 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;
if (this.viewport_dirty) {
this.viewport_dirty = false;
this.canvas.setAttribute('width', this.tileset.size_x * this.viewport_size_x);
this.canvas.setAttribute('height', this.tileset.size_y * this.viewport_size_y);
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
}
let tic = (this.level.tic_counter ?? 0) + tic_offset;
let tw = this.tileset.size_x;
let th = this.tileset.size_y;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
@ -221,11 +93,21 @@ export class CanvasRenderer {
// TODO what about levels smaller than the viewport...? shrink the canvas in set_level?
let xmargin = (this.viewport_size_x - 1) / 2;
let ymargin = (this.viewport_size_y - 1) / 2;
let [px, py] = this.level.player.visual_position(update_progress, packet.update_rate);
let px, py;
// FIXME editor vs player
if (this.level.player) {
[px, py] = this.level.player.visual_position(tic_offset);
}
else {
[px, py] = [0, 0];
}
// Figure out where to start drawing
// TODO support overlapping regions better
let x0 = px - xmargin;
let y0 = py - ymargin;
// FIXME editor vs player again ugh, which is goofy since none of this is even relevant;
// maybe need to have a separate positioning method
if (this.level.stored_level) {
for (let region of this.level.stored_level.camera_regions) {
if (px >= region.left && px < region.right &&
py >= region.top && py < region.bottom)
@ -234,6 +116,7 @@ export class CanvasRenderer {
y0 = Math.max(region.top, Math.min(region.bottom - this.viewport_size_y, y0));
}
}
}
// Always keep us within the map bounds
x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, x0));
y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, y0));
@ -257,131 +140,73 @@ export class CanvasRenderer {
// include the tiles just outside it, so we allow this fencepost problem to fly
let x1 = Math.min(this.level.size_x - 1, Math.ceil(x0 + this.viewport_size_x));
let y1 = Math.min(this.level.size_y - 1, Math.ceil(y0 + this.viewport_size_y));
// Tiles in motion (i.e., actors) don't want to be overdrawn by neighboring tiles' terrain,
// so draw in three passes: everything below actors, actors, and everything above actors
// Draw one layer at a time, so animated objects aren't overdrawn by
// neighboring terrain
for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) {
let cell = this.level.cell(x, y);
for (let layer = 0; layer < LAYERS.actor; layer++) {
let tile = cell[layer];
if (! tile)
continue;
// FIXME this is a bit inefficient when there are a lot of rarely-used layers; consider
// instead drawing everything under actors, then actors, then everything above actors?
for (let layer = 0; layer < DRAW_LAYERS.MAX; layer++) {
for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) {
for (let tile of this.level.cells[y][x]) {
if (tile.type.draw_layer !== layer)
continue;
packet.x = x - x0;
packet.y = y - y0;
this.tileset.draw(tile, packet);
}
}
}
for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) {
let cell = this.level.cell(x, y);
let actor = cell[LAYERS.actor];
if (! actor)
continue;
let vx, vy;
if (tile.type.is_actor &&
// FIXME kind of a hack for the editor, which uses bare tile objects
tile.visual_position)
{
// Handle smooth scrolling
[vx, vy] = tile.visual_position(tic_offset);
// Round this to the pixel grid too!
vx = Math.floor(vx * tw + 0.5) / tw;
vy = Math.floor(vy * th + 0.5) / th;
}
else {
// Non-actors can't move
vx = x;
vy = y;
}
// Handle smooth scrolling
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
// Round this to the pixel grid too!
vx = Math.floor(vx * tw + 0.5) / tw;
vy = Math.floor(vy * th + 0.5) / th;
// For blocks, perception only applies if there's something of interest underneath
if (this.perception !== 'normal' && actor.type.is_block &&
! cell.some(t => t && t.type.layer < LAYERS.actor && ! (
t.type.name === 'floor' && (t.wire_directions | t.wire_tunnel_directions) === 0)))
{
packet.perception = 'normal';
}
else {
packet.perception = this.perception;
}
packet.x = x - x0;
packet.y = y - y0;
packet.offsetx = vx - x;
packet.offsety = vy - y;
// Draw the active player background
if (actor === this.active_player) {
this.tileset.draw_type('#active-player-background', null, packet);
}
this.tileset.draw(actor, packet);
// If they killed the player, indicate as such. The indicator has an arrow at the
// bottom; align that about 3/4 up the killer
if (actor.is_killer && '#killer-indicator' in this.tileset.layout) {
this.tileset.draw_type('#killer-indicator', null, packet);
}
}
}
packet.perception = this.perception;
packet.offsetx = 0;
packet.offsety = 0;
for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) {
let cell = this.level.cell(x, y);
for (let layer = LAYERS.actor + 1; layer < LAYERS.MAX; layer++) {
let tile = cell[layer];
if (! tile)
continue;
packet.x = x - x0;
packet.y = y - y0;
this.tileset.draw(tile, packet);
// Note that the blit we pass to the tileset has a different signature:
// blit(
// source_tile_x, source_tile_y,
// mask_x = 0, mask_y = 0, mask_width = 1, mask_height = mask_width,
// mask_dx = mask_x, mask_dy = mask_y)
// This makes it easier to use in the extremely common case of drawing
// part of one tile atop another tile, but still aligned to the grid.
this.tileset.draw(tile, tic, (tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) =>
this.blit(this.ctx,
tx + mx, ty + my,
vx - x0 + mdx, vy - y0 + mdy,
mw, mh));
}
}
}
}
if (this.use_rewind_effect) {
this.draw_rewind_effect(packet.clock);
}
// Debug overlays
if (this.show_actor_bboxes) {
if (this.show_actor_bboxes && ! this.level.linear_cells) { // FIXME dumb hack so this doesn't happen in editor
this.ctx.fillStyle = '#f004';
for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) {
let actor = this.level.cell(x, y).get_actor();
let actor = this.level.cells[y][x].get_actor();
if (! actor)
continue;
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
let [vx, vy] = actor.visual_position(tic_offset);
// Don't round to the pixel grid; we want to know if the bbox is misaligned!
this.ctx.fillRect((vx - x0) * tw, (vy - y0) * th, 1 * tw, 1 * th);
}
}
}
if (this.show_actor_order) {
this.ctx.fillStyle = '#fff';
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 3;
this.ctx.font = '16px monospace';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
for (let [n, actor] of this.level.actors.entries()) {
let cell = actor.cell;
if (! cell)
continue;
if (cell.x < xf0 || cell.x > x1 || cell.y < yf0 || cell.y > y1)
continue;
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
let x = (vx + 0.5 - x0) * tw;
let y = (vy + 0.5 - y0) * th;
let label = String(this.level.actors.length - 1 - n);
this.ctx.strokeText(label, x, y);
this.ctx.fillText(label, x, y);
}
if (this.use_rewind_effect) {
this.draw_rewind_effect(tic);
}
}
draw_rewind_effect(clock) {
draw_rewind_effect(tic) {
// 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();
let rewind_start = tic / 20 % 1;
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++) {
@ -389,105 +214,15 @@ 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),
// assuming nothing is moving.
draw_static_region(x0, y0, x1, y1, destx = x0, desty = y0) {
this.draw_static_generic({x0, y0, x1, y1, destx, desty});
}
// Most generic possible form of drawing a static region; mainly useful if you want to use a
// different canvas or draw a custom block of cells
// TODO does this actually need any state at all? could it just be, dare i ask, a function?
draw_static_generic({
x0, y0, x1, y1, destx = x0, desty = y0, cells = null, width = null,
ctx = this.ctx, perception = this.perception, show_facing = this.show_facing,
}) {
if (ctx === this.ctx) {
this._adjust_viewport_if_dirty();
}
width = width ?? this.level.size_x;
cells = cells ?? this.level.linear_cells;
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];
if (! cell)
continue;
let seen_anything_interesting;
for (let tile of cell) {
if (! tile)
continue;
// For actors (i.e., blocks), perception only applies if there's something
// of potential interest underneath
if (perception !== 'normal' && tile.type.is_block && ! seen_anything_interesting) {
packet.perception = 'normal';
}
else {
packet.perception = perception;
}
if (tile.type.layer < LAYERS.actor && ! (
tile.type.name === 'floor' && (tile.wire_directions | tile.wire_tunnel_directions) === 0))
{
seen_anything_interesting = true;
}
// Don't draw facing arrows atop blocks, unless they're on a cloner or trap
// where it matters (it's distracting in large clumps and makes it hard to see
// frame arrows)
packet.show_facing = show_facing;
if (show_facing && tile.type.is_block) {
let terrain_name = cell[LAYERS.terrain].type.name;
if (! (terrain_name === 'cloner' || terrain_name === 'trap')) {
packet.show_facing = false;
}
}
packet.x = destx + x - x0;
packet.y = desty + y - y0;
this.tileset.draw(tile, packet);
}
}
}
}
// TODO one wonders why this operates on a separate canvas and we don't just make new renderers
// or something, or maybe make this a tileset method
draw_single_tile_type(name, tile = null, canvas = null, x = 0, y = 0) {
if (! canvas) {
canvas = this.constructor.make_canvas(this.tileset.size_x, this.tileset.size_y);
}
create_tile_type_canvas(name, tile = null) {
let canvas = mk('canvas', {width: this.tileset.size_x, height: this.tileset.size_y});
let ctx = canvas.getContext('2d');
// Individual tile types always reveal what they are
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);
this.tileset.draw_type(name, tile, 0, this.perception, (tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) =>
this.blit(ctx, tx + mx, ty + my, mdx, mdy, mw, mh));
return canvas;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,40 +1,11 @@
import * as fflate from './vendor/fflate.js';
// Base class for custom errors
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) {
// KnuthFisherYates, 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) {
@ -59,12 +30,6 @@ export function mk(tag_selector, ...children) {
return _mk(el, children);
}
export function mk_button(label, onclick) {
let el = mk('button', {type: 'button'}, label);
el.addEventListener('click', onclick);
return el;
}
export const SVG_NS = 'http://www.w3.org/2000/svg';
export function mk_svg(tag_selector, ...children) {
let [tag, ...classes] = tag_selector.split('.');
@ -75,22 +40,6 @@ export function mk_svg(tag_selector, ...children) {
return _mk(el, children);
}
export function trigger_local_download(filename, blob) {
let url = URL.createObjectURL(blob);
// To download a file, um, make an <a> and click it. Not kidding
let a = mk('a', {
href: url,
download: filename,
});
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);
}
export function handle_drop(element, options) {
let dropzone_class = options.dropzone_class ?? null;
let on_drop = options.on_drop;
@ -194,11 +143,11 @@ export function promise_event(element, success_event, failure_event) {
}
export async function fetch(url, response_type = 'arraybuffer') {
export async function fetch(url) {
let xhr = new XMLHttpRequest;
let promise = promise_event(xhr, 'load', 'error');
xhr.open('GET', url);
xhr.responseType = response_type;
xhr.responseType = 'arraybuffer';
xhr.send();
await promise;
if (xhr.status !== 200)
@ -232,16 +181,9 @@ export function b64decode(data) {
}
export function format_duration(seconds, places = 0) {
let sign = '';
if (seconds < 0) {
seconds = -seconds;
sign = '-';
}
let mins = Math.floor(seconds / 60);
let secs = seconds % 60;
let rounded_secs = secs.toFixed(places);
// TODO hours?
return `${sign}${mins}:${parseFloat(rounded_secs) < 10 ? '0' : ''}${rounded_secs}`;
return `${mins}:${secs < 10 ? '0' : ''}${secs.toFixed(places)}`;
}
export class DelayTimer {
@ -285,56 +227,52 @@ 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) {
if (a === goal_x && b === goal_y) {
yield [a, b];
yield [a, b];
if (a === goal_x && b === goal_y)
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;
@ -351,13 +289,9 @@ 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) {
if (a === goal_x && b === goal_y) {
yield [a, b];
yield [a, b];
if (a === goal_x && b === goal_y)
return;
}
if (err !== dx) {
yield [a, b];
}
if (err > 0) {
err -= dy;
@ -371,33 +305,8 @@ 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);
}
}
window.walk_grid = walk_grid;
// console.table(Array.from(walk_grid(13, 27.133854389190674, 12.90625, 27.227604389190674)))
// Root class to indirect over where we might get files from
// - a pool of uploaded in-memory files
@ -406,14 +315,11 @@ export class BitVector {
// - HTTP (but only for files we choose ourselves, not arbitrary ones, due to CORS)
// Note that where possible, these classes lowercase all filenames, in keeping with C2G's implicit
// requirement that filenames are case-insensitive :/
export class FileSource {
class FileSource {
constructor() {}
// 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 {
@ -434,10 +340,6 @@ 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 {
@ -452,58 +354,6 @@ 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
@ -554,35 +404,4 @@ 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
// individual files as needed, but it's also pretty new so maybe later?
export class ZipFileSource extends FileSource {
constructor(buf) {
super();
// TODO async? has some setup time but won't freeze browser
let files = fflate.unzipSync(new Uint8Array(buf));
this.files = {};
for (let [path, file] of Object.entries(files)) {
this.files['/' + path.toLowerCase()] = file;
}
}
async get(path) {
let file = this.files[path.toLowerCase()];
if (! file)
throw new LLError(`No such file in zip: ${path}`);
return file.buffer;
}
iter_all_files() {
return Object.keys(this.files);
}
}

3
js/vendor/fflate.js vendored

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,3 +0,0 @@
{
"type": "module"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More