Compare commits
No files matched your search
@@ -1,8 +1,16 @@
|
|||||||
# Lexy's Labyrinth
|
# Lexy's Labyrinth
|
||||||
|
|
||||||
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!
|
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)).
|
||||||
|
|
||||||
It is a work in progress and also might be abandoned and forgotten at any time.
|
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.
|
||||||
|
|
||||||
## Play online
|
## Play online
|
||||||
|
|
||||||
@@ -10,37 +18,41 @@ Give it a try, I guess! [https://c.eev.ee/lexys-labyrinth/](https://c.eev.ee/le
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- Supports 99% of Chip's Challenge 1
|
- Fully compatible with Chip's Challenge 1 levels... barring a few obscure rule changes
|
||||||
- Supports 75% of Chip's Challenge 2
|
- Fully compatible with Chip's Challenge 2 levels... barring a few obscure bugs
|
||||||
- Completely original tileset, sound effects, and music
|
- Completely original tileset, sound effects, and music
|
||||||
- Can load MS Chip's Challenge DAT/CCL files and Steam Chip's Challenge C2M files
|
- Compatible with MS Chip's Challenge DAT/CCL files, Steam Chip's Challenge C2G/C2M files, and ZIP files
|
||||||
- Can load levels from your hard drive
|
- Can load one of its built-in level packs, the original levels, or anything you've got lying around
|
||||||
- Can play back replays (demos) from C2M files, though some may desync
|
- Able to record and play back demos (replays) from Steam-format levels
|
||||||
- Allows undoing moves, with moderate success
|
- Lets you rewind your mistakes, up to 30 seconds back
|
||||||
- Has the beginning bits of a level editor
|
- 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)
|
||||||
|
|
||||||
### Planned features
|
### Planned features
|
||||||
|
|
||||||
- Save your score, and compare it to the BBC leaderboards
|
|
||||||
- Load levels directly from the BBC set list
|
- Load levels directly from the BBC set list
|
||||||
- Support for all of the nonsense in Chip's Challenge 2
|
- Mouse support
|
||||||
- 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
|
|
||||||
|
|
||||||
### Noble aspirations
|
## For developers
|
||||||
|
|
||||||
- New exclusive puzzle elements?? Embrace extend extinguish baby
|
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.
|
||||||
|
|
||||||
## Special thanks
|
## Special thanks
|
||||||
|
|
||||||
- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord
|
- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord, including:
|
||||||
|
- ruben for documenting the CC2 PRNG
|
||||||
|
- The Architect for documenting the CC2 C2G parser
|
||||||
- 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
|
- 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
|
- [Tile World](https://wiki.bitbusters.club/Tile_World) for being an incredible reference on Lynx mechanics
|
||||||
- Everyone who contributed music — see [`js/soundtrack.js`](js/soundtrack.js) for a list!
|
- Everyone who contributed music — see [`js/soundtrack.js`](js/soundtrack.js) for a list!
|
||||||
|
|
||||||
Not associated with or blessed by Chuck Somerville, Niffler, or AOP.
|
|
||||||
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 508 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 491 B |
|
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 329 B |
|
Before Width: | Height: | Size: 716 B After Width: | Height: | Size: 711 B |
|
After Width: | Height: | Size: 681 B |
|
After Width: | Height: | Size: 670 B |
|
After Width: | Height: | Size: 235 B |
|
After Width: | Height: | Size: 235 B |
|
After Width: | Height: | Size: 396 B |
|
After Width: | Height: | Size: 147 B |
|
After Width: | Height: | Size: 185 B |
|
Before Width: | Height: | Size: 257 B After Width: | Height: | Size: 259 B |
@@ -5,201 +5,436 @@
|
|||||||
<title>Lexy's Labyrinth</title>
|
<title>Lexy's Labyrinth</title>
|
||||||
<link rel="stylesheet" type="text/css" href="style.css">
|
<link rel="stylesheet" type="text/css" href="style.css">
|
||||||
<link rel="shortcut icon" type="image/png" href="icon.png">
|
<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>
|
<script type="module" src="js/main.js"></script>
|
||||||
<meta name="og:type" content="website">
|
<meta name="og:type" content="website">
|
||||||
<meta name="og:image" content="https://c.eev.ee/lexys-labyrinth/og-preview.png">
|
<meta name="og:image" content="https://c.eev.ee/lexys-labyrinth/og-preview.png">
|
||||||
<meta name="og:title" content="Lexy's Labyrinth">
|
<meta name="og:title" content="Lexy's Labyrinth">
|
||||||
<meta name="og:description" content="A (work in progress) reimplementation of Chip's Challenge 1 and 2, using entirely free assets.">
|
<meta name="og:description" content="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="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||||
</head>
|
</head>
|
||||||
<body data-mode="splash">
|
<body data-mode="failed">
|
||||||
|
<script>document.body.setAttribute('data-mode', 'loading');</script>
|
||||||
|
<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>
|
||||||
|
</g>
|
||||||
|
<g id="svg-icon-right">
|
||||||
|
<use href="#svg-icon-up" transform="rotate(90 8 8)"></use>
|
||||||
|
</g>
|
||||||
|
<g id="svg-icon-down">
|
||||||
|
<use href="#svg-icon-up" transform="rotate(180 8 8)"></use>
|
||||||
|
</g>
|
||||||
|
<g id="svg-icon-left">
|
||||||
|
<use href="#svg-icon-up" transform="rotate(270 8 8)"></use>
|
||||||
|
</g>
|
||||||
|
<g id="svg-icon-drop">
|
||||||
|
<path d="M6,0 h4 v9 h3 l-5,5 h7 v2 h-14 v-2 h7 l-5,-5 h3"></path>
|
||||||
|
</g>
|
||||||
|
<g id="svg-icon-cycle">
|
||||||
|
<path d="M2,3 H11 V1 l4,4 -4,4 V7 H2 Z"></path>
|
||||||
|
<path d="M14,9 H5 V7 l-4,4 4,4 v-2 h9 z"></path>
|
||||||
|
</g>
|
||||||
|
<g id="svg-icon-swap">
|
||||||
|
<path d="m 7,1 h 2 l 1,1 V 6 L 9,7 v 4 L 8,11.5 7,11 V 7 L 6,6 V 2 Z"></path>
|
||||||
|
<path d="M 8,13 13,11 8,9 3,11 Z m 0,2 7,-3 V 11 L 8,8 1,11 v 1 z"></path>
|
||||||
|
<ellipse cx="5.5" cy="11" rx="0.75" ry="0.5"></ellipse>
|
||||||
|
</g>
|
||||||
|
<!-- 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>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
<header id="header-main">
|
<header id="header-main">
|
||||||
<img src="icon.png" alt="">
|
<img id="header-icon" src="icon.png" alt="">
|
||||||
<h1>Lexy's Labyrinth</h1>
|
<h1><a href="https://github.com/eevee/lexys-labyrinth">Lexy's Labyrinth</a></h1>
|
||||||
<p>— a game by <a href="https://eev.ee/">eevee</a></p>
|
<p>— an <a href="https://github.com/eevee/lexys-labyrinth">open source</a> game by <a href="https://eev.ee/">eevee</a></p>
|
||||||
<nav>
|
<nav>
|
||||||
<button id="main-about" type="button">about</button>
|
<button id="main-compat" type="button">mode: <output>lexy</output></button>
|
||||||
<button id="main-help" type="button" disabled>help</button>
|
<button id="main-options" type="button">options</button>
|
||||||
<button id="main-options" type="button" disabled>options</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<header id="header-pack">
|
<header id="header-pack">
|
||||||
<h2 id="level-pack-name">Chip's Challenge Level Pack 1</h2>
|
<h2 id="level-pack-name">Chip's Challenge Level Pack 1</h2>
|
||||||
<nav>
|
<nav>
|
||||||
|
<button id="main-test-pack" type="button">Bulk test</button>
|
||||||
<button id="main-change-pack" type="button">Change pack</button>
|
<button id="main-change-pack" type="button">Change pack</button>
|
||||||
<button id="player-edit" type="button">Edit</button>
|
<button id="player-edit" type="button">Edit</button>
|
||||||
<button id="editor-play" type="button">Test</button>
|
<button id="editor-play" type="button">Play</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<header id="header-level">
|
<header id="header-level">
|
||||||
<h3 id="level-name">Level 1 — Key Pyramid</h3>
|
<h3 id="level-name">Level 1 — Key Pyramid</h3>
|
||||||
<nav>
|
<nav>
|
||||||
<button id="main-prev-level" type="button">←</button>
|
<button id="main-prev-level" type="button">
|
||||||
|
<svg class="svg-icon" viewBox="0 0 16 16" title="previous"><use href="#svg-icon-prev"></svg>
|
||||||
|
</button>
|
||||||
<button id="main-choose-level" type="button">Level select</button>
|
<button id="main-choose-level" type="button">Level select</button>
|
||||||
<button id="main-next-level" type="button">→</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>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main id="splash">
|
<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 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>
|
||||||
|
<div class="drag-overlay"></div>
|
||||||
<header>
|
<header>
|
||||||
<h1><img src="og-preview.png" alt="">Lexy's Labyrinth</h1>
|
<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>
|
||||||
</header>
|
</header>
|
||||||
<section id="splash-intro">
|
<div id="splash-links">
|
||||||
<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>
|
<a href="https://github.com/eevee/lexys-labyrinth/wiki">About</a>
|
||||||
<p>(This is a Chip's Challenge <em>emulator</em>, designed to be an accessible way to play community-made levels with free assets. It's 99% compatible with Chip's Challenge 1, and support for Chip's Challenge 2 is underway. But you can safely ignore all that and treat this as its own game.)</p>
|
<a href="https://github.com/eevee/lexys-labyrinth/wiki/How-To-Play">How to play</a>
|
||||||
<p>Please note that <em>levels themselves</em> may contain hints or lore referring to a guy named Chip collecting computer chips, even though you are clearly a fox named Lexy collecting hearts. Weird, right? Sorry for any confusion!</p>
|
<a href="https://github.com/eevee/lexys-labyrinth">Source code and more</a>
|
||||||
<p>Pick a level pack to get started! 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) via <a href="https://www.patreon.com/eevee">Patreon</a>!</p>
|
<a href="https://patreon.com/eevee">Support on Patreon</a>
|
||||||
<!-- TODO i want to make clear this is a chip's challenge emulator without bogging people down too much about what that means -->
|
</div>
|
||||||
</section>
|
<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-stock-levels">
|
<section id="splash-stock-levels">
|
||||||
<h2>Just play something</h2>
|
<h2>Play</h2>
|
||||||
<!-- populated by js -->
|
<ul class="played-pack-list" id="splash-stock-pack-list">
|
||||||
</section>
|
<!-- 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>
|
||||||
|
|
||||||
<section id="splash-upload-levels">
|
<h2>More levels</h2>
|
||||||
<h2>Other 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>
|
||||||
<p>You can play <code>CHIPS.DAT</code> from the original Microsoft version, any custom levels you have lying around, or perhaps ones you found on the <a href="https://sets.bitbusters.club/">Bit Busters Club set list</a>!</p>
|
<div class="button-row">
|
||||||
<!-- TODO explain how to find chips.dat or steam folder -->
|
<input id="splash-upload-file" type="file" accept=".dat,.ccl,.c2m,.ccs,.zip" multiple>
|
||||||
<!-- TODO drag and drop? -->
|
<input id="splash-upload-dir" type="file" webkitdirectory>
|
||||||
<input id="splash-upload" type="file" accept=".dat,.ccl,.c2m,.ccs">
|
<button type="button" id="splash-upload-file-button" class="button-big">Load files</button>
|
||||||
<button type="button" id="splash-upload-button" class="button-big">Open a local level<!-- TODO: <br>(or drag and drop a file into this window) --></button>
|
<button type="button" id="splash-upload-dir-button" class="button-big">Load directory</button>
|
||||||
<p>Supports both the old Microsoft <code>CHIPS.DAT</code> format and the Steam <code>C2M</code> format.</p>
|
</div>
|
||||||
<p>Does <em>not</em> yet support the Steam <code>C2G</code> format, so tragically, the original Steam levels can only be played one at a time. This should be fixed soon!</p>
|
<ul class="played-pack-list" id="splash-other-pack-list">
|
||||||
<!--
|
<!-- populated by js -->
|
||||||
<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>
|
</ul>
|
||||||
<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.</li>
|
|
||||||
</ol>
|
|
||||||
-->
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="splash-your-levels">
|
<section id="splash-your-levels">
|
||||||
<h2>Make your own (WIP lol)</h2>
|
<h2>Create</h2>
|
||||||
<p>Please note that the level editor is <strong>extremely</strong> unfinished, and can't even save yet.</p>
|
<div class="button-row">
|
||||||
<p><button type="button" id="splash-create-level" class="button-big">Create a level</button></p>
|
<button type="button" id="splash-create-pack" class="button-big">New pack</button>
|
||||||
|
<button type="button" id="splash-create-level" class="button-big">New scratch level<br>(won't be saved!)</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<main id="player" hidden>
|
<main id="player" hidden>
|
||||||
<section class="-main-area">
|
<div id="player-main">
|
||||||
<div class="level"><!-- level canvas and any overlays go here --></div>
|
<div id="player-controls">
|
||||||
<div class="overlay-message">
|
<button class="control-pause" type="button" title="pause">
|
||||||
<h1 class="-top"></h1>
|
<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>
|
||||||
<div class="-middle"></div>
|
<span class="-optional-label">pause</span> <span class="keyhint"><kbd>p</kbd></span></button>
|
||||||
<p class="-bottom"></p>
|
<button class="control-restart" type="button" title="restart">
|
||||||
<p class="-keyhint"></p>
|
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M13,13 A 7,7 270 1,1 13,3 L15,1 15,7 9,7 11,5 A 4,4 270 1,0 11,11 z"></path></svg>
|
||||||
|
<span class="-optional-label">retry</span>
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="message"></div>
|
<div id="player-actions">
|
||||||
<div class="chips">
|
<button class="action-drop" type="button">
|
||||||
<h3>Hearts</h3>
|
<svg class="svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-drop"></use></svg>
|
||||||
<output></output>
|
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>
|
</div>
|
||||||
<div class="time">
|
<section id="player-game-area">
|
||||||
<h3>Time</h3>
|
<div class="level"><!-- level canvas and any overlays go here --></div>
|
||||||
<output></output>
|
<div class="player-overlay-message"></div>
|
||||||
</div>
|
<div class="player-hint-wrapper">
|
||||||
<div class="bonus">
|
<div class="player-hint"></div>
|
||||||
<h3>Bonus</h3>
|
<svg class="player-hint-bg-icon svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-hint"></use></svg>
|
||||||
<output></output>
|
</div>
|
||||||
</div>
|
<div class="player-level-number">
|
||||||
<div class="inventory"></div>
|
Level
|
||||||
</section>
|
<output></output>
|
||||||
<div id="player-music">
|
</div>
|
||||||
<div id="player-music-left">
|
<div class="chips">
|
||||||
🎵 <a id="player-music-title">title</a> by <a id="player-music-author">author</a>
|
<h3>
|
||||||
</div>
|
<svg class="svg-icon" viewBox="0 0 16 16" title="Hearts">
|
||||||
<div id="player-music-right">
|
<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>
|
||||||
<input id="player-music-volume" type="range" min="0" max="1" step="0.05" value="1">
|
</svg>
|
||||||
<input id="player-music-unmute" type="checkbox" checked>
|
</h3>
|
||||||
</div>
|
<output></output>
|
||||||
<audio loop preload="auto">
|
</div>
|
||||||
</div>
|
<div class="time">
|
||||||
<div class="controls">
|
<h3>
|
||||||
<div class="play-controls">
|
<svg class="svg-icon" viewBox="0 0 16 16" title="Time">
|
||||||
<button class="control-pause" type="button">Pause (p)</button>
|
<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>
|
||||||
<button class="control-restart" type="button">Restart</button>
|
<!-- cap -->
|
||||||
<button class="control-undo" type="button">Undo</button>
|
<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>
|
||||||
<button class="control-rewind" type="button">Rewind (z)</button>
|
<!-- arrow -->
|
||||||
</div>
|
<path d="M 8,9 10,6 7,8 Z"></path>
|
||||||
<div class="demo-controls">
|
<!-- center -->
|
||||||
<button class="demo-play" type="button">View replay</button>
|
<circle cx="7" cy="9" r="1"></circle>
|
||||||
<button class="demo-step-1" type="button">Step 1 tic</button>
|
</svg>
|
||||||
<button class="demo-step-4" type="button">Step 1 move</button>
|
</h3>
|
||||||
<div class="input"></div>
|
<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>
|
||||||
|
<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>
|
||||||
|
<audio loop preload="auto">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- TODO debug panel?
|
<form id="player-debug">
|
||||||
- current tic
|
<h3>Time</h3>
|
||||||
- list of actors, or currently pointed-to actor?
|
<table class="-time-controls">
|
||||||
<div class="debug">
|
<tr>
|
||||||
<button>« 4 tics</button>
|
<td><button type="button" class="-time-button" data-dt="-1">← 1 tic</button></td>
|
||||||
<button>« 1 tic</button>
|
<td id="player-debug-time-tics">0</td>
|
||||||
<button>1 tic »</button>
|
<td>tics</td>
|
||||||
<button>4 tics »</button>
|
<td><button type="button" class="-time-button" data-dt="1">1 tic →</button></td>
|
||||||
</div>
|
</tr>
|
||||||
-->
|
<tr>
|
||||||
|
<td><button type="button" class="-time-button" data-dt="-4">← 1 move</button></td>
|
||||||
|
<td id="player-debug-time-moves">0</td>
|
||||||
|
<td>moves</td>
|
||||||
|
<td><button type="button" class="-time-button" data-dt="4">1 move →</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><button type="button" class="-time-button" data-dt="-20">← 1 s</button></td>
|
||||||
|
<td id="player-debug-time-secs">0</td>
|
||||||
|
<td>seconds</td>
|
||||||
|
<td><button type="button" class="-time-button" data-dt="20">1 s →</button></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div class="-buttons" id="player-debug-time-buttons">
|
||||||
|
<!-- populated in js -->
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h3>Inventory</h3>
|
||||||
|
<div class="-inventory">
|
||||||
|
<!-- populated in js -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Replay</h3>
|
||||||
|
<!-- TODO...
|
||||||
|
play back replay
|
||||||
|
record replay, including altering it from here
|
||||||
|
stop replay, without restarting the level
|
||||||
|
show progress in %, length in tics + time
|
||||||
|
browse replay? jump to any point? label points???
|
||||||
|
edit manually?
|
||||||
|
-->
|
||||||
|
<div class="-replay-columns">
|
||||||
|
<div id="player-debug-input"></div>
|
||||||
|
<div class="-replay-status">
|
||||||
|
<!-- This should be a fixed height and is always showing one of the following -->
|
||||||
|
<div class="-none">No replay in progress</div>
|
||||||
|
<div class="-playback">
|
||||||
|
<progress max="0" value="0"></progress>
|
||||||
|
<output>100%</output>
|
||||||
|
<span>0 tics (0:00s)</span>
|
||||||
|
<button disabled>Relinquish control</button>
|
||||||
|
</div>
|
||||||
|
<div class="-recording">Recording...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- js inserts a bunch of stuff here -->
|
||||||
|
|
||||||
|
<h3>Misc</h3>
|
||||||
|
<p>Viewport size:
|
||||||
|
<select id="player-debug-viewport">
|
||||||
|
<option value="default" selected>Standard</option>
|
||||||
|
<option value="12">12 × 12</option>
|
||||||
|
<option value="16">16 × 16</option>
|
||||||
|
<option value="24">24 × 24</option>
|
||||||
|
<option value="32">32 × 32</option>
|
||||||
|
<option value="max">Entire level</option>
|
||||||
|
</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" 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>
|
||||||
|
<li><label><input type="checkbox" disabled> Player levitates</label></li>
|
||||||
|
-->
|
||||||
|
</ul>
|
||||||
|
<div class="-buttons" id="player-debug-misc-buttons">
|
||||||
|
<!-- populated in js -->
|
||||||
|
</div>
|
||||||
|
<p>Tip: Middle-click to teleport.</p>
|
||||||
|
<!-- TODO?
|
||||||
|
- inspect with mouse
|
||||||
|
- list of actors, or currently pointed-to actor?
|
||||||
|
- activate something manually?
|
||||||
|
- click a button ingame?
|
||||||
|
- pan viewport (like editor)
|
||||||
|
- show connections, directions, other editor features
|
||||||
|
- look under anything
|
||||||
|
|
||||||
|
- other game info?
|
||||||
|
- count tiles?
|
||||||
|
- total hearts?
|
||||||
|
- total bonus flags?
|
||||||
|
-->
|
||||||
|
</form>
|
||||||
</main>
|
</main>
|
||||||
<main id="editor" hidden>
|
<main id="editor" hidden>
|
||||||
<header>
|
<header></header>
|
||||||
<!-- TODO
|
<div class="editor-canvas">
|
||||||
- close
|
<div class="-container">
|
||||||
- export
|
<!-- level canvas and any overlays go here -->
|
||||||
- delete??
|
<!-- the container is to allow them to scroll as a single unit -->
|
||||||
|
</div>
|
||||||
- zoom
|
|
||||||
|
|
||||||
also deal with levels vs level /packs/ somehow, not sure how that'll work (including downloading them, yeargh?)
|
|
||||||
-->
|
|
||||||
</header>
|
|
||||||
<div class="level"><!-- level canvas and any overlays go here --></div>
|
|
||||||
<div class="controls">
|
|
||||||
<button id="editor-share-url" type="button">Share?</button>
|
|
||||||
<!--
|
|
||||||
<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>
|
|
||||||
-->
|
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="controls"></nav>
|
||||||
<div class="palette"></div>
|
<div class="palette"></div>
|
||||||
<!-- TODO:
|
<div id="editor-statusbar"></div>
|
||||||
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>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { DIRECTIONS, DIRECTION_ORDER } from './defs.js';
|
||||||
|
|
||||||
|
export function trace_floor_circuit(level, start_cell, start_edge, on_wire, on_dead_end) {
|
||||||
|
let is_first = true;
|
||||||
|
let pending = [[start_cell, start_edge]];
|
||||||
|
let seen_cells = new Map;
|
||||||
|
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 actor = cell.get_actor();
|
||||||
|
let wire_directions = terrain.wire_directions;
|
||||||
|
if ((actor?.wire_directions ?? null !== null) && (actor.movement_cooldown === 0 || level.compat.tiles_react_instantly))
|
||||||
|
{
|
||||||
|
wire_directions = actor.wire_directions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wire comes in from this edge towards the center; see how it connects within this
|
||||||
|
// cell, then check for any neighbors
|
||||||
|
let connections = edgeinfo.bit;
|
||||||
|
if (! is_first && ((wire_directions ?? 0) & edgeinfo.bit) === 0) {
|
||||||
|
// There's not actually a wire here (but not if this is our starting cell, in which
|
||||||
|
// case we trust the caller)
|
||||||
|
if (on_dead_end) {
|
||||||
|
on_dead_end(terrain.cell, edge);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else if (terrain.type.wire_propagation_mode === 'none') {
|
||||||
|
// The wires in this tile never connect to each other
|
||||||
|
}
|
||||||
|
else if (terrain.type.wire_propagation_mode === 'cross' ||
|
||||||
|
(wire_directions === 0x0f && terrain.type.wire_propagation_mode !== 'all'))
|
||||||
|
{
|
||||||
|
// This is a cross pattern, so only opposite edges connect
|
||||||
|
if (wire_directions & edgeinfo.opposite_bit) {
|
||||||
|
connections |= edgeinfo.opposite_bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Everything connects
|
||||||
|
connections |= wire_directions;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen_cells.set(cell, seen_edges | connections);
|
||||||
|
|
||||||
|
if (on_wire) {
|
||||||
|
on_wire(terrain, 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
|
||||||
|
neighbor = find_matching_wire_tunnel(level, cell.x, cell.y, direction);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
neighbor = level.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function find_matching_wire_tunnel(level, x, y, direction) {
|
||||||
|
let dirinfo = DIRECTIONS[direction];
|
||||||
|
let [dx, dy] = dirinfo.movement;
|
||||||
|
let nesting = 0;
|
||||||
|
while (true) {
|
||||||
|
x += dx;
|
||||||
|
y += dy;
|
||||||
|
let candidate = level.cell(x, y);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO make this guy work generically for orange, red, brown buttons? others...?
|
||||||
|
export function find_implicit_connection() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterates over a grid in a diamond pattern, spreading out from the given start cell (but not
|
||||||
|
// including it). Only used for connecting orange buttons.
|
||||||
|
export function* iter_cells_in_diamond(levelish, x0, y0) {
|
||||||
|
let max_search_radius = Math.max(levelish.size_x, levelish.size_y) + 1;
|
||||||
|
for (let dist = 1; dist <= max_search_radius; dist++) {
|
||||||
|
// Start east and move counterclockwise
|
||||||
|
let sx = x0 + dist;
|
||||||
|
let sy = y0;
|
||||||
|
for (let direction of [[-1, -1], [-1, 1], [1, 1], [1, -1]]) {
|
||||||
|
for (let i = 0; i < dist; i++) {
|
||||||
|
let cell = levelish.cell(sx, sy);
|
||||||
|
if (cell) {
|
||||||
|
yield cell;
|
||||||
|
}
|
||||||
|
sx += direction[0];
|
||||||
|
sy += direction[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,33 +4,310 @@ export const DIRECTIONS = {
|
|||||||
north: {
|
north: {
|
||||||
movement: [0, -1],
|
movement: [0, -1],
|
||||||
bit: 0x01,
|
bit: 0x01,
|
||||||
|
opposite_bit: 0x04,
|
||||||
|
index: 0,
|
||||||
action: 'up',
|
action: 'up',
|
||||||
left: 'west',
|
left: 'west',
|
||||||
right: 'east',
|
right: 'east',
|
||||||
opposite: 'south',
|
opposite: 'south',
|
||||||
|
mirrored: 'north',
|
||||||
|
flipped: 'south',
|
||||||
},
|
},
|
||||||
south: {
|
south: {
|
||||||
movement: [0, 1],
|
movement: [0, 1],
|
||||||
bit: 0x04,
|
bit: 0x04,
|
||||||
|
opposite_bit: 0x01,
|
||||||
|
index: 2,
|
||||||
action: 'down',
|
action: 'down',
|
||||||
left: 'east',
|
left: 'east',
|
||||||
right: 'west',
|
right: 'west',
|
||||||
opposite: 'north',
|
opposite: 'north',
|
||||||
|
mirrored: 'south',
|
||||||
|
flipped: 'north',
|
||||||
},
|
},
|
||||||
west: {
|
west: {
|
||||||
movement: [-1, 0],
|
movement: [-1, 0],
|
||||||
bit: 0x08,
|
bit: 0x08,
|
||||||
|
opposite_bit: 0x02,
|
||||||
|
index: 3,
|
||||||
action: 'left',
|
action: 'left',
|
||||||
left: 'south',
|
left: 'south',
|
||||||
right: 'north',
|
right: 'north',
|
||||||
opposite: 'east',
|
opposite: 'east',
|
||||||
|
mirrored: 'east',
|
||||||
|
flipped: 'west',
|
||||||
},
|
},
|
||||||
east: {
|
east: {
|
||||||
movement: [1, 0],
|
movement: [1, 0],
|
||||||
bit: 0x02,
|
bit: 0x02,
|
||||||
|
opposite_bit: 0x08,
|
||||||
|
index: 1,
|
||||||
action: 'right',
|
action: 'right',
|
||||||
left: 'north',
|
left: 'north',
|
||||||
right: 'south',
|
right: 'south',
|
||||||
opposite: 'west',
|
opposite: 'west',
|
||||||
|
mirrored: 'west',
|
||||||
|
flipped: 'east',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// Should match the bit ordering above, and CC2's order
|
||||||
|
export const DIRECTION_ORDER = ['north', 'east', 'south', 'west'];
|
||||||
|
|
||||||
|
export const INPUT_BITS = {
|
||||||
|
drop: 0x01,
|
||||||
|
down: 0x02,
|
||||||
|
left: 0x04,
|
||||||
|
right: 0x08,
|
||||||
|
up: 0x10,
|
||||||
|
swap: 0x20,
|
||||||
|
cycle: 0x40,
|
||||||
|
// Not real input; used to force advancement for turn-based mode
|
||||||
|
wait: 0x8000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LAYERS = {
|
||||||
|
terrain: 0,
|
||||||
|
item: 1,
|
||||||
|
item_mod: 2,
|
||||||
|
actor: 3,
|
||||||
|
vfx: 4,
|
||||||
|
swivel: 5,
|
||||||
|
thin_wall: 6,
|
||||||
|
canopy: 7,
|
||||||
|
|
||||||
|
MAX: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COLLISION = {
|
||||||
|
real_player1: 0x0001,
|
||||||
|
real_player2: 0x0002,
|
||||||
|
real_player: 0x0003,
|
||||||
|
doppel1: 0x0004,
|
||||||
|
doppel2: 0x0008,
|
||||||
|
doppel: 0x000c,
|
||||||
|
playerlike1: 0x0005,
|
||||||
|
playerlike2: 0x000a,
|
||||||
|
playerlike: 0x000f,
|
||||||
|
|
||||||
|
block_cc1: 0x0010,
|
||||||
|
block_cc2: 0x0020, // ice + frame (+ circuit, etc)
|
||||||
|
bowling_ball: 0x0040, // rolling ball, dynamite
|
||||||
|
|
||||||
|
// Monsters are a little complicated, because some of them have special rules, e.g. fireballs
|
||||||
|
// aren't blocked by fire.
|
||||||
|
// For a monster's MASK, you should use ONLY ONE of these specific monster bits (if
|
||||||
|
// appropriate), OR the generic bit -- DO NOT combine them!
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Combo masks used for matching
|
||||||
|
all_but_ghost: 0xffff & ~0x8000,
|
||||||
|
all_but_real_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
|
||||||
|
export const COMPAT_FLAGS = [
|
||||||
|
// Level loading
|
||||||
|
// TODO? /strictly/ speaking, these should be turned on for lynx+ms/lynx respectively, but then i'd
|
||||||
|
// have to also alter the behavior of the corresponding terrain, which seems kind of silly
|
||||||
|
{
|
||||||
|
key: 'no_auto_convert_ccl_popwalls',
|
||||||
|
label: "Recessed walls under actors in CCL levels are left alone",
|
||||||
|
rulesets: new Set(['steam-strict']),
|
||||||
|
}, {
|
||||||
|
key: 'no_auto_convert_ccl_blue_walls',
|
||||||
|
label: "Blue walls under blocks in CCL levels are left alone",
|
||||||
|
rulesets: new Set(['steam-strict']),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Core
|
||||||
|
{
|
||||||
|
key: 'allow_double_cooldowns',
|
||||||
|
label: "Actors may cooldown twice in one tic",
|
||||||
|
rulesets: new Set(['steam', 'steam-strict', 'lynx']),
|
||||||
|
}, {
|
||||||
|
key: 'no_separate_idle_phase',
|
||||||
|
label: "Actors teleport immediately after moving",
|
||||||
|
rulesets: new Set(['steam', 'steam-strict']),
|
||||||
|
}, {
|
||||||
|
key: 'player_moves_last',
|
||||||
|
label: "Player always moves last",
|
||||||
|
rulesets: new Set(['lynx', 'ms']),
|
||||||
|
}, {
|
||||||
|
key: 'player_protected_by_items',
|
||||||
|
label: "Players can't be trampled when standing on items",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
}, {
|
||||||
|
// Note that this requires no_early_push as well
|
||||||
|
key: 'player_safe_at_decision_time',
|
||||||
|
label: "Players can't be trampled at decision time",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
}, {
|
||||||
|
key: 'emulate_60fps',
|
||||||
|
label: "Game runs at 60 FPS",
|
||||||
|
rulesets: new Set(['steam', 'steam-strict']),
|
||||||
|
}, {
|
||||||
|
key: 'reuse_actor_slots',
|
||||||
|
label: "Game reuses slots in the actor list",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
}, {
|
||||||
|
key: 'force_lynx_animation_lengths',
|
||||||
|
label: "Animations use Lynx duration",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tiles
|
||||||
|
{
|
||||||
|
// XXX this is goofy
|
||||||
|
key: 'tiles_react_instantly',
|
||||||
|
label: "Tiles react when approached",
|
||||||
|
rulesets: new Set(['ms']),
|
||||||
|
}, {
|
||||||
|
key: 'rff_actually_random',
|
||||||
|
label: "Random force floors are actually random",
|
||||||
|
rulesets: new Set(['ms']),
|
||||||
|
}, {
|
||||||
|
key: 'no_backwards_override',
|
||||||
|
label: "Player can't override backwards on a force floor",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
}, {
|
||||||
|
key: 'force_floors_inert_on_first_tic',
|
||||||
|
label: "Force floors don't trigger on the first tic",
|
||||||
|
rulesets: new Set(['lynx', 'ms']),
|
||||||
|
}, {
|
||||||
|
key: 'traps_like_lynx',
|
||||||
|
label: "Traps eject faster, and even when already open",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
}, {
|
||||||
|
key: 'blue_floors_vanish_on_arrive',
|
||||||
|
label: "Fake blue walls vanish on arrival",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
}, {
|
||||||
|
key: 'green_teleports_can_fail',
|
||||||
|
label: "Green teleporters sometimes fail",
|
||||||
|
rulesets: new Set(['steam-strict']),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Items
|
||||||
|
{
|
||||||
|
key: 'no_immediate_detonate_bombs',
|
||||||
|
label: "Mines under non-player actors don't explode at level start",
|
||||||
|
rulesets: new Set(['lynx', 'ms']),
|
||||||
|
}, {
|
||||||
|
key: 'detonate_bombs_under_players',
|
||||||
|
label: "Mines under players explode at level start",
|
||||||
|
rulesets: new Set(['steam', 'steam-strict']),
|
||||||
|
}, {
|
||||||
|
key: 'cloned_bowling_balls_can_be_lost',
|
||||||
|
label: "Bowling balls on cloners are destroyed when fired at point blank",
|
||||||
|
rulesets: new Set(['steam-strict']),
|
||||||
|
}, {
|
||||||
|
key: 'monsters_ignore_keys',
|
||||||
|
label: "Monsters completely ignore keys",
|
||||||
|
rulesets: new Set(['ms']),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Blocks
|
||||||
|
{
|
||||||
|
key: 'no_early_push',
|
||||||
|
label: "Pushing blocks happens at move time",
|
||||||
|
rulesets: new Set(['lynx', 'ms']),
|
||||||
|
}, {
|
||||||
|
key: 'use_legacy_hooking',
|
||||||
|
label: "Pulling blocks with the hook happens at decision time",
|
||||||
|
rulesets: new Set(['steam', 'steam-strict']),
|
||||||
|
}, {
|
||||||
|
// FIXME this is kind of annoying, there are some collision rules too
|
||||||
|
key: 'tanks_teeth_push_ice_blocks',
|
||||||
|
label: "Ice blocks emulate pgchip rules",
|
||||||
|
rulesets: new Set(['ms']),
|
||||||
|
}, {
|
||||||
|
key: 'allow_pushing_blocks_off_faux_walls',
|
||||||
|
label: "Blocks may be pushed off of blue (fake), invisible, and revealing walls",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
}, {
|
||||||
|
key: 'emulate_spring_mining',
|
||||||
|
label: "Spring mining is possible",
|
||||||
|
rulesets: new Set(['steam-strict']),
|
||||||
|
/* XXX not implemented
|
||||||
|
}, {
|
||||||
|
key: 'emulate_flicking',
|
||||||
|
label: "Flicking is possible",
|
||||||
|
rulesets: new Set(['ms']),
|
||||||
|
*/
|
||||||
|
},
|
||||||
|
|
||||||
|
// Monsters
|
||||||
|
{
|
||||||
|
// TODO? in lynx they ignore the button while in motion too
|
||||||
|
// TODO what about in a trap, in every game??
|
||||||
|
// TODO what does ms do when a tank is on ice or a ff? wiki's description is wacky
|
||||||
|
// TODO yellow tanks seem to have memory too??
|
||||||
|
key: 'tanks_always_obey_button',
|
||||||
|
label: "Blue tanks always obey blue buttons",
|
||||||
|
rulesets: new Set(['steam-strict']),
|
||||||
|
}, {
|
||||||
|
key: 'tanks_ignore_button_while_moving',
|
||||||
|
label: "Blue tanks ignore blue buttons while moving",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
}, {
|
||||||
|
key: 'blobs_use_tw_prng',
|
||||||
|
label: "Blobs use the Tile World RNG",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
}, {
|
||||||
|
key: 'teeth_target_internal_position',
|
||||||
|
label: "Teeth target the player's internal position",
|
||||||
|
rulesets: new Set(['lynx']),
|
||||||
|
}, {
|
||||||
|
key: 'rff_blocks_monsters',
|
||||||
|
label: "Random force floors block monsters",
|
||||||
|
rulesets: new Set(['ms']),
|
||||||
|
}, {
|
||||||
|
key: 'bonking_isnt_instant',
|
||||||
|
label: "Bonking while sliding doesn't apply instantly",
|
||||||
|
rulesets: new Set(['lynx', 'ms']),
|
||||||
|
}, {
|
||||||
|
key: 'fire_allows_monsters',
|
||||||
|
label: "Fire doesn't block monsters",
|
||||||
|
rulesets: new Set(['ms']),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function compat_flags_for_ruleset(ruleset) {
|
||||||
|
let compat = {};
|
||||||
|
for (let compatdef of COMPAT_FLAGS) {
|
||||||
|
if (compatdef.rulesets.has(ruleset)) {
|
||||||
|
compat[compatdef.key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return compat;
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
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.resize_level(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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// Small helper classes used by the editor, often with their own UI for the SVG overlay.
|
||||||
|
import { mk, mk_svg } from '../util.js';
|
||||||
|
|
||||||
|
export class SVGConnection {
|
||||||
|
constructor(sx, sy, dx, dy) {
|
||||||
|
this.source = mk_svg('circle.-source', {r: 0.5});
|
||||||
|
this.line = mk_svg('line.-arrow', {});
|
||||||
|
this.dest = mk_svg('rect.-dest', {width: 1, height: 1});
|
||||||
|
this.element = mk_svg('g.overlay-connection', this.source, this.line, this.dest);
|
||||||
|
this.set_source(sx, sy);
|
||||||
|
this.set_dest(dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.line.setAttribute('x1', sx + 0.5);
|
||||||
|
this.line.setAttribute('y1', sy + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_dest(dx, dy) {
|
||||||
|
this.dx = dx;
|
||||||
|
this.dy = dy;
|
||||||
|
this.line.setAttribute('x2', dx + 0.5);
|
||||||
|
this.line.setAttribute('y2', dy + 0.5);
|
||||||
|
this.dest.setAttribute('x', dx);
|
||||||
|
this.dest.setAttribute('y', dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// TODO probably need to combine this with Selection somehow since it IS one, just not committed yet
|
||||||
|
export class PendingSelection {
|
||||||
|
constructor(owner) {
|
||||||
|
this.owner = owner;
|
||||||
|
this.element = mk_svg('rect.overlay-pending-selection');
|
||||||
|
this.owner.svg_group.append(this.element);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
commit() {
|
||||||
|
this.owner.set_from_rect(this.rect);
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
discard() {
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Selection {
|
||||||
|
constructor(editor) {
|
||||||
|
this.editor = editor;
|
||||||
|
|
||||||
|
this.svg_group = mk_svg('g');
|
||||||
|
this.editor.svg_overlay.append(this.svg_group);
|
||||||
|
|
||||||
|
this.rect = null;
|
||||||
|
this.element = mk_svg('rect.overlay-selection.overlay-transient');
|
||||||
|
this.svg_group.append(this.element);
|
||||||
|
|
||||||
|
this.floated_cells = null;
|
||||||
|
this.floated_element = null;
|
||||||
|
this.floated_canvas = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get is_empty() {
|
||||||
|
return this.rect === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get is_floating() {
|
||||||
|
return !! this.floated_cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(x, y) {
|
||||||
|
// Empty selection means everything is selected?
|
||||||
|
if (this.rect === null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return this.rect.left <= x && x < this.rect.right && this.rect.top <= y && y < this.rect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
create_pending() {
|
||||||
|
return new PendingSelection(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_from_rect(rect) {
|
||||||
|
let old_rect = this.rect;
|
||||||
|
this.editor._do(
|
||||||
|
() => this._set_from_rect(rect),
|
||||||
|
() => {
|
||||||
|
if (old_rect) {
|
||||||
|
this._set_from_rect(old_rect);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this._clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_set_from_rect(rect) {
|
||||||
|
this.rect = rect;
|
||||||
|
this.element.classList.add('--visible');
|
||||||
|
this.element.setAttribute('x', this.rect.x);
|
||||||
|
this.element.setAttribute('y', this.rect.y);
|
||||||
|
this.element.setAttribute('width', this.rect.width);
|
||||||
|
this.element.setAttribute('height', this.rect.height);
|
||||||
|
|
||||||
|
if (this.floated_element) {
|
||||||
|
let tileset = this.editor.renderer.tileset;
|
||||||
|
this.floated_canvas.width = rect.width * tileset.size_x;
|
||||||
|
this.floated_canvas.height = rect.height * tileset.size_y;
|
||||||
|
let foreign_obj = this.floated_element.querySelector('foreignObject');
|
||||||
|
foreign_obj.setAttribute('width', this.floated_canvas.width);
|
||||||
|
foreign_obj.setAttribute('height', this.floated_canvas.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
move_by(dx, dy) {
|
||||||
|
if (! this.rect)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.rect.x += dx;
|
||||||
|
this.rect.y += dy;
|
||||||
|
this.element.setAttribute('x', this.rect.x);
|
||||||
|
this.element.setAttribute('y', this.rect.y);
|
||||||
|
|
||||||
|
if (! this.floated_element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let bbox = this.rect;
|
||||||
|
this.floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
let rect = this.rect;
|
||||||
|
if (! rect)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.editor._do(
|
||||||
|
() => this._clear(),
|
||||||
|
() => this._set_from_rect(rect),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clear() {
|
||||||
|
this.rect = null;
|
||||||
|
this.element.classList.remove('--visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
*iter_coords() {
|
||||||
|
if (! this.rect)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let stored_level = this.editor.stored_level;
|
||||||
|
for (let y = this.rect.top; y < this.rect.bottom; y++) {
|
||||||
|
for (let x = this.rect.left; x < this.rect.right; x++) {
|
||||||
|
let n = stored_level.coords_to_scalar(x, y);
|
||||||
|
yield [x, y, n];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
|
||||||
|
let floated_cells = [];
|
||||||
|
let tileset = this.editor.renderer.tileset;
|
||||||
|
let stored_level = this.editor.stored_level;
|
||||||
|
let bbox = this.rect;
|
||||||
|
let canvas = mk('canvas', {width: bbox.width * tileset.size_x, height: bbox.height * tileset.size_y});
|
||||||
|
let ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(
|
||||||
|
this.editor.renderer.canvas,
|
||||||
|
bbox.x * tileset.size_x, bbox.y * tileset.size_y, bbox.width * tileset.size_x, bbox.height * tileset.size_y,
|
||||||
|
0, 0, bbox.width * tileset.size_x, bbox.height * tileset.size_y);
|
||||||
|
for (let [x, y, n] of this.iter_coords()) {
|
||||||
|
let cell = stored_level.linear_cells[n];
|
||||||
|
if (copy) {
|
||||||
|
floated_cells.push(cell.map(tile => tile ? {...tile} : null));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
floated_cells.push(cell);
|
||||||
|
this.editor.replace_cell(cell, this.editor.make_blank_cell(x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let floated_element = mk_svg('g', mk_svg('foreignObject', {
|
||||||
|
x: 0, y: 0,
|
||||||
|
width: canvas.width, height: canvas.height,
|
||||||
|
transform: `scale(${1/tileset.size_x} ${1/tileset.size_y})`,
|
||||||
|
}, canvas));
|
||||||
|
floated_element.setAttribute('transform', `translate(${bbox.x} ${bbox.y})`);
|
||||||
|
|
||||||
|
// FIXME far more memory efficient to recreate the canvas in the redo, rather than hold onto
|
||||||
|
// it forever
|
||||||
|
this.editor._do(
|
||||||
|
() => {
|
||||||
|
this.floated_canvas = canvas;
|
||||||
|
this.floated_element = floated_element;
|
||||||
|
this.floated_cells = floated_cells;
|
||||||
|
this.svg_group.append(floated_element);
|
||||||
|
},
|
||||||
|
() => this._defloat(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp_float(copy = false) {
|
||||||
|
if (! this.floated_element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let stored_level = this.editor.stored_level;
|
||||||
|
let i = 0;
|
||||||
|
for (let [x, y, n] of this.iter_coords()) {
|
||||||
|
let cell = this.floated_cells[i];
|
||||||
|
if (copy) {
|
||||||
|
cell = cell.map(tile => tile ? {...tile} : null);
|
||||||
|
}
|
||||||
|
cell.x = x;
|
||||||
|
cell.y = y;
|
||||||
|
this.editor.replace_cell(stored_level.linear_cells[n], cell);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defloat() {
|
||||||
|
if (! this.floated_element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.stamp_float();
|
||||||
|
|
||||||
|
let element = this.floated_element;
|
||||||
|
let canvas = this.floated_canvas;
|
||||||
|
let cells = this.floated_cells;
|
||||||
|
this.editor._do(
|
||||||
|
() => this._defloat(),
|
||||||
|
() => {
|
||||||
|
this.floated_cells = cells;
|
||||||
|
this.floated_canvas = canvas;
|
||||||
|
this.floated_element = element;
|
||||||
|
this.svg_group.append(element);
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_defloat() {
|
||||||
|
this.floated_element.remove();
|
||||||
|
this.floated_element = null;
|
||||||
|
this.floated_canvas = null;
|
||||||
|
this.floated_cells = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redraw the selection canvas from scratch
|
||||||
|
redraw() {
|
||||||
|
if (! this.floated_canvas)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// FIXME uhoh, how do i actually do this? we have no renderer of our own, we have a
|
||||||
|
// separate canvas, and all the renderer stuff expects to get ahold of a level. i guess
|
||||||
|
// refactor it to draw a block of cells?
|
||||||
|
this.editor.renderer.draw_static_generic({
|
||||||
|
x0: 0, y0: 0,
|
||||||
|
x1: this.rect.width, y1: this.rect.height,
|
||||||
|
cells: this.floated_cells,
|
||||||
|
width: this.rect.width,
|
||||||
|
ctx: this.floated_canvas.getContext('2d'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO allow floating/dragging, ctrl-dragging to copy, anchoring...
|
||||||
|
// TODO make more stuff respect this (more things should go through Editor for undo reasons anyway)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
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 {
|
||||||
|
constructor(conductor) {
|
||||||
|
let root = mk('form.editor-popup-tile-editor');
|
||||||
|
super(conductor, root);
|
||||||
|
this.editor = conductor.editor;
|
||||||
|
this.tile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
edit_tile(tile, cell) {
|
||||||
|
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) {
|
||||||
|
// FIXME maybe this should be on the tile type, so it functions as documentation there?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LetterTileEditor extends TileEditorOverlay {
|
||||||
|
constructor(conductor) {
|
||||||
|
super(conductor);
|
||||||
|
|
||||||
|
this.root.append(mk('h3', "Letter tile"));
|
||||||
|
let list = mk('ol.editor-letter-tile-picker');
|
||||||
|
this.root.append(list);
|
||||||
|
this.glyph_elements = {};
|
||||||
|
let add = glyph => {
|
||||||
|
let input = mk('input', {type: 'radio', name: 'glyph', value: glyph});
|
||||||
|
this.glyph_elements[glyph] = input;
|
||||||
|
let item = mk('li', mk('label', input, mk('span.-glyph', glyph)));
|
||||||
|
list.append(item);
|
||||||
|
};
|
||||||
|
let arrows = ["⬆", "➡", "⬇", "⬅"];
|
||||||
|
for (let c = 32; c < 96; c++) {
|
||||||
|
let glyph = String.fromCharCode(c);
|
||||||
|
add(glyph);
|
||||||
|
// Add the arrows to the ends of the rows
|
||||||
|
if (c % 16 === 15) {
|
||||||
|
add(arrows[(c - 47) / 16]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener('change', ev => {
|
||||||
|
if (this.tile) {
|
||||||
|
this.mark_dirty();
|
||||||
|
this.tile.overlaid_glyph = this.root.elements['glyph'].value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
edit_tile(tile, cell) {
|
||||||
|
super.edit_tile(tile, cell);
|
||||||
|
this.root.elements['glyph'].value = tile.overlaid_glyph;
|
||||||
|
}
|
||||||
|
|
||||||
|
static configure_tile_defaults(tile) {
|
||||||
|
tile.type.populate_defaults(tile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HintTileEditor extends TileEditorOverlay {
|
||||||
|
constructor(conductor) {
|
||||||
|
super(conductor);
|
||||||
|
|
||||||
|
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.tile.hint_text = this.text.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
edit_tile(tile, cell) {
|
||||||
|
super.edit_tile(tile, cell);
|
||||||
|
this.text.value = tile.hint_text ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
static configure_tile_defaults(tile) {
|
||||||
|
tile.hint_text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FrameBlockTileEditor extends TileEditorOverlay {
|
||||||
|
constructor(conductor) {
|
||||||
|
super(conductor);
|
||||||
|
|
||||||
|
let svg_icons = [];
|
||||||
|
for (let center of [[16, 0], [16, 16], [0, 16], [0, 0]]) {
|
||||||
|
let symbol = mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||||
|
mk_svg('circle', {cx: center[0], cy: center[1], r: 3}),
|
||||||
|
mk_svg('circle', {cx: center[0], cy: center[1], r: 13}),
|
||||||
|
);
|
||||||
|
svg_icons.push(symbol);
|
||||||
|
}
|
||||||
|
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||||
|
mk_svg('rect', {x: -2, y: 3, width: 20, height: 10}),
|
||||||
|
));
|
||||||
|
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||||
|
mk_svg('rect', {x: 3, y: -2, width: 10, height: 20}),
|
||||||
|
));
|
||||||
|
|
||||||
|
this.root.append(mk('h3', "Arrows"));
|
||||||
|
let arrow_list = mk('ol.editor-directional-block-tile-arrows.editor-tile-editor-svg-parts');
|
||||||
|
// Arrange the arrows in a grid
|
||||||
|
for (let [direction, icon] of [
|
||||||
|
[null, mk_svg('path', {d: 'M 8,16 v -8 h 8'})],
|
||||||
|
['north', mk_svg('path', {d: 'M 0,12 h 16 l -8,-8 z'})],
|
||||||
|
[null, mk_svg('path', {d: 'M 0,8 h 8 v 8'})],
|
||||||
|
['west', mk_svg('path', {d: 'M 12,16 v -16 l -8,8 z'})],
|
||||||
|
[null, null],
|
||||||
|
['east', mk_svg('path', {d: 'M 4,0 v 16 l 8,-8 z'})],
|
||||||
|
[null, mk_svg('path', {d: 'M 16,8 h -8 v -8'})],
|
||||||
|
['south', mk_svg('path', {d: 'M 16,4 h -16 l 8,8 z'})],
|
||||||
|
[null, mk_svg('path', {d: 'M 8,0 v 8 h -8'})],
|
||||||
|
]) {
|
||||||
|
let li = mk('li');
|
||||||
|
let svg;
|
||||||
|
if (icon) {
|
||||||
|
svg = mk_svg('svg', {viewBox: '0 0 16 16'}, icon);
|
||||||
|
}
|
||||||
|
if (direction === null) {
|
||||||
|
if (svg) {
|
||||||
|
li.append(svg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let input = mk('input', {type: 'checkbox', name: 'direction', value: direction});
|
||||||
|
li.append(mk('label', input, svg));
|
||||||
|
}
|
||||||
|
arrow_list.append(li);
|
||||||
|
}
|
||||||
|
arrow_list.addEventListener('change', ev => {
|
||||||
|
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.root.append(arrow_list);
|
||||||
|
}
|
||||||
|
|
||||||
|
edit_tile(tile, cell) {
|
||||||
|
super.edit_tile(tile, cell);
|
||||||
|
|
||||||
|
for (let input of this.root.elements['direction']) {
|
||||||
|
input.checked = tile.arrows.has(input.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static configure_tile_defaults(tile) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RailroadTileEditor extends TileEditorOverlay {
|
||||||
|
constructor(conductor) {
|
||||||
|
super(conductor);
|
||||||
|
|
||||||
|
let svg_icons = [];
|
||||||
|
for (let center of [[16, 0], [16, 16], [0, 16], [0, 0]]) {
|
||||||
|
let symbol = mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||||
|
mk_svg('circle', {cx: center[0], cy: center[1], r: 3}),
|
||||||
|
mk_svg('circle', {cx: center[0], cy: center[1], r: 13}),
|
||||||
|
);
|
||||||
|
svg_icons.push(symbol);
|
||||||
|
}
|
||||||
|
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||||
|
mk_svg('rect', {x: -2, y: 3, width: 20, height: 10}),
|
||||||
|
));
|
||||||
|
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
|
||||||
|
mk_svg('rect', {x: 3, y: -2, width: 10, height: 20}),
|
||||||
|
));
|
||||||
|
|
||||||
|
this.root.append(mk('h3', "Tracks"));
|
||||||
|
let track_list = mk('ul.editor-railroad-tile-tracks.editor-tile-editor-svg-parts');
|
||||||
|
// Shown as two rows, this puts the straight parts first and the rest in a circle
|
||||||
|
let track_order = [4, 1, 2, 5, 0, 3];
|
||||||
|
for (let i of track_order) {
|
||||||
|
let input = mk('input', {type: 'checkbox', name: 'track', value: i});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
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))));
|
||||||
|
}
|
||||||
|
// TODO if they remove a track it should change the switch
|
||||||
|
// 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.root.append(switch_list);
|
||||||
|
|
||||||
|
// TODO need a way to set no actor at all
|
||||||
|
// TODO initial actor facing (maybe only if there's an actor in the cell)
|
||||||
|
}
|
||||||
|
|
||||||
|
edit_tile(tile, cell) {
|
||||||
|
super.edit_tile(tile, cell);
|
||||||
|
|
||||||
|
for (let input of this.root.elements['track']) {
|
||||||
|
input.checked = !! (tile.tracks & (1 << input.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tile.track_switch === null) {
|
||||||
|
this.root.elements['switch'].value = '';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.root.elements['switch'].value = tile.track_switch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static configure_tile_defaults(tile) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TILES_WITH_PROPS = {
|
||||||
|
floor_letter: LetterTileEditor,
|
||||||
|
hint: HintTileEditor,
|
||||||
|
frame_block: FrameBlockTileEditor,
|
||||||
|
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 later, custom floor/wall selection
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { LAYERS } from './defs.js';
|
||||||
|
import * as util from './util.js';
|
||||||
|
|
||||||
|
export class StoredCell extends Array {
|
||||||
|
constructor() {
|
||||||
|
super(LAYERS.MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Replay {
|
||||||
|
constructor(initial_force_floor_direction, blob_seed, inputs = null, step_parity = null, tw_seed = 0) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
else if (t < this.duration) {
|
||||||
|
return this.inputs[t];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Last input is implicitly repeated indefinitely
|
||||||
|
return this.inputs[this.duration - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(t, input) {
|
||||||
|
if (t >= this.inputs.length) {
|
||||||
|
let new_inputs = new Uint8Array(this.inputs.length + 1024);
|
||||||
|
for (let i = 0; i < this.inputs.length; i++) {
|
||||||
|
new_inputs[i] = this.inputs[i];
|
||||||
|
}
|
||||||
|
this.inputs = new_inputs;
|
||||||
|
}
|
||||||
|
this.inputs[t] = input;
|
||||||
|
if (t >= this.duration) {
|
||||||
|
this.duration = t + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clone() {
|
||||||
|
let new_inputs = new Uint8Array(this.duration);
|
||||||
|
for (let i = 0; i < this.duration; i++) {
|
||||||
|
new_inputs[i] = this.inputs[i];
|
||||||
|
}
|
||||||
|
return new this.constructor(this.initial_force_floor_direction, this.blob_seed, new_inputs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StoredLevel extends LevelInterface {
|
||||||
|
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.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;
|
||||||
|
// 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
|
||||||
|
this._replay = null;
|
||||||
|
this._replay_data = null;
|
||||||
|
this._replay_decoder = null;
|
||||||
|
|
||||||
|
this.size_x = 0;
|
||||||
|
this.size_y = 0;
|
||||||
|
this.linear_cells = [];
|
||||||
|
|
||||||
|
// Maps of button positions to trap/cloner positions, as scalars
|
||||||
|
this.has_custom_connections = false;
|
||||||
|
this.custom_connections = {};
|
||||||
|
|
||||||
|
// New LL feature: custom camera regions, as lists of {x, y, width, height}
|
||||||
|
this.camera_regions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
}
|
||||||
|
|
||||||
|
get has_replay() {
|
||||||
|
return this._replay || (this._replay_data && this._replay_decoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
get replay() {
|
||||||
|
if (! this._replay) {
|
||||||
|
this._replay = this._replay_decoder(this._replay_data);
|
||||||
|
}
|
||||||
|
return this._replay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StoredPack {
|
||||||
|
constructor(identifier, level_loader) {
|
||||||
|
this.identifier = identifier;
|
||||||
|
this.title = "";
|
||||||
|
this._level_loader = level_loader;
|
||||||
|
|
||||||
|
// Simple objects containing keys that are usually:
|
||||||
|
// title: level title
|
||||||
|
// index: level index, used internally only
|
||||||
|
// number: level number (may not match index due to C2G shenanigans)
|
||||||
|
// error: any error received while loading the level
|
||||||
|
// bytes: Uint8Array of the encoded level data
|
||||||
|
this.level_metadata = [];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
load_level(index) {
|
||||||
|
let meta = this.level_metadata[index];
|
||||||
|
if (! meta)
|
||||||
|
throw new util.LLError(`No such level number ${index}`);
|
||||||
|
if (meta.error)
|
||||||
|
throw meta.error;
|
||||||
|
|
||||||
|
if (meta.stored_level) {
|
||||||
|
// The editor stores inflated levels at times, so respect that
|
||||||
|
return meta.stored_level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
}
|
||||||
|
return stored_level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StoredGame = StoredPack;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as util from './format-util.js';
|
import { DIRECTIONS, LAYERS } from './defs.js';
|
||||||
|
import * as format_base from './format-base.js';
|
||||||
import TILE_TYPES from './tiletypes.js';
|
import TILE_TYPES from './tiletypes.js';
|
||||||
|
import * as util from './util.js';
|
||||||
|
|
||||||
const TILE_ENCODING = {
|
const TILE_ENCODING = {
|
||||||
0x00: 'floor',
|
0x00: 'floor',
|
||||||
@@ -8,10 +10,10 @@ const TILE_ENCODING = {
|
|||||||
0x03: 'water',
|
0x03: 'water',
|
||||||
0x04: 'fire',
|
0x04: 'fire',
|
||||||
0x05: 'wall_invisible',
|
0x05: 'wall_invisible',
|
||||||
0x06: 'thinwall_n',
|
0x06: ['thin_walls', {edges: DIRECTIONS['north'].bit}],
|
||||||
0x07: 'thinwall_w',
|
0x07: ['thin_walls', {edges: DIRECTIONS['west'].bit}],
|
||||||
0x08: 'thinwall_s',
|
0x08: ['thin_walls', {edges: DIRECTIONS['south'].bit}],
|
||||||
0x09: 'thinwall_e',
|
0x09: ['thin_walls', {edges: DIRECTIONS['east'].bit}],
|
||||||
// This is MSCC's incomprehensible non-directional dirt block, which needs a direction for Lynx
|
// This is MSCC's incomprehensible non-directional dirt block, which needs a direction for Lynx
|
||||||
// purposes; Tile World defaults it to north
|
// purposes; Tile World defaults it to north
|
||||||
0x0a: ['dirt_block', 'north'],
|
0x0a: ['dirt_block', 'north'],
|
||||||
@@ -53,7 +55,7 @@ const TILE_ENCODING = {
|
|||||||
0x2d: 'gravel',
|
0x2d: 'gravel',
|
||||||
0x2e: 'popwall',
|
0x2e: 'popwall',
|
||||||
0x2f: 'hint',
|
0x2f: 'hint',
|
||||||
0x30: 'thinwall_se',
|
0x30: ['thin_walls', {edges: DIRECTIONS['south'].bit | DIRECTIONS['east'].bit}],
|
||||||
0x31: 'cloner',
|
0x31: 'cloner',
|
||||||
0x32: 'force_floor_all',
|
0x32: 'force_floor_all',
|
||||||
0x33: 'bogus_player_drowned',
|
0x33: 'bogus_player_drowned',
|
||||||
@@ -61,7 +63,7 @@ const TILE_ENCODING = {
|
|||||||
0x35: 'bogus_player_burned',
|
0x35: 'bogus_player_burned',
|
||||||
0x36: 'wall_invisible', // unused
|
0x36: 'wall_invisible', // unused
|
||||||
0x37: 'wall_invisible', // unused
|
0x37: 'wall_invisible', // unused
|
||||||
0x38: 'wall_invisible', // unused
|
0x38: 'ice_block', // unused, but co-opted by pgchip
|
||||||
0x39: 'bogus_player_win',
|
0x39: 'bogus_player_win',
|
||||||
0x3a: 'bogus_player_win',
|
0x3a: 'bogus_player_win',
|
||||||
0x3b: 'bogus_player_win',
|
0x3b: 'bogus_player_win',
|
||||||
@@ -118,19 +120,108 @@ const TILE_ENCODING = {
|
|||||||
0x6e: ['player', 'south'],
|
0x6e: ['player', 'south'],
|
||||||
0x6f: ['player', 'east'],
|
0x6f: ['player', 'east'],
|
||||||
};
|
};
|
||||||
|
|
||||||
function parse_level(buf, number) {
|
const REVERSE_TILE_ENCODING = {};
|
||||||
let level = new util.StoredLevel(number);
|
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++) {
|
||||||
|
password.push(bytes[start + i] ^ 0x99);
|
||||||
|
}
|
||||||
|
return String.fromCharCode.apply(null, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse_level_metadata(bytes) {
|
||||||
|
let meta = {};
|
||||||
|
|
||||||
|
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
|
||||||
|
// Level number; rest of level header is unused
|
||||||
|
meta.number = view.getUint16(0, true);
|
||||||
|
|
||||||
|
// Map layout
|
||||||
|
// Same structure twice, for the two layers
|
||||||
|
let p = 8;
|
||||||
|
for (let l = 0; l < 2; l++) {
|
||||||
|
let layer_length = view.getUint16(p, true);
|
||||||
|
p += 2 + layer_length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional metadata fields
|
||||||
|
let meta_length = view.getUint16(p, true);
|
||||||
|
p += 2;
|
||||||
|
let end = p + meta_length;
|
||||||
|
while (p < end) {
|
||||||
|
// Common header
|
||||||
|
let field_type = view.getUint8(p, true);
|
||||||
|
let field_length = view.getUint8(p + 1, true);
|
||||||
|
p += 2;
|
||||||
|
if (field_type === 0x03) {
|
||||||
|
// Title, including trailing NUL
|
||||||
|
meta.title = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||||
|
}
|
||||||
|
else if (field_type === 0x06) {
|
||||||
|
// Password, with trailing NUL, and XORed with 0x99 (???)
|
||||||
|
meta.password = decode_password(bytes, p, field_length - 1);
|
||||||
|
}
|
||||||
|
p += field_length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse_level(bytes, number) {
|
||||||
|
let level = new format_base.StoredLevel(number);
|
||||||
|
level.has_custom_connections = true;
|
||||||
|
level.format = 'ccl';
|
||||||
|
level.uses_ll_extensions = false;
|
||||||
|
level.chips_required = 0;
|
||||||
// Map size is always fixed as 32x32 in CC1
|
// Map size is always fixed as 32x32 in CC1
|
||||||
level.size_x = 32;
|
level.size_x = 32;
|
||||||
level.size_y = 32;
|
level.size_y = 32;
|
||||||
for (let i = 0; i < 1024; i++) {
|
for (let i = 0; i < 1024; i++) {
|
||||||
level.linear_cells.push(new util.StoredCell);
|
level.linear_cells.push(new format_base.StoredCell);
|
||||||
}
|
}
|
||||||
level.use_cc1_boots = true;
|
level.use_cc1_boots = true;
|
||||||
|
|
||||||
let view = new DataView(buf);
|
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
let bytes = new Uint8Array(buf);
|
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
let level_number = view.getUint16(0, true);
|
let level_number = view.getUint16(0, true);
|
||||||
@@ -141,6 +232,7 @@ function parse_level(buf, number) {
|
|||||||
let unknown = view.getUint16(6, true);
|
let unknown = view.getUint16(6, true);
|
||||||
// Same structure twice, for the two layers
|
// Same structure twice, for the two layers
|
||||||
let p = 8;
|
let p = 8;
|
||||||
|
let hint_tiles = [];
|
||||||
for (let l = 0; l < 2; l++) {
|
for (let l = 0; l < 2; l++) {
|
||||||
let layer_length = view.getUint16(p, true);
|
let layer_length = view.getUint16(p, true);
|
||||||
p += 2;
|
p += 2;
|
||||||
@@ -161,17 +253,21 @@ function parse_level(buf, number) {
|
|||||||
// TODO could be more forgiving for goofy levels doing goofy things
|
// TODO could be more forgiving for goofy levels doing goofy things
|
||||||
if (! spec) {
|
if (! spec) {
|
||||||
let [x, y] = level.scalar_to_coords(c);
|
let [x, y] = level.scalar_to_coords(c);
|
||||||
throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y}) in level ${number}`);
|
throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let name, direction;
|
let name, extra;
|
||||||
if (spec instanceof Array) {
|
if (spec instanceof Array) {
|
||||||
[name, direction] = spec;
|
[name, extra] = spec;
|
||||||
|
if (typeof extra === 'string') {
|
||||||
|
extra = {direction: extra};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
name = spec;
|
name = spec;
|
||||||
|
extra = {};
|
||||||
}
|
}
|
||||||
let type = TILE_TYPES[name];
|
let tile = {type: TILE_TYPES[name], ...extra};
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
if (c >= 1024)
|
if (c >= 1024)
|
||||||
@@ -189,7 +285,22 @@ function parse_level(buf, number) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
cell.unshift({type, direction});
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (c !== 1024)
|
if (c !== 1024)
|
||||||
@@ -198,9 +309,8 @@ function parse_level(buf, number) {
|
|||||||
|
|
||||||
// Fix the "floor/empty" nonsense here by adding floor to any cell with no terrain on bottom
|
// Fix the "floor/empty" nonsense here by adding floor to any cell with no terrain on bottom
|
||||||
for (let cell of level.linear_cells) {
|
for (let cell of level.linear_cells) {
|
||||||
if (cell.length === 0 || cell[0].type.draw_layer !== 0) {
|
if (! cell[LAYERS.terrain]) {
|
||||||
// No terrain; insert a floor
|
cell[LAYERS.terrain] = { type: TILE_TYPES['floor'] };
|
||||||
cell.unshift({ type: TILE_TYPES['floor'] });
|
|
||||||
}
|
}
|
||||||
// TODO we could also deal with weird cases where there's terrain /on top of/ something
|
// 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...
|
// else: things underwater, the quirk where a glider will erase the item underneath...
|
||||||
@@ -225,11 +335,11 @@ function parse_level(buf, number) {
|
|||||||
}
|
}
|
||||||
else if (field_type === 0x03) {
|
else if (field_type === 0x03) {
|
||||||
// Title, including trailing NUL
|
// Title, including trailing NUL
|
||||||
level.title = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1));
|
level.title = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||||
}
|
}
|
||||||
else if (field_type === 0x04) {
|
else if (field_type === 0x04) {
|
||||||
// Trap linkages (MSCC only, not in Lynx or CC2)
|
// Trap linkages (MSCC only, not in Lynx or CC2)
|
||||||
let field_view = new DataView(buf.slice(p, p + field_length));
|
let field_view = new DataView(bytes.buffer, bytes.byteOffset + p, field_length);
|
||||||
let q = 0;
|
let q = 0;
|
||||||
while (q < field_length) {
|
while (q < field_length) {
|
||||||
let button_x = field_view.getUint16(q + 0, true);
|
let button_x = field_view.getUint16(q + 0, true);
|
||||||
@@ -238,12 +348,18 @@ function parse_level(buf, number) {
|
|||||||
let trap_y = field_view.getUint16(q + 6, true);
|
let trap_y = field_view.getUint16(q + 6, true);
|
||||||
// Fifth u16 is always zero, possibly live game state
|
// Fifth u16 is always zero, possibly live game state
|
||||||
q += 10;
|
q += 10;
|
||||||
level.custom_trap_wiring[button_x + button_y * level.size_x] = trap_x + trap_y * level.size_x;
|
// 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[s] = d;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (field_type === 0x05) {
|
else if (field_type === 0x05) {
|
||||||
// Cloner linkages (MSCC only, not in Lynx or CC2)
|
// Cloner linkages (MSCC only, not in Lynx or CC2)
|
||||||
let field_view = new DataView(buf.slice(p, p + field_length));
|
let field_view = new DataView(bytes.buffer, bytes.byteOffset + p, field_length);
|
||||||
let q = 0;
|
let q = 0;
|
||||||
while (q < field_length) {
|
while (q < field_length) {
|
||||||
let button_x = field_view.getUint16(q + 0, true);
|
let button_x = field_view.getUint16(q + 0, true);
|
||||||
@@ -251,25 +367,34 @@ function parse_level(buf, number) {
|
|||||||
let cloner_x = field_view.getUint16(q + 4, true);
|
let cloner_x = field_view.getUint16(q + 4, true);
|
||||||
let cloner_y = field_view.getUint16(q + 6, true);
|
let cloner_y = field_view.getUint16(q + 6, true);
|
||||||
q += 8;
|
q += 8;
|
||||||
level.custom_cloner_wiring[button_x + button_y * level.size_x] = cloner_x + cloner_y * level.size_x;
|
// 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[s] = d;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (field_type === 0x06) {
|
else if (field_type === 0x06) {
|
||||||
// Password, with trailing NUL, and otherwise XORed with 0x99 (?!)
|
// Password, with trailing NUL, and otherwise XORed with 0x99 (???)
|
||||||
let password = [];
|
level.password = decode_password(bytes, p, field_length - 1);
|
||||||
for (let i = 0; i < field_length - 1; i++) {
|
|
||||||
password.push(view.getUint8(p + i, true) ^ 0x99);
|
|
||||||
}
|
|
||||||
level.password = String.fromCharCode.apply(null, password);
|
|
||||||
}
|
}
|
||||||
else if (field_type === 0x07) {
|
else if (field_type === 0x07) {
|
||||||
// Hint, including trailing NUL, of course
|
// Hint, including trailing NUL, of course
|
||||||
level.hint = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1));
|
let hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
|
||||||
|
for (let tile of hint_tiles) {
|
||||||
|
tile.hint_text = hint;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (field_type === 0x08) {
|
else if (field_type === 0x08) {
|
||||||
// Password, but not encoded
|
// Password, but not encoded
|
||||||
// TODO ???
|
// 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) {
|
else if (field_type === 0x0a) {
|
||||||
// Initial actor order
|
// Initial actor order
|
||||||
// TODO ??? should i... trust this...
|
// TODO ??? should i... trust this...
|
||||||
@@ -280,8 +405,13 @@ function parse_level(buf, number) {
|
|||||||
return level;
|
return level;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This thin wrapper is passed to StoredGame as the parser function
|
||||||
|
function _parse_level_from_stored_meta(meta) {
|
||||||
|
return parse_level(meta.bytes, meta.number);
|
||||||
|
}
|
||||||
|
|
||||||
export function parse_game(buf) {
|
export function parse_game(buf) {
|
||||||
let game = new util.StoredGame;
|
let game = new format_base.StoredGame(null, _parse_level_from_stored_meta);
|
||||||
|
|
||||||
let full_view = new DataView(buf);
|
let full_view = new DataView(buf);
|
||||||
let magic = full_view.getUint32(0, true);
|
let magic = full_view.getUint32(0, true);
|
||||||
@@ -293,6 +423,10 @@ export function parse_game(buf) {
|
|||||||
// OK
|
// OK
|
||||||
// TODO tile world convention, use lynx rules
|
// TODO tile world convention, use lynx rules
|
||||||
}
|
}
|
||||||
|
else if (magic === 0x0003aaac) {
|
||||||
|
// OK
|
||||||
|
// TODO add in ice block i guess???
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
throw new Error(`Unrecognized magic number ${magic.toString(16)}`);
|
throw new Error(`Unrecognized magic number ${magic.toString(16)}`);
|
||||||
}
|
}
|
||||||
@@ -303,12 +437,273 @@ export function parse_game(buf) {
|
|||||||
let p = 6;
|
let p = 6;
|
||||||
for (let l = 1; l <= level_count; l++) {
|
for (let l = 1; l <= level_count; l++) {
|
||||||
let length = full_view.getUint16(p, true);
|
let length = full_view.getUint16(p, true);
|
||||||
let level_buf = buf.slice(p + 2, p + 2 + length);
|
let bytes = new Uint8Array(buf, p + 2, length);
|
||||||
p += 2 + length;
|
p += 2 + length;
|
||||||
|
|
||||||
let level = parse_level(level_buf, l);
|
let meta;
|
||||||
game.levels.push(level);
|
try {
|
||||||
|
meta = parse_level_metadata(bytes);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
meta = {error: e};
|
||||||
|
}
|
||||||
|
meta.index = l - 1;
|
||||||
|
meta.bytes = bytes;
|
||||||
|
game.level_metadata.push(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 && i in stored_level.custom_connections) {
|
||||||
|
let dest = stored_level.custom_connections[i];
|
||||||
|
let dest_cell = stored_level.linear_cells[dest];
|
||||||
|
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 to_words(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, to_words(trap_cxns));
|
||||||
|
}
|
||||||
|
if (cloner_cxns.length > 0) {
|
||||||
|
add_block(5, to_words(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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,
|
||||||
|
];
|
||||||
|
|
||||||
|
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, don't ask
|
||||||
|
let fmt = bytes[q] & 0x3;
|
||||||
|
let fmt2 = (bytes[q] >> 4) & 0x1;
|
||||||
|
if (fmt === 0) {
|
||||||
|
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)) {
|
||||||
|
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 { // 3-1
|
||||||
|
// variable-size and only needed for ms so let's just hope not
|
||||||
|
throw new Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.levels[number - 1] = new format_base.Replay(initial_rff, 0, inputs, step_parity, initial_rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
is_first = false;
|
||||||
|
p += len;
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
export function string_from_buffer_ascii(buf) {
|
|
||||||
return String.fromCharCode.apply(null, new Uint8Array(buf));
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StoredCell extends Array {
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StoredLevel {
|
|
||||||
constructor(number) {
|
|
||||||
this.number = number; // one-based
|
|
||||||
this.title = '';
|
|
||||||
this.password = null;
|
|
||||||
this.hint = '';
|
|
||||||
this.chips_required = 0;
|
|
||||||
this.time_limit = 0;
|
|
||||||
this.viewport_size = 9;
|
|
||||||
this.extra_chunks = [];
|
|
||||||
this.use_cc1_boots = false;
|
|
||||||
|
|
||||||
this.size_x = 0;
|
|
||||||
this.size_y = 0;
|
|
||||||
this.linear_cells = [];
|
|
||||||
|
|
||||||
// Maps of button positions to trap/cloner positions, as scalar indexes
|
|
||||||
// in the linear cell list
|
|
||||||
this.custom_trap_wiring = {};
|
|
||||||
this.custom_cloner_wiring = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
scalar_to_coords(n) {
|
|
||||||
return [n % this.size_x, Math.floor(n / this.size_x)];
|
|
||||||
}
|
|
||||||
|
|
||||||
coords_to_scalar(x, y) {
|
|
||||||
return x + y * this.size_x;
|
|
||||||
}
|
|
||||||
|
|
||||||
check() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StoredGame {
|
|
||||||
constructor(identifier) {
|
|
||||||
this.identifier = identifier;
|
|
||||||
this.levels = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import { compat_flags_for_ruleset } from '../defs.js';
|
||||||
|
import { Level } from '../game.js';
|
||||||
|
import * as format_c2g from '../format-c2g.js';
|
||||||
|
import * as format_dat from '../format-dat.js';
|
||||||
|
import * as format_tws from '../format-tws.js';
|
||||||
|
import * as util from '../util.js';
|
||||||
|
|
||||||
|
import { argv, exit, stderr, stdout } from 'process';
|
||||||
|
import { opendir, readFile, stat } from 'fs/promises';
|
||||||
|
import { performance } from 'perf_hooks';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
export class LocalDirectorySource extends util.FileSource {
|
||||||
|
constructor(root) {
|
||||||
|
super();
|
||||||
|
this.root = root;
|
||||||
|
this.files = {};
|
||||||
|
this._loaded_promise = this._scan_dir('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _scan_dir(path) {
|
||||||
|
let dir = await opendir(this.root + path);
|
||||||
|
for await (let dirent of dir) {
|
||||||
|
if (dirent.isDirectory()) {
|
||||||
|
await this._scan_dir(path + dirent.name + '/');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let filepath = path + dirent.name;
|
||||||
|
this.files[filepath.toLowerCase()] = filepath;
|
||||||
|
if (this.files.size > 2000)
|
||||||
|
throw `way, way too many files in local directory source ${this.root}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(path) {
|
||||||
|
let realpath = this.files[path.toLowerCase()];
|
||||||
|
if (realpath) {
|
||||||
|
return (await readFile(this.root + realpath)).buffer;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error(`No such file: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(s, n) {
|
||||||
|
return s.substring(0, n).padEnd(n, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESULT_TYPES = {
|
||||||
|
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";
|
||||||
|
|
||||||
|
async function test_pack(pack, ruleset, level_filter = null) {
|
||||||
|
let dummy_sfx = {
|
||||||
|
set_player_position() {},
|
||||||
|
play() {},
|
||||||
|
play_once() {},
|
||||||
|
};
|
||||||
|
let compat = compat_flags_for_ruleset(ruleset);
|
||||||
|
|
||||||
|
// TODO factor out the common parts maybe?
|
||||||
|
stdout.write(pad(`${pack.title} (${ruleset})`, 20) + " ");
|
||||||
|
let num_levels = pack.level_metadata.length;
|
||||||
|
let num_passed = 0;
|
||||||
|
let num_missing = 0;
|
||||||
|
let total_tics = 0;
|
||||||
|
let t0 = performance.now();
|
||||||
|
let last_pause = t0;
|
||||||
|
let failures = [];
|
||||||
|
for (let i = 0; i < num_levels; i++) {
|
||||||
|
let stored_level, level;
|
||||||
|
let level_start_time = performance.now();
|
||||||
|
let record_result = (token, short_status, include_canvas, comment) => {
|
||||||
|
let result_stuff = RESULT_TYPES[token];
|
||||||
|
stdout.write(result_stuff.color + result_stuff.symbol);
|
||||||
|
if (token === 'failure' || token === 'short' || token === 'error') {
|
||||||
|
failures.push({
|
||||||
|
token,
|
||||||
|
short_status,
|
||||||
|
comment,
|
||||||
|
level,
|
||||||
|
stored_level,
|
||||||
|
index: i,
|
||||||
|
fail_reason: level ? level.fail_reason : null,
|
||||||
|
time_elapsed: performance.now() - level_start_time,
|
||||||
|
time_expected: stored_level ? stored_level.replay.duration / 20 : null,
|
||||||
|
title: stored_level ? stored_level.title : "[error]",
|
||||||
|
time_simulated: level ? level.tic_counter / 20 : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (level) {
|
||||||
|
/*
|
||||||
|
mk('td.-clock', util.format_duration(level.tic_counter / TICS_PER_SECOND)),
|
||||||
|
mk('td.-delta', util.format_duration((level.tic_counter - stored_level.replay.duration) / TICS_PER_SECOND, 2)),
|
||||||
|
mk('td.-speed', ((level.tic_counter / TICS_PER_SECOND) / (performance.now() - level_start_time) * 1000).toFixed(2) + '×'),
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}`)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (level) {
|
||||||
|
total_tics += level.tic_counter;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (level_filter && ! level_filter.has(i + 1)) {
|
||||||
|
record_result('skipped', "Skipped");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stored_level = pack.load_level(i);
|
||||||
|
if (! stored_level.has_replay) {
|
||||||
|
record_result('no-replay', "No replay");
|
||||||
|
num_missing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO? this.current_status.textContent = `Testing level ${i + 1}/${num_levels} ${stored_level.title}...`;
|
||||||
|
|
||||||
|
let replay = stored_level.replay;
|
||||||
|
level = new Level(stored_level, compat);
|
||||||
|
level.sfx = dummy_sfx;
|
||||||
|
level.undo_enabled = false; // slight performance boost
|
||||||
|
replay.configure_level(level);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let input = replay.get(level.tic_counter);
|
||||||
|
level.advance_tic(input);
|
||||||
|
|
||||||
|
if (level.state === 'success') {
|
||||||
|
if (level.tic_counter < replay.duration - 10) {
|
||||||
|
// Early exit is dubious (e.g. this happened sometimes before multiple
|
||||||
|
// players were implemented correctly)
|
||||||
|
record_result('early', "Won early", true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
record_result('success', "Won");
|
||||||
|
}
|
||||||
|
num_passed += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (level.state === 'failure') {
|
||||||
|
record_result('failure', "Lost", true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (level.tic_counter >= replay.duration + 220) {
|
||||||
|
// This threshold of 11 seconds was scientifically calculated by noticing that
|
||||||
|
// the TWS of Southpole runs 11 seconds past its last input
|
||||||
|
record_result('short', "Out of input", true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level.tic_counter % 20 === 1) {
|
||||||
|
// XXX
|
||||||
|
/*
|
||||||
|
if (handle.cancel) {
|
||||||
|
record_result('interrupted', "Interrupted");
|
||||||
|
this.current_status.textContent = `Interrupted on level ${i + 1}/${num_levels}; ${num_passed} passed`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Don't run for more than 100ms at a time, to avoid janking the browser...
|
||||||
|
// TOO much. I mean, we still want it to reflow the stuff we've added, but
|
||||||
|
// we also want to be pretty aggressive so this finishes quickly
|
||||||
|
// XXX unnecessary headless
|
||||||
|
/*
|
||||||
|
let now = performance.now();
|
||||||
|
if (now - last_pause > 100) {
|
||||||
|
await util.sleep(4);
|
||||||
|
last_pause = now;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
// FIXME this does not seem to work
|
||||||
|
record_result(
|
||||||
|
'error', "Error", true,
|
||||||
|
`Replay failed due to internal error (see console for traceback): ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_real_elapsed = (performance.now() - t0) / 1000;
|
||||||
|
|
||||||
|
stdout.write(`${ANSI_RESET} ${num_passed}/${num_levels - num_missing}\n`);
|
||||||
|
for (let failure of failures) {
|
||||||
|
let short_status = failure.short_status;
|
||||||
|
if (failure.token === 'failure') {
|
||||||
|
short_status += ": ";
|
||||||
|
short_status += failure.fail_reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = [
|
||||||
|
String(failure.index + 1).padStart(5),
|
||||||
|
pad(failure.title.replace(/[\r\n]+/, " "), 32),
|
||||||
|
RESULT_TYPES[failure.token].color + pad(short_status, 20) + ANSI_RESET,
|
||||||
|
];
|
||||||
|
if (failure.time_simulated !== null) {
|
||||||
|
parts.push("ran for" + util.format_duration(failure.time_simulated).padStart(6, " "));
|
||||||
|
}
|
||||||
|
if (failure.token === 'failure') {
|
||||||
|
parts.push(" with" + util.format_duration(failure.time_expected - failure.time_simulated).padStart(6, " ") + " still to go");
|
||||||
|
}
|
||||||
|
stdout.write(parts.join(" ") + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
num_passed,
|
||||||
|
num_missing,
|
||||||
|
num_failed: num_levels - num_passed - num_missing,
|
||||||
|
time_elapsed: total_real_elapsed,
|
||||||
|
time_simulated: total_tics / 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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 pack;
|
||||||
|
if ((await stat(testdef.pack_path)).isDirectory()) {
|
||||||
|
let source = new LocalDirectorySource(testdef.pack_path);
|
||||||
|
pack = await _scan_source(source);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let pack_data = await readFile(testdef.pack_path);
|
||||||
|
if (testdef.pack_path.match(/[.]zip$/)) {
|
||||||
|
let source = new util.ZipFileSource(pack_data.buffer);
|
||||||
|
pack = await _scan_source(source);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
pack = format_dat.parse_game(pack_data.buffer);
|
||||||
|
|
||||||
|
let solutions_data = await readFile(testdef.solutions_path);
|
||||||
|
let solutions = format_tws.parse_solutions(solutions_data.buffer);
|
||||||
|
pack.level_replays = solutions.levels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! pack.title) {
|
||||||
|
let match = testdef.pack_path.match(/(?:^|\/)([^/.]+)(?:\..*)?\/?$/);
|
||||||
|
if (match) {
|
||||||
|
pack.title = match[1];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
pack.title = testdef.pack_path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await test_pack(pack, testdef.ruleset, testdef.level_filter);
|
||||||
|
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`);
|
||||||
|
|
||||||
|
}
|
||||||
|
main();
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { mk, mk_svg } from './util.js';
|
||||||
|
|
||||||
|
// Superclass for the main display modes: the player, the editor, and the splash screen
|
||||||
|
export class PrimaryView {
|
||||||
|
constructor(conductor, root) {
|
||||||
|
this.conductor = conductor;
|
||||||
|
this.root = root;
|
||||||
|
this.active = false;
|
||||||
|
this._done_setup = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setup() {}
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
this.root.removeAttribute('hidden');
|
||||||
|
this.active = true;
|
||||||
|
if (! this._done_setup) {
|
||||||
|
this.setup();
|
||||||
|
this._done_setup = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
this.root.setAttribute('hidden', '');
|
||||||
|
this.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
reload_options(options) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stackable modal overlay of some kind, usually a dialog
|
||||||
|
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
|
||||||
|
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
|
||||||
|
this.root.addEventListener('keydown', ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
if (this.root.isConnected) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.conductor.player.state === 'playing') {
|
||||||
|
this.conductor.player.set_state('paused');
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlay = mk('div.overlay', this.root);
|
||||||
|
document.body.append(overlay);
|
||||||
|
|
||||||
|
// Remove the overlay when clicking outside the element
|
||||||
|
overlay.addEventListener('click', ev => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay styled like a popup of some sort
|
||||||
|
export class TransientOverlay extends Overlay {
|
||||||
|
open() {
|
||||||
|
// TODO i don't like how vaguely arbitrary this feels.
|
||||||
|
let overlay = super.open();
|
||||||
|
overlay.classList.add('--transient');
|
||||||
|
return 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) {
|
||||||
|
super(conductor, mk('form.dialog'));
|
||||||
|
|
||||||
|
this.root.append(
|
||||||
|
this.header = mk('header'),
|
||||||
|
this.main = mk('section'),
|
||||||
|
this.footer = mk('footer'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
set_title(title) {
|
||||||
|
this.header.textContent = '';
|
||||||
|
this.header.append(mk('h1', {}, title));
|
||||||
|
}
|
||||||
|
|
||||||
|
add_button(label, onclick) {
|
||||||
|
let button = mk('button', {type: 'button'}, label);
|
||||||
|
button.addEventListener('click', onclick);
|
||||||
|
this.footer.append(button);
|
||||||
|
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", ev => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yes/no popup dialog
|
||||||
|
export class ConfirmOverlay extends DialogOverlay {
|
||||||
|
constructor(conductor, message, what) {
|
||||||
|
super(conductor);
|
||||||
|
this.set_title("just checking");
|
||||||
|
this.main.append(mk('p', {}, message));
|
||||||
|
this.add_button("yep", ev => {
|
||||||
|
this.close();
|
||||||
|
what();
|
||||||
|
});
|
||||||
|
this.add_button("nope", ev => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flash_button(button) {
|
||||||
|
button.classList.add('--button-glow-ok');
|
||||||
|
window.setTimeout(() => {
|
||||||
|
button.classList.add('--button-glow');
|
||||||
|
button.classList.remove('--button-glow-ok');
|
||||||
|
}, 10);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
button.classList.remove('--button-glow');
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function save_json_to_storage(key, value) {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
@@ -1,7 +1,39 @@
|
|||||||
import { DIRECTIONS } from './defs.js';
|
import { DIRECTIONS, LAYERS } from './defs.js';
|
||||||
import { mk } from './util.js';
|
import { mk } from './util.js';
|
||||||
|
import { DrawPacket } from './tileset.js';
|
||||||
import TILE_TYPES from './tiletypes.js';
|
import TILE_TYPES from './tiletypes.js';
|
||||||
|
|
||||||
|
class CanvasRendererDrawPacket extends DrawPacket {
|
||||||
|
constructor(renderer, ctx, perception, clock, update_progress, update_rate) {
|
||||||
|
super(perception, renderer.hide_logic, clock, update_progress, update_rate);
|
||||||
|
this.renderer = renderer;
|
||||||
|
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;
|
||||||
|
// Compatibility settings
|
||||||
|
this.use_cc2_anim_speed = renderer.use_cc2_anim_speed;
|
||||||
|
this.show_facing = renderer.show_facing;
|
||||||
|
}
|
||||||
|
|
||||||
|
blit(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) {
|
||||||
|
this.renderer.blit(this.ctx,
|
||||||
|
tx + mx, ty + my,
|
||||||
|
this.x + this.offsetx + mdx, this.y + this.offsety + mdy,
|
||||||
|
mw, mh);
|
||||||
|
}
|
||||||
|
|
||||||
|
blit_aligned(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) {
|
||||||
|
this.renderer.blit(this.ctx,
|
||||||
|
tx + mx, ty + my,
|
||||||
|
this.x + mdx, this.y + mdy,
|
||||||
|
mw, mh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class CanvasRenderer {
|
export class CanvasRenderer {
|
||||||
constructor(tileset, fixed_size = null) {
|
constructor(tileset, fixed_size = null) {
|
||||||
this.tileset = tileset;
|
this.tileset = tileset;
|
||||||
@@ -11,7 +43,6 @@ export class CanvasRenderer {
|
|||||||
// to do so, but then we wouldn't make a canvas so it couldn't be
|
// to do so, but then we wouldn't make a canvas so it couldn't be
|
||||||
// hooked, yadda yadda
|
// hooked, yadda yadda
|
||||||
if (fixed_size) {
|
if (fixed_size) {
|
||||||
this.viewport_is_fixed = true;
|
|
||||||
this.viewport_size_x = fixed_size;
|
this.viewport_size_x = fixed_size;
|
||||||
this.viewport_size_y = fixed_size;
|
this.viewport_size_y = fixed_size;
|
||||||
}
|
}
|
||||||
@@ -19,13 +50,33 @@ export class CanvasRenderer {
|
|||||||
this.viewport_size_x = 9;
|
this.viewport_size_x = 9;
|
||||||
this.viewport_size_y = 9;
|
this.viewport_size_y = 9;
|
||||||
}
|
}
|
||||||
this.canvas = mk('canvas', {width: tileset.size_x * this.viewport_size_x, height: tileset.size_y * this.viewport_size_y});
|
this.canvas = this.constructor.make_canvas(
|
||||||
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
|
tileset.size_x * this.viewport_size_x,
|
||||||
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
|
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.ctx = this.canvas.getContext('2d');
|
this.ctx = this.canvas.getContext('2d');
|
||||||
this.viewport_x = 0;
|
this.viewport_x = 0;
|
||||||
this.viewport_y = 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.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 mk('canvas', {width: w, height: h});
|
||||||
}
|
}
|
||||||
|
|
||||||
set_level(level) {
|
set_level(level) {
|
||||||
@@ -33,6 +84,34 @@ export class CanvasRenderer {
|
|||||||
// TODO update viewport size... or maybe Game should do that since you might be cheating
|
// 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;
|
||||||
|
this.viewport_size_y = y;
|
||||||
|
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) {
|
cell_coords_from_event(ev) {
|
||||||
let rect = this.canvas.getBoundingClientRect();
|
let rect = this.canvas.getBoundingClientRect();
|
||||||
let scale_x = rect.width / this.canvas.width;
|
let scale_x = rect.width / this.canvas.width;
|
||||||
@@ -42,6 +121,33 @@ export class CanvasRenderer {
|
|||||||
return [x, y];
|
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;
|
||||||
|
let scale_y = rect.height / this.canvas.height;
|
||||||
|
let x = (ev.clientX - rect.x) / scale_x / this.tileset.size_x + this.viewport_x;
|
||||||
|
let y = (ev.clientY - rect.y) / scale_y / this.tileset.size_y + this.viewport_y;
|
||||||
|
return [x, y];
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Draw to a canvas using tile coordinates
|
||||||
blit(ctx, sx, sy, dx, dy, w = 1, h = w) {
|
blit(ctx, sx, sy, dx, dy, w = 1, h = w) {
|
||||||
let tw = this.tileset.size_x;
|
let tw = this.tileset.size_x;
|
||||||
@@ -52,13 +158,36 @@ export class CanvasRenderer {
|
|||||||
dx * tw, dy * th, w * tw, h * th);
|
dx * tw, dy * th, w * tw, h * th);
|
||||||
}
|
}
|
||||||
|
|
||||||
draw(tic_offset = 0) {
|
_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) {
|
||||||
if (! this.level) {
|
if (! this.level) {
|
||||||
console.warn("CanvasRenderer.draw: No level to render");
|
console.warn("CanvasRenderer.draw: No level to render");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tic = (this.level.tic_counter ?? 0) + tic_offset;
|
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 CanvasRendererDrawPacket(
|
||||||
|
this, this.ctx, this.perception, Math.max(0, clock), update_progress, this.update_rate);
|
||||||
|
|
||||||
let tw = this.tileset.size_x;
|
let tw = this.tileset.size_x;
|
||||||
let th = this.tileset.size_y;
|
let th = this.tileset.size_y;
|
||||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
@@ -67,17 +196,22 @@ export class CanvasRenderer {
|
|||||||
// TODO what about levels smaller than the viewport...? shrink the canvas in set_level?
|
// TODO what about levels smaller than the viewport...? shrink the canvas in set_level?
|
||||||
let xmargin = (this.viewport_size_x - 1) / 2;
|
let xmargin = (this.viewport_size_x - 1) / 2;
|
||||||
let ymargin = (this.viewport_size_y - 1) / 2;
|
let ymargin = (this.viewport_size_y - 1) / 2;
|
||||||
let px, py;
|
let [px, py] = this.level.player.visual_position(update_progress, packet.update_rate);
|
||||||
// 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
|
// Figure out where to start drawing
|
||||||
let x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, px - xmargin));
|
// TODO support overlapping regions better
|
||||||
let y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, py - ymargin));
|
let x0 = px - xmargin;
|
||||||
|
let y0 = py - ymargin;
|
||||||
|
for (let region of this.level.stored_level.camera_regions) {
|
||||||
|
if (px >= region.left && px < region.right &&
|
||||||
|
py >= region.top && py < region.bottom)
|
||||||
|
{
|
||||||
|
x0 = Math.max(region.left, Math.min(region.right - this.viewport_size_x, x0));
|
||||||
|
y0 = Math.max(region.top, Math.min(region.bottom - this.viewport_size_y, y0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always keep us within the map bounds
|
||||||
|
x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, x0));
|
||||||
|
y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, y0));
|
||||||
// Round to the pixel grid
|
// Round to the pixel grid
|
||||||
x0 = Math.floor(x0 * tw + 0.5) / tw;
|
x0 = Math.floor(x0 * tw + 0.5) / tw;
|
||||||
y0 = Math.floor(y0 * th + 0.5) / th;
|
y0 = Math.floor(y0 * th + 0.5) / th;
|
||||||
@@ -86,58 +220,141 @@ export class CanvasRenderer {
|
|||||||
// The viewport might not be aligned to the grid, so split off any fractional part.
|
// The viewport might not be aligned to the grid, so split off any fractional part.
|
||||||
let xf0 = Math.floor(x0);
|
let xf0 = Math.floor(x0);
|
||||||
let yf0 = Math.floor(y0);
|
let yf0 = Math.floor(y0);
|
||||||
// Note that when the viewport is exactly aligned to the grid, we need to draw the cells
|
// We need to draw one cell beyond the viewport, or we'll miss objects partway through
|
||||||
// just outside of it, or we'll miss objects partway through crossing the border
|
// crossing the border moving away from us
|
||||||
if (xf0 === x0 && xf0 > 0) {
|
if (xf0 > 0) {
|
||||||
xf0 -= 1;
|
xf0 -= 1;
|
||||||
}
|
}
|
||||||
if (yf0 === y0 && yf0 > 0) {
|
if (yf0 > 0) {
|
||||||
yf0 -= 1;
|
yf0 -= 1;
|
||||||
}
|
}
|
||||||
// Find where to stop drawing. As with above, if we're aligned to the grid, we need to
|
// Find where to stop drawing. As with above, if we're aligned to the grid, we need to
|
||||||
// include the tiles just outside it, so we allow this fencepost problem to fly
|
// 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 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));
|
let y1 = Math.min(this.level.size_y - 1, Math.ceil(y0 + this.viewport_size_y));
|
||||||
// Draw one layer at a time, so animated objects aren't overdrawn by
|
// 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
|
||||||
// neighboring terrain
|
// neighboring terrain
|
||||||
// XXX layer count hardcoded here
|
for (let x = xf0; x <= x1; x++) {
|
||||||
for (let layer = 0; layer < 4; layer++) {
|
for (let y = yf0; y <= y1; y++) {
|
||||||
for (let x = xf0; x <= x1; x++) {
|
let cell = this.level.cell(x, y);
|
||||||
for (let y = yf0; y <= y1; y++) {
|
for (let layer = 0; layer < LAYERS.actor; layer++) {
|
||||||
for (let tile of this.level.cells[y][x]) {
|
let tile = cell[layer];
|
||||||
if (tile.type.draw_layer !== layer)
|
if (! tile)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (tile.type.is_actor &&
|
packet.x = x - x0;
|
||||||
// FIXME kind of a hack for the editor, which uses bare tile objects
|
packet.y = y - y0;
|
||||||
tile.visual_position)
|
this.tileset.draw(tile, packet);
|
||||||
{
|
}
|
||||||
// Handle smooth scrolling
|
}
|
||||||
let [vx, vy] = tile.visual_position(tic_offset);
|
}
|
||||||
// Round this to the pixel grid too!
|
for (let x = xf0; x <= x1; x++) {
|
||||||
vx = Math.floor(vx * tw + 0.5) / tw;
|
for (let y = yf0; y <= y1; y++) {
|
||||||
vy = Math.floor(vy * th + 0.5) / th;
|
let cell = this.level.cell(x, y);
|
||||||
this.tileset.draw(tile, tic, (sx, sy, dx = 0, dy = 0, w = 1, h = w) =>
|
let actor = cell[LAYERS.actor];
|
||||||
this.blit(this.ctx, sx, sy, vx - x0 + dx, vy - y0 + dy, w, h));
|
if (! actor)
|
||||||
}
|
continue;
|
||||||
else {
|
|
||||||
// Non-actors can't move
|
// Handle smooth scrolling
|
||||||
this.tileset.draw(tile, tic, (sx, sy, dx = 0, dy = 0, w = 1, h = w) =>
|
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
|
||||||
this.blit(this.ctx, sx, sy, x - x0 + dx, y - y0 + dy, w, h));
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.use_rewind_effect) {
|
if (this.use_rewind_effect) {
|
||||||
this.draw_rewind_effect(tic);
|
this.draw_rewind_effect(packet.clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug overlays
|
||||||
|
if (this.show_actor_bboxes) {
|
||||||
|
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();
|
||||||
|
if (! actor)
|
||||||
|
continue;
|
||||||
|
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
draw_rewind_effect(tic) {
|
draw_rewind_effect(clock) {
|
||||||
// Shift several rows over in a recurring pattern, like a VHS, whatever that is
|
// Shift several rows over in a recurring pattern, like a VHS, whatever that is
|
||||||
let rewind_start = tic / 20 % 1;
|
let rewind_start = clock / 20 % 1;
|
||||||
for (let chunk = 0; chunk < 4; chunk++) {
|
for (let chunk = 0; chunk < 4; chunk++) {
|
||||||
let y = Math.floor(this.canvas.height * (chunk + rewind_start) / 4);
|
let y = Math.floor(this.canvas.height * (chunk + rewind_start) / 4);
|
||||||
for (let dy = 1; dy < 5; dy++) {
|
for (let dy = 1; dy < 5; dy++) {
|
||||||
@@ -149,11 +366,85 @@ export class CanvasRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
create_tile_type_canvas(name) {
|
// Used by the editor and map previews. Draws a region of the level (probably a StoredLevel),
|
||||||
let canvas = mk('canvas', {width: this.tileset.size_x, height: this.tileset.size_y});
|
// 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 CanvasRendererDrawPacket(this, ctx, perception);
|
||||||
|
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);
|
||||||
|
}
|
||||||
let ctx = canvas.getContext('2d');
|
let ctx = canvas.getContext('2d');
|
||||||
this.tileset.draw_type(name, null, 0, (sx, sy, dx = 0, dy = 0, w = 1, h = w) =>
|
|
||||||
this.blit(ctx, sx, sy, dx, dy, w, h));
|
// Individual tile types always reveal what they are
|
||||||
|
let packet = new CanvasRendererDrawPacket(this, ctx, 'palette');
|
||||||
|
packet.x = x;
|
||||||
|
packet.y = y;
|
||||||
|
this.tileset.draw_type(name, tile, packet);
|
||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default [{
|
|||||||
title: "conundrum",
|
title: "conundrum",
|
||||||
author: "fluffy",
|
author: "fluffy",
|
||||||
url: 'https://beesbuzz.biz/',
|
url: 'https://beesbuzz.biz/',
|
||||||
beepbox: 'https://www.beepbox.co/#8n31sbk0l00e0rt2mm0a7g0rj07i0r1o3210T1v1L4u01q3d7fay3z6C0c1A5F4B0V1Q0248Pac74E0085T1v1L4u01q1d1f4y4z9C0c1A1F0B0V1Q200ePd593E0787T5v1L4u05q3d0f1y4z0C2c0h0H_--D-quSRIAJJST4v1L4u04q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00bkzgid18Q4zgid0000000018Nwz55p88Nhnh5t000000ichgR5zkicg004hcx4O4jhkohd14z4isp25PFJvy1wqY58llp2GhKFLh_wFLh-czArdWfRuKrdQ_p13c5FKfZ_tRBPjub_aq_TYtjhZgtBdJlldtlldtlleKGGCKGGCK1wqoiGGiOdI3j1RlkOJBtdZllmG1wqoiGGCK1JFxaEm1STTQMBlldtlldtlJKoHrarqGGqWGGqWGGth7ihT4sChON7a4sIpqPhDkCz9FFOG8YwzOF8Wp8WFOHyfAVARQVB4ughVkAtcAtkVl97Op8Wd8Td6nl9EOaqSqcyIz9UzMbaXbAenShhAPvehhrBRApcVmnlsV5vaGysD9CKsyyLbH8OpOIKGVEGKttV56jdkV55uTnpeldpuptSltdRKpEPpFFOW8YwzOF8Wp8WFOGU0',
|
beepbox: 'https://www.beepbox.co/#8n31sbk0l00e0rt2mm0a7g0rj07i0r1o3210T1v1L4u01q3d7fay3z6C0c1A5F4B0V1Q0248Pac74E0085T1v1L4u01q1d1f4y4z9C0c1A1F0B0V1Q200ePd593E0787T5v1L4u05q3d0f1y4z0C2c0h0H_--D-quSRIAJJST4v1L4u04q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4zgid18Q4zgid0000000018Nwz55p88Nhnh5t000000ichgR5zkicg004hcx4O4jhkohd14z4isp25PFJvy1wqY58llp2GhKFLh_wFLh-czArdWfRuKrdQ_p13c5FKfZ_tRBPjub_aq_TYtjhZgtBdJlldtlldtlleKGGCKGGCK1wqoiGGiOdI3j1RlkOJBtdZllmG1wqoiGGCK1JFxaEm1STTQMBlldtlldtlJKoHrarqGGqWGGqWGGth7ihT4sChON7a4sIpqPhDkCz9FFOG8YwzOF8Wp8WFOHyfAVARQVB4ughVkAtcAtkVl97Op8Wd8Td6nl9EOaqSqcyIz9UzMbaXbAenShhAPvehhrBRApcVmnlsV5vaGysD9CKsyyLbH8OpOIKGVEGKttV56jdkV55uTnpeldpuptSltdRKpEPpFFOW8YwzOF8Wp8WFOGU0',
|
||||||
path: 'music/conundrum.ogg',
|
path: 'music/conundrum.ogg',
|
||||||
}, {
|
}, {
|
||||||
title: "kinda song",
|
title: "kinda song",
|
||||||
@@ -51,4 +51,29 @@ export default [{
|
|||||||
twitter: "glitchedpuppet",
|
twitter: "glitchedpuppet",
|
||||||
beepbox: 'https://jummbus.bitbucket.io/#j2N07Unnamedn310s0k0l00e0jt2mm0a7g0jj07i0r1O_U0000o3210T1v0wL0OD0Ou01q1d5f6y0z6C1c0A1F2B5V6Q20a0Pe64bE0171T1v0pL0OD0Ou92q1d4f7y2z1C0c2AbF6B6V9Q0490Pb976E0001T1v0pL0OD0Ou94q1d2f7y2z1C0c2A9F5B5V6Q290dPa883E0011T4v0pL0OD0Ouf0q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4x8Qd000lBu7014x4i4Qd3gQlmoh4ia2cz8OcChA4h4y8w01cPhjl0p27hFCLwATnMkCKChW2ZcngbUxcnikQ7sQO_inQ5WCkXyW9jbZ9vlO_lODhdcDjQNtgJ0Gp7IpAukFczR2FyX2frIzQ4zOGOOfpauP9vainRihQPK4tClqoKMnaAzQQnQQnRknXx7pBnK2OFjSO_oELFAzFIOWa8WF8WpHW3nEdv26LgqZcLQQap7Iu6P9j5R2Q2Q2Q2VeRfbEbWGq2-DinQBZttkHUMRWPn9HFAuwzEe3E8W2ehFyUsNncLQThuCnW2_aGydcngbkO_rZdkPjdcSpvx9jbZ3cRZtcO_lipvFSFkO_lN4YlAjBpdp6hahFyWz5OXbWFbWF8YEmChy3wWiehcK0bWaoEIlChw3JHp5K5E5E5w1sPb5P2f9as0LVds0bkbukQni0JFyQ5c0bokRyXrbxqxr2CyWSOSAzw1qxqxrNrxqxrhtwJgJMJJhvmbibikO8J5JwJEJEJtE',
|
beepbox: 'https://jummbus.bitbucket.io/#j2N07Unnamedn310s0k0l00e0jt2mm0a7g0jj07i0r1O_U0000o3210T1v0wL0OD0Ou01q1d5f6y0z6C1c0A1F2B5V6Q20a0Pe64bE0171T1v0pL0OD0Ou92q1d4f7y2z1C0c2AbF6B6V9Q0490Pb976E0001T1v0pL0OD0Ou94q1d2f7y2z1C0c2A9F5B5V6Q290dPa883E0011T4v0pL0OD0Ouf0q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4x8Qd000lBu7014x4i4Qd3gQlmoh4ia2cz8OcChA4h4y8w01cPhjl0p27hFCLwATnMkCKChW2ZcngbUxcnikQ7sQO_inQ5WCkXyW9jbZ9vlO_lODhdcDjQNtgJ0Gp7IpAukFczR2FyX2frIzQ4zOGOOfpauP9vainRihQPK4tClqoKMnaAzQQnQQnRknXx7pBnK2OFjSO_oELFAzFIOWa8WF8WpHW3nEdv26LgqZcLQQap7Iu6P9j5R2Q2Q2Q2VeRfbEbWGq2-DinQBZttkHUMRWPn9HFAuwzEe3E8W2ehFyUsNncLQThuCnW2_aGydcngbkO_rZdkPjdcSpvx9jbZ3cRZtcO_lipvFSFkO_lN4YlAjBpdp6hahFyWz5OXbWFbWF8YEmChy3wWiehcK0bWaoEIlChw3JHp5K5E5E5w1sPb5P2f9as0LVds0bkbukQni0JFyQ5c0bokRyXrbxqxr2CyWSOSAzw1qxqxrNrxqxrhtwJgJMJJhvmbibikO8J5JwJEJEJtE',
|
||||||
path: 'music/kinda-song.ogg',
|
path: 'music/kinda-song.ogg',
|
||||||
|
}, {
|
||||||
|
title: "learning has occurred",
|
||||||
|
author: "jneen",
|
||||||
|
twitter: "jneen_",
|
||||||
|
path: 'music/learning-has-occurred.ogg',
|
||||||
|
}, {
|
||||||
|
title: "escape on star road",
|
||||||
|
author: "jneen",
|
||||||
|
twitter: "jneen_",
|
||||||
|
path: 'music/escape-on-star-road.ogg',
|
||||||
|
}, {
|
||||||
|
title: "inner orbit",
|
||||||
|
author: "notchris",
|
||||||
|
twitter: "chrismcgrane",
|
||||||
|
path: 'music/inner-orbit.ogg',
|
||||||
|
}, {
|
||||||
|
title: "canopy",
|
||||||
|
author: "notchris",
|
||||||
|
twitter: "chrismcgrane",
|
||||||
|
path: 'music/canopy.ogg',
|
||||||
|
}, {
|
||||||
|
title: "asteroid prairie",
|
||||||
|
author: "notchris",
|
||||||
|
twitter: "chrismcgrane",
|
||||||
|
path: 'music/asteroid-prairie.ogg',
|
||||||
}];
|
}];
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import * as fflate from './vendor/fflate.js';
|
||||||
|
|
||||||
|
// Base class for custom errors
|
||||||
|
export class LLError extends Error {}
|
||||||
|
|
||||||
|
// Random choice
|
||||||
export function random_choice(list) {
|
export function random_choice(list) {
|
||||||
return list[Math.floor(Math.random() * list.length)];
|
return list[Math.floor(Math.random() * list.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// DOM stuff
|
||||||
function _mk(el, children) {
|
function _mk(el, children) {
|
||||||
if (children.length > 0) {
|
if (children.length > 0) {
|
||||||
if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") {
|
if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") {
|
||||||
@@ -18,17 +26,114 @@ function _mk(el, children) {
|
|||||||
export function mk(tag_selector, ...children) {
|
export function mk(tag_selector, ...children) {
|
||||||
let [tag, ...classes] = tag_selector.split('.');
|
let [tag, ...classes] = tag_selector.split('.');
|
||||||
let el = document.createElement(tag);
|
let el = document.createElement(tag);
|
||||||
el.classList = classes.join(' ');
|
if (classes.length > 0) {
|
||||||
|
el.classList = classes.join(' ');
|
||||||
|
}
|
||||||
return _mk(el, 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) {
|
export function mk_svg(tag_selector, ...children) {
|
||||||
let [tag, ...classes] = tag_selector.split('.');
|
let [tag, ...classes] = tag_selector.split('.');
|
||||||
let el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
let el = document.createElementNS(SVG_NS, tag);
|
||||||
el.classList = classes.join(' ');
|
if (classes.length > 0) {
|
||||||
|
el.classList = classes.join(' ');
|
||||||
|
}
|
||||||
return _mk(el, 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;
|
||||||
|
|
||||||
|
let require_file = options.require_file ?? false;
|
||||||
|
let is_valid = ev => {
|
||||||
|
// TODO this requires files, should make some args for this
|
||||||
|
if (options.require_file) {
|
||||||
|
let dt = ev.dataTransfer;
|
||||||
|
if (! dt || dt.items.length === 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Only test the first item I guess? If it's a file then they should all be files
|
||||||
|
if (dt.items[0].kind !== 'file')
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_drop = () => {
|
||||||
|
if (dropzone_class !== null) {
|
||||||
|
element.classList.remove(dropzone_class);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO should have a filter function for when a drag is valid but i forget which of these
|
||||||
|
// should have that
|
||||||
|
element.addEventListener('dragenter', ev => {
|
||||||
|
if (! is_valid(ev))
|
||||||
|
return;
|
||||||
|
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
if (dropzone_class !== null) {
|
||||||
|
element.classList.add(dropzone_class);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
element.addEventListener('dragover', ev => {
|
||||||
|
if (! is_valid(ev))
|
||||||
|
return;
|
||||||
|
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
});
|
||||||
|
element.addEventListener('dragleave', ev => {
|
||||||
|
if (ev.relatedTarget && element.contains(ev.relatedTarget))
|
||||||
|
return;
|
||||||
|
|
||||||
|
end_drop();
|
||||||
|
});
|
||||||
|
element.addEventListener('drop', ev => {
|
||||||
|
if (! is_valid(ev))
|
||||||
|
return;
|
||||||
|
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
end_drop();
|
||||||
|
on_drop(ev);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(t) {
|
||||||
|
return new Promise(res => {
|
||||||
|
setTimeout(res, t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function promise_event(element, success_event, failure_event) {
|
export function promise_event(element, success_event, failure_event) {
|
||||||
let resolve, reject;
|
let resolve, reject;
|
||||||
let promise = new Promise((res, rej) => {
|
let promise = new Promise((res, rej) => {
|
||||||
@@ -36,21 +141,21 @@ export function promise_event(element, success_event, failure_event) {
|
|||||||
reject = rej;
|
reject = rej;
|
||||||
});
|
});
|
||||||
|
|
||||||
let success_handler = e => {
|
let success_handler = ev => {
|
||||||
element.removeEventListener(success_event, success_handler);
|
element.removeEventListener(success_event, success_handler);
|
||||||
if (failure_event) {
|
if (failure_event) {
|
||||||
element.removeEventListener(failure_event, failure_handler);
|
element.removeEventListener(failure_event, failure_handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(e);
|
resolve(ev);
|
||||||
};
|
};
|
||||||
let failure_handler = e => {
|
let failure_handler = ev => {
|
||||||
element.removeEventListener(success_event, success_handler);
|
element.removeEventListener(success_event, success_handler);
|
||||||
if (failure_event) {
|
if (failure_event) {
|
||||||
element.removeEventListener(failure_event, failure_handler);
|
element.removeEventListener(failure_event, failure_handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(e);
|
reject(ev);
|
||||||
};
|
};
|
||||||
|
|
||||||
element.addEventListener(success_event, success_handler);
|
element.addEventListener(success_event, success_handler);
|
||||||
@@ -61,6 +166,7 @@ export function promise_event(element, success_event, failure_event) {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function fetch(url) {
|
export async function fetch(url) {
|
||||||
let xhr = new XMLHttpRequest;
|
let xhr = new XMLHttpRequest;
|
||||||
let promise = promise_event(xhr, 'load', 'error');
|
let promise = promise_event(xhr, 'load', 'error');
|
||||||
@@ -68,11 +174,73 @@ export async function fetch(url) {
|
|||||||
xhr.responseType = 'arraybuffer';
|
xhr.responseType = 'arraybuffer';
|
||||||
xhr.send();
|
xhr.send();
|
||||||
await promise;
|
await promise;
|
||||||
|
if (xhr.status !== 200)
|
||||||
|
throw new Error(`Failed to load ${url} -- ${xhr.status} ${xhr.statusText}`);
|
||||||
return xhr.response;
|
return xhr.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function string_from_buffer_ascii(buf, start = 0, len) {
|
||||||
|
if (ArrayBuffer.isView(buf)) {
|
||||||
|
start += buf.byteOffset;
|
||||||
|
buf = buf.buffer;
|
||||||
|
}
|
||||||
|
return String.fromCharCode.apply(null, new Uint8Array(buf, start, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts a string to a buffer, using NO ENCODING, assuming single-byte characters
|
||||||
|
export function bytestring_to_buffer(bytestring) {
|
||||||
|
return Uint8Array.from(bytestring, c => c.charCodeAt(0)).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64encode(value) {
|
||||||
|
if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
|
||||||
|
value = string_from_buffer_ascii(value);
|
||||||
|
}
|
||||||
|
// Make URL-safe and strip trailing padding
|
||||||
|
return btoa(value).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64decode(data) {
|
||||||
|
return bytestring_to_buffer(atob(data.replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function format_duration(seconds, places = 0) {
|
||||||
|
let 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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DelayTimer {
|
||||||
|
constructor() {
|
||||||
|
this.active = false;
|
||||||
|
this._handle = null;
|
||||||
|
this._bound_alarm = this._alarm.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(duration) {
|
||||||
|
if (this._handle) {
|
||||||
|
window.clearTimeout(this._handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.active = true;
|
||||||
|
this._handle = window.setTimeout(this._bound_alarm, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
_alarm() {
|
||||||
|
this._handle = null;
|
||||||
|
this.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cast a line through a grid and yield every cell it touches
|
// Cast a line through a grid and yield every cell it touches
|
||||||
export function* walk_grid(x0, y0, x1, y1) {
|
export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
|
||||||
// TODO if the ray starts outside the grid (extremely unlikely), we should
|
// TODO if the ray starts outside the grid (extremely unlikely), we should
|
||||||
// find the point where it ENTERS the grid, otherwise the 'while'
|
// find the point where it ENTERS the grid, otherwise the 'while'
|
||||||
// conditions below will stop immediately
|
// conditions below will stop immediately
|
||||||
@@ -112,27 +280,24 @@ export function* walk_grid(x0, y0, x1, y1) {
|
|||||||
step_a = -step_a;
|
step_a = -step_a;
|
||||||
offset_x = 1 - offset_x;
|
offset_x = 1 - offset_x;
|
||||||
}
|
}
|
||||||
// Zero offset means we're on a grid line, so we're actually a full cell
|
else if (offset_x === 0) {
|
||||||
// away from the next grid line
|
// Zero offset means we're on a grid line, so we're a full cell away from the next grid line
|
||||||
if (offset_x === 0) {
|
// (if we're moving forward; if we're moving backward, the next cell really is 0 away)
|
||||||
offset_x = 1;
|
offset_x = 1;
|
||||||
}
|
}
|
||||||
let step_b = 1;
|
let step_b = 1;
|
||||||
let offset_y = 1 - (y0 - b);
|
let offset_y = 1 - (y0 - b);
|
||||||
if (dy < 0) {
|
if (dy < 0) {
|
||||||
dy = -dy;
|
dy = -dy;
|
||||||
step_b = -step_b;
|
step_b = -step_b;
|
||||||
offset_y = 1 - offset_y;
|
offset_y = 1 - offset_y;
|
||||||
}
|
}
|
||||||
if (offset_y === 0) {
|
else if (offset_y === 0) {
|
||||||
offset_y = 1;
|
offset_y = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let err = dy * offset_x - dx * offset_y;
|
let err = dy * offset_x - dx * offset_y;
|
||||||
|
|
||||||
let min_a = 0, min_b = 0;
|
|
||||||
// TODO get these passed in fool
|
|
||||||
let max_a = 31, max_b = 31;
|
|
||||||
if (dx > dy) {
|
if (dx > dy) {
|
||||||
// Main axis is x/a
|
// Main axis is x/a
|
||||||
while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) {
|
while (min_a <= a && a <= max_a && min_b <= b && b <= max_b) {
|
||||||
@@ -171,3 +336,123 @@ export function* walk_grid(x0, y0, x1, y1) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Root class to indirect over where we might get files from
|
||||||
|
// - a pool of uploaded in-memory files
|
||||||
|
// - a single uploaded zip file
|
||||||
|
// - a local directory provided via the webkit Entry api
|
||||||
|
// - HTTP (but only for files we choose ourselves, not arbitrary ones, due to CORS)
|
||||||
|
// Note that where possible, these classes lowercase all filenames, in keeping with C2G's implicit
|
||||||
|
// requirement that filenames are case-insensitive :/
|
||||||
|
export class FileSource {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
// Get a file's contents as an ArrayBuffer
|
||||||
|
async get(path) {}
|
||||||
|
}
|
||||||
|
// Files we have had uploaded one at a time (note that each upload becomes its own source)
|
||||||
|
export class FileFileSource extends FileSource {
|
||||||
|
constructor(files) {
|
||||||
|
super();
|
||||||
|
this.files = {};
|
||||||
|
for (let file of files) {
|
||||||
|
this.files[(file.webkitRelativePath ?? file.name).toLowerCase()] = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(path) {
|
||||||
|
let file = this.files[path.toLowerCase()];
|
||||||
|
if (file) {
|
||||||
|
return file.arrayBuffer();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return Promise.reject(new Error(`No such file was provided: ${path}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Regular HTTP fetch
|
||||||
|
export class HTTPFileSource extends FileSource {
|
||||||
|
// Should be given a URL object as a root
|
||||||
|
constructor(root) {
|
||||||
|
super();
|
||||||
|
this.root = root;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(path) {
|
||||||
|
let url = new URL(path, this.root);
|
||||||
|
return fetch(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// WebKit Entry interface
|
||||||
|
// XXX this does not appear to work if you drag in a link to a directory but that is probably beyond
|
||||||
|
// my powers to fix
|
||||||
|
export class EntryFileSource extends FileSource {
|
||||||
|
constructor(entries) {
|
||||||
|
super();
|
||||||
|
this.files = {};
|
||||||
|
let file_count = 0;
|
||||||
|
|
||||||
|
let read_directory = async (directory_entry, dir_prefix) => {
|
||||||
|
let reader = directory_entry.createReader();
|
||||||
|
let all_entries = [];
|
||||||
|
while (true) {
|
||||||
|
let entries = await new Promise((res, rej) => reader.readEntries(res, rej));
|
||||||
|
all_entries.push.apply(all_entries, entries);
|
||||||
|
if (entries.length === 0)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handle_entries(all_entries, dir_prefix);
|
||||||
|
};
|
||||||
|
let handle_entries = (entries, dir_prefix) => {
|
||||||
|
file_count += entries.length;
|
||||||
|
if (file_count > 4096)
|
||||||
|
throw new LLError("Found way too many files; did you drag in the wrong directory?");
|
||||||
|
|
||||||
|
let dir_promises = [];
|
||||||
|
for (let entry of entries) {
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
dir_promises.push(read_directory(entry, dir_prefix + entry.name + '/'));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.files[(dir_prefix + entry.name).toLowerCase()] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(dir_promises);
|
||||||
|
};
|
||||||
|
|
||||||
|
this._loaded_promise = handle_entries(entries, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(path) {
|
||||||
|
let entry = this.files[path.toLowerCase()];
|
||||||
|
if (! entry)
|
||||||
|
throw new LLError(`No such file in local directory: ${path}`);
|
||||||
|
|
||||||
|
let file = await new Promise((res, rej) => entry.file(res, rej));
|
||||||
|
return await file.arrayBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// fflate 0.6.7, obtained from https://cdn.skypack.dev/fflate?min
|
||||||
|
// original project at https://github.com/101arrowz/fflate
|
||||||
|
var gn={},bn=function(n,r,t,e,i){var a=gn[r]||(gn[r]=URL.createObjectURL(new Blob([n],{type:"text/javascript"}))),o=new Worker(a);return o.onerror=function(f){return i(f.error,null)},o.onmessage=function(f){return i(null,f.data)},o.postMessage(t,e),o},A=Uint8Array,R=Uint16Array,nr=Uint32Array,ur=new A([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),lr=new A([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Mr=new A([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),wn=function(n,r){for(var t=new R(31),e=0;e<31;++e)t[e]=r+=1<<n[e-1];for(var i=new nr(t[30]),e=1;e<30;++e)for(var a=t[e];a<t[e+1];++a)i[a]=a-t[e]<<5|e;return[t,i]},yn=wn(ur,2),Nr=yn[0],Ir=yn[1];Nr[28]=258,Ir[258]=28;for(var mn=wn(lr,0),zn=mn[0],Qr=mn[1],Ur=new R(32768),T=0;T<32768;++T){var ir=(T&43690)>>>1|(T&21845)<<1;ir=(ir&52428)>>>2|(ir&13107)<<2,ir=(ir&61680)>>>4|(ir&3855)<<4,Ur[T]=((ir&65280)>>>8|(ir&255)<<8)>>>1}for(var V=function(n,r,t){for(var e=n.length,i=0,a=new R(r);i<e;++i)++a[n[i]-1];var o=new R(r);for(i=0;i<r;++i)o[i]=o[i-1]+a[i-1]<<1;var f;if(t){f=new R(1<<r);var h=15-r;for(i=0;i<e;++i)if(n[i])for(var s=i<<4|n[i],u=r-n[i],l=o[n[i]-1]++<<u,c=l|(1<<u)-1;l<=c;++l)f[Ur[l]>>>h]=s}else for(f=new R(e),i=0;i<e;++i)n[i]&&(f[i]=Ur[o[n[i]-1]++]>>>15-n[i]);return f},tr=new A(288),T=0;T<144;++T)tr[T]=8;for(var T=144;T<256;++T)tr[T]=9;for(var T=256;T<280;++T)tr[T]=7;for(var T=280;T<288;++T)tr[T]=8;for(var vr=new A(32),T=0;T<32;++T)vr[T]=5;var xn=V(tr,9,0),An=V(tr,9,1),Dn=V(vr,5,0),Mn=V(vr,5,1),Gr=function(n){for(var r=n[0],t=1;t<n.length;++t)n[t]>r&&(r=n[t]);return r},X=function(n,r,t){var e=r/8|0;return(n[e]|n[e+1]<<8)>>(r&7)&t},Or=function(n,r){var t=r/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(r&7)},Cr=function(n){return(n/8|0)+(n&7&&1)},$=function(n,r,t){(r==null||r<0)&&(r=0),(t==null||t>n.length)&&(t=n.length);var e=new(n instanceof R?R:n instanceof nr?nr:A)(t-r);return e.set(n.subarray(r,t)),e},Fr=function(n,r,t){var e=n.length;if(!e||t&&!t.l&&e<5)return r||new A(0);var i=!r||t,a=!t||t.i;t||(t={}),r||(r=new A(e*3));var o=function(Tr){var Dr=r.length;if(Tr>Dr){var hr=new A(Math.max(Dr*2,Tr));hr.set(r),r=hr}},f=t.f||0,h=t.p||0,s=t.b||0,u=t.l,l=t.d,c=t.m,m=t.n,p=e*8;do{if(!u){t.f=f=X(n,h,1);var g=X(n,h+1,3);if(h+=3,g)if(g==1)u=An,l=Mn,c=9,m=5;else if(g==2){var y=X(n,h,31)+257,D=X(n,h+10,15)+4,F=y+X(n,h+5,31)+1;h+=14;for(var U=new A(F),x=new A(19),v=0;v<D;++v)x[Mr[v]]=X(n,h+v*3,7);h+=D*3;for(var P=Gr(x),k=(1<<P)-1,N=V(x,P,1),v=0;v<F;){var I=N[X(n,h,k)];h+=I&15;var w=I>>>4;if(w<16)U[v++]=w;else{var S=0,B=0;for(w==16?(B=3+X(n,h,3),h+=2,S=U[v-1]):w==17?(B=3+X(n,h,7),h+=3):w==18&&(B=11+X(n,h,127),h+=7);B--;)U[v++]=S}}var G=U.subarray(0,y),Z=U.subarray(y);c=Gr(G),m=Gr(Z),u=V(G,c,1),l=V(Z,m,1)}else throw"invalid block type";else{var w=Cr(h)+4,M=n[w-4]|n[w-3]<<8,z=w+M;if(z>e){if(a)throw"unexpected EOF";break}i&&o(s+M),r.set(n.subarray(w,z),s),t.b=s+=M,t.p=h=z*8;continue}if(h>p){if(a)throw"unexpected EOF";break}}i&&o(s+131072);for(var O=(1<<c)-1,H=(1<<m)-1,j=h;;j=h){var S=u[Or(n,h)&O],Q=S>>>4;if(h+=S&15,h>p){if(a)throw"unexpected EOF";break}if(!S)throw"invalid length/literal";if(Q<256)r[s++]=Q;else if(Q==256){j=h,u=null;break}else{var W=Q-254;if(Q>264){var v=Q-257,d=ur[v];W=X(n,h,(1<<d)-1)+Nr[v],h+=d}var _=l[Or(n,h)&H],J=_>>>4;if(!_)throw"invalid distance";h+=_&15;var Z=zn[J];if(J>3){var d=lr[J];Z+=Or(n,h)&(1<<d)-1,h+=d}if(h>p){if(a)throw"unexpected EOF";break}i&&o(s+131072);for(var q=s+W;s<q;s+=4)r[s]=r[s-Z],r[s+1]=r[s+1-Z],r[s+2]=r[s+2-Z],r[s+3]=r[s+3-Z];s=q}}t.l=u,t.p=j,t.b=s,u&&(f=1,t.m=c,t.d=l,t.n=m)}while(!f);return s==r.length?r:$(r,0,s)},b=function(n,r,t){t<<=r&7;var e=r/8|0;n[e]|=t,n[e+1]|=t>>>8},cr=function(n,r,t){t<<=r&7;var e=r/8|0;n[e]|=t,n[e+1]|=t>>>8,n[e+2]|=t>>>16},Er=function(n,r){for(var t=[],e=0;e<n.length;++e)n[e]&&t.push({s:e,f:n[e]});var i=t.length,a=t.slice();if(!i)return[er,0];if(i==1){var o=new A(t[0].s+1);return o[t[0].s]=1,[o,1]}t.sort(function(F,U){return F.f-U.f}),t.push({s:-1,f:25001});var f=t[0],h=t[1],s=0,u=1,l=2;for(t[0]={s:-1,f:f.f+h.f,l:f,r:h};u!=i-1;)f=t[t[s].f<t[l].f?s++:l++],h=t[s!=u&&t[s].f<t[l].f?s++:l++],t[u++]={s:-1,f:f.f+h.f,l:f,r:h};for(var c=a[0].s,e=1;e<i;++e)a[e].s>c&&(c=a[e].s);var m=new R(c+1),p=Pr(t[u-1],m,0);if(p>r){var e=0,g=0,w=p-r,M=1<<w;for(a.sort(function(U,x){return m[x.s]-m[U.s]||U.f-x.f});e<i;++e){var z=a[e].s;if(m[z]>r)g+=M-(1<<p-m[z]),m[z]=r;else break}for(g>>>=w;g>0;){var y=a[e].s;m[y]<r?g-=1<<r-m[y]++-1:++e}for(;e>=0&&g;--e){var D=a[e].s;m[D]==r&&(--m[D],++g)}p=r}return[new A(m),p]},Pr=function(n,r,t){return n.s==-1?Math.max(Pr(n.l,r,t+1),Pr(n.r,r,t+1)):r[n.s]=t},Vr=function(n){for(var r=n.length;r&&!n[--r];);for(var t=new R(++r),e=0,i=n[0],a=1,o=function(h){t[e++]=h},f=1;f<=r;++f)if(n[f]==i&&f!=r)++a;else{if(!i&&a>2){for(;a>138;a-=138)o(32754);a>2&&(o(a>10?a-11<<5|28690:a-3<<5|12305),a=0)}else if(a>3){for(o(i),--a;a>6;a-=6)o(8304);a>2&&(o(a-3<<5|8208),a=0)}for(;a--;)o(i);a=1,i=n[f]}return[t.subarray(0,e),r]},pr=function(n,r){for(var t=0,e=0;e<r.lenLine truncated
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 89 KiB |