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>
|
</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">
|
||||||
|
|||||||
@ -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,32 @@ 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;
|
||||||
|
|
||||||
|
// 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 { 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 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
226
js/main.js
226
js/main.js
@ -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_util 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,
|
||||||
@ -1253,24 +1254,60 @@ 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
|
||||||
@ -1287,53 +1324,38 @@ class Splash extends PrimaryView {
|
|||||||
|
|
||||||
// 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_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.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??
|
if (ext === 'c2g' || ext === 'dat' || ext === 'ccl') {
|
||||||
// TODO ah, there's more metadata in CCX, crapola
|
let buf = await source.get(path);
|
||||||
let magic = String.fromCharCode.apply(null, new Uint8Array(buf.slice(0, 4)));
|
await this.conductor.parse_and_load_game(buf, source, path);
|
||||||
let stored_game;
|
break;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1540,7 +1562,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) {
|
||||||
@ -1564,10 +1586,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),
|
||||||
@ -1576,7 +1606,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 => {
|
||||||
@ -1645,7 +1685,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();
|
||||||
@ -1732,7 +1772,8 @@ class Conductor {
|
|||||||
|
|
||||||
change_level(level_index) {
|
change_level(level_index) {
|
||||||
this.level_index = 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
|
// 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}`;
|
||||||
@ -1747,7 +1788,7 @@ class Conductor {
|
|||||||
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() {
|
||||||
@ -1772,6 +1813,67 @@ 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");
|
||||||
|
}
|
||||||
|
this.load_game(stored_game, identifier);
|
||||||
|
this.switch_to_player();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1818,14 +1920,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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) {
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
31
style.css
31
style.css
@ -82,6 +82,9 @@ p:first-child {
|
|||||||
p:last-child {
|
p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
code {
|
||||||
|
color: #c0c0e0;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #c0c0c0;
|
color: #c0c0c0;
|
||||||
@ -188,6 +191,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 +357,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 +415,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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user