Add support for drag/drop, dir upload, C2G, AND lazy level loading!
This commit is contained in:
parent
36b9f2efd7
commit
edbe32c148
20
index.html
20
index.html
@ -40,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>
|
||||
@ -58,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">
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
export function string_from_buffer_ascii(buf) {
|
||||
return String.fromCharCode.apply(null, new Uint8Array(buf));
|
||||
}
|
||||
import * as util from './util.js';
|
||||
|
||||
export class StoredCell extends Array {
|
||||
}
|
||||
@ -47,8 +45,32 @@ export class StoredLevel {
|
||||
}
|
||||
|
||||
export class StoredGame {
|
||||
constructor(identifier) {
|
||||
constructor(identifier, level_loader) {
|
||||
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;
|
||||
|
||||
// The editor stores inflated levels at times, so respect that
|
||||
if (meta.stored_level)
|
||||
return meta.stored_level;
|
||||
|
||||
// Otherwise, attempt to load the level
|
||||
return this._level_loader(meta.bytes);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -398,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,
|
||||
@ -410,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,
|
||||
@ -424,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,
|
||||
@ -433,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,
|
||||
@ -555,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',
|
||||
},
|
||||
@ -576,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)) {
|
||||
@ -619,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;
|
||||
}
|
||||
@ -654,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
|
||||
@ -716,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;
|
||||
}
|
||||
@ -740,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;
|
||||
@ -788,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 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();
|
||||
|
||||
@ -818,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);
|
||||
}
|
||||
}
|
||||
@ -893,27 +961,25 @@ 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 (section_type === 'LXCM') {
|
||||
else if (type === 'LXCM') {
|
||||
// Camera regions
|
||||
if (section_length % 4 !== 0)
|
||||
throw new Error(`Expected LXCM chunk to be a multiple of 4 bytes; got ${section_length}`);
|
||||
if (bytes.length % 4 !== 0)
|
||||
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;
|
||||
while (p < section_length) {
|
||||
while (p < bytes.length) {
|
||||
let x = bytes[p + 0];
|
||||
let y = bytes[p + 1];
|
||||
let w = bytes[p + 2];
|
||||
@ -924,7 +990,7 @@ export function parse_level(buf, number = 1) {
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -1171,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;
|
||||
}
|
||||
@ -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,20 +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);
|
||||
@ -163,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;
|
||||
@ -227,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);
|
||||
@ -245,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);
|
||||
@ -257,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
|
||||
@ -283,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);
|
||||
@ -305,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;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 CanvasRenderer from './renderer-canvas.js';
|
||||
import TILE_TYPES from './tiletypes.js';
|
||||
@ -617,7 +617,7 @@ export class Editor extends PrimaryView {
|
||||
|
||||
// Toolbar buttons
|
||||
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
|
||||
let stringy_buf = Array.from(new Uint8Array(buf)).map(n => String.fromCharCode(n)).join('');
|
||||
// Make URL-safe and strip trailing padding
|
||||
|
||||
224
js/main.js
224
js/main.js
@ -1,9 +1,9 @@
|
||||
// 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
|
||||
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 format_util from './format-util.js';
|
||||
import * as format_util from './format-base.js';
|
||||
import { Level } from './game.js';
|
||||
import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay } from './main-base.js';
|
||||
import { Editor } from './main-editor.js';
|
||||
@ -11,7 +11,8 @@ import CanvasRenderer from './renderer-canvas.js';
|
||||
import SOUNDTRACK from './soundtrack.js';
|
||||
import { Tileset, CC2_TILESET_LAYOUT, LL_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT } from './tileset.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";
|
||||
|
||||
@ -167,7 +168,7 @@ class SFXPlayer {
|
||||
}
|
||||
|
||||
async init_sound(name, path) {
|
||||
let buf = await fetch(path);
|
||||
let buf = await util.fetch(path);
|
||||
let audiobuf = await this.ctx.decodeAudioData(buf);
|
||||
this.sounds[name] = {
|
||||
buf: buf,
|
||||
@ -1253,24 +1254,60 @@ class Splash extends PrimaryView {
|
||||
mk('span.-score', score),
|
||||
);
|
||||
button.addEventListener('click', ev => {
|
||||
this.fetch_pack(packdef.path, packdef.title);
|
||||
this.conductor.fetch_pack(packdef.path, packdef.title);
|
||||
});
|
||||
pack_list.append(button);
|
||||
}
|
||||
|
||||
// Bind to file upload control
|
||||
let upload_el = this.root.querySelector('#splash-upload');
|
||||
// Clear it out in case of refresh
|
||||
upload_el.value = '';
|
||||
this.root.querySelector('#splash-upload-button').addEventListener('click', ev => {
|
||||
upload_el.click();
|
||||
// File loading: allow providing either a single file, multiple files, OR an entire
|
||||
// directory (via the hokey WebKit Entry interface)
|
||||
let upload_file_el = this.root.querySelector('#splash-upload-file');
|
||||
let upload_dir_el = this.root.querySelector('#splash-upload-dir');
|
||||
// Clear out the file controls in case of refresh
|
||||
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 buf = await file.arrayBuffer();
|
||||
this.load_file(buf, this.extract_identifier_from_path(file.name));
|
||||
// TODO get title out of C2G when it's supported
|
||||
this.conductor.level_pack_name_el.textContent = file.name;
|
||||
await this.conductor.parse_and_load_game(buf, new util.FileFileSource(ev.target.files), 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
|
||||
@ -1287,53 +1324,38 @@ class Splash extends PrimaryView {
|
||||
|
||||
// FIXME definitely gonna need a name here chief
|
||||
let stored_game = new format_util.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.switch_to_editor();
|
||||
});
|
||||
}
|
||||
|
||||
extract_identifier_from_path(path) {
|
||||
let ident = path.match(/^(?:.*\/)?[.]*([^.]+)(?:[.]|$)/)[1];
|
||||
if (ident) {
|
||||
return ident.toLowerCase();
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
// Look for something we can load, and load it
|
||||
async search_multi_source(source) {
|
||||
// TODO not entiiirely kosher, but not sure if we should have an api for this or what
|
||||
if (source._loaded_promise) {
|
||||
await source._loaded_promise;
|
||||
}
|
||||
|
||||
// TODO wait why aren't these just on conductor
|
||||
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 fetch(path);
|
||||
this.load_file(buf, this.extract_identifier_from_path(path));
|
||||
// TODO get title out of C2G when it's supported
|
||||
this.conductor.level_pack_name_el.textContent = title || path;
|
||||
}
|
||||
let paths = Object.keys(source.files);
|
||||
// TODO should handle having multiple candidates, but this is good enough for now
|
||||
paths.sort((a, b) => a.length - b.length);
|
||||
for (let path of paths) {
|
||||
let m = path.match(/[.]([^./]+)$/);
|
||||
if (! m)
|
||||
continue;
|
||||
|
||||
load_file(buf, identifier = null) {
|
||||
// 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 ') {
|
||||
stored_game = new format_util.StoredGame;
|
||||
stored_game.levels.push(c2m.parse_level(buf));
|
||||
// Don't make a savefile for individual levels
|
||||
identifier = null;
|
||||
let ext = m[1];
|
||||
if (ext === 'c2g' || ext === 'dat' || ext === 'ccl') {
|
||||
let buf = await source.get(path);
|
||||
await this.conductor.parse_and_load_game(buf, source, path);
|
||||
break;
|
||||
}
|
||||
else if (magic === '\xac\xaa\x02\x00' || magic == '\xac\xaa\x02\x01') {
|
||||
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();
|
||||
// TODO else...? complain we couldn't find anything? list what we did find?? idk
|
||||
}
|
||||
}
|
||||
|
||||
@ -1540,7 +1562,7 @@ class LevelBrowserOverlay extends DialogOverlay {
|
||||
this.main.append(table);
|
||||
let savefile = conductor.current_pack_savefile;
|
||||
// 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 score = "—", time = "—", abstime = "—";
|
||||
if (scorecard) {
|
||||
@ -1564,10 +1586,18 @@ class LevelBrowserOverlay extends DialogOverlay {
|
||||
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},
|
||||
mk('td.-number', i + 1),
|
||||
mk('td.-title', stored_level.title),
|
||||
mk('td.-number', meta.number),
|
||||
mk('td.-title', title),
|
||||
mk('td.-time', time),
|
||||
mk('td.-time', abstime),
|
||||
mk('td.-score', score),
|
||||
@ -1576,7 +1606,17 @@ class LevelBrowserOverlay extends DialogOverlay {
|
||||
// your wallclock time also?
|
||||
// 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
|
||||
));
|
||||
);
|
||||
|
||||
// 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 => {
|
||||
@ -1645,7 +1685,7 @@ class Conductor {
|
||||
});
|
||||
this.nav_next_button.addEventListener('click', ev => {
|
||||
// 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);
|
||||
}
|
||||
ev.target.blur();
|
||||
@ -1732,7 +1772,8 @@ class Conductor {
|
||||
|
||||
change_level(level_index) {
|
||||
this.level_index = level_index;
|
||||
this.stored_level = this.stored_game.levels[level_index];
|
||||
// FIXME handle errors here
|
||||
this.stored_level = this.stored_game.load_level(level_index);
|
||||
|
||||
// FIXME do better
|
||||
this.level_name_el.textContent = `Level ${level_index + 1} — ${this.stored_level.title}`;
|
||||
@ -1747,7 +1788,7 @@ class Conductor {
|
||||
update_nav_buttons() {
|
||||
this.nav_choose_level_button.disabled = !this.stored_game;
|
||||
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() {
|
||||
@ -1772,6 +1813,67 @@ class Conductor {
|
||||
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");
|
||||
}
|
||||
this.load_game(stored_game, identifier);
|
||||
this.switch_to_player();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1818,14 +1920,14 @@ async function main() {
|
||||
let path = query.get('setpath');
|
||||
let b64level = query.get('level');
|
||||
if (path && path.match(/^levels[/]/)) {
|
||||
conductor.splash.fetch_pack(path);
|
||||
conductor.fetch_pack(path);
|
||||
}
|
||||
else if (b64level) {
|
||||
// TODO all the more important to show errors!!
|
||||
// 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 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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
187
js/util.js
187
js/util.js
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
31
style.css
31
style.css
@ -82,6 +82,9 @@ p:first-child {
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
code {
|
||||
color: #c0c0e0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #c0c0c0;
|
||||
@ -188,6 +191,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;
|
||||
}
|
||||
@ -350,10 +357,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;
|
||||
@ -387,7 +415,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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user