Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Timothy Stiles 2020-10-22 18:04:43 +11:00
commit fedbd200fc
10 changed files with 1207 additions and 214 deletions

View File

@ -40,6 +40,7 @@
</nav> </nav>
</header> </header>
<main id="splash"> <main id="splash">
<div class="drag-overlay"></div>
<header> <header>
<h1><img src="og-preview.png" alt="">Lexy's Labyrinth</h1> <h1><img src="og-preview.png" alt="">Lexy's Labyrinth</h1>
</header> </header>
@ -58,21 +59,20 @@
<section id="splash-upload-levels"> <section id="splash-upload-levels">
<h2>Other levels</h2> <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> <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 explain how to find chips.dat or steam folder --> <!-- TODO zip files! -->
<!-- TODO drag and drop? --> <p>You can also drag and drop files or directories into this window.</p>
<input id="splash-upload" type="file" accept=".dat,.ccl,.c2m,.ccs"> <input id="splash-upload-file" type="file" accept=".dat,.ccl,.c2m,.ccs" multiple>
<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> <input id="splash-upload-dir" type="file" webkitdirectory>
<p>Supports both the old Microsoft <code>CHIPS.DAT</code> format and the Steam <code>C2M</code> format.</p> <button type="button" id="splash-upload-file-button" class="button-big">Load files</button>
<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> <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> <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"> <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>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>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> </ol>
-->
</section> </section>
<section id="splash-your-levels"> <section id="splash-your-levels">

View File

@ -1,6 +1,4 @@
export function string_from_buffer_ascii(buf) { import * as util from './util.js';
return String.fromCharCode.apply(null, new Uint8Array(buf));
}
export class StoredCell extends Array { export class StoredCell extends Array {
} }
@ -47,8 +45,34 @@ export class StoredLevel {
} }
export class StoredGame { export class StoredGame {
constructor(identifier) { constructor(identifier, level_loader) {
this.identifier = identifier; this.identifier = identifier;
this.levels = []; 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);
}
} }
} }

View File

@ -1,6 +1,7 @@
import { DIRECTIONS } from './defs.js'; 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 TILE_TYPES from './tiletypes.js';
import * as util from './util.js';
const CC2_DEMO_INPUT_MASK = { const CC2_DEMO_INPUT_MASK = {
drop: 0x01, drop: 0x01,
@ -13,9 +14,8 @@ const CC2_DEMO_INPUT_MASK = {
}; };
class CC2Demo { class CC2Demo {
constructor(buf) { constructor(bytes) {
this.buf = buf; this.bytes = bytes;
this.bytes = new Uint8Array(buf);
// byte 0 is unknown, always 0? // byte 0 is unknown, always 0?
// Force floor seed can apparently be anything; my best guess, based on the Desert Oasis // Force floor seed can apparently be anything; my best guess, based on the Desert Oasis
@ -398,8 +398,18 @@ const TILE_ENCODING = {
has_next: true, has_next: true,
extra_args: [arg_direction], extra_args: [arg_direction],
}, },
// 0x57: Timid teeth : '#direction', '#next' 0x57: {
// 0x58: Explosion animation (unused in main levels) : '#direction', '#next' 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: { 0x59: {
name: 'hiking_boots', name: 'hiking_boots',
has_next: true, has_next: true,
@ -410,7 +420,11 @@ const TILE_ENCODING = {
0x5b: { 0x5b: {
name: 'no_player1_sign', 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: { 0x5e: {
name: 'button_pink', name: 'button_pink',
modifier: modifier_wire, modifier: modifier_wire,
@ -424,7 +438,11 @@ const TILE_ENCODING = {
0x61: { 0x61: {
name: 'button_orange', name: 'button_orange',
}, },
// 0x62: Lightning bolt : '#next' 0x62: {
name: 'lightning_bolt',
has_next: true,
error: "The lightning bolt is not yet implemented, sorry!",
},
0x63: { 0x63: {
name: 'tank_yellow', name: 'tank_yellow',
has_next: true, has_next: true,
@ -433,8 +451,18 @@ const TILE_ENCODING = {
0x64: { 0x64: {
name: 'button_yellow', name: 'button_yellow',
}, },
// 0x65: Mirror Chip : '#direction', '#next' 0x65: {
// 0x66: Mirror Melinda : '#direction', '#next' 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: { 0x68: {
name: 'bowling_ball', name: 'bowling_ball',
has_next: true, has_next: true,
@ -555,8 +583,14 @@ const TILE_ENCODING = {
name: 'button_black', name: 'button_black',
modifier: modifier_wire, modifier: modifier_wire,
}, },
// 0x88: ON/OFF switch (OFF) : 0x88: {
// 0x89: ON/OFF switch (ON) : 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: { 0x8a: {
name: 'thief_keys', name: 'thief_keys',
}, },
@ -576,9 +610,21 @@ const TILE_ENCODING = {
name: 'xray_eye', name: 'xray_eye',
has_next: true, has_next: true,
}, },
// 0x8f: Thief bribe : '#next' 0x8f: {
// 0x90: Speed boots : '#next' name: 'bribe',
// 0x92: Hook : '#next' 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 = {}; const REVERSE_TILE_ENCODING = {};
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) { for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
@ -619,21 +665,18 @@ function read_n_bytes(view, start, n) {
} }
} }
// Decompress the little ad-hoc compression scheme used for both map data and // Decompress the little ad-hoc compression scheme used for both map data and solution playback
// solution playback function decompress(bytes) {
function decompress(buf) { let decompressed_length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(0, true);
let decompressed_length = new DataView(buf).getUint16(0, true); let outbytes = new Uint8Array(decompressed_length);
let out = new ArrayBuffer(decompressed_length);
let outbytes = new Uint8Array(out);
let bytes = new Uint8Array(buf);
let p = 2; let p = 2;
let q = 0; let q = 0;
while (p < buf.byteLength) { while (p < bytes.length) {
let len = bytes[p]; let len = bytes[p];
p++; p++;
if (len < 0x80) { if (len < 0x80) {
// Data block // 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; p += len;
q += len; q += len;
} }
@ -654,59 +697,85 @@ function decompress(buf) {
} }
if (q !== decompressed_length) if (q !== decompressed_length)
throw new Error(`Expected to decode ${decompressed_length} bytes but got ${q} instead`); throw new Error(`Expected to decode ${decompressed_length} bytes but got ${q} instead`);
return out; return outbytes;
} }
export function parse_level(buf, number = 1) { // Iterates over a C2M file and yields: [section type, uint8 array view of the section]
let level = new util.StoredLevel(number); function* read_c2m_sections(buf) {
let full_view = new DataView(buf); let full_view = new DataView(buf);
let next_section_start = 0; let next_section_start = 0;
let extra_hints = [];
let hint_tiles = [];
while (next_section_start < buf.byteLength) { while (next_section_start < buf.byteLength) {
// Read section header and length // Read section header and length
let section_start = next_section_start; 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); let section_length = full_view.getUint32(section_start + 4, true);
next_section_start = section_start + 8 + section_length; next_section_start = section_start + 8 + section_length;
if (next_section_start > buf.byteLength) 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 ') if (section_type === 'END ')
break; return;
if (section_type === 'CC2M' || section_type === 'LOCK' || section_type === 'VERS' || yield [section_type, new Uint8Array(buf, section_start + 8, section_length)];
section_type === 'TITL' || section_type === 'AUTH' || }
section_type === 'CLUE' || section_type === 'NOTE') }
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) // These are all singular strings (with a terminating NUL, for some reason)
// XXX character encoding?? // 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 // TODO store more of this, at least for idempotence, maybe
if (section_type === 'CC2M') { if (type === 'CC2M') {
// File version, doesn't seem interesting // File version, doesn't seem interesting
} }
else if (section_type === 'LOCK') { else if (type === 'LOCK') {
// Unclear, seems to be a comment about the editor...? // Unclear, seems to be a comment about the editor...?
} }
else if (section_type === 'VERS') { else if (type === 'VERS') {
// Editor version which created this level // Editor version which created this level
} }
else if (section_type === 'TITL') { else if (type === 'TITL') {
// Level title // Level title
level.title = str; level.title = str;
} }
else if (section_type === 'AUTH') { else if (type === 'AUTH') {
// Author's name // Author's name
level.author = str; level.author = str;
} }
else if (section_type === 'CLUE') { else if (type === 'CLUE') {
// Level hint // Level hint
level.hint = str; level.hint = str;
} }
else if (section_type === 'NOTE') { else if (type === 'NOTE') {
// Author's comments... but might also include multiple hints // Author's comments... but might also include multiple hints
// for levels with multiple hint tiles, delineated by [CLUE]. // for levels with multiple hint tiles, delineated by [CLUE].
// For my purposes, extra hints are associated with the // For my purposes, extra hints are associated with the
@ -716,16 +785,15 @@ export function parse_level(buf, number = 1) {
continue; continue;
} }
let section_buf = buf.slice(section_start + 8, next_section_start); let view = new DataView(buf, bytes.byteOffset, bytes.byteLength);
let section_view = new DataView(buf, section_start + 8, section_length);
if (section_type === 'OPTN') { if (type === 'OPTN') {
// Level options, which may be truncated at any point // Level options, which may be truncated at any point
// TODO implement most of these // 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 // 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) { if (viewport === 0) {
level.viewport_size = 10; level.viewport_size = 10;
} }
@ -740,42 +808,40 @@ export function parse_level(buf, number = 1) {
throw new Error(`Unrecognized viewport size option ${viewport}`); throw new Error(`Unrecognized viewport size option ${viewport}`);
} }
if (section_view.byteLength <= 3) if (view.byteLength <= 3)
continue; 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; 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; 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; 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)); //section_start + 6, section_start + 22));
if (section_view.byteLength <= 22) if (view.byteLength <= 22)
continue; 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; 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; continue;
//level.blob_behavior = section_view.getUint8(24, true); //level.blob_behavior = view.getUint8(24, true);
} }
else if (section_type === 'MAP ' || section_type === 'PACK') { else if (type === 'MAP ' || type === 'PACK') {
let data = section_buf; if (type === 'PACK') {
if (section_type === 'PACK') { bytes = decompress(bytes);
data = decompress(data);
} }
let bytes = new Uint8Array(data); let map_view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let map_view = new DataView(data);
let width = bytes[0]; let width = bytes[0];
let height = bytes[1]; let height = bytes[1];
level.size_x = width; level.size_x = width;
@ -788,17 +854,19 @@ export function parse_level(buf, number = 1) {
let tile_byte = bytes[p]; let tile_byte = bytes[p];
p++; p++;
if (tile_byte === undefined) 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]; let spec = TILE_ENCODING[tile_byte];
if (! spec) 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; return spec;
} }
for (n = 0; n < width * height; n++) { for (n = 0; n < width * height; n++) {
let cell = new util.StoredCell; let cell = new format_base.StoredCell;
while (true) { while (true) {
let spec = read_spec(); let spec = read_spec();
@ -818,7 +886,7 @@ export function parse_level(buf, number = 1) {
p += 4; p += 4;
} }
spec = read_spec(); spec = read_spec();
if (! spec.modifier) { if (! spec.modifier && ! (spec.name instanceof Array)) {
console.warn("Got unexpected modifier for tile:", spec.name); console.warn("Got unexpected modifier for tile:", spec.name);
} }
} }
@ -893,27 +961,25 @@ export function parse_level(buf, number = 1) {
level.linear_cells.push(cell); 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 // "Replay", i.e. demo solution
let data = section_buf; if (type === 'PRPL') {
if (section_type === 'PRPL') { bytes = decompress(bytes);
data = decompress(data);
} }
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 // TODO LL custom chunks, should distinguish somehow
else if (section_type === 'LXCM') { else if (type === 'LXCM') {
// Camera regions // Camera regions
if (section_length % 4 !== 0) if (bytes.length % 4 !== 0)
throw new Error(`Expected LXCM chunk to be a multiple of 4 bytes; got ${section_length}`); throw new Error(`Expected LXCM chunk to be a multiple of 4 bytes; got ${bytes.length}`);
let bytes = new Uint8Array(section_buf);
let p = 0; let p = 0;
while (p < section_length) { while (p < bytes.length) {
let x = bytes[p + 0]; let x = bytes[p + 0];
let y = bytes[p + 1]; let y = bytes[p + 1];
let w = bytes[p + 2]; let w = bytes[p + 2];
@ -924,7 +990,7 @@ export function parse_level(buf, number = 1) {
} }
} }
else { 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 // TODO save it, persist when editing level
} }
} }
@ -1171,3 +1237,479 @@ export function synthesize_level(stored_level) {
return c2m.serialize(); 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;
}

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 TILE_TYPES from './tiletypes.js';
import * as util from './util.js';
const TILE_ENCODING = { const TILE_ENCODING = {
0x00: 'floor', 0x00: 'floor',
@ -118,21 +119,71 @@ const TILE_ENCODING = {
0x6e: ['player', 'south'], 0x6e: ['player', 'south'],
0x6f: ['player', 'east'], 0x6f: ['player', 'east'],
}; };
function parse_level(buf, number) { function decode_password(bytes, start, len) {
let level = new util.StoredLevel(number); 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.has_custom_connections = true;
level.use_ccl_compat = true; level.use_ccl_compat = true;
// Map size is always fixed as 32x32 in CC1 // Map size is always fixed as 32x32 in CC1
level.size_x = 32; level.size_x = 32;
level.size_y = 32; level.size_y = 32;
for (let i = 0; i < 1024; i++) { for (let i = 0; i < 1024; i++) {
level.linear_cells.push(new util.StoredCell); level.linear_cells.push(new format_base.StoredCell);
} }
level.use_cc1_boots = true; level.use_cc1_boots = true;
let view = new DataView(buf); let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let bytes = new Uint8Array(buf);
// Header // Header
let level_number = view.getUint16(0, true); let level_number = view.getUint16(0, true);
@ -163,7 +214,7 @@ function parse_level(buf, number) {
// TODO could be more forgiving for goofy levels doing goofy things // TODO could be more forgiving for goofy levels doing goofy things
if (! spec) { if (! spec) {
let [x, y] = level.scalar_to_coords(c); let [x, y] = level.scalar_to_coords(c);
throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y}) in level ${number}`); throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y})`);
} }
let name, direction; let name, direction;
@ -227,11 +278,11 @@ function parse_level(buf, number) {
} }
else if (field_type === 0x03) { else if (field_type === 0x03) {
// Title, including trailing NUL // Title, including trailing NUL
level.title = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1)); level.title = util.string_from_buffer_ascii(bytes, p, field_length - 1);
} }
else if (field_type === 0x04) { else if (field_type === 0x04) {
// Trap linkages (MSCC only, not in Lynx or CC2) // Trap linkages (MSCC only, not in Lynx or CC2)
let field_view = new DataView(buf.slice(p, p + field_length)); let field_view = new DataView(bytes.buffer, bytes.byteOffset + p, field_length);
let q = 0; let q = 0;
while (q < field_length) { while (q < field_length) {
let button_x = field_view.getUint16(q + 0, true); let button_x = field_view.getUint16(q + 0, true);
@ -245,7 +296,7 @@ function parse_level(buf, number) {
} }
else if (field_type === 0x05) { else if (field_type === 0x05) {
// Cloner linkages (MSCC only, not in Lynx or CC2) // Cloner linkages (MSCC only, not in Lynx or CC2)
let field_view = new DataView(buf.slice(p, p + field_length)); let field_view = new DataView(bytes.buffer, bytes.byteOffset + p, field_length);
let q = 0; let q = 0;
while (q < field_length) { while (q < field_length) {
let button_x = field_view.getUint16(q + 0, true); let button_x = field_view.getUint16(q + 0, true);
@ -257,16 +308,12 @@ function parse_level(buf, number) {
} }
} }
else if (field_type === 0x06) { else if (field_type === 0x06) {
// Password, with trailing NUL, and otherwise XORed with 0x99 (?!) // Password, with trailing NUL, and otherwise XORed with 0x99 (???)
let password = []; level.password = decode_password(bytes, p, field_length - 1);
for (let i = 0; i < field_length - 1; i++) {
password.push(view.getUint8(p + i, true) ^ 0x99);
}
level.password = String.fromCharCode.apply(null, password);
} }
else if (field_type === 0x07) { else if (field_type === 0x07) {
// Hint, including trailing NUL, of course // Hint, including trailing NUL, of course
level.hint = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1)); level.hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
} }
else if (field_type === 0x08) { else if (field_type === 0x08) {
// Password, but not encoded // Password, but not encoded
@ -283,7 +330,7 @@ function parse_level(buf, number) {
} }
export function parse_game(buf) { export function parse_game(buf) {
let game = new util.StoredGame; let game = new format_base.StoredGame(null, parse_level);
let full_view = new DataView(buf); let full_view = new DataView(buf);
let magic = full_view.getUint32(0, true); let magic = full_view.getUint32(0, true);
@ -305,11 +352,19 @@ export function parse_game(buf) {
let p = 6; let p = 6;
for (let l = 1; l <= level_count; l++) { for (let l = 1; l <= level_count; l++) {
let length = full_view.getUint16(p, true); let length = full_view.getUint16(p, true);
let level_buf = buf.slice(p + 2, p + 2 + length); let bytes = new Uint8Array(buf, p + 2, length);
p += 2 + length; p += 2 + length;
let level = parse_level(level_buf, l); let meta;
game.levels.push(level); try {
meta = parse_level_metadata(bytes);
}
catch (e) {
meta = {error: e};
}
meta.index = l - 1;
meta.bytes = bytes;
game.level_metadata.push(meta);
} }
return game; return game;

View File

@ -253,22 +253,18 @@ export class Level {
if (tile.type.is_player) { if (tile.type.is_player) {
// TODO handle multiple players, also chip and melinda both // TODO handle multiple players, also chip and melinda both
// TODO complain if no chip // TODO complain if no player
this.player = tile; 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) { if (has_cloner) {
// TODO is there any reason not to add clone templates to the actor // TODO is there any reason not to add clone templates to the actor
// list? // list?
tile.stuck = true; tile.stuck = true;
} }
else { }
this.actors.push(tile); if (! tile.stuck) {
} this.actors.push(tile);
} }
cell._add(tile); cell._add(tile);
@ -391,7 +387,13 @@ export class Level {
// immediately), we still want every time's animation to finish, or it'll look like some // immediately), we still want every time's animation to finish, or it'll look like some
// objects move backwards when the death screen appears! // objects move backwards when the death screen appears!
let cell_steppers = []; 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 // Actors with no cell were destroyed
if (! actor.cell) if (! actor.cell)
continue; continue;
@ -442,8 +444,10 @@ export class Level {
// Second pass: actors decide their upcoming movement simultaneously // Second pass: actors decide their upcoming movement simultaneously
// (we'll do the player's decision in part 2!) // (we'll do the player's decision in part 2!)
for (let actor of this.actors) { for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
if (actor != this.player) if (actor != this.player)
continue;
{ {
this.actor_decision(actor, p1_primary_direction); this.actor_decision(actor, p1_primary_direction);
} }
@ -456,7 +460,8 @@ export class Level {
this.actor_decision(this.player, p1_primary_direction); this.actor_decision(this.player, p1_primary_direction);
// Third pass: everyone actually moves // 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) if (! actor.cell)
continue; continue;

View File

@ -1,5 +1,5 @@
import { DIRECTIONS, TICS_PER_SECOND } from './defs.js'; import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
import * as c2m from './format-c2m.js'; import * as c2g from './format-c2g.js';
import { PrimaryView, DialogOverlay } from './main-base.js'; import { PrimaryView, DialogOverlay } from './main-base.js';
import CanvasRenderer from './renderer-canvas.js'; import CanvasRenderer from './renderer-canvas.js';
import TILE_TYPES from './tiletypes.js'; import TILE_TYPES from './tiletypes.js';
@ -617,7 +617,7 @@ export class Editor extends PrimaryView {
// Toolbar buttons // Toolbar buttons
this.root.querySelector('#editor-share-url').addEventListener('click', ev => { this.root.querySelector('#editor-share-url').addEventListener('click', ev => {
let buf = c2m.synthesize_level(this.stored_level); let buf = c2g.synthesize_level(this.stored_level);
// FIXME Not ideal, but btoa() wants a string rather than any of the myriad binary types // 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(''); let stringy_buf = Array.from(new Uint8Array(buf)).map(n => String.fromCharCode(n)).join('');
// Make URL-safe and strip trailing padding // Make URL-safe and strip trailing padding

View File

@ -1,9 +1,9 @@
// TODO bugs and quirks i'm aware of: // TODO bugs and quirks i'm aware of:
// - steam: if a player character starts on a force floor they won't be able to make any voluntary movements until they are no longer on a force floor // - steam: if a player character starts on a force floor they won't be able to make any voluntary movements until they are no longer on a force floor
import { DIRECTIONS, TICS_PER_SECOND } from './defs.js'; import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
import * as c2m from './format-c2m.js'; import * as c2g from './format-c2g.js';
import * as dat from './format-dat.js'; import * as dat from './format-dat.js';
import * as format_util from './format-util.js'; import * as format_base from './format-base.js';
import { Level } from './game.js'; import { Level } from './game.js';
import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay } from './main-base.js'; import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay } from './main-base.js';
import { Editor } from './main-editor.js'; import { Editor } from './main-editor.js';
@ -11,7 +11,8 @@ import CanvasRenderer from './renderer-canvas.js';
import SOUNDTRACK from './soundtrack.js'; import SOUNDTRACK from './soundtrack.js';
import { Tileset, CC2_TILESET_LAYOUT, LL_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT } from './tileset.js'; import { Tileset, CC2_TILESET_LAYOUT, LL_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT } from './tileset.js';
import TILE_TYPES from './tiletypes.js'; import TILE_TYPES from './tiletypes.js';
import { random_choice, mk, mk_svg, promise_event, fetch } from './util.js'; import { random_choice, mk, mk_svg, promise_event } from './util.js';
import * as util from './util.js';
const PAGE_TITLE = "Lexy's Labyrinth"; const PAGE_TITLE = "Lexy's Labyrinth";
@ -167,7 +168,7 @@ class SFXPlayer {
} }
async init_sound(name, path) { async init_sound(name, path) {
let buf = await fetch(path); let buf = await util.fetch(path);
let audiobuf = await this.ctx.decodeAudioData(buf); let audiobuf = await this.ctx.decodeAudioData(buf);
this.sounds[name] = { this.sounds[name] = {
buf: buf, buf: buf,
@ -1312,9 +1313,15 @@ class Splash extends PrimaryView {
let score; let score;
let packinfo = conductor.stash.packs[packdef.ident]; let packinfo = conductor.stash.packs[packdef.ident];
if (packinfo && packinfo.total_score !== undefined) { if (packinfo && packinfo.total_score !== undefined) {
// TODO tack on a star if the game is "beaten"? what's that mean? every level if (packinfo.total_score === null) {
// beaten i guess? // Whoops, some NaNs got in here :(
score = packinfo.total_score.toLocaleString(); score = "computing...";
}
else {
// TODO tack on a star if the game is "beaten"? what's that mean? every level
// beaten i guess?
score = packinfo.total_score.toLocaleString();
}
} }
else { else {
score = "unplayed"; score = "unplayed";
@ -1326,87 +1333,109 @@ class Splash extends PrimaryView {
mk('span.-score', score), mk('span.-score', score),
); );
button.addEventListener('click', ev => { button.addEventListener('click', ev => {
this.fetch_pack(packdef.path, packdef.title); this.conductor.fetch_pack(packdef.path, packdef.title);
}); });
pack_list.append(button); pack_list.append(button);
} }
// Bind to file upload control // File loading: allow providing either a single file, multiple files, OR an entire
let upload_el = this.root.querySelector('#splash-upload'); // directory (via the hokey WebKit Entry interface)
// Clear it out in case of refresh let upload_file_el = this.root.querySelector('#splash-upload-file');
upload_el.value = ''; let upload_dir_el = this.root.querySelector('#splash-upload-dir');
this.root.querySelector('#splash-upload-button').addEventListener('click', ev => { // Clear out the file controls in case of refresh
upload_el.click(); upload_file_el.value = '';
upload_dir_el.value = '';
this.root.querySelector('#splash-upload-file-button').addEventListener('click', ev => {
upload_file_el.click();
}); });
upload_el.addEventListener('change', async ev => { this.root.querySelector('#splash-upload-dir-button').addEventListener('click', ev => {
upload_dir_el.click();
});
upload_file_el.addEventListener('change', async ev => {
if (upload_file_el.files.length === 0)
return;
// TODO throw up a 'loading' overlay
// FIXME handle multiple files! but if there's only one, explicitly load /that/ one
let file = ev.target.files[0]; let file = ev.target.files[0];
let buf = await file.arrayBuffer(); let buf = await file.arrayBuffer();
this.load_file(buf, this.extract_identifier_from_path(file.name)); await this.conductor.parse_and_load_game(buf, new util.FileFileSource(ev.target.files), file.name);
// TODO get title out of C2G when it's supported });
this.conductor.level_pack_name_el.textContent = file.name; upload_dir_el.addEventListener('change', async ev => {
// TODO throw up a 'loading' overlay
// The directory selector populates 'files' with every single file, recursively, which
// is kind of wild but also /much/ easier to deal with
let files = upload_dir_el.files;
if (files.length > 4096)
throw new util.LLError("Got way too many files; did you upload the right directory?");
await this.search_multi_source(new util.FileFileSource(files));
});
// Allow loading a local directory onto us, via the WebKit
// file entry interface
// TODO? this always takes a moment to register, not sure why...
// FIXME as written this won't correctly handle CCLs
util.handle_drop(this.root, {
require_file: true,
dropzone_class: '--drag-hover',
on_drop: async ev => {
// TODO for now, always use the entry interface, but if these are all files then
// they can just be loaded normally
let entries = [];
for (let item of ev.dataTransfer.items) {
entries.push(item.webkitGetAsEntry());
}
await this.search_multi_source(new util.EntryFileSource(entries));
},
}); });
// Bind to "create level" button // Bind to "create level" button
this.root.querySelector('#splash-create-level').addEventListener('click', ev => { this.root.querySelector('#splash-create-level').addEventListener('click', ev => {
let stored_level = new format_util.StoredLevel; let stored_level = new format_base.StoredLevel(1);
stored_level.size_x = 32; stored_level.size_x = 32;
stored_level.size_y = 32; stored_level.size_y = 32;
for (let i = 0; i < 1024; i++) { for (let i = 0; i < 1024; i++) {
let cell = new format_util.StoredCell; let cell = new format_base.StoredCell;
cell.push({type: TILE_TYPES['floor']}); cell.push({type: TILE_TYPES['floor']});
stored_level.linear_cells.push(cell); stored_level.linear_cells.push(cell);
} }
stored_level.linear_cells[0].push({type: TILE_TYPES['player']}); stored_level.linear_cells[0].push({type: TILE_TYPES['player']});
// FIXME definitely gonna need a name here chief // FIXME definitely gonna need a name here chief
let stored_game = new format_util.StoredGame(null); let stored_game = new format_base.StoredGame(null);
stored_game.levels.push(stored_level); stored_game.level_metadata.push({
stored_level: stored_level,
});
this.conductor.load_game(stored_game); this.conductor.load_game(stored_game);
this.conductor.switch_to_editor(); this.conductor.switch_to_editor();
}); });
} }
extract_identifier_from_path(path) { // Look for something we can load, and load it
let ident = path.match(/^(?:.*\/)?[.]*([^.]+)(?:[.]|$)/)[1]; async search_multi_source(source) {
if (ident) { // TODO not entiiirely kosher, but not sure if we should have an api for this or what
return ident.toLowerCase(); if (source._loaded_promise) {
await source._loaded_promise;
} }
else {
return null;
}
}
// TODO wait why aren't these just on conductor let paths = Object.keys(source.files);
async fetch_pack(path, title) { // TODO should handle having multiple candidates, but this is good enough for now
// TODO indicate we're downloading something paths.sort((a, b) => a.length - b.length);
// TODO handle errors for (let path of paths) {
// TODO cancel a download if we start another one? let m = path.match(/[.]([^./]+)$/);
let buf = await fetch(path); if (! m)
this.load_file(buf, this.extract_identifier_from_path(path)); continue;
// TODO get title out of C2G when it's supported
this.conductor.level_pack_name_el.textContent = title || path;
}
load_file(buf, identifier = null) { let ext = m[1];
// TODO also support tile world's DAC when reading from local?? // TODO this can't load an individual c2m, hmmm
// TODO ah, there's more metadata in CCX, crapola if (ext === 'c2g' || ext === 'dat' || ext === 'ccl') {
let magic = String.fromCharCode.apply(null, new Uint8Array(buf.slice(0, 4))); let buf = await source.get(path);
let stored_game; await this.conductor.parse_and_load_game(buf, source, path);
if (magic === 'CC2M' || magic === 'CCS ') { break;
stored_game = new format_util.StoredGame; }
stored_game.levels.push(c2m.parse_level(buf));
// Don't make a savefile for individual levels
identifier = null;
} }
else if (magic === '\xac\xaa\x02\x00' || magic == '\xac\xaa\x02\x01') { // TODO else...? complain we couldn't find anything? list what we did find?? idk
stored_game = dat.parse_game(buf);
}
else {
throw new Error("Unrecognized file format");
}
this.conductor.load_game(stored_game, identifier);
this.conductor.switch_to_player();
} }
} }
@ -1414,6 +1443,30 @@ class Splash extends PrimaryView {
// ------------------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------------------
// Central controller, thingy // Central controller, thingy
// Report an error when a level fails to load
class LevelErrorOverlay extends DialogOverlay {
constructor(conductor, error) {
super(conductor);
this.set_title("bummer");
this.main.append(
mk('p', "Whoopsadoodle! I seem to be having some trouble loading this level. I got this error, which may or may not be useful:"),
mk('pre.error', error.toString()),
mk('p',
"It's probably entirely my fault, and I'm very sorry. ",
"Unless you're doing something weird and it's actually your fault, I guess. ",
"This is just a prerecorded message, so it's hard for me to tell! ",
"But if it's my fault and you're feeling up to it, you can let me know by ",
mk('a', {href: 'https://github.com/eevee/lexys-labyrinth/issues'}, "filing an issue on GitHub"),
" or finding me on Discord or Twitter or whatever.",
),
mk('p', "In the more immediate future, you can see if any other levels work by jumping around manually with the 'level select' button. Unless this was the first level of a set, in which case you're completely out of luck."),
);
this.add_button("welp, you get what you pay for", ev => {
this.close();
});
}
}
// About dialog // About dialog
const ABOUT_HTML = ` const ABOUT_HTML = `
<p>Welcome to Lexy's Labyrinth, an exciting old-school tile-based puzzle adventure that is compatible with — but legally distinct from! — <a href="https://store.steampowered.com/app/346850/Chips_Challenge_1/">Chip's Challenge</a> and its long-awaited sequel <a href="https://store.steampowered.com/app/348300/Chips_Challenge_2/">Chip's Challenge 2</a>.</p> <p>Welcome to Lexy's Labyrinth, an exciting old-school tile-based puzzle adventure that is compatible with — but legally distinct from! — <a href="https://store.steampowered.com/app/346850/Chips_Challenge_1/">Chip's Challenge</a> and its long-awaited sequel <a href="https://store.steampowered.com/app/348300/Chips_Challenge_2/">Chip's Challenge 2</a>.</p>
@ -1613,7 +1666,7 @@ class LevelBrowserOverlay extends DialogOverlay {
this.main.append(table); this.main.append(table);
let savefile = conductor.current_pack_savefile; let savefile = conductor.current_pack_savefile;
// TODO if i stop eagerloading everything in a .DAT then this will not make sense any more // TODO if i stop eagerloading everything in a .DAT then this will not make sense any more
for (let [i, stored_level] of conductor.stored_game.levels.entries()) { for (let [i, meta] of conductor.stored_game.level_metadata.entries()) {
let scorecard = savefile.scorecards[i]; let scorecard = savefile.scorecards[i];
let score = "—", time = "—", abstime = "—"; let score = "—", time = "—", abstime = "—";
if (scorecard) { if (scorecard) {
@ -1637,10 +1690,18 @@ class LevelBrowserOverlay extends DialogOverlay {
abstime = `${absmin}:${abssec < 10 ? '0' : ''}${abssec.toFixed(2)}`; abstime = `${absmin}:${abssec < 10 ? '0' : ''}${abssec.toFixed(2)}`;
} }
tbody.append(mk(i >= savefile.highest_level ? 'tr.--unvisited' : 'tr', let title = meta.title;
if (meta.error) {
title = '[failed to load]';
}
else if (! title) {
title = '(untitled)';
}
let tr = mk('tr',
{'data-index': i}, {'data-index': i},
mk('td.-number', i + 1), mk('td.-number', meta.number),
mk('td.-title', stored_level.title), mk('td.-title', title),
mk('td.-time', time), mk('td.-time', time),
mk('td.-time', abstime), mk('td.-time', abstime),
mk('td.-score', score), mk('td.-score', score),
@ -1649,7 +1710,17 @@ class LevelBrowserOverlay extends DialogOverlay {
// your wallclock time also? // your wallclock time also?
// TODO other stats?? num chips, time limit? don't know that without loading all // TODO other stats?? num chips, time limit? don't know that without loading all
// the levels upfront though, which i currently do but want to stop doing // the levels upfront though, which i currently do but want to stop doing
)); );
// TODO sigh, does not actually indicate visited in C2G world
if (i >= savefile.highest_level) {
tr.classList.add('--unvisited');
}
if (meta.error) {
tr.classList.add('--error');
}
tbody.append(tr);
} }
tbody.addEventListener('click', ev => { tbody.addEventListener('click', ev => {
@ -1658,8 +1729,9 @@ class LevelBrowserOverlay extends DialogOverlay {
return; return;
let index = parseInt(tr.getAttribute('data-index'), 10); let index = parseInt(tr.getAttribute('data-index'), 10);
this.conductor.change_level(index); if (this.conductor.change_level(index)) {
this.close(); this.close();
}
}); });
this.add_button("nevermind", ev => { this.add_button("nevermind", ev => {
@ -1718,7 +1790,7 @@ class Conductor {
}); });
this.nav_next_button.addEventListener('click', ev => { this.nav_next_button.addEventListener('click', ev => {
// TODO confirm // TODO confirm
if (this.stored_game && this.level_index < this.stored_game.levels.length - 1) { if (this.stored_game && this.level_index < this.stored_game.level_metadata.length - 1) {
this.change_level(this.level_index + 1); this.change_level(this.level_index + 1);
} }
ev.target.blur(); ev.target.blur();
@ -1786,6 +1858,13 @@ class Conductor {
if (identifier !== null) { if (identifier !== null) {
// TODO again, enforce something about the shape here // TODO again, enforce something about the shape here
this.current_pack_savefile = JSON.parse(window.localStorage.getItem(STORAGE_PACK_PREFIX + identifier)); this.current_pack_savefile = JSON.parse(window.localStorage.getItem(STORAGE_PACK_PREFIX + identifier));
if (this.current_pack_savefile.total_score === null) {
// Fix some NaNs that slipped in
this.current_pack_savefile.total_score = this.current_pack_savefile.scorecards
.map(scorecard => scorecard ? scorecard.score : 0)
.reduce((a, b) => a + b, 0);
this.save_savefile();
}
} }
if (! this.current_pack_savefile) { if (! this.current_pack_savefile) {
this.current_pack_savefile = { this.current_pack_savefile = {
@ -1800,12 +1879,20 @@ class Conductor {
this.player.load_game(stored_game); this.player.load_game(stored_game);
this.editor.load_game(stored_game); this.editor.load_game(stored_game);
this.change_level(0); return this.change_level(0);
} }
change_level(level_index) { change_level(level_index) {
// FIXME handle errors here
try {
this.stored_level = this.stored_game.load_level(level_index);
}
catch (e) {
new LevelErrorOverlay(this, e).open();
return false;
}
this.level_index = level_index; this.level_index = level_index;
this.stored_level = this.stored_game.levels[level_index];
// FIXME do better // FIXME do better
this.level_name_el.textContent = `Level ${level_index + 1}${this.stored_level.title}`; this.level_name_el.textContent = `Level ${level_index + 1}${this.stored_level.title}`;
@ -1815,12 +1902,13 @@ class Conductor {
this.player.load_level(this.stored_level); this.player.load_level(this.stored_level);
this.editor.load_level(this.stored_level); this.editor.load_level(this.stored_level);
return true;
} }
update_nav_buttons() { update_nav_buttons() {
this.nav_choose_level_button.disabled = !this.stored_game; this.nav_choose_level_button.disabled = !this.stored_game;
this.nav_prev_button.disabled = !this.stored_game || this.level_index <= 0; this.nav_prev_button.disabled = !this.stored_game || this.level_index <= 0;
this.nav_next_button.disabled = !this.stored_game || this.level_index >= this.stored_game.levels.length; this.nav_next_button.disabled = !this.stored_game || this.level_index >= this.stored_game.level_metadata.length;
} }
save_stash() { save_stash() {
@ -1845,6 +1933,68 @@ class Conductor {
this.save_stash(); this.save_stash();
} }
} }
// ------------------------------------------------------------------------------------------------
// File loading
extract_identifier_from_path(path) {
let ident = path.match(/^(?:.*\/)?[.]*([^.]+)(?:[.]|$)/)[1];
if (ident) {
return ident.toLowerCase();
}
else {
return null;
}
}
async fetch_pack(path, title) {
// TODO indicate we're downloading something
// TODO handle errors
// TODO cancel a download if we start another one?
let buf = await util.fetch(path);
await this.parse_and_load_game(buf, new util.HTTPFileSource(new URL(location)), path);
}
async parse_and_load_game(buf, source, path, identifier, title) {
if (identifier === undefined) {
identifier = this.extract_identifier_from_path(path);
}
// TODO get title out of C2G when it's supported
this.level_pack_name_el.textContent = title ?? identifier ?? '(untitled)';
// TODO also support tile world's DAC when reading from local??
// TODO ah, there's more metadata in CCX, crapola
let magic = String.fromCharCode.apply(null, new Uint8Array(buf.slice(0, 4)));
let stored_game;
if (magic === 'CC2M' || magic === 'CCS ') {
// This is an individual level, so concoct a fake game for it, and don't save anything
stored_game = c2g.wrap_individual_level(buf);
identifier = null;
}
else if (magic === '\xac\xaa\x02\x00' || magic == '\xac\xaa\x02\x01') {
stored_game = dat.parse_game(buf);
}
else if (magic.toLowerCase() === 'game') {
// TODO this isn't really a magic number and isn't required to be first, so, maybe
// this one should just go by filename
console.log(path);
let dir;
if (! path.match(/[/]/)) {
dir = '';
}
else {
dir = path.replace(/[/][^/]+$/, '');
}
stored_game = await c2g.parse_game(buf, source, dir);
}
else {
throw new Error("Unrecognized file format");
}
if (this.load_game(stored_game, identifier)) {
this.switch_to_player();
}
}
} }
@ -1891,14 +2041,14 @@ async function main() {
let path = query.get('setpath'); let path = query.get('setpath');
let b64level = query.get('level'); let b64level = query.get('level');
if (path && path.match(/^levels[/]/)) { if (path && path.match(/^levels[/]/)) {
conductor.splash.fetch_pack(path); conductor.fetch_pack(path);
} }
else if (b64level) { else if (b64level) {
// TODO all the more important to show errors!! // TODO all the more important to show errors!!
// FIXME Not ideal, but atob() returns a string rather than any of the myriad binary types // FIXME Not ideal, but atob() returns a string rather than any of the myriad binary types
let stringy_buf = atob(b64level.replace(/-/g, '+').replace(/_/g, '/')); let stringy_buf = atob(b64level.replace(/-/g, '+').replace(/_/g, '/'));
let buf = Uint8Array.from(stringy_buf, c => c.charCodeAt(0)).buffer; let buf = Uint8Array.from(stringy_buf, c => c.charCodeAt(0)).buffer;
conductor.splash.load_file(buf); await conductor.parse_and_load_game(buf, null, 'shared.c2m', null, "Shared level");
} }
} }

View File

@ -882,7 +882,8 @@ const TILE_TYPES = {
level.sfx.play_once('button-press', me.cell); level.sfx.play_once('button-press', me.cell);
// Move all yellow tanks one tile in the direction of the pressing actor // Move all yellow tanks one tile in the direction of the pressing actor
for (let actor of level.actors) { for (let i = level.actors.length - 1; i >= 0; i--) {
let actor = level.actors[i];
// TODO generify somehow?? // TODO generify somehow??
if (actor.type.name === 'tank_yellow') { if (actor.type.name === 'tank_yellow') {
level.attempt_step(actor, other.direction); level.attempt_step(actor, other.direction);

View File

@ -1,7 +1,13 @@
// Base class for custom errors
export class LLError extends Error {}
// Random choice
export function random_choice(list) { export function random_choice(list) {
return list[Math.floor(Math.random() * list.length)]; return list[Math.floor(Math.random() * list.length)];
} }
// DOM stuff
function _mk(el, children) { function _mk(el, children) {
if (children.length > 0) { if (children.length > 0) {
if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") { if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") {
@ -29,6 +35,70 @@ export function mk_svg(tag_selector, ...children) {
return _mk(el, 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) { export function promise_event(element, success_event, failure_event) {
let resolve, reject; let resolve, reject;
let promise = new Promise((res, rej) => { let promise = new Promise((res, rej) => {
@ -36,21 +106,21 @@ export function promise_event(element, success_event, failure_event) {
reject = rej; reject = rej;
}); });
let success_handler = e => { let success_handler = ev => {
element.removeEventListener(success_event, success_handler); element.removeEventListener(success_event, success_handler);
if (failure_event) { if (failure_event) {
element.removeEventListener(failure_event, failure_handler); element.removeEventListener(failure_event, failure_handler);
} }
resolve(e); resolve(ev);
}; };
let failure_handler = e => { let failure_handler = ev => {
element.removeEventListener(success_event, success_handler); element.removeEventListener(success_event, success_handler);
if (failure_event) { if (failure_event) {
element.removeEventListener(failure_event, failure_handler); element.removeEventListener(failure_event, failure_handler);
} }
reject(e); reject(ev);
}; };
element.addEventListener(success_event, success_handler); element.addEventListener(success_event, success_handler);
@ -61,6 +131,7 @@ export function promise_event(element, success_event, failure_event) {
return promise; return promise;
} }
export async function fetch(url) { export async function fetch(url) {
let xhr = new XMLHttpRequest; let xhr = new XMLHttpRequest;
let promise = promise_event(xhr, 'load', 'error'); let promise = promise_event(xhr, 'load', 'error');
@ -68,9 +139,19 @@ export async function fetch(url) {
xhr.responseType = 'arraybuffer'; xhr.responseType = 'arraybuffer';
xhr.send(); xhr.send();
await promise; await promise;
if (xhr.status !== 200)
throw new Error(`Failed to load ${url} -- ${xhr.status} ${xhr.statusText}`);
return xhr.response; return xhr.response;
} }
export function string_from_buffer_ascii(buf, start = 0, len) {
if (ArrayBuffer.isView(buf)) {
start += buf.byteOffset;
buf = buf.buffer;
}
return String.fromCharCode.apply(null, new Uint8Array(buf, start, len));
}
// Cast a line through a grid and yield every cell it touches // Cast a line through a grid and yield every cell it touches
export function* walk_grid(x0, y0, x1, y1) { export function* walk_grid(x0, y0, x1, y1) {
// TODO if the ray starts outside the grid (extremely unlikely), we should // 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();
}
}

View File

@ -82,6 +82,12 @@ p:first-child {
p:last-child { p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
pre {
white-space: pre-wrap;
}
code {
color: #c0c0e0;
}
a { a {
color: #c0c0c0; color: #c0c0c0;
@ -154,6 +160,11 @@ a:active {
overflow: auto; overflow: auto;
padding: 1em; padding: 1em;
} }
.dialog pre.error {
color: #400000;
background: #f0d0d0;
padding: 0.5em 1em;
}
/* Individual overlays */ /* Individual overlays */
table.level-browser { table.level-browser {
@ -188,6 +199,10 @@ table.level-browser tr.--unvisited {
color: #606060; color: #606060;
font-style: italic; font-style: italic;
} }
table.level-browser tr.--error {
color: #600000;
font-style: italic;
}
table.level-browser tbody tr { table.level-browser tbody tr {
cursor: pointer; cursor: pointer;
} }
@ -350,10 +365,31 @@ body[data-mode=player] #editor-play {
; ;
gap: 1em; gap: 1em;
position: relative;
padding: 1em 10%; padding: 1em 10%;
margin: auto; margin: auto;
overflow: 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 { #splash > header {
grid-area: header; grid-area: header;
@ -387,7 +423,8 @@ body[data-mode=player] #editor-play {
#splash > #splash-upload-levels { #splash > #splash-upload-levels {
grid-area: upload; grid-area: upload;
} }
#splash-upload { #splash-upload-file,
#splash-upload-dir {
/* Hide the file upload control, which is ugly */ /* Hide the file upload control, which is ugly */
display: none; display: none;
} }