35 Commits
Author SHA1 Message Date
Eevee (Evelyn Woods) 8c2f71294f Fix crash caused by last fix dammit (fixes #9) 2020-10-22 14:44:05 -06:00
Eevee (Evelyn Woods) d03d61516f Fix NaNs sneaking into the save file 2020-10-21 23:34:59 -06:00
Eevee (Evelyn Woods) 09eb03dad6 Iterate actors in reverse order to match Lynx/CC2 (fixes #7) 2020-10-21 21:24:59 -06:00
Eevee (Evelyn Woods) 8073604271 Add... error handling... sort of... for levels at least 2020-10-21 21:07:50 -06:00
Eevee (Evelyn Woods) edbe32c148 Add support for drag/drop, dir upload, C2G, AND lazy level loading! 2020-10-21 20:47:07 -06:00
Eevee (Evelyn Woods) 36b9f2efd7 Make (most) actors pick up blue keys 2020-10-07 15:28:51 -06:00
Eevee (Evelyn Woods) 197113c842 Add two new tracks from jneen 2020-10-07 15:16:20 -06:00
Eevee (Evelyn Woods) b20e2cac8c Tanks in traps turn around 2020-10-04 09:52:59 -06:00
Eevee (Evelyn Woods) 5c2fc32546 Tanks always decide to move in their given direction, even when blocked 2020-10-04 09:47:03 -06:00
Eevee (Evelyn Woods) 7f8efaa4e0 Fix flicker when moving in sync with a N/W actor at the edge of the viewport 2020-10-04 09:40:52 -06:00
Eevee (Evelyn Woods) 8adb630862 Add partial wiring support 2020-10-01 06:46:07 -06:00
Eevee (Evelyn Woods) 4cd0585d0b Revert the blue wall compat fix and replace them with popwalls instead (fixes #5) 2020-10-01 03:22:49 -06:00
Eevee (Evelyn Woods) 4f5d169d06 Reduce SFX volume in levels with a lot of off-screen button presses 2020-10-01 03:08:54 -06:00
Eevee (Evelyn Woods) 9b873764fb Shim around several compat issues that affect CCLP levels
- CCLP1 #81 requires pushing blocks off of blue walls, which is
  impossible in CC2 but allowed in TW Lynx (unclear if this is a lynx
  behavior or a tw bug)

- CCLP1 #89 has a tank start on a recessed wall and drive off of it,
  expecting the recessed wall to be left alone, but under CC2 rules it
  becomes a wall; such walls are now automatically converted to a new
  tile, the "doubly recessed wall", which restores the expected behavior
  without changing how recessed walls work in general

- CCLP4 #135 expects pressing a blue button to not affect blue tanks
  that are currently in mid-slide

In addition, the behavior of blue buttons now matches the Lynx/Steam
behavior: the press is stored as a flag and queued until the tank is
next able to move.
2020-10-01 03:08:25 -06:00
Eevee (Evelyn Woods) 8326b42bc7 Pad "real time" seconds to two places; allow tied scores without aid to replace scores with aid 2020-09-30 02:37:43 -06:00
Eevee (Evelyn Woods) f99b9826be Detect trap buttons that are held down when the level begins 2020-09-30 02:20:33 -06:00
Eevee (Evelyn Woods) f4363b8fda Rewrite how connections work
- Teleporters now connect on the fly, rather than having fixed
  connections (important because dynamite can destroy teleporters!)

- If custom connections are present, red and brown buttons ONLY use
  those, rather than falling back to CC2 connection rules

- Multiple brown buttons connected to the same trap should now work
  correctly
2020-09-30 02:11:17 -06:00
Eevee (Evelyn Woods) 6df0c96d1b Make the green key and lock more colorblind-friendly 2020-09-29 10:58:37 -06:00
Eevee (Evelyn Woods) db3af69ff7 Make gray buttons undoable 2020-09-28 20:44:46 -06:00
Eevee (Evelyn Woods) dfa0fa4a9e Add gray buttons to the C2M implementation 2020-09-28 20:43:22 -06:00
Eevee (Evelyn Woods) 325a06395d Partly implement gray buttons; expose them and green stuff in the editor 2020-09-28 04:09:24 -06:00
Eevee (Evelyn Woods) 76051870b7 Basically finish the camera region editing tool; add save/load support for it 2020-09-28 04:00:55 -06:00
Eevee (Evelyn Woods) 432bb881e6 Split out mouse operations; add camera regions, our first custom feature 2020-09-28 02:58:11 -06:00
Eevee (Evelyn Woods) 8711d87a36 Split the editor into its own module 2020-09-28 00:58:31 -06:00
Eevee (Evelyn Woods) ade135514b Undo actor animation (fixes #4) 2020-09-28 00:38:24 -06:00
Eevee (Evelyn Woods) 684021f93d Fix typo in drowned player tile 2020-09-27 04:03:15 -06:00
Eevee (Evelyn Woods) 78bb1f2dc1 Populate player 2 state sprites so she can actually be drawn 2020-09-26 20:38:50 -06:00
Eevee (Evelyn Woods) 70d6739465 Ice blocks splash in water 2020-09-26 20:21:00 -06:00
Eevee (Evelyn Woods) 760ca374ce Fall back to webkitAudioContext if necessary, sigh 2020-09-26 19:47:51 -06:00
Eevee (Evelyn Woods) 4a0ba44116 Restore floors under thin walls in CCL maps 2020-09-26 04:49:58 -06:00
Eevee (Evelyn Woods) 967520c1bd Change fake floors to not be blocking; causes bad visual/audio feedback 2020-09-26 03:15:01 -06:00
Eevee (Evelyn Woods) b40805c02e Take a rough swing at phone support 2020-09-26 02:55:39 -06:00
Eevee (Evelyn Woods) a2e1ed4820 Remove unused sound effect 2020-09-26 02:55:23 -06:00
Eevee (Evelyn Woods) fb71dd1ff1 Fix conundrum, which I accidentally corrupted a bit 2020-09-26 01:44:06 -06:00
Eevee (Evelyn Woods) 12066072ec Disable the big ol cheat whoops 2020-09-26 00:17:15 -06:00
22 changed files with 3091 additions and 1034 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

+21 -14
View File
@@ -10,6 +10,7 @@
<meta name="og:image" content="https://c.eev.ee/lexys-labyrinth/og-preview.png">
<meta name="og:title" content="Lexy's Labyrinth">
<meta name="og:description" content="A (work in progress) reimplementation of Chip's Challenge 1 and 2, using entirely free assets.">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body data-mode="splash">
<header id="header-main">
@@ -39,6 +40,7 @@
</nav>
</header>
<main id="splash">
<div class="drag-overlay"></div>
<header>
<h1><img src="og-preview.png" alt="">Lexy's Labyrinth</h1>
</header>
@@ -57,21 +59,20 @@
<section id="splash-upload-levels">
<h2>Other levels</h2>
<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>
<!-- TODO explain how to find chips.dat or steam folder -->
<!-- TODO drag and drop? -->
<input id="splash-upload" type="file" accept=".dat,.ccl,.c2m,.ccs">
<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>
<p>Supports both the old Microsoft <code>CHIPS.DAT</code> format and the Steam <code>C2M</code> format.</p>
<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>
<!--
<p>Load and play any levels you have on hand — both the original levels and any custom ones, perhaps ones you found on the <a href="https://sets.bitbusters.club/">Bit Busters Club set list</a>. Supports CCL/DAT, C2G, and individual C2Ms (though scores aren't saved for those).</p>
<!-- TODO zip files! -->
<p>You can also drag and drop files or directories into this window.</p>
<input id="splash-upload-file" type="file" accept=".dat,.ccl,.c2m,.ccs" multiple>
<input id="splash-upload-dir" type="file" webkitdirectory>
<button type="button" id="splash-upload-file-button" class="button-big">Load files</button>
<button type="button" id="splash-upload-dir-button" class="button-big">Load directory</button>
<p>If you still have the original Microsoft "BOWEP" game lying around, you can play the Chip's Challenge 1 levels by loading <code>CHIPS.DAT</code>.</p>
<p>If you own the Steam versions of <a href="https://store.steampowered.com/app/346850/Chips_Challenge_1/">Chip's Challenge 1</a> (<em>free!</em>) or <a href="https://store.steampowered.com/app/348300/Chips_Challenge_2/">Chip's Challenge 2</a> ($5 last I checked), you can play those too, even on Linux or Mac:</p>
<ol class="normal-list">
<li>Right-click the game in Steam and choose <em>Properties</em>. On the <em>Local Files</em> tab, click <em>Browse local files</em>.</li>
<li>Open the <code>data</code> folder, then <code>games</code>.</li>
<li>You should see either a <code>cc1</code> or <code>cc2</code> folder. Drag it into this window.</li>
<li>You should see either a <code>cc1</code> or <code>cc2</code> folder. Drag it into this window, or load it with the button above.</li>
</ol>
-->
</section>
<section id="splash-your-levels">
@@ -116,10 +117,10 @@
</div>
<div class="controls">
<div class="play-controls">
<button class="control-pause" type="button">Pause (p)</button>
<button class="control-pause" type="button">Pause <span class="keyhint">(p)</span></button>
<button class="control-restart" type="button">Restart</button>
<button class="control-undo" type="button">Undo</button>
<button class="control-rewind" type="button">Rewind (z)</button>
<button class="control-rewind" type="button">Rewind <span class="keyhint">(z)</span></button>
</div>
<div class="demo-controls">
<button class="demo-play" type="button">View replay</button>
@@ -152,8 +153,14 @@
-->
</header>
<div class="level"><!-- level canvas and any overlays go here --></div>
<div class="controls">
<nav class="controls">
<div id="editor-toolbar">
<!-- tools go here -->
</div>
<button id="editor-share-url" type="button">Share?</button>
<div id="editor-tool-help">
<strong>Pencil</strong> — <span>Select a tile and draw with the left mouse button. Erase with the right mouse button.</span>
</div>
<!--
<p style>
Tip: Right click to color drop.<br>
@@ -178,7 +185,7 @@
map size
</pre>
-->
</div>
</nav>
<div class="palette"></div>
<!-- TODO:
controls
+78
View File
@@ -0,0 +1,78 @@
import * as util from './util.js';
export class StoredCell extends Array {
}
export class StoredLevel {
constructor(number) {
// TODO still not sure this belongs here
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.use_ccl_compat = 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
// TODO merge these imo
this.has_custom_connections = false;
this.custom_trap_wiring = {};
this.custom_cloner_wiring = {};
// New LL feature: custom camera regions, as lists of {x, y, width, height}
this.camera_regions = [];
}
scalar_to_coords(n) {
return [n % this.size_x, Math.floor(n / this.size_x)];
}
coords_to_scalar(x, y) {
return x + y * this.size_x;
}
check() {
}
}
export class StoredGame {
constructor(identifier, level_loader) {
this.identifier = identifier;
this._level_loader = level_loader;
// Simple objects containing keys:
// 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 = [];
}
// 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;
}
else {
// Otherwise, attempt to load the level
return this._level_loader(meta.bytes, meta.number);
}
}
}
+659 -83
View File
@@ -1,6 +1,7 @@
import { DIRECTIONS } from './defs.js';
import * as util from './format-util.js';
import * as format_base from './format-base.js';
import TILE_TYPES from './tiletypes.js';
import * as util from './util.js';
const CC2_DEMO_INPUT_MASK = {
drop: 0x01,
@@ -13,9 +14,8 @@ const CC2_DEMO_INPUT_MASK = {
};
class CC2Demo {
constructor(buf) {
this.buf = buf;
this.bytes = new Uint8Array(buf);
constructor(bytes) {
this.bytes = bytes;
// byte 0 is unknown, always 0?
// Force floor seed can apparently be anything; my best guess, based on the Desert Oasis
@@ -69,6 +69,7 @@ class CC2Demo {
let modifier_wire = {
decode(tile, modifier) {
tile.wire_directions = modifier & 0x0f;
// TODO wait, what happens if you use wire tunnels on steel or something other than floor?
tile.wire_tunnel_directions = (modifier & 0xf0) >> 4;
},
encode(tile) {
@@ -340,7 +341,9 @@ const TILE_ENCODING = {
0x46: {
name: 'force_floor_all',
},
// 0x47: 'button_gray',
0x47: {
name: 'button_gray',
},
// FIXME swivel floors... argh...
0x48: {
name: 'swivel_sw',
@@ -395,8 +398,18 @@ const TILE_ENCODING = {
has_next: true,
extra_args: [arg_direction],
},
// 0x57: Timid teeth : '#direction', '#next'
// 0x58: Explosion animation (unused in main levels) : '#direction', '#next'
0x57: {
name: 'teeth_timid',
has_next: true,
extra_args: [arg_direction],
error: "Timid chomper is not yet implemented, sorry!",
},
0x58: {
// TODO??? unused in main levels -- name: 'doppelganger2',
has_next: true,
extra_args: [arg_direction],
error: "Explosion animation is not implemented, sorry!",
},
0x59: {
name: 'hiking_boots',
has_next: true,
@@ -407,7 +420,11 @@ const TILE_ENCODING = {
0x5b: {
name: 'no_player1_sign',
},
// 0x5c: Inverter gate (N) : Modifier allows other gates, see below
0x5c: {
// TODO (modifier chooses logic gate) name: 'doppelganger2',
// TODO modifier: ...
error: "Logic gates are not yet implemented, sorry!",
},
0x5e: {
name: 'button_pink',
modifier: modifier_wire,
@@ -421,7 +438,11 @@ const TILE_ENCODING = {
0x61: {
name: 'button_orange',
},
// 0x62: Lightning bolt : '#next'
0x62: {
name: 'lightning_bolt',
has_next: true,
error: "The lightning bolt is not yet implemented, sorry!",
},
0x63: {
name: 'tank_yellow',
has_next: true,
@@ -430,8 +451,18 @@ const TILE_ENCODING = {
0x64: {
name: 'button_yellow',
},
// 0x65: Mirror Chip : '#direction', '#next'
// 0x66: Mirror Melinda : '#direction', '#next'
0x65: {
name: 'doppelganger1',
has_next: true,
extra_args: [arg_direction],
error: "Doppelganger Lexy is not yet implemented, sorry!",
},
0x66: {
name: 'doppelganger2',
has_next: true,
extra_args: [arg_direction],
error: "Doppelganger Cerise is not yet implemented, sorry!",
},
0x68: {
name: 'bowling_ball',
has_next: true,
@@ -552,8 +583,14 @@ const TILE_ENCODING = {
name: 'button_black',
modifier: modifier_wire,
},
// 0x88: ON/OFF switch (OFF) :
// 0x89: ON/OFF switch (ON) :
0x88: {
name: 'light_switch_off',
error: "The light switch is not yet implemented, sorry!",
},
0x89: {
name: 'light_switch_on',
error: "The light switch is not yet implemented, sorry!",
},
0x8a: {
name: 'thief_keys',
},
@@ -573,9 +610,21 @@ const TILE_ENCODING = {
name: 'xray_eye',
has_next: true,
},
// 0x8f: Thief bribe : '#next'
// 0x90: Speed boots : '#next'
// 0x92: Hook : '#next'
0x8f: {
name: 'bribe',
has_next: true,
error: "The bribe is not yet implemented, sorry!",
},
0x90: {
name: 'speed_boots',
has_next: true,
error: "The speed boots are not yet implemented, sorry!",
},
0x91: {
name: 'hook',
has_next: true,
error: "The hook is not yet implemented, sorry!",
},
};
const REVERSE_TILE_ENCODING = {};
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
@@ -616,21 +665,18 @@ function read_n_bytes(view, start, n) {
}
}
// Decompress the little ad-hoc compression scheme used for both map data and
// solution playback
function decompress(buf) {
let decompressed_length = new DataView(buf).getUint16(0, true);
let out = new ArrayBuffer(decompressed_length);
let outbytes = new Uint8Array(out);
let bytes = new Uint8Array(buf);
// Decompress the little ad-hoc compression scheme used for both map data and solution playback
function decompress(bytes) {
let decompressed_length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(0, true);
let outbytes = new Uint8Array(decompressed_length);
let p = 2;
let q = 0;
while (p < buf.byteLength) {
while (p < bytes.length) {
let len = bytes[p];
p++;
if (len < 0x80) {
// Data block
outbytes.set(new Uint8Array(buf.slice(p, p + len)), q);
outbytes.set(new Uint8Array(bytes.buffer, bytes.byteOffset + p, len), q);
p += len;
q += len;
}
@@ -651,59 +697,85 @@ function decompress(buf) {
}
if (q !== decompressed_length)
throw new Error(`Expected to decode ${decompressed_length} bytes but got ${q} instead`);
return out;
return outbytes;
}
export function parse_level(buf, number = 1) {
let level = new util.StoredLevel(number);
// Iterates over a C2M file and yields: [section type, uint8 array view of the section]
function* read_c2m_sections(buf) {
let full_view = new DataView(buf);
let next_section_start = 0;
let extra_hints = [];
let hint_tiles = [];
while (next_section_start < buf.byteLength) {
// Read section header and length
let section_start = next_section_start;
let section_type = util.string_from_buffer_ascii(buf.slice(section_start, section_start + 4));
let section_type = util.string_from_buffer_ascii(buf, section_start, 4);
let section_length = full_view.getUint32(section_start + 4, true);
next_section_start = section_start + 8 + section_length;
if (next_section_start > buf.byteLength)
throw new Error(`Section at byte ${section_start} of type '${section_type}' extends ${buf.length - next_section_start} bytes past the end of the file`);
throw new util.LLError(`Section at byte ${section_start} of type '${section_type}' extends ${buf.length - next_section_start} bytes past the end of the file`);
// This chunk marks the end of the file regardless
// This chunk marks the end of the file, full stop; a lot of canonical files have garbage
// newlines afterwards and will fail to continue to parse beyond this point
if (section_type === 'END ')
break;
return;
if (section_type === 'CC2M' || section_type === 'LOCK' || section_type === 'VERS' ||
section_type === 'TITL' || section_type === 'AUTH' ||
section_type === 'CLUE' || section_type === 'NOTE')
yield [section_type, new Uint8Array(buf, section_start + 8, section_length)];
}
}
export function parse_level_metadata(buf) {
let meta = {
title: null,
};
for (let [type, bytes] of read_c2m_sections(buf)) {
if (type === 'TITL') {
meta.title = util.string_from_buffer_ascii(bytes, 0, bytes.length - 1).replace(/\r\n/g, "\n");
// TODO anything else we want for now?
break;
}
}
return meta;
}
export function parse_level(buf, number = 1) {
if (ArrayBuffer.isView(buf)) {
buf = buf.buffer;
}
let level = new format_base.StoredLevel(number);
let extra_hints = [];
let hint_tiles = [];
for (let [type, bytes] of read_c2m_sections(buf)) {
if (type === 'CC2M' || type === 'LOCK' || type === 'VERS' ||
type === 'TITL' || type === 'AUTH' ||
type === 'CLUE' || type === 'NOTE')
{
// These are all singular strings (with a terminating NUL, for some reason)
// XXX character encoding??
let str = util.string_from_buffer_ascii(buf.slice(section_start + 8, next_section_start - 1)).replace(/\r\n/g, "\n");
let str = util.string_from_buffer_ascii(bytes, 0, bytes.length - 1).replace(/\r\n/g, "\n");
// TODO store more of this, at least for idempotence, maybe
if (section_type === 'CC2M') {
if (type === 'CC2M') {
// File version, doesn't seem interesting
}
else if (section_type === 'LOCK') {
else if (type === 'LOCK') {
// Unclear, seems to be a comment about the editor...?
}
else if (section_type === 'VERS') {
else if (type === 'VERS') {
// Editor version which created this level
}
else if (section_type === 'TITL') {
else if (type === 'TITL') {
// Level title
level.title = str;
}
else if (section_type === 'AUTH') {
else if (type === 'AUTH') {
// Author's name
level.author = str;
}
else if (section_type === 'CLUE') {
else if (type === 'CLUE') {
// Level hint
level.hint = str;
}
else if (section_type === 'NOTE') {
else if (type === 'NOTE') {
// Author's comments... but might also include multiple hints
// for levels with multiple hint tiles, delineated by [CLUE].
// For my purposes, extra hints are associated with the
@@ -713,16 +785,15 @@ export function parse_level(buf, number = 1) {
continue;
}
let section_buf = buf.slice(section_start + 8, next_section_start);
let section_view = new DataView(buf, section_start + 8, section_length);
let view = new DataView(buf, bytes.byteOffset, bytes.byteLength);
if (section_type === 'OPTN') {
if (type === 'OPTN') {
// Level options, which may be truncated at any point
// TODO implement most of these
level.time_limit = section_view.getUint16(0, true);
level.time_limit = view.getUint16(0, true);
// TODO 0 - 10x10, 1 - 9x9, 2 - split, otherwise unknown which needs handling
let viewport = section_view.getUint8(2, true);
let viewport = view.getUint8(2, true);
if (viewport === 0) {
level.viewport_size = 10;
}
@@ -737,42 +808,40 @@ export function parse_level(buf, number = 1) {
throw new Error(`Unrecognized viewport size option ${viewport}`);
}
if (section_view.byteLength <= 3)
if (view.byteLength <= 3)
continue;
//options.has_solution = section_view.getUint8(3, true);
//options.has_solution = view.getUint8(3, true);
if (section_view.byteLength <= 4)
if (view.byteLength <= 4)
continue;
//options.show_map_in_editor = section_view.getUint8(4, true);
//options.show_map_in_editor = view.getUint8(4, true);
if (section_view.byteLength <= 5)
if (view.byteLength <= 5)
continue;
//options.is_editable = section_view.getUint8(5, true);
//options.is_editable = view.getUint8(5, true);
if (section_view.byteLength <= 6)
if (view.byteLength <= 6)
continue;
//options.solution_hash = util.string_from_buffer_ascii(buf.slice(
//options.solution_hash = format_base.string_from_buffer_ascii(buf.slice(
//section_start + 6, section_start + 22));
if (section_view.byteLength <= 22)
if (view.byteLength <= 22)
continue;
//options.hide_logic = section_view.getUint8(22, true);
//options.hide_logic = view.getUint8(22, true);
if (section_view.byteLength <= 23)
if (view.byteLength <= 23)
continue;
level.use_cc1_boots = section_view.getUint8(23, true);
level.use_cc1_boots = view.getUint8(23, true);
if (section_view.byteLength <= 24)
if (view.byteLength <= 24)
continue;
//level.blob_behavior = section_view.getUint8(24, true);
//level.blob_behavior = view.getUint8(24, true);
}
else if (section_type === 'MAP ' || section_type === 'PACK') {
let data = section_buf;
if (section_type === 'PACK') {
data = decompress(data);
else if (type === 'MAP ' || type === 'PACK') {
if (type === 'PACK') {
bytes = decompress(bytes);
}
let bytes = new Uint8Array(data);
let map_view = new DataView(data);
let map_view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let width = bytes[0];
let height = bytes[1];
level.size_x = width;
@@ -785,17 +854,19 @@ export function parse_level(buf, number = 1) {
let tile_byte = bytes[p];
p++;
if (tile_byte === undefined)
throw new Error(`Read past end of file in cell ${n}`);
throw new util.LLError(`Read past end of file in cell ${n}`);
let spec = TILE_ENCODING[tile_byte];
if (! spec)
throw new Error(`Unrecognized tile type 0x${tile_byte.toString(16)}`);
throw new util.LLError(`Invalid tile type 0x${tile_byte.toString(16)}`);
if (spec.error)
throw new util.LLError(spec.error);
return spec;
}
for (n = 0; n < width * height; n++) {
let cell = new util.StoredCell;
let cell = new format_base.StoredCell;
while (true) {
let spec = read_spec();
@@ -815,7 +886,7 @@ export function parse_level(buf, number = 1) {
p += 4;
}
spec = read_spec();
if (! spec.modifier) {
if (! spec.modifier && ! (spec.name instanceof Array)) {
console.warn("Got unexpected modifier for tile:", spec.name);
}
}
@@ -890,22 +961,36 @@ export function parse_level(buf, number = 1) {
level.linear_cells.push(cell);
}
}
else if (section_type === 'KEY ') {
else if (type === 'KEY ') {
}
else if (section_type === 'REPL' || section_type === 'PRPL') {
else if (type === 'REPL' || type === 'PRPL') {
// "Replay", i.e. demo solution
let data = section_buf;
if (section_type === 'PRPL') {
data = decompress(data);
if (type === 'PRPL') {
bytes = decompress(bytes);
}
level.demo = new CC2Demo(data);
level.demo = new CC2Demo(bytes);
}
else if (section_type === 'RDNY') {
else if (type === 'RDNY') {
}
// TODO LL custom chunks, should distinguish somehow
else if (type === 'LXCM') {
// Camera regions
if (bytes.length % 4 !== 0)
throw new Error(`Expected LXCM chunk to be a multiple of 4 bytes; got ${bytes.length}`);
let p = 0;
while (p < bytes.length) {
let x = bytes[p + 0];
let y = bytes[p + 1];
let w = bytes[p + 2];
let h = bytes[p + 3];
// TODO validate? must be smaller than map?
level.camera_regions.push(new DOMRect(x, y, w, h));
p += 4;
}
else if (section_type === 'END ') {
}
else {
console.warn(`Unrecognized section type '${section_type}' at offset ${section_start}`);
console.warn(`Unrecognized section type '${type}' at offset ${bytes.byteOffset}`);
// TODO save it, persist when editing level
}
}
@@ -1071,6 +1156,21 @@ export function synthesize_level(stored_level) {
let c2m = new C2M;
c2m.add_section('CC2M', '133');
// Store camera regions
// TODO LL feature, should be distinguished somehow
if (stored_level.camera_regions.length > 0) {
let bytes = new Uint8Array(4 * stored_level.camera_regions.length);
let p = 0;
for (let region of stored_level.camera_regions) {
bytes[p + 0] = region.x;
bytes[p + 1] = region.y;
bytes[p + 2] = region.width;
bytes[p + 3] = region.height;
p += 4;
}
c2m.add_section('LXCM', bytes.buffer);
}
// FIXME well this will not do
let map_bytes = new Uint8Array(4096);
let map_view = new DataView(map_bytes.buffer);
@@ -1137,3 +1237,479 @@ export function synthesize_level(stored_level) {
return c2m.serialize();
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// C2G, the text format that stitches levels together into a game
// NOTE: C2G is surprisingly complicated for a game layout format, and most of its features are not
// currently supported. Most of them have also never been used in practice, so that's fine.
// TODO this is not quite right yet; the architect has more specific lexing documentation
// Split a statement into a number of tokens. This is, thankfully, relatively easy, due to the
// minimal syntax and the lack of string escapes (so we don't have to check for " vs \" vs \\").
// The tokens seem to be one of:
// - a bareword (could be a variable or keyword)
// - an operator
// - a literal number
// - a quoted string
// - a label
// - a comment
// And that's it! So here's a regex to find all of them, and then we just use matchAll.
const TOKENIZE_RX = RegExp(
// Eat any leading horizontal whitespace
'[ \\t]*(?:' +
// 1: Catch newlines as their own thing, since they are (sigh) important, sometimes
'(\\n)' +
// 2: Comments are preceded by ; or // for some reason and run to the end of the line
'|(?:;|//)(.*)' +
// 3: Strings are double-quoted (only!) and contain no escapes
'|"([^"]+?)"' +
// 4: Labels are indicated by a #, including when used with 'goto'
// (the exact set of allowed characters is unclear and i'm fudging it here)
'|#(\\w+)' +
// 5: Only decimal integers are allowed
'|(\\d+)' +
// 6: Operators are part of a fixed set
'|(==|<=|>=|!=|&&|\\|\\||[-+*/<>=&|&^])' +
// 7: Barewords appear to allow literally fucking anything as long as they start with a
// letter -- the official playcc2 contains `really?'"` as an accidental unquoted string and
// it's accepted but ignored, so I can only assume it's treated as a variable
// TODO i really don't like this, it's beyond error-prone
'|([a-zA-Z]\\S*)' +
// 8: Anything else is an error
'|(\\S+)' +
')', 'g');
const DIRECTIVES = {
// Important stuff
'chdir': ['string'],
'do': 'statement', // special
'game': ['string'],
'goto': ['label'],
'map': ['string'],
'music': ['string'],
'script': 'script', // special
// Weird stuff
'edit': [],
// Seemingly unused, or at least not understood
'art': ['string'],
'chain': ['string'],
'dlc': ['string'],
'end': [],
'main': [], // allegedly jumps to playcc2.c2g??
'wav': ['string'],
};
const OPERATORS = {
'==': {
argc: 2,
},
'<=': {
},
'>=': {
},
'!=': {
},
'<': {
},
'>': {
},
'=': {
},
'*': {
},
'/': {
},
'+': {
},
'-': {
},
'&&': {
},
'||': {
},
'&': {
},
'|': {
},
'%': {
},
'^': {
},
};
function* tokenize(statement) {
for (let match of statement.matchAll(TOKENIZE_RX)) {
if (match[1] !== undefined) {
// Newline(s)
yield {type: 'newline'};
}
else if (match[2] !== undefined) {
// Comment, do nothing
}
else if (match[3] !== undefined) {
// String
yield {type: 'string', value: match[3]};
}
else if (match[4] !== undefined) {
// Label
yield {type: 'label', value: match[4].toLowerCase()};
}
else if (match[5] !== undefined) {
// Number
yield {type: 'number', value: parseInt(match[5], 10)};
}
else if (match[6] !== undefined) {
// Operator
yield {type: 'op', value: match[6]};
}
else if (match[7] !== undefined) {
// Bareword; either a directive or a variable name
let word = match[7].toLowerCase();
if (DIRECTIVES[word] !== undefined) {
yield {type: 'directive', value: word};
}
else {
yield {type: 'variable', value: word};
}
}
else {
yield {type: 'error', value: match[8]};
}
}
}
class ParseError extends Error {
constructor(message, parser) {
super(`${message} at line ${parser.lineno}`);
}
}
class Parser {
constructor(string) {
this.string = string;
this.lexer = tokenize(string);
this.lineno = 1;
this.done = false;
this._peek = null;
}
peek() {
if (this._peek === null) {
let next = this.lexer.next();
if (! next.done) {
this._peek = next.value;
if (this._peek.type === 'error')
throw new ParseError(`Bad syntax: ${this._peek.value}`, this);
}
}
return this._peek;
}
advance() {
if (this.done)
return null;
let token;
if (this._peek !== null) {
token = this._peek;
this._peek = null;
}
else {
let next = this.lexer.next();
if (next.done) {
this.done = true;
return null;
}
token = next.value;
if (token.type === 'error')
throw new ParseError(`Bad syntax: ${token.value}`, this);
}
if (token && token.type === 'newline') {
this.lineno++;
}
return token;
}
advance_ignore_newlines() {
if (this.done)
return null;
let token = this.advance();
while (token && token.type === 'newline') {
token = this.advance();
}
return token;
}
parse_statement() {
let token = this.advance_ignore_newlines();
if (! token)
return null;
// Check for a directive and handle it separately
if (token.type === 'directive') {
return this.parse_directive(token.value);
}
// A string (outside of a script block) doesn't seem to do anything?
if (token.type === 'string') {
return {
kind: 'noop',
tokens: [token],
};
}
// A lone label is a label declaration
if (token.type === 'label') {
return {
kind: 'label',
name: token.value,
};
}
// An operator is not a valid start; this uses RPN so values must come first
if (token.type === 'op')
throw new ParseError(`Unexpected operator: ${token.value}`, this);
// Otherwise (number, bareword presumed to be a variable), we have an RPN expression; keep
// consuming tokens until we finish the expression
let branches = [token];
while (true) {
let next = this.peek();
if (! next) {
break;
}
else if (next.type === 'number' || next.type === 'variable') {
let token = this.advance();
branches.push(token);
}
else if (next.type === 'op') {
let token = this.advance();
if (! token || token.type === 'newline')
break;
// All operators are binary, so pop the last two expressions
if (branches.length < 2)
throw new ParseError(`Not enough arguments for operator: ${token.value}`, this);
let a = branches.pop();
let b = branches.pop();
branches.push({
op: token.value,
left: a,
right: b,
});
// TODO return now if we just did an =?
}
else {
break;
}
}
return {
kind: 'expression',
trees: branches,
};
}
parse_directive(name) {
let argspec = DIRECTIVES[name];
if (argspec === 'statement') {
// TODO implement this for real
// eat the rest of the line for now
while (true) {
let token = this.advance();
if (! token || token.type === 'newline') {
break;
}
}
}
else if (argspec === 'script') {
// Script mode; expect a newline, then sequences of [string, values..., newline]
let lines = [];
let newline = this.advance();
if (newline && newline.type !== 'newline')
throw new ParseError(`Expected a newline after 'script' directive`, this);
while (true) {
let next = this.peek();
while (next && next.type === 'newline') {
this.advance();
next = this.peek();
}
if (! next)
break;
// If this is a string, we're still in script mode; eat the whole line
if (next.type === 'string') {
let string = this.advance();
let args = [];
// TODO can args be expressions??
while (true) {
let arg = this.advance();
if (! arg || arg.type === 'newline') {
break;
}
else if (arg.type === 'number' || arg.type === 'variable') {
args.push(arg);
}
else {
throw new ParseError(`Unexpected ${arg.type} token found in script mode: ${arg.value}`, this);
}
}
lines.push({
string: string,
args: args,
});
}
// If not a string, script mode is over
else {
break;
}
}
return {
kind: 'script',
lines: lines,
};
}
else {
// Normal arguments
let args = [];
for (let argtype of argspec) {
let token = this.advance();
if (! token || token.type === 'newline') {
// If we're cut off early, the whole directive is ignored
return {
kind: 'noop',
directive: name,
tokens: args,
};
}
else if (token.type === argtype) {
args.push(token);
}
else {
throw new ParseError(`Directive ${name} expected a ${argtype} token but got ${token.type}`, this);
}
}
return {
kind: 'directive',
name: name,
args: args,
};
}
}
}
// C2G is a Chip's Challenge 2 format that describes the structure of a level set, which is helpful
// since CC2 levels are all stored in separate files
// XXX observations i have made about this hell format:
// - newlines are optional, except after: do, map, script, goto
// - `1 level = music "+Intro"` crashes the game
// - `map\n"path"` is completely ignored, and in fact newlines between a directive and its arguments
// in general seem to separate them
const MAX_SIMULTANEOUS_REQUESTS = 5;
/*async*/ export function parse_game(buf, source, base_path) {
// TODO maybe do something with this later
let warn = () => {};
let resolve;
let promise = new Promise((res, rej) => { resolve = res });
let game = new format_base.StoredGame(undefined, parse_level);
let parser;
let active_map_fetches = new Set;
let pending_map_fetches = [];
let _fetch_map = (path, n) => {
let promise = source.get(base_path + '/' + path);
active_map_fetches.add(promise);
let meta = {
// TODO this will not always fly, the slot is not the same as the number
index: n - 1,
number: n,
};
game.level_metadata[meta.index] = meta;
promise.then(buf => {
meta.bytes = new Uint8Array(buf);
Object.assign(meta, parse_level_metadata(buf));
})
.then(null, err => {
// TODO should have: what level, what file, position, etc attached to errors
console.error(err);
meta.error = err;
})
.then(() => {
// Always remove our promise and start a new map load if any are waiting
active_map_fetches.delete(promise);
if (active_map_fetches.size < MAX_SIMULTANEOUS_REQUESTS && pending_map_fetches.length > 0) {
_fetch_map(...pending_map_fetches.shift());
}
else if (active_map_fetches.size === 0 && pending_map_fetches.length === 0 && parser.done) {
// FIXME this is a bit of a mess
resolve(game);
}
});
};
let fetch_map = (path, n) => {
if (active_map_fetches.size >= MAX_SIMULTANEOUS_REQUESTS) {
pending_map_fetches.push([path, n]);
return;
}
_fetch_map(path, n);
};
// FIXME and right off the bat we have an Issue: this is a text format so i want a string, not
// an arraybuffer!
let contents = util.string_from_buffer_ascii(buf);
parser = new Parser(contents);
let statements = [];
let level_number = 1;
while (! parser.done) {
let stmt = parser.parse_statement();
if (stmt === null)
break;
// TODO search 'do' as well
if (stmt.kind === 'directive' && stmt.name === 'map') {
let path = stmt.args[0].value;
path = path.replace(/\\/, '/');
fetch_map(path, level_number);
level_number++;
}
statements.push(stmt);
}
// FIXME grody
if (active_map_fetches.size === 0 && pending_map_fetches.length === 0) {
resolve(game);
}
console.log(game);
return promise;
}
// Individual levels don't make sense on their own, but we can wrap them in a dummy one-level game
export function wrap_individual_level(buf) {
let game = new format_base.StoredGame(undefined, parse_level);
let meta = {
index: 0,
number: 1,
bytes: new Uint8Array(buf),
};
try {
Object.assign(meta, parse_level_metadata(buf));
}
catch (e) {
meta.error = e;
}
game.level_metadata.push(meta);
return game;
}
+78 -21
View File
@@ -1,5 +1,6 @@
import * as util from './format-util.js';
import * as format_base from './format-base.js';
import TILE_TYPES from './tiletypes.js';
import * as util from './util.js';
const TILE_ENCODING = {
0x00: 'floor',
@@ -119,18 +120,70 @@ const TILE_ENCODING = {
0x6f: ['player', 'east'],
};
function parse_level(buf, number) {
let level = new util.StoredLevel(number);
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);
}
else if (field_type === 0x07) {
// Hint, including trailing NUL, of course
meta.hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
}
p += field_length;
}
return meta;
}
function parse_level(bytes, number) {
let level = new format_base.StoredLevel(number);
level.has_custom_connections = true;
level.use_ccl_compat = true;
// Map size is always fixed as 32x32 in CC1
level.size_x = 32;
level.size_y = 32;
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;
let view = new DataView(buf);
let bytes = new Uint8Array(buf);
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
// Header
let level_number = view.getUint16(0, true);
@@ -161,7 +214,7 @@ function parse_level(buf, number) {
// TODO could be more forgiving for goofy levels doing goofy things
if (! spec) {
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;
@@ -225,11 +278,11 @@ function parse_level(buf, number) {
}
else if (field_type === 0x03) {
// 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) {
// 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;
while (q < field_length) {
let button_x = field_view.getUint16(q + 0, true);
@@ -243,7 +296,7 @@ function parse_level(buf, number) {
}
else if (field_type === 0x05) {
// 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;
while (q < field_length) {
let button_x = field_view.getUint16(q + 0, true);
@@ -255,16 +308,12 @@ function parse_level(buf, number) {
}
}
else if (field_type === 0x06) {
// Password, with trailing NUL, and otherwise XORed with 0x99 (?!)
let password = [];
for (let i = 0; i < field_length - 1; i++) {
password.push(view.getUint8(p + i, true) ^ 0x99);
}
level.password = String.fromCharCode.apply(null, password);
// Password, with trailing NUL, and otherwise XORed with 0x99 (???)
level.password = decode_password(bytes, p, field_length - 1);
}
else if (field_type === 0x07) {
// Hint, including trailing NUL, of course
level.hint = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1));
level.hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
}
else if (field_type === 0x08) {
// Password, but not encoded
@@ -281,7 +330,7 @@ function parse_level(buf, number) {
}
export function parse_game(buf) {
let game = new util.StoredGame;
let game = new format_base.StoredGame(null, parse_level);
let full_view = new DataView(buf);
let magic = full_view.getUint32(0, true);
@@ -303,11 +352,19 @@ export function parse_game(buf) {
let p = 6;
for (let l = 1; l <= level_count; l++) {
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;
let level = parse_level(level_buf, l);
game.levels.push(level);
let meta;
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;
-47
View File
@@ -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 = [];
}
}
+246 -91
View File
@@ -125,6 +125,17 @@ export class Cell extends Array {
return index;
}
get_wired_tile() {
let ret = null;
for (let tile of this) {
if (tile.wire_directions || tile.wire_tunnel_directions) {
ret = tile;
// Don't break; we want the topmost tile!
}
}
return ret;
}
blocks_leaving(actor, direction) {
for (let tile of this) {
if (tile !== actor &&
@@ -148,6 +159,8 @@ export class Cell extends Array {
return false;
}
}
Cell.prototype.was_powered = false;
Cell.prototype.is_powered = false;
class GameEnded extends Error {}
@@ -162,7 +175,7 @@ export class Level {
}
restart(compat) {
this.compat = {};
this.compat = compat;
// playing: normal play
// success: has been won
@@ -205,6 +218,9 @@ export class Level {
let n = 0;
let connectables = [];
// Handle CC2 wiring; a contiguous region of wire is all updated as a single unit, so detect
// those units ahead of time for simplicity and call them "clusters"
this.wire_clusters = [];
// FIXME handle traps correctly:
// - if an actor is in the cell, set the trap to open and unstick everything in it
for (let y = 0; y < this.height; y++) {
@@ -237,25 +253,19 @@ export class Level {
if (tile.type.is_player) {
// TODO handle multiple players, also chip and melinda both
// TODO complain if no chip
// TODO complain if no player
this.player = tile;
// Always put the player at the start of the actor list
// (accomplished traditionally with a swap)
this.actors.push(this.actors[0]);
this.actors[0] = tile;
}
else if (tile.type.is_actor) {
if (tile.type.is_actor) {
if (has_cloner) {
// TODO is there any reason not to add clone templates to the actor
// list?
tile.stuck = true;
}
else {
if (has_trap) {
// FIXME wait, not if the trap is open! crap
tile.stuck = true;
}
if (! tile.stuck) {
this.actors.push(tile);
}
}
cell._add(tile);
if (tile.type.connects_to) {
@@ -272,10 +282,10 @@ export class Level {
let x = cell.x;
let y = cell.y;
let goal = connectable.type.connects_to;
let found = false;
// Check for custom wiring, for MSCC .DAT levels
let n = x + y * this.width;
if (this.stored_level.has_custom_connections) {
let n = this.stored_level.coords_to_scalar(x, y);
let target_cell_n = null;
if (goal === 'trap') {
target_cell_n = this.stored_level.custom_trap_wiring[n] ?? null;
@@ -283,49 +293,37 @@ export class Level {
else if (goal === 'cloner') {
target_cell_n = this.stored_level.custom_cloner_wiring[n] ?? null;
}
if (target_cell_n) {
// TODO this N could be outside the map bounds
let target_cell_x = target_cell_n % this.width;
let target_cell_y = Math.floor(target_cell_n / this.width);
for (let tile of this.cells[target_cell_y][target_cell_x]) {
if (target_cell_n && target_cell_n < this.width * this.height) {
let [tx, ty] = this.stored_level.scalar_to_coords(target_cell_n);
for (let tile of this.cells[ty][tx]) {
if (tile.type.name === goal) {
connectable.connection = tile;
found = true;
break;
}
}
if (found)
}
continue;
}
// Otherwise, look in reading order
let direction = 1;
if (connectable.type.connect_order === 'backward') {
direction = -1;
for (let tile of this.iter_tiles_in_reading_order(cell, goal)) {
// TODO ideally this should be a weak connection somehow, since dynamite can destroy
// empty cloners and probably traps too
connectable.connection = tile;
// Just grab the first
break;
}
for (let i = 0; i < num_cells - 1; i++) {
x += direction;
if (x >= this.width) {
x -= this.width;
y = (y + 1) % this.height;
}
else if (x < 0) {
x += this.width;
y = (y - 1 + this.height) % this.height;
}
for (let tile of this.cells[y][x]) {
if (tile.type.name === goal) {
// TODO should be weak, but you can't destroy cloners so in practice not a concern
connectable.connection = tile;
found = true;
break;
// Finally, let all tiles do any custom init behavior
for (let row of this.cells) {
for (let cell of row) {
for (let tile of cell) {
if (tile.type.on_ready) {
tile.type.on_ready(tile, this);
}
}
if (found)
break;
}
// TODO soft warn for e.g. a button with no cloner? (or a cloner with no button?)
}
}
@@ -371,7 +369,13 @@ export class Level {
// immediately), we still want every time's animation to finish, or it'll look like some
// objects move backwards when the death screen appears!
let cell_steppers = [];
for (let actor of this.actors) {
// Note that we iterate in reverse order, DESPITE keeping dead actors around with null
// cells, to match the Lynx and CC2 behavior. This is actually important in some cases;
// check out the start of CCLP3 #54, where the gliders will eat the blue key immediately if
// they act in forward order! (More subtly, even the earlier passes do things like advance
// the RNG, so for replay compatibility they need to be in reverse order too.)
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
// Actors with no cell were destroyed
if (! actor.cell)
continue;
@@ -412,23 +416,23 @@ export class Level {
this.step_on_cell(actor, cell);
}
// Now we handle wiring
this.update_wiring();
// Only reset the player's is_pushing between movement, so it lasts for the whole push
if (this.player.movement_cooldown <= 0) {
this.player.is_pushing = false;
}
// Second pass: actors decide their upcoming movement simultaneously
for (let actor of this.actors) {
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
if (! actor.cell)
continue;
if (actor.movement_cooldown > 0)
continue;
// XXX does the cooldown drop while in a trap? is this even right?
if (actor.stuck && ! actor.type.is_player)
continue;
// Teeth can only move the first 4 of every 8 tics, though "first"
// can be adjusted
if (actor.slide_mode == null &&
@@ -439,10 +443,15 @@ export class Level {
}
let direction_preference;
// Actors can't make voluntary moves on ice, so they're stuck with
// whatever they've got
if (this.compat.sliding_tanks_ignore_button &&
actor.slide_mode && actor.pending_reverse)
{
this._set_prop(actor, 'pending_reverse', false);
}
if (actor.slide_mode === 'ice') {
direction_preference = [actor.direction];
// Actors can't make voluntary moves on ice; they just slide
actor.decision = actor.direction;
continue;
}
else if (actor.slide_mode === 'force') {
// Only the player can make voluntary moves on a force floor,
@@ -455,25 +464,38 @@ export class Level {
p1_primary_direction &&
actor.last_move_was_force)
{
direction_preference = [p1_primary_direction];
actor.decision = p1_primary_direction;
this._set_prop(actor, 'last_move_was_force', false);
}
else {
direction_preference = [actor.direction];
actor.decision = actor.direction;
if (actor === this.player) {
this._set_prop(actor, 'last_move_was_force', true);
}
}
continue;
}
else if (actor === this.player) {
if (p1_primary_direction) {
direction_preference = [p1_primary_direction];
actor.decision = p1_primary_direction;
this._set_prop(actor, 'last_move_was_force', false);
}
continue;
}
else if (actor.type.movement_mode === 'forward') {
// blue tank behavior: keep moving forward
direction_preference = [actor.direction];
// blue tank behavior: keep moving forward, reverse if the flag is set
let direction = actor.direction;
if (actor.pending_reverse) {
direction = DIRECTIONS[actor.direction].opposite;
this._set_prop(actor, 'pending_reverse', false);
}
// Tanks are controlled explicitly so they don't check if they're blocked
// TODO tanks in traps turn around, but tanks on cloners do not, and i use the same
// prop for both
if (! actor.cell.some(tile => tile.type.name === 'cloner')) {
actor.decision = direction;
}
continue;
}
else if (actor.type.movement_mode === 'follow-left') {
// bug behavior: always try turning as left as possible, and
@@ -555,15 +577,9 @@ export class Level {
// Check which of those directions we *can*, probably, move in
// TODO i think player on force floor will still have some issues here
if (direction_preference) {
// Players and sliding actors always move the way they want, even if blocked
if (actor.type.is_player || actor.slide_mode) {
actor.decision = direction_preference[0];
continue;
}
if (direction_preference && ! actor.stuck) {
for (let direction of direction_preference) {
let dest_cell = this.cell_with_offset(actor.cell, direction);
let dest_cell = this.get_neighboring_cell(actor.cell, direction);
if (! dest_cell)
continue;
@@ -579,7 +595,8 @@ export class Level {
}
// Third pass: everyone actually moves
for (let actor of this.actors) {
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
if (! actor.cell)
continue;
@@ -605,7 +622,7 @@ export class Level {
if (actor.type.is_player && dir2 &&
! old_cell.blocks_leaving(actor, dir2))
{
let neighbor = this.cell_with_offset(old_cell, dir2);
let neighbor = this.get_neighboring_cell(old_cell, dir2);
if (neighbor) {
let could_push = ! neighbor.blocks_entering(actor, dir2, this, true);
for (let tile of Array.from(neighbor)) {
@@ -682,7 +699,7 @@ export class Level {
let move = DIRECTIONS[direction].movement;
if (!actor.cell) console.error(actor);
let goal_cell = this.cell_with_offset(actor.cell, direction);
let goal_cell = this.get_neighboring_cell(actor.cell, direction);
// TODO this could be a lot simpler if i could early-return! should ice bumping be
// somewhere else?
@@ -795,10 +812,9 @@ export class Level {
if (actor.cell === goal_cell)
return;
// TODO undo this stuff?
actor.previous_cell = actor.cell;
actor.animation_speed = speed;
actor.animation_progress = 0;
this._set_prop(actor, 'previous_cell', actor.cell);
this._set_prop(actor, 'animation_speed', speed);
this._set_prop(actor, 'animation_progress', 0);
let original_cell = actor.cell;
this.remove_tile(actor);
@@ -877,7 +893,7 @@ export class Level {
}
this.remove_tile(tile);
}
else if (tile.type.is_teleporter) {
else if (tile.type.teleport_dest_order) {
teleporter = tile;
}
else if (tile.type.on_arrive) {
@@ -888,42 +904,147 @@ export class Level {
// Handle teleporting, now that the dust has cleared
// FIXME something funny happening here, your input isn't ignore while walking out of it?
if (teleporter) {
let goal = teleporter;
// TODO in pathological cases this might infinite loop
while (true) {
goal = goal.connection;
for (let dest of teleporter.type.teleport_dest_order(teleporter, this)) {
// Teleporters already containing an actor are blocked and unusable
if (goal.cell.some(tile => tile.type.is_actor && tile !== actor))
if (dest.cell.some(tile => tile.type.is_actor && tile !== actor))
continue;
// Physically move the actor to the new teleporter
// XXX is this right, compare with tile world? i overhear it's actually implemented as a slide?
// XXX not especially undo-efficient
this.remove_tile(actor);
this.add_tile(actor, goal.cell);
this.add_tile(actor, dest.cell);
if (this.attempt_step(actor, actor.direction)) {
// Success, teleportation complete
// Sound plays from the origin cell simply because that's where the sfx player
// thinks the player is currently
this.sfx.play_once('teleport', cell);
// thinks the player is currently; position isn't updated til next turn
this.sfx.play_once('teleport', dest.cell);
break;
}
if (goal === teleporter)
// We've tried every teleporter, including the one they
// stepped on, so leave them on it
break;
// Otherwise, try the next one
}
}
}
cell_with_offset(cell, direction) {
// Update the state of all wired tiles in the game.
// XXX need to be clear on the order of events here. say everything starts out unpowered.
// then:
// 1. you step on a pink button, which flags itself as going to be powered next frame
// 2. this pass happens. every unpowered-but-wired cell is inspected. if a powered one is
// found, floodfill from there
// FIXME can probably skip this if we know there are no wires at all, like in a CCL, or just an
// unwired map
// FIXME this feels inefficient. most of the time none of the inputs have changed so none of
// this needs to happen at all
// FIXME none of this is currently undoable
update_wiring() {
// Turn off power to every cell
// TODO wonder if i need a linear cell list, or even a flat list of all tiles (that sounds
// like hell to keep updated though)
for (let row of this.cells) {
for (let cell of row) {
cell.was_powered = cell.is_powered;
cell.is_powered = false;
}
}
// Iterate through the grid looking for emitters — tiles that are generating current — and
// propagated it via flood-fill through neighboring wires
for (let row of this.cells) {
for (let cell of row) {
// TODO probably this should set a prop on the tile
if (! cell.some(tile => tile.type.is_emitting && tile.type.is_emitting(tile, this)))
continue;
// We have an emitter! Flood-fill outwards
let neighbors = [cell];
for (let neighbor of neighbors) {
// Power it even if it's not wired itself, so that e.g. purple tiles work
neighbor.is_powered = true;
let wire = neighbor.get_wired_tile();
if (! wire)
continue;
// Emit along every wire direction, and add any unpowered neighbors to the
// pending list to continue the floodfill
// TODO but only if wires connect
// TODO handle wire tunnels
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
if (! (wire.wire_directions & dirinfo.bit))
continue;
let neighbor2, wire2;
let opposite_bit = DIRECTIONS[dirinfo.opposite].bit;
if (wire.wire_tunnel_directions & dirinfo.bit) {
// Search in the given direction until we find a matching tunnel
// FIXME these act like nested parens!
let x = neighbor.x;
let y = neighbor.y;
let nesting = 0;
while (true) {
x += dirinfo.movement[0];
y += dirinfo.movement[1];
if (! this.is_point_within_bounds(x, y))
break;
let candidate = this.cells[y][x];
wire2 = candidate.get_wired_tile();
if (wire2 && (wire2.wire_tunnel_directions ?? 0) & opposite_bit) {
neighbor2 = candidate;
break;
}
}
}
else {
// Otherwise this is easy
neighbor2 = this.get_neighboring_cell(neighbor, direction);
wire2 = neighbor2.get_wired_tile();
}
if (neighbor2 && ! neighbor2.is_powered &&
// Unwired tiles are OK; they might be something activated by power.
// Wired tiles that do NOT connect to us are ignored.
(! wire2 || wire2.wire_directions & opposite_bit))
{
neighbors.push(neighbor2);
}
}
}
}
}
// Inform any affected cells of power changes
for (let row of this.cells) {
for (let cell of row) {
if (cell.was_powered !== cell.is_powered) {
let method = cell.is_powered ? 'on_power' : 'on_depower';
for (let tile of cell) {
if (tile.type[method]) {
tile.type[method](tile, this);
}
}
}
}
}
}
// Performs a depth-first search for connected wires and wire objects, extending out from the
// given starting cell
*follow_circuit(cell) {
}
// -------------------------------------------------------------------------
// Board inspection
is_point_within_bounds(x, y) {
return (x >= 0 && x < this.width && y >= 0 && y < this.height);
}
get_neighboring_cell(cell, direction) {
let move = DIRECTIONS[direction].movement;
let goal_x = cell.x + move[0];
let goal_y = cell.y + move[1];
if (goal_x >= 0 && goal_x < this.width && goal_y >= 0 && goal_y < this.height) {
if (this.is_point_within_bounds(goal_x, goal_y)) {
return this.cells[goal_y][goal_x];
}
else {
@@ -931,6 +1052,39 @@ export class Level {
}
}
// Iterates over the grid in (reverse?) reading order and yields all tiles with the given name.
// The starting cell is iterated last.
*iter_tiles_in_reading_order(start_cell, name, reverse = false) {
let x = start_cell.x;
let y = start_cell.y;
while (true) {
if (reverse) {
x -= 1;
if (x < 0) {
x = this.width - 1;
y = (y - 1 + this.height) % this.height;
}
}
else {
x += 1;
if (x >= this.width) {
x = 0;
y = (y + 1) % this.height;
}
}
let cell = this.cells[y][x];
for (let tile of cell) {
if (tile.type.name === name) {
yield tile;
}
}
if (cell === start_cell)
return;
}
}
// -------------------------------------------------------------------------
// Undo handling
@@ -1078,8 +1232,8 @@ export class Level {
spawn_animation(cell, name) {
let type = TILE_TYPES[name];
let tile = new Tile(type);
tile.animation_speed = type.ttl;
tile.animation_progress = 0;
this._set_prop(tile, 'animation_speed', tile.type.ttl);
this._set_prop(tile, 'animation_progress', 0);
cell._add(tile);
this.actors.push(tile);
this.pending_undo.push(() => {
@@ -1103,6 +1257,7 @@ export class Level {
}
}
// Give an item to an actor, even if it's not supposed to have an inventory
give_actor(actor, name) {
if (! actor.type.is_actor)
return false;
+105
View File
@@ -0,0 +1,105 @@
import { mk, mk_svg, walk_grid } from './util.js';
// Superclass for the main display modes: the player, the editor, and the splash screen
export class PrimaryView {
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;
}
}
// Stackable modal overlay of some kind, usually a dialog
export class Overlay {
constructor(conductor, root) {
this.conductor = conductor;
this.root = root;
// 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();
});
}
open() {
// FIXME ah, but keystrokes can still go to the game, including
// spacebar to begin it if it was waiting. how do i completely disable
// an entire chunk of the page?
if (this.conductor.player.state === 'playing') {
this.conductor.player.set_state('paused');
}
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();
});
}
close() {
this.root.closest('.overlay').remove();
}
}
// Overlay styled like a dialog box
export class DialogOverlay extends Overlay {
constructor(conductor) {
super(conductor, mk('div.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);
}
}
// 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));
let yes = mk('button', {type: 'button'}, "yep");
let no = mk('button', {type: 'button'}, "nope");
yes.addEventListener('click', ev => {
this.close();
what();
});
no.addEventListener('click', ev => {
this.close();
});
this.footer.append(yes, no);
}
}
+796
View File
@@ -0,0 +1,796 @@
import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
import * as c2g from './format-c2g.js';
import { PrimaryView, DialogOverlay } from './main-base.js';
import CanvasRenderer from './renderer-canvas.js';
import TILE_TYPES from './tiletypes.js';
import { mk, mk_svg, walk_grid } from './util.js';
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 => {
navigator.clipboard.writeText(url);
// TODO feedback?
});
this.main.append(copy_button);
let ok = mk('button', {type: 'button'}, "neato");
ok.addEventListener('click', ev => {
this.close();
});
this.footer.append(ok);
}
}
// Stores and controls what the mouse is doing during a movement, mostly by dispatching to functions
// defined for the individual tools
const MOUSE_BUTTON_MASKS = [1, 4, 2]; // MouseEvent.button/buttons are ordered differently
class MouseOperation {
constructor(editor, ev, target = null) {
this.editor = editor;
this.target = target;
this.button_mask = MOUSE_BUTTON_MASKS[ev.button];
// Client coordinates of the initial click
this.mx0 = ev.clientX;
this.my0 = ev.clientY;
// Real cell coordinates (i.e. including fractional position within a cell) of the click
[this.gx0f, this.gy0f] = this.editor.renderer.real_cell_coords_from_event(ev);
// Cell coordinates
this.gx0 = Math.floor(this.gx0f);
this.gy0 = Math.floor(this.gy0f);
// Same as above but for the previous mouse position
this.mx1 = this.mx0;
this.my1 = this.mx1;
this.gx1f = this.gx0f;
this.gy1f = this.gy0f;
this.gx1 = this.gx0;
this.gy1 = this.gy0;
this.start(ev);
}
cell(gx, gy) {
return this.editor.stored_level.cells[Math.floor(gy)][Math.floor(gx)];
}
do_mousemove(ev) {
let [gxf, gyf] = this.editor.renderer.real_cell_coords_from_event(ev);
let gx = Math.floor(gxf);
let gy = Math.floor(gyf);
this.step(ev.clientX, ev.clientY, gxf, gyf, gx, gy);
this.mx1 = ev.clientX;
this.my1 = ev.clientY;
this.gx1f = gxf;
this.gy1f = gyf;
this.gx1 = gx;
this.gy1 = gy;
}
do_commit() {
this.commit();
this.cleanup();
}
do_abort() {
this.abort();
this.cleanup();
}
// Implement these
start() {}
step(x, y) {}
commit() {}
abort() {}
cleanup() {}
}
class PanOperation extends MouseOperation {
step(mx, my) {
this.editor.viewport_el.scrollLeft -= mx - this.mx1;
this.editor.viewport_el.scrollTop -= my - this.my1;
}
}
class DrawOperation extends MouseOperation {
}
class PencilOperation extends DrawOperation {
start() {
this.editor.place_in_cell(this.gx1, this.gy1, this.editor.palette_selection);
}
step(mx, my, gxf, gyf) {
for (let [x, y] of walk_grid(this.gx1f, this.gy1f, gxf, gyf)) {
this.editor.place_in_cell(x, y, this.editor.palette_selection);
}
}
}
class ForceFloorOperation extends DrawOperation {
start() {
// Begin by placing an all-way force floor under the mouse
this.editor.place_in_cell(x, y, 'force_floor_all');
}
step(mx, my, gxf, gyf) {
// Walk the mouse movement and change each we touch to match the direction we
// crossed the border
// FIXME occasionally i draw a tetris S kinda shape and both middle parts point
// the same direction, but shouldn't
let i = 0;
let prevx, prevy;
for (let [x, y] of walk_grid(this.gx1f, this.gy1f, gxf, gyf)) {
i++;
// The very first cell is the one the mouse was already in, and we don't
// have a movement direction yet, so leave that alone
if (i === 1) {
prevx = x;
prevy = y;
continue;
}
let name;
if (x === prevx) {
if (y > prevy) {
name = 'force_floor_s';
}
else {
name = 'force_floor_n';
}
}
else {
if (x > prevx) {
name = 'force_floor_e';
}
else {
name = 'force_floor_w';
}
}
// The second cell tells us the direction to use for the first, assuming it
// had some kind of force floor
if (i === 2) {
let prevcell = this.editor.stored_level.cells[prevy][prevx];
if (prevcell[0].type.name.startsWith('force_floor_')) {
prevcell[0].type = TILE_TYPES[name];
}
}
// Drawing a loop with force floors creates ice (but not in the previous
// cell, obviously)
let cell = this.editor.stored_level.cells[y][x];
if (cell[0].type.name.startsWith('force_floor_') &&
cell[0].type.name !== name)
{
name = 'ice';
}
this.editor.place_in_cell(x, y, name);
prevx = x;
prevy = y;
}
}
}
// Tiles the "adjust" tool will turn into each other
const ADJUST_TOGGLES = {
floor_custom_green: 'wall_custom_green',
floor_custom_pink: 'wall_custom_pink',
floor_custom_yellow: 'wall_custom_yellow',
floor_custom_blue: 'wall_custom_blue',
wall_custom_green: 'floor_custom_green',
wall_custom_pink: 'floor_custom_pink',
wall_custom_yellow: 'floor_custom_yellow',
wall_custom_blue: 'floor_custom_blue',
fake_floor: 'fake_wall',
fake_wall: 'fake_floor',
wall_invisible: 'wall_appearing',
wall_appearing: 'wall_invisible',
green_floor: 'green_wall',
green_wall: 'green_floor',
green_bomb: 'green_chip',
green_chip: 'green_bomb',
purple_floor: 'purple_wall',
purple_wall: 'purple_floor',
thief_keys: 'thief_tools',
thief_tools: 'thief_keys',
};
class AdjustOperation extends MouseOperation {
start() {
let cell = this.editor.stored_level.cells[this.gy1][this.gx1];
for (let tile of cell) {
// Toggle tiles that go in obvious pairs
let other = ADJUST_TOGGLES[tile.type.name];
if (other) {
tile.type = TILE_TYPES[other];
}
// Rotate actors
if (TILE_TYPES[tile.type.name].is_actor) {
tile.direction = DIRECTIONS[tile.direction ?? 'south'].right;
}
}
}
// Adjust tool doesn't support dragging
// TODO should it?
}
// FIXME currently allows creating outside the map bounds and moving beyond the right/bottom, sigh
class CameraOperation extends MouseOperation {
start(ev) {
this.offset_x = 0;
this.offset_y = 0;
this.resize_x = 0;
this.resize_y = 0;
let cursor;
this.target = ev.target.closest('.overlay-camera');
if (! this.target) {
// Clicking in empty space creates a new camera region
this.mode = 'create';
cursor = 'move';
this.region = new DOMRect(this.gx0, this.gy0, 1, 1);
this.target = mk_svg('rect.overlay-camera', {
x: this.gx0, y: this.gy1, width: 1, height: 1,
'data-region-index': this.editor.stored_level.camera_regions.length,
});
this.editor.connections_g.append(this.target);
}
else {
this.region = this.editor.stored_level.camera_regions[parseInt(this.target.getAttribute('data-region-index'), 10)];
// If we're grabbing an edge, resize it
let rect = this.target.getBoundingClientRect();
let grab_left = (this.mx0 < rect.left + 16);
let grab_right = (this.mx0 > rect.right - 16);
let grab_top = (this.my0 < rect.top + 16);
let grab_bottom = (this.my0 > rect.bottom - 16);
if (grab_left || grab_right || grab_top || grab_bottom) {
this.mode = 'resize';
if (grab_left) {
this.resize_edge_x = -1;
}
else if (grab_right) {
this.resize_edge_x = 1;
}
else {
this.resize_edge_x = 0;
}
if (grab_top) {
this.resize_edge_y = -1;
}
else if (grab_bottom) {
this.resize_edge_y = 1;
}
else {
this.resize_edge_y = 0;
}
if ((grab_top && grab_left) || (grab_bottom && grab_right)) {
cursor = 'nwse-resize';
}
else if ((grab_top && grab_right) || (grab_bottom && grab_left)) {
cursor = 'nesw-resize';
}
else if (grab_top || grab_bottom) {
cursor = 'ns-resize';
}
else {
cursor = 'ew-resize';
}
}
else {
this.mode = 'move';
cursor = 'move';
}
}
this.editor.viewport_el.style.cursor = cursor;
// Create a text element to show the size while editing
this.size_text = mk_svg('text.overlay-edit-tip', {
// Center it within the rectangle probably (x and y are set in _update_size_text)
'text-anchor': 'middle', 'dominant-baseline': 'middle',
});
this._update_size_text();
this.editor.svg_overlay.append(this.size_text);
}
_update_size_text() {
this.size_text.setAttribute('x', this.region.x + this.offset_x + (this.region.width + this.resize_x) / 2);
this.size_text.setAttribute('y', this.region.y + this.offset_y + (this.region.height + this.resize_y) / 2);
this.size_text.textContent = `${this.region.width + this.resize_x} × ${this.region.height + this.resize_y}`;
}
step(mx, my, gxf, gyf, gx, gy) {
// FIXME not right if we zoom, should use gxf
let dx = Math.floor((mx - this.mx0) / this.editor.conductor.tileset.size_x + 0.5);
let dy = Math.floor((my - this.my0) / this.editor.conductor.tileset.size_y + 0.5);
let stored_level = this.editor.stored_level;
if (this.mode === 'create') {
// Just make the new region span between the original click and the new position
this.region.x = Math.min(gx, this.gx0);
this.region.y = Math.min(gy, this.gy0);
this.region.width = Math.max(gx, this.gx0) + 1 - this.region.x;
this.region.height = Math.max(gy, this.gy0) + 1 - this.region.y;
}
else if (this.mode === 'move') {
// Keep it within the map!
this.offset_x = Math.max(- this.region.x, Math.min(stored_level.size_x - this.region.width, dx));
this.offset_y = Math.max(- this.region.y, Math.min(stored_level.size_y - this.region.height, dy));
}
else {
// Resize, based on the edge we originally grabbed
if (this.resize_edge_x < 0) {
// Left
dx = Math.max(-this.region.x, Math.min(this.region.width - 1, dx));
this.resize_x = -dx;
this.offset_x = dx;
}
else if (this.resize_edge_x > 0) {
// Right
dx = Math.max(-(this.region.width - 1), Math.min(stored_level.size_x - this.region.right, dx));
this.resize_x = dx;
this.offset_x = 0;
}
if (this.resize_edge_y < 0) {
// Top
dy = Math.max(-this.region.y, Math.min(this.region.height - 1, dy));
this.resize_y = -dy;
this.offset_y = dy;
}
else if (this.resize_edge_y > 0) {
// Bottom
dy = Math.max(-(this.region.height - 1), Math.min(stored_level.size_y - this.region.bottom, dy));
this.resize_y = dy;
this.offset_y = 0;
}
}
this.target.setAttribute('x', this.region.x + this.offset_x);
this.target.setAttribute('y', this.region.y + this.offset_y);
this.target.setAttribute('width', this.region.width + this.resize_x);
this.target.setAttribute('height', this.region.height + this.resize_y);
this._update_size_text();
}
commit() {
if (this.mode === 'create') {
// Region is already updated, just add it to the level
this.editor.stored_level.camera_regions.push(this.region);
}
else {
// Actually edit the underlying region
this.region.x += this.offset_x;
this.region.y += this.offset_y;
this.region.width += this.resize_x;
this.region.height += this.resize_y;
}
}
abort() {
if (this.mode === 'create') {
// The element was fake, so delete it
this.target.remove();
}
else {
// Move the element back to its original location
this.target.setAttribute('x', this.region.x);
this.target.setAttribute('y', this.region.y);
this.target.setAttribute('width', this.region.width);
this.target.setAttribute('height', this.region.height);
}
}
cleanup() {
this.editor.viewport_el.style.cursor = '';
this.size_text.remove();
}
}
class CameraEraseOperation extends MouseOperation {
start(ev) {
let target = ev.target.closest('.overlay-camera');
if (target) {
let index = parseInt(target.getAttribute('data-region-index'), 10);
target.remove();
this.editor.stored_level.camera_regions.splice(index, 1);
}
}
}
const EDITOR_TOOLS = {
pencil: {
icon: 'icons/tool-pencil.png',
name: "Pencil",
desc: "Draw individual tiles",
op1: PencilOperation,
//op2: EraseOperation,
},
line: {
// TODO not implemented
icon: 'icons/tool-line.png',
name: "Line",
desc: "Draw straight lines",
},
box: {
// TODO not implemented
icon: 'icons/tool-box.png',
name: "Box",
desc: "Fill a rectangular area with tiles",
},
fill: {
// TODO not implemented
icon: 'icons/tool-fill.png',
name: "Fill",
desc: "Flood-fill an area with tiles",
},
'force-floors': {
icon: 'icons/tool-force-floors.png',
name: "Force floors",
desc: "Draw force floors in the direction you draw",
op1: ForceFloorOperation,
},
adjust: {
icon: 'icons/tool-adjust.png',
name: "Adjust",
desc: "Toggle blocks and rotate actors",
op1: AdjustOperation,
},
connect: {
// TODO not implemented
icon: 'icons/tool-connect.png',
name: "Connect",
desc: "Set up CC1 clone and trap connections",
},
wire: {
// TODO not implemented
icon: 'icons/tool-wire.png',
name: "Wire",
desc: "Draw CC2 wiring",
},
camera: {
icon: 'icons/tool-camera.png',
name: "Camera",
desc: "Draw and edit custom camera regions",
help: "Draw and edit camera regions. Right-click to erase a region. When the player is within a camera region, the camera will avoid showing anything outside that region. LL only.",
op1: CameraOperation,
op2: CameraEraseOperation,
},
// TODO text tool; thin walls tool; ice tool; map generator?; subtools for select tool (copy, paste, crop)
// TODO interesting option: rotate an actor as you draw it by dragging? or hold a key like in
// slade when you have some selected?
// TODO ah, railroads...
};
const EDITOR_TOOL_ORDER = ['pencil', 'force-floors', 'adjust', 'camera'];
// TODO this MUST use a LL tileset!
const EDITOR_PALETTE = [{
title: "Basics",
tiles: [
'player',
'chip', 'chip_extra',
'floor', 'wall', 'hint', 'socket', 'exit',
],
}, {
title: "Terrain",
tiles: [
'popwall',
'fake_floor', 'fake_wall',
'wall_invisible', 'wall_appearing',
'gravel',
'dirt',
'door_blue', 'door_red', 'door_yellow', 'door_green',
'water', 'turtle', 'fire',
'ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se',
'force_floor_n', 'force_floor_s', 'force_floor_w', 'force_floor_e', 'force_floor_all',
],
}, {
title: "Items",
tiles: [
'key_blue', 'key_red', 'key_yellow', 'key_green',
'flippers', 'fire_boots', 'cleats', 'suction_boots',
],
}, {
title: "Creatures",
tiles: [
'tank_blue',
'ball',
'fireball',
'glider',
'bug',
'paramecium',
'walker',
'teeth',
'blob',
],
}, {
title: "Mechanisms",
tiles: [
'bomb',
'dirt_block',
'ice_block',
'button_gray',
'button_green',
'green_floor',
'green_wall',
'green_chip',
'green_bomb',
'button_blue',
'button_red', 'cloner',
'button_brown', 'trap',
'teleport_blue',
'teleport_red',
'teleport_green',
'teleport_yellow',
],
}];
export class Editor extends PrimaryView {
constructor(conductor) {
super(conductor, document.body.querySelector('main#editor'));
this.viewport_el = this.root.querySelector('.level');
// FIXME don't hardcode size here, convey this to renderer some other way
this.renderer = new CanvasRenderer(this.conductor.tileset, 32);
// FIXME need this in load_level which is called even if we haven't been setup yet
this.connections_g = mk_svg('g');
}
setup() {
// Level canvas and mouse handling
// This SVG draws vectors on top of the editor, like monster paths and button connections
// FIXME change viewBox in load_level, can't right now because order of ops
this.svg_overlay = mk_svg('svg.level-editor-overlay', {viewBox: '0 0 32 32'}, this.connections_g);
this.viewport_el.append(this.renderer.canvas, this.svg_overlay);
this.mouse_op = null;
this.viewport_el.addEventListener('mousedown', ev => {
this.cancel_mouse_operation();
if (ev.button === 0) {
// Left button: activate tool
let op_type = EDITOR_TOOLS[this.current_tool].op1;
if (! op_type)
return;
this.mouse_op = new op_type(this, ev);
ev.preventDefault();
ev.stopPropagation();
this.renderer.draw();
}
else if (ev.button === 1) {
// Middle button: always pan
this.mouse_op = new PanOperation(this, ev);
ev.preventDefault();
ev.stopPropagation();
}
else if (ev.button === 2) {
// Right button: activate tool's alt mode
let op_type = EDITOR_TOOLS[this.current_tool].op2;
if (! op_type)
return;
this.mouse_op = new op_type(this, ev);
ev.preventDefault();
ev.stopPropagation();
this.renderer.draw();
}
});
this.viewport_el.addEventListener('mousemove', ev => {
if (! this.mouse_op)
return;
if ((ev.buttons & this.mouse_op.button_mask) === 0) {
this.cancel_mouse_operation();
return;
}
this.mouse_op.do_mousemove(ev);
this.renderer.draw();
});
// TODO should this happen for a mouseup anywhere?
this.viewport_el.addEventListener('mouseup', ev => {
if (this.mouse_op) {
this.mouse_op.do_commit();
this.mouse_op = null;
ev.stopPropagation();
ev.preventDefault();
}
});
// Disable context menu, which interferes with right-click tools
this.viewport_el.addEventListener('contextmenu', ev => {
ev.preventDefault();
});
window.addEventListener('blur', ev => {
this.cancel_mouse_operation();
});
// Toolbar buttons
this.root.querySelector('#editor-share-url').addEventListener('click', ev => {
let buf = c2g.synthesize_level(this.stored_level);
// FIXME Not ideal, but btoa() wants a string rather than any of the myriad binary types
let stringy_buf = Array.from(new Uint8Array(buf)).map(n => String.fromCharCode(n)).join('');
// Make URL-safe and strip trailing padding
let data = btoa(stringy_buf).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
let url = new URL(location);
url.searchParams.delete('level');
url.searchParams.delete('setpath');
url.searchParams.append('level', data);
new EditorShareOverlay(this.conductor, url.toString()).open();
});
// Toolbox
let toolbox = mk('div.icon-button-set')
this.root.querySelector('.controls').append(toolbox);
this.tool_button_els = {};
for (let toolname of EDITOR_TOOL_ORDER) {
let tooldef = EDITOR_TOOLS[toolname];
let button = mk(
'button', {
type: 'button',
'data-tool': toolname,
},
mk('img', {
src: tooldef.icon,
alt: tooldef.name,
title: `${tooldef.name}: ${tooldef.desc}`,
}),
);
this.tool_button_els[toolname] = button;
toolbox.append(button);
}
this.current_tool = 'pencil';
this.tool_button_els['pencil'].classList.add('-selected');
toolbox.addEventListener('click', ev => {
let button = ev.target.closest('.icon-button-set button');
if (! button)
return;
this.select_tool(button.getAttribute('data-tool'));
});
// Tile palette
let palette_el = this.root.querySelector('.palette');
this.palette = {}; // name => element
for (let sectiondef of EDITOR_PALETTE) {
let section_el = mk('section');
palette_el.append(mk('h2', sectiondef.title), section_el);
for (let name of sectiondef.tiles) {
let entry = this.renderer.create_tile_type_canvas(name);
entry.setAttribute('data-tile-name', name);
entry.classList = 'palette-entry';
this.palette[name] = entry;
section_el.append(entry);
}
}
palette_el.addEventListener('click', ev => {
let entry = ev.target.closest('canvas.palette-entry');
if (! entry)
return;
this.select_palette(entry.getAttribute('data-tile-name'));
});
this.palette_selection = null;
this.select_palette('floor');
}
activate() {
super.activate();
this.renderer.draw();
}
load_game(stored_game) {
}
load_level(stored_level) {
// TODO support a game too i guess
this.stored_level = stored_level;
// XXX need this for renderer compat. but i guess it's nice in general idk
this.stored_level.cells = [];
let row;
for (let [i, cell] of this.stored_level.linear_cells.entries()) {
if (i % this.stored_level.size_x === 0) {
row = [];
this.stored_level.cells.push(row);
}
row.push(cell);
}
// Load connections
this.connections_g.textContent = '';
for (let [src, dest] of Object.entries(this.stored_level.custom_trap_wiring)) {
let [sx, sy] = this.stored_level.scalar_to_coords(src);
let [dx, dy] = this.stored_level.scalar_to_coords(dest);
this.connections_g.append(
mk_svg('rect.overlay-cxn', {x: sx, y: sy, width: 1, height: 1}),
mk_svg('line.overlay-cxn', {x1: sx + 0.5, y1: sy + 0.5, x2: dx + 0.5, y2: dy + 0.5}),
);
}
// TODO why are these in connections_g lol
for (let [i, region] of this.stored_level.camera_regions.entries()) {
let el = mk_svg('rect.overlay-camera', {x: region.x, y: region.y, width: region.width, height: region.height});
this.connections_g.append(el);
}
this.renderer.set_level(stored_level);
if (this.active) {
this.renderer.draw();
}
}
select_tool(tool) {
if (tool === this.current_tool)
return;
if (! this.tool_button_els[tool])
return;
this.tool_button_els[this.current_tool].classList.remove('-selected');
this.current_tool = tool;
this.tool_button_els[this.current_tool].classList.add('-selected');
}
select_palette(name) {
if (name === this.palette_selection)
return;
if (this.palette_selection) {
this.palette[this.palette_selection].classList.remove('--selected');
}
this.palette_selection = name;
if (this.palette_selection) {
this.palette[this.palette_selection].classList.add('--selected');
}
// Some tools obviously don't work with a palette selection, in which case changing tiles
// should default you back to the pencil
if (this.current_tool === 'adjust') {
this.select_tool('pencil');
}
}
place_in_cell(x, y, name) {
// TODO weird api?
if (! name)
return;
let type = TILE_TYPES[name];
let cell = this.stored_level.cells[y][x];
// For terrain tiles, erase the whole cell. For other tiles, only
// replace whatever's on the same layer
// TODO probably not the best heuristic yet, since i imagine you can
// combine e.g. the tent with thin walls
if (type.draw_layer === 0) {
cell.length = 0;
cell.push({type});
}
else {
for (let i = cell.length - 1; i >= 0; i--) {
if (cell[i].type.draw_layer === type.draw_layer) {
cell.splice(i, 1);
}
}
cell.push({type});
cell.sort((a, b) => a.type.draw_layer - b.type.draw_layer);
}
}
cancel_mouse_operation() {
if (this.mouse_op) {
this.mouse_op.do_abort();
this.mouse_op = null;
}
}
}
+423 -684
View File
File diff suppressed because it is too large. Load diff
+31 -6
View File
@@ -42,6 +42,15 @@ export class CanvasRenderer {
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];
}
// Draw to a canvas using tile coordinates
blit(ctx, sx, sy, dx, dy, w = 1, h = w) {
let tw = this.tileset.size_x;
@@ -76,8 +85,24 @@ export class CanvasRenderer {
[px, py] = [0, 0];
}
// Figure out where to start drawing
let x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, px - xmargin));
let y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, py - ymargin));
// TODO support overlapping regions better
let x0 = px - xmargin;
let y0 = py - ymargin;
// FIXME editor vs player again ugh, which is goofy since none of this is even relevant;
// maybe need to have a separate positioning method
if (this.level.stored_level) {
for (let region of this.level.stored_level.camera_regions) {
if (px >= region.left && px < region.right &&
py >= region.top && py < region.bottom)
{
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
x0 = Math.floor(x0 * tw + 0.5) / tw;
y0 = Math.floor(y0 * th + 0.5) / th;
@@ -86,12 +111,12 @@ export class CanvasRenderer {
// The viewport might not be aligned to the grid, so split off any fractional part.
let xf0 = Math.floor(x0);
let yf0 = Math.floor(y0);
// Note that when the viewport is exactly aligned to the grid, we need to draw the cells
// just outside of it, or we'll miss objects partway through crossing the border
if (xf0 === x0 && xf0 > 0) {
// We need to draw one cell beyond the viewport, or we'll miss objects partway through
// crossing the border moving away from us
if (xf0 > 0) {
xf0 -= 1;
}
if (yf0 === y0 && yf0 > 0) {
if (yf0 > 0) {
yf0 -= 1;
}
// Find where to stop drawing. As with above, if we're aligned to the grid, we need to
+11 -1
View File
@@ -43,7 +43,7 @@ export default [{
title: "conundrum",
author: "fluffy",
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',
}, {
title: "kinda song",
@@ -51,4 +51,14 @@ export default [{
twitter: "glitchedpuppet",
beepbox: 'https://jummbus.bitbucket.io/#j2N07Unnamedn310s0k0l00e0jt2mm0a7g0jj07i0r1O_U0000o3210T1v0wL0OD0Ou01q1d5f6y0z6C1c0A1F2B5V6Q20a0Pe64bE0171T1v0pL0OD0Ou92q1d4f7y2z1C0c2AbF6B6V9Q0490Pb976E0001T1v0pL0OD0Ou94q1d2f7y2z1C0c2A9F5B5V6Q290dPa883E0011T4v0pL0OD0Ouf0q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4x8Qd000lBu7014x4i4Qd3gQlmoh4ia2cz8OcChA4h4y8w01cPhjl0p27hFCLwATnMkCKChW2ZcngbUxcnikQ7sQO_inQ5WCkXyW9jbZ9vlO_lODhdcDjQNtgJ0Gp7IpAukFczR2FyX2frIzQ4zOGOOfpauP9vainRihQPK4tClqoKMnaAzQQnQQnRknXx7pBnK2OFjSO_oELFAzFIOWa8WF8WpHW3nEdv26LgqZcLQQap7Iu6P9j5R2Q2Q2Q2VeRfbEbWGq2-DinQBZttkHUMRWPn9HFAuwzEe3E8W2ehFyUsNncLQThuCnW2_aGydcngbkO_rZdkPjdcSpvx9jbZ3cRZtcO_lipvFSFkO_lN4YlAjBpdp6hahFyWz5OXbWFbWF8YEmChy3wWiehcK0bWaoEIlChw3JHp5K5E5E5w1sPb5P2f9as0LVds0bkbukQni0JFyQ5c0bokRyXrbxqxr2CyWSOSAzw1qxqxrNrxqxrhtwJgJMJJhvmbibikO8J5JwJEJEJtE',
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',
}];
+73 -13
View File
@@ -197,9 +197,15 @@ export const CC2_TILESET_LAYOUT = {
wired: [[4, 10], [5, 10], [6, 10], [7, 10]],
},
popwall: [8, 10],
popwall2: [8, 10],
gravel: [9, 10],
ball: [[10, 10], [11, 10], [12, 10], [13, 10], [14, 10]],
steel: [15, 10],
steel: {
// Wiring!
base: [15, 10],
wired: [9, 26],
is_wired_optional: true,
},
// TODO only animates while moving
teeth: {
@@ -215,7 +221,7 @@ export const CC2_TILESET_LAYOUT = {
swivel_ne: [11, 11],
swivel_se: [12, 11],
swivel_floor: [13, 11],
// TODO some kinda four-edges thing again
'#wire-tunnel': [14, 11],
stopwatch_penalty: [15, 11],
paramecium: {
north: [[0, 12], [1, 12], [2, 12]],
@@ -343,7 +349,7 @@ export const CC2_TILESET_LAYOUT = {
west: [[6, 24], [7, 24]],
},
bogus_player_drowned: {
overlay: [3, 3], // splash
overlay: [5, 5], // splash
base: 'water',
},
bogus_player_burned_fire: {
@@ -365,18 +371,45 @@ export const CC2_TILESET_LAYOUT = {
'#powered': [15, 26],
player2: {
moving: {
north: [[0, 27], [1, 27], [2, 27], [3, 27], [4, 27], [5, 27], [6, 27], [7, 27]],
south: [[0, 28], [1, 28], [2, 28], [3, 28], [4, 28], [5, 28], [6, 28], [7, 28]],
west: [[8, 28], [9, 28], [10, 28], [11, 28], [12, 28], [13, 28], [14, 28], [15, 28]],
east: [[8, 27], [9, 27], [10, 27], [11, 27], [12, 27], [13, 27], [14, 27], [15, 27]],
},
normal: {
north: [0, 27],
south: [0, 28],
west: [8, 28],
east: [8, 27],
},
blocked: 'pushing',
moving: {
north: [[0, 27], [1, 27], [2, 27], [3, 27], [4, 27], [5, 27], [6, 27], [7, 27]],
south: [[0, 28], [1, 28], [2, 28], [3, 28], [4, 28], [5, 28], [6, 28], [7, 28]],
west: [[8, 28], [9, 28], [10, 28], [11, 28], [12, 28], [13, 28], [14, 28], [15, 28]],
east: [[8, 27], [9, 27], [10, 27], [11, 27], [12, 27], [13, 27], [14, 27], [15, 27]],
},
pushing: {
north: [8, 29],
east: [9, 29],
south: [10, 29],
west: [11, 29],
},
swimming: {
north: [[0, 29], [1, 29]],
east: [[2, 29], [3, 29]],
south: [[4, 29], [5, 29]],
west: [[6, 29], [7, 29]],
},
// The classic CC2 behavior, spinning on ice
skating: [[0, 27], [8, 27], [0, 28], [8, 28]],
// TODO i don't know what CC2 does
forced: {
north: [2, 27],
east: [10, 27],
south: [2, 28],
west: [10, 28],
},
// These are frames from the splash/explosion animations
drowned: [5, 5],
burned: [1, 5],
exploded: [1, 5],
failed: [1, 5],
},
fire: [
[12, 29],
@@ -451,6 +484,7 @@ export const TILE_WORLD_TILESET_LAYOUT = {
wall_appearing: [2, 12],
gravel: [2, 13],
popwall: [2, 14],
popwall2: [2, 14],
hint: [2, 15],
thinwall_se: [3, 0],
@@ -564,6 +598,7 @@ export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
teeth: Object.assign({}, CC2_TILESET_LAYOUT.teeth, {
north: [[0, 32], [1, 32], [2, 32], [1, 32]],
}),
popwall2: [9, 32],
// Extra player sprites
player: Object.assign({}, CC2_TILESET_LAYOUT.player, {
@@ -581,6 +616,7 @@ export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, {
west: [7, 33],
},
}),
// TODO player2 equivalents
bogus_player_burned_fire: {
overlay: [6, 33],
base: 'fire',
@@ -635,10 +671,10 @@ export class Tileset {
coords = drawspec.tile;
}
else if (drawspec.wired) {
if (tile && tile.wire_directions !== undefined && tile.wire_directions !== 0) {
// TODO all four is a different thing entirely
if (tile && tile.wire_directions) {
// TODO all four is a different thing entirely with two separate parts, ugh
// Draw the appropriate wire underlay
this.draw_type('#unpowered', tile, tic, blit);
this.draw_type(tile.cell.is_powered ? '#powered' : '#unpowered', tile, tic, blit);
// Draw a masked part of the base tile
let wiredir = tile.wire_directions;
@@ -695,7 +731,7 @@ export class Tileset {
}
// Apply custom per-type visual states
if (TILE_TYPES[name].visual_state) {
if (TILE_TYPES[name] && TILE_TYPES[name].visual_state) {
// Note that these accept null, too, and return a default
let state = TILE_TYPES[name].visual_state(tile);
// If it's a string, that's an alias for another state
@@ -766,6 +802,30 @@ export class Tileset {
blit(coords[0], coords[1]);
}
// Wired tiles may also have tunnels, drawn on top of everything else
if (drawspec.wired && tile && tile.wire_tunnel_directions) {
let tunnel_coords = this.layout['#wire-tunnel'];
let tunnel_width = 6/32;
let tunnel_length = 12/32;
let tunnel_offset = (1 - tunnel_width) / 2;
if (tile.wire_tunnel_directions & DIRECTIONS['north'].bit) {
blit(tunnel_coords[0] + tunnel_offset, tunnel_coords[1],
tunnel_offset, 0, tunnel_width, tunnel_length);
}
if (tile.wire_tunnel_directions & DIRECTIONS['south'].bit) {
blit(tunnel_coords[0] + tunnel_offset, tunnel_coords[1] + 1 - tunnel_length,
tunnel_offset, 1 - tunnel_length, tunnel_width, tunnel_length);
}
if (tile.wire_tunnel_directions & DIRECTIONS['west'].bit) {
blit(tunnel_coords[0], tunnel_coords[1] + tunnel_offset,
0, tunnel_offset, tunnel_length, tunnel_width);
}
if (tile.wire_tunnel_directions & DIRECTIONS['east'].bit) {
blit(tunnel_coords[0] + 1 - tunnel_length, tunnel_coords[1] + tunnel_offset,
1 - tunnel_length, tunnel_offset, tunnel_length, tunnel_width);
}
}
// Special behavior for special objects
// TODO? hardcode this less?
if (name === 'floor_letter') {
+239 -54
View File
@@ -1,4 +1,5 @@
import { DIRECTIONS } from './defs.js';
import { random_choice } from './util.js';
// Draw layers
const LAYER_TERRAIN = 0;
@@ -106,34 +107,62 @@ const TILE_TYPES = {
draw_layer: LAYER_TERRAIN,
blocks_monsters: true,
blocks_blocks: true,
on_ready(me, level) {
if (level.compat.auto_convert_ccl_popwalls &&
me.cell.some(tile => tile.type.is_actor))
{
// Fix blocks and other actors on top of popwalls by turning them into double
// popwalls, which preserves CC2 popwall behavior
me.type = TILE_TYPES['popwall2'];
}
},
on_depart(me, level, other) {
level.transmute_tile(me, 'wall');
},
},
// FIXME these should be OVERLAY by cc2 rules, but the cc1 tiles are opaque and cover everything else
thinwall_n: {
// LL specific tile that can only be stepped on /twice/, originally used to repair differences
// with popwall behavior between Lynx and Steam
popwall2: {
draw_layer: LAYER_TERRAIN,
blocks_monsters: true,
blocks_blocks: true,
on_depart(me, level, other) {
level.transmute_tile(me, 'popwall');
},
},
// FIXME in a cc1 tileset, these tiles are opaque >:S
thinwall_n: {
draw_layer: LAYER_OVERLAY,
thin_walls: new Set(['north']),
},
thinwall_s: {
draw_layer: LAYER_TERRAIN,
draw_layer: LAYER_OVERLAY,
thin_walls: new Set(['south']),
},
thinwall_e: {
draw_layer: LAYER_TERRAIN,
draw_layer: LAYER_OVERLAY,
thin_walls: new Set(['east']),
},
thinwall_w: {
draw_layer: LAYER_TERRAIN,
draw_layer: LAYER_OVERLAY,
thin_walls: new Set(['west']),
},
thinwall_se: {
draw_layer: LAYER_TERRAIN,
draw_layer: LAYER_OVERLAY,
thin_walls: new Set(['south', 'east']),
},
fake_wall: {
draw_layer: LAYER_TERRAIN,
blocks_all: true,
on_ready(me, level) {
if (level.compat.auto_convert_ccl_blue_walls &&
me.cell.some(tile => tile.type.is_actor))
{
// Blocks can be pushed off of blue walls in TW Lynx, which only works due to a tiny
// quirk of the engine that I don't want to replicate, so replace them with popwalls
me.type = TILE_TYPES['popwall'];
}
},
on_bump(me, level, other) {
if (other.type.can_reveal_walls) {
level.transmute_tile(me, 'wall');
@@ -142,7 +171,8 @@ const TILE_TYPES = {
},
fake_floor: {
draw_layer: LAYER_TERRAIN,
blocks_all: true,
blocks_monsters: true,
blocks_blocks: true,
on_bump(me, level, other) {
if (other.type.can_reveal_walls) {
level.transmute_tile(me, 'floor');
@@ -354,7 +384,7 @@ const TILE_TYPES = {
level.transmute_tile(me, 'dirt');
}
else if (other.type.name === 'ice_block') {
level.remove_tile(other);
level.transmute_tile(other, 'splash');
level.transmute_tile(me, 'ice');
}
else if (other.type.is_player) {
@@ -558,10 +588,16 @@ const TILE_TYPES = {
},
green_floor: {
draw_layer: LAYER_TERRAIN,
on_gray_button(me, level) {
level.transmute_tile(me, 'green_wall');
},
},
green_wall: {
draw_layer: LAYER_TERRAIN,
blocks_all: true,
on_gray_button(me, level) {
level.transmute_tile(me, 'green_floor');
},
},
green_chip: {
draw_layer: LAYER_ITEM,
@@ -575,6 +611,7 @@ const TILE_TYPES = {
level.remove_tile(me);
}
},
// Not affected by gray buttons
},
green_bomb: {
draw_layer: LAYER_ITEM,
@@ -589,15 +626,32 @@ const TILE_TYPES = {
level.transmute_tile(other, 'explosion');
}
},
// Not affected by gray buttons
},
purple_floor: {
// TODO wired
draw_layer: LAYER_TERRAIN,
on_gray_button(me, level) {
level.transmute_tile(me, 'purple_wall');
},
on_power(me, level) {
me.type.on_gray_button(me, level);
},
on_depower(me, level) {
me.type.on_gray_button(me, level);
},
},
purple_wall: {
// TODO wired
draw_layer: LAYER_TERRAIN,
blocks_all: true,
on_gray_button(me, level) {
level.transmute_tile(me, 'purple_floor');
},
on_power(me, level) {
me.type.on_gray_button(me, level);
},
on_depower(me, level) {
me.type.on_gray_button(me, level);
},
},
cloner: {
draw_layer: LAYER_TERRAIN,
@@ -628,11 +682,40 @@ const TILE_TYPES = {
}
}
},
// Also clones on rising pulse or gray button
on_power(me, level) {
me.type.activate(me, level);
},
on_gray_button(me, level) {
me.type.activate(me, level);
},
},
trap: {
draw_layer: LAYER_TERRAIN,
on_ready(me, level) {
// Trap any actor standing on us, unless we already know we're pressed
if (me.presses)
return;
for (let tile of me.cell) {
if (tile.type.is_actor) {
tile.stuck = true;
}
}
},
add_press_ready(me, level, other) {
// Same as below, but without using the undo stack or ejection
me.presses = (me.presses ?? 0) + 1;
if (me.presses === 1) {
for (let tile of me.cell) {
if (tile.type.is_actor) {
tile.stuck = false;
}
}
}
},
on_arrive(me, level, other) {
if (me.open) {
if (me.presses) {
// Lynx: Traps immediately eject their contents, if possible
// TODO compat this, cc2 doens't do it!
//level.attempt_step(other, other.direction);
@@ -641,8 +724,42 @@ const TILE_TYPES = {
level.set_actor_stuck(other, true);
}
},
add_press(me, level) {
level._set_prop(me, 'presses', (me.presses ?? 0) + 1);
if (me.presses === 1) {
// Free everything on us, if we went from 0 to 1 presses (i.e. closed to open)
for (let tile of Array.from(me.cell)) {
if (tile.type.is_actor) {
level.set_actor_stuck(tile, false);
// Forcibly move anything released from a trap, to keep it in sync with
// whatever pushed the button
level.attempt_step(tile, tile.direction);
}
}
}
},
remove_press(me, level) {
level._set_prop(me, 'presses', me.presses - 1);
if (me.presses === 0) {
// Trap everything on us, if we went from 1 to 0 presses (i.e. open to closed)
for (let tile of me.cell) {
if (tile.type.is_actor) {
level.set_actor_stuck(tile, true);
}
}
}
},
on_power(me, level) {
// Treat being powered or not as an extra kind of brown button press
me.type.add_press(me, level);
console.log("powering UP", me.presses);
},
on_depower(me, level) {
me.type.remove_press(me, level);
console.log("powering down...", me.presses);
},
visual_state(me) {
if (me && me.open) {
if (me && me.presses) {
return 'open';
}
else {
@@ -689,37 +806,57 @@ const TILE_TYPES = {
},
teleport_blue: {
draw_layer: LAYER_TERRAIN,
connects_to: 'teleport_blue',
connect_order: 'backward',
is_teleporter: true,
teleport_dest_order(me, level) {
return level.iter_tiles_in_reading_order(me.cell, 'teleport_blue', true);
},
},
teleport_red: {
draw_layer: LAYER_TERRAIN,
connects_to: 'teleport_red',
connect_order: 'forward',
is_teleporter: true,
teleport_dest_order(me, level) {
// FIXME you can control your exit direction from red teleporters
return level.iter_tiles_in_reading_order(me.cell, 'teleport_red');
},
},
teleport_green: {
draw_layer: LAYER_TERRAIN,
// connects_to: 'teleport_red',
// connect_order: 'forward',
// is_teleporter: true,
// FIXME completely different behavior from other teleporters
teleport_dest_order(me, level) {
// FIXME exit direction is random; unclear if it's any direction or only unblocked ones
let all = Array.from(level.iter_tiles_in_reading_order(me.cell, 'teleport_green'));
// FIXME this should use the lynxish rng
return [random_choice(all), me];
},
},
teleport_yellow: {
draw_layer: LAYER_TERRAIN,
connects_to: 'teleport_yellow',
connect_order: 'backward',
is_teleporter: true,
teleport_dest_order(me, level) {
// FIXME special pickup behavior
return level.iter_tiles_in_reading_order(me.cell, 'teleport_yellow', true);
},
},
// FIXME do i want these as separate objects? what would they do, turn into each other? or should it be one with state?
flame_jet_off: {
draw_layer: LAYER_TERRAIN,
activate(me, level) {
level.transmute_tile(me, 'flame_jet_on');
},
on_gray_button(me, level) {
me.type.activate(me, level);
},
on_power(me, level) {
me.type.activate(me, level);
},
},
flame_jet_on: {
draw_layer: LAYER_TERRAIN,
// FIXME every tic, kills every actor in the cell
activate(me, level) {
level.transmute_tile(me, 'flame_jet_off');
},
on_gray_button(me, level) {
me.type.activate(me, level);
},
on_power(me, level) {
me.type.activate(me, level);
},
},
// Buttons
button_blue: {
@@ -731,7 +868,7 @@ const TILE_TYPES = {
for (let actor of level.actors) {
// TODO generify somehow??
if (actor.type.name === 'tank_blue') {
level.set_actor_direction(actor, DIRECTIONS[actor.direction].opposite);
level._set_prop(actor, 'pending_reverse', ! actor.pending_reverse);
}
}
},
@@ -745,7 +882,8 @@ const TILE_TYPES = {
level.sfx.play_once('button-press', me.cell);
// Move all yellow tanks one tile in the direction of the pressing actor
for (let actor of level.actors) {
for (let i = level.actors.length - 1; i >= 0; i--) {
let actor = level.actors[i];
// TODO generify somehow??
if (actor.type.name === 'tank_yellow') {
level.attempt_step(actor, other.direction);
@@ -790,36 +928,32 @@ const TILE_TYPES = {
draw_layer: LAYER_TERRAIN,
connects_to: 'trap',
connect_order: 'forward',
on_arrive(me, level, other) {
level.sfx.play_once('button-press', me.cell);
if (me.connection && me.connection.cell) {
on_ready(me, level) {
// Inform the trap of any actors that start out holding us down
let trap = me.connection;
level._set_prop(trap, 'open', true);
for (let tile of trap.cell) {
if (! (trap && trap.cell))
return;
for (let tile of me.cell) {
if (tile.type.is_actor) {
if (tile.stuck) {
level.set_actor_stuck(tile, false);
}
// Forcibly move anything released from a trap, to keep
// it in sync with whatever pushed the button
level.attempt_step(tile, tile.direction);
}
trap.type.add_press_ready(trap, level);
}
}
},
on_arrive(me, level, other) {
level.sfx.play_once('button-press', me.cell);
let trap = me.connection;
if (trap && trap.cell) {
trap.type.add_press(trap, level);
}
},
on_depart(me, level, other) {
// TODO this doesn't play if you walk straight across
level.sfx.play_once('button-release', me.cell);
if (me.connection && me.connection.cell) {
let trap = me.connection;
level._set_prop(trap, 'open', false);
for (let tile of trap.cell) {
if (tile.type.is_actor) {
level.set_actor_stuck(tile, true);
}
}
if (trap && trap.cell) {
trap.type.remove_press(trap, level);
}
},
},
@@ -830,8 +964,9 @@ const TILE_TYPES = {
on_arrive(me, level, other) {
level.sfx.play_once('button-press', me.cell);
if (me.connection && me.connection.cell) {
me.connection.type.activate(me.connection, level);
let cloner = me.connection;
if (cloner && cloner.cell) {
cloner.type.activate(cloner, level);
}
},
on_depart(me, level, other) {
@@ -843,13 +978,47 @@ const TILE_TYPES = {
// FIXME toggles flame jets, connected somehow, ???
},
button_pink: {
// TODO not implemented
draw_layer: LAYER_TERRAIN,
is_emitting(me, level) {
// We emit current as long as there's an actor on us
return me.cell.some(tile => tile.type.is_actor);
},
on_arrive(me, level, other) {
level.sfx.play_once('button-press', me.cell);
},
on_depart(me, level, other) {
level.sfx.play_once('button-release', me.cell);
},
},
button_black: {
// TODO not implemented
draw_layer: LAYER_TERRAIN,
},
button_gray: {
// TODO only partially implemented
draw_layer: LAYER_TERRAIN,
on_arrive(me, level, other) {
level.sfx.play_once('button-press', me.cell);
for (let x = Math.max(0, me.cell.x - 2); x <= Math.min(level.width - 1, me.cell.x + 2); x++) {
for (let y = Math.max(0, me.cell.y - 2); y <= Math.min(level.height - 1, me.cell.y + 2); y++) {
let cell = level.cells[y][x];
// TODO wait is this right
if (cell === me.cell)
continue;
for (let tile of cell) {
if (tile.type.on_gray_button) {
tile.type.on_gray_button(tile, level);
}
}
}
}
},
on_depart(me, level, other) {
level.sfx.play_once('button-release', me.cell);
},
},
// Time alteration
stopwatch_bonus: {
@@ -1017,17 +1186,32 @@ const TILE_TYPES = {
movement_speed: 4,
},
// Keys
// Note that red and blue keys do NOT block monsters, but yellow and green DO
// Keys, whose behavior varies
key_red: {
// TODO Red key can ONLY be picked up by players (and doppelgangers), no other actor that
// has an inventory
draw_layer: LAYER_ITEM,
is_item: true,
is_key: true,
},
key_blue: {
// Blue key is picked up by dirt blocks and all monsters, including those that don't have an
// inventory normally
draw_layer: LAYER_ITEM,
is_item: true,
is_key: true,
on_arrive(me, level, other) {
// Call it... everything except ice and directional blocks? These rules are weird.
// Note that the game itself normally handles picking items up, so we only get here for
// actors who aren't supposed to have an inventory
// TODO make this a... flag? i don't know?
// TODO major difference from lynx...
if (other.type.name !== 'ice_block' && other.type.name !== 'directional_block') {
if (level.give_actor(other, me.type.name)) {
level.remove_tile(me);
}
}
},
},
key_yellow: {
draw_layer: LAYER_ITEM,
@@ -1176,6 +1360,7 @@ const TILE_TYPES = {
infinite_items: {
key_yellow: true,
},
visual_state: player_visual_state,
},
chip: {
draw_layer: LAYER_ITEM,
+183 -4
View File
@@ -1,7 +1,13 @@
// Base class for custom errors
export class LLError extends Error {}
// Random choice
export function random_choice(list) {
return list[Math.floor(Math.random() * list.length)];
}
// DOM stuff
function _mk(el, children) {
if (children.length > 0) {
if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") {
@@ -29,6 +35,70 @@ export function mk_svg(tag_selector, ...children) {
return _mk(el, children);
}
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 promise_event(element, success_event, failure_event) {
let resolve, reject;
let promise = new Promise((res, rej) => {
@@ -36,21 +106,21 @@ export function promise_event(element, success_event, failure_event) {
reject = rej;
});
let success_handler = e => {
let success_handler = ev => {
element.removeEventListener(success_event, success_handler);
if (failure_event) {
element.removeEventListener(failure_event, failure_handler);
}
resolve(e);
resolve(ev);
};
let failure_handler = e => {
let failure_handler = ev => {
element.removeEventListener(success_event, success_handler);
if (failure_event) {
element.removeEventListener(failure_event, failure_handler);
}
reject(e);
reject(ev);
};
element.addEventListener(success_event, success_handler);
@@ -61,6 +131,7 @@ export function promise_event(element, success_event, failure_event) {
return promise;
}
export async function fetch(url) {
let xhr = new XMLHttpRequest;
let promise = promise_event(xhr, 'load', 'error');
@@ -68,9 +139,19 @@ export async function fetch(url) {
xhr.responseType = 'arraybuffer';
xhr.send();
await promise;
if (xhr.status !== 200)
throw new Error(`Failed to load ${url} -- ${xhr.status} ${xhr.statusText}`);
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));
}
// Cast a line through a grid and yield every cell it touches
export function* walk_grid(x0, y0, x1, y1) {
// TODO if the ray starts outside the grid (extremely unlikely), we should
@@ -171,3 +252,101 @@ 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 :/
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();
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+148 -16
View File
@@ -82,6 +82,12 @@ p:first-child {
p:last-child {
margin-bottom: 0;
}
pre {
white-space: pre-wrap;
}
code {
color: #c0c0e0;
}
a {
color: #c0c0c0;
@@ -154,6 +160,11 @@ a:active {
overflow: auto;
padding: 1em;
}
.dialog pre.error {
color: #400000;
background: #f0d0d0;
padding: 0.5em 1em;
}
/* Individual overlays */
table.level-browser {
@@ -188,6 +199,10 @@ table.level-browser tr.--unvisited {
color: #606060;
font-style: italic;
}
table.level-browser tr.--error {
color: #600000;
font-style: italic;
}
table.level-browser tbody tr {
cursor: pointer;
}
@@ -281,7 +296,7 @@ body > header {
align-items: center;
gap: 0.5em;
margin: 0.25em;
padding: 0.5em;
line-height: 1.125;
}
body > header h1 {
@@ -291,14 +306,13 @@ body > header h2 {
font-size: 1.33em;
}
body > header h3 {
font-size: 1.25em;
font-size: 1.75em;
}
body > header > nav {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 0.5em;
margin: 0.25rem 0.5rem;
}
body > header button {
font-size: 0.75em;
@@ -317,8 +331,25 @@ body[data-mode=player] #editor-play {
order: 3;
color: #606060;
}
#header-level {
font-size: 1.5em;
@media (max-width: 800px) {
body > header {
padding: 0.25em;
}
/* All these headings are way too big on phones */
body > header h1 {
font-size: 1.25em;
}
body > header h2 {
font-size: 1.125em;
}
body > header h3 {
font-size: 1.0625em;
}
body > header p {
/* "a game by eevee" takes up too much space :( */
display: none;
}
}
/**************************************************************************************************/
@@ -334,10 +365,31 @@ body[data-mode=player] #editor-play {
;
gap: 1em;
position: relative;
padding: 1em 10%;
margin: auto;
overflow: auto;
}
#splash > .drag-overlay {
display: none;
justify-content: center;
align-items: center;
font-size: 10vmin;
position: absolute;
top: 0;
bottom: 0;
left: 1rem;
right: 1rem;
background: #fff2;
border: 0.25rem dashed white;
border-radius: 1rem;
text-shadow: 0 1px 5px black;
text-align: center;
}
#splash.--drag-hover > .drag-overlay {
display: flex;
}
#splash > header {
grid-area: header;
@@ -371,7 +423,8 @@ body[data-mode=player] #editor-play {
#splash > #splash-upload-levels {
grid-area: upload;
}
#splash-upload {
#splash-upload-file,
#splash-upload-dir {
/* Hide the file upload control, which is ugly */
display: none;
}
@@ -379,6 +432,23 @@ body[data-mode=player] #editor-play {
grid-area: yours;
}
@media (max-width: 800px) {
#splash {
/* Grid layout doesn't fit, just stack everything */
display: flex;
flex-direction: column;
/* 10% padding is way way too much */
padding: 1em;
}
/* Shrink logo and title */
#splash > header img {
width: 48px;
}
#splash > header h1 {
font-size: 2em;
}
}
button.level-pack-button {
display: grid;
grid:
@@ -469,7 +539,7 @@ button.level-pack-button p {
justify-content: center;
align-items: center;
z-index: 1;
z-index: 2;
font-size: calc(0.5 * var(--tile-width) * var(--scale));
padding: 2%;
background: #0009;
@@ -552,7 +622,6 @@ dl.score-chart .-sum {
.bonus output {
flex: 1;
font-size: 2em;
padding: 0.125em;
min-width: 2em;
min-height: 1em;
line-height: 1;
@@ -584,9 +653,11 @@ dl.score-chart .-sum {
}
#player .bonus {
visibility: hidden;
display: none;
}
#player.--bonus-visible .bonus {
visibility: initial;
display: initial;
}
.message {
@@ -644,11 +715,14 @@ dl.score-chart .-sum {
#player-music {
grid-area: music;
display: flex;
gap: 1em;
margin: 0 1em;
text-transform: lowercase;
color: #909090;
}
#player-music #player-music-left {
flex: 1 0 auto;
/* allow me to wrap if need be */
flex: 1 0 0px;
}
#player-music #player-music-right {
text-align: right;
@@ -709,6 +783,45 @@ main.--has-demo .demo-controls {
}
@media (max-width: 800px) {
#player {
/* sentinel for js */
--is-portrait: 1;
/* The play area isn't necessarily the biggest thing any more, and it's ugly when stretched */
align-items: center;
}
#player > .-main-area {
/* Rearrange the grid to be vertical */
grid:
"level level"
"chips inventory"
"time inventory"
"bonus inventory"
/ min-content min-content
;
row-gap: 0.5em;
column-gap: 1em;
padding: 0.5em;
}
#player .inventory {
/* stick me in the center right */
place-self: center end;
}
#player .message {
/* Overlay hints on the inventory area */
grid-row: chips / bonus;
grid-column: level;
z-index: 1;
font-size: calc(var(--tile-height) * var(--scale) / 2.5);
}
#player-music {
/* Stack the title/artist on the volume, since they don't fit well side by side */
font-size: 0.875em;
}
}
/**************************************************************************************************/
/* Editor */
@@ -716,9 +829,9 @@ main.--has-demo .demo-controls {
flex: 1 1 auto;
display: grid;
grid:
"controls level" min-content
"controls controls" min-content
"palette level" 1fr
/ minmax(25%, auto) auto
/ minmax(25%, auto) 1fr
;
gap: 0.5em;
@@ -731,7 +844,12 @@ main.--has-demo .demo-controls {
grid-area: level;
position: relative;
overflow: auto;
/* Padding and background make it easier to tell when we're at the edge of the map */
/* TODO padding should be half a cell, and svg should respect it too */
/* padding: 1em; */
background: #202020;
}
/* SVG overlays */
#editor svg.level-editor-overlay {
position: absolute;
top: 0;
@@ -741,16 +859,30 @@ main.--has-demo .demo-controls {
height: 1024px;
/* allow clicks to go through us! */
pointer-events: none;
}
#editor .level-editor-overlay rect.overlay-cxn {
/* default svg properties */
stroke-width: 0.0625;
stroke: red;
fill: none;
}
#editor .level-editor-overlay line.overlay-cxn {
stroke-width: 0.0625;
#editor .level-editor-overlay rect.overlay-cxn {
stroke: red;
}
#editor .level-editor-overlay line.overlay-cxn {
stroke: red;
}
#editor .level-editor-overlay rect.overlay-camera {
stroke: #808080;
fill: #80808040;
pointer-events: auto;
}
#editor .level-editor-overlay text {
/* Each cell is one "pixel", so text needs to be real small */
font-size: 1px;
}
#editor .level-editor-overlay text.overlay-edit-tip {
stroke: none;
fill: black;
}
#editor .controls {
grid-area: controls;
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.