Add support for drag/drop, dir upload, C2G, AND lazy level loading!

This commit is contained in:
Eevee (Evelyn Woods) 2020-10-21 20:47:07 -06:00
parent 36b9f2efd7
commit edbe32c148
8 changed files with 1121 additions and 192 deletions

View File

@ -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">

View File

@ -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);
}
}

View File

@ -1,6 +1,7 @@
import { DIRECTIONS } from './defs.js';
import * as util from './format-util.js';
import * as format_base from './format-base.js';
import TILE_TYPES from './tiletypes.js';
import * as util from './util.js';
const CC2_DEMO_INPUT_MASK = {
drop: 0x01,
@ -13,9 +14,8 @@ const CC2_DEMO_INPUT_MASK = {
};
class CC2Demo {
constructor(buf) {
this.buf = buf;
this.bytes = new Uint8Array(buf);
constructor(bytes) {
this.bytes = bytes;
// byte 0 is unknown, always 0?
// Force floor seed can apparently be anything; my best guess, based on the Desert Oasis
@ -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;
}

View File

@ -1,5 +1,6 @@
import * as util from './format-util.js';
import * as format_base from './format-base.js';
import TILE_TYPES from './tiletypes.js';
import * as util from './util.js';
const TILE_ENCODING = {
0x00: 'floor',
@ -119,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;

View File

@ -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

View File

@ -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");
}
}

View File

@ -1,7 +1,13 @@
// Base class for custom errors
export class LLError extends Error {}
// Random choice
export function random_choice(list) {
return list[Math.floor(Math.random() * list.length)];
}
// DOM stuff
function _mk(el, children) {
if (children.length > 0) {
if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") {
@ -29,6 +35,70 @@ export function mk_svg(tag_selector, ...children) {
return _mk(el, children);
}
export function handle_drop(element, options) {
let dropzone_class = options.dropzone_class ?? null;
let on_drop = options.on_drop;
let require_file = options.require_file ?? false;
let is_valid = ev => {
// TODO this requires files, should make some args for this
if (options.require_file) {
let dt = ev.dataTransfer;
if (! dt || dt.items.length === 0)
return false;
// Only test the first item I guess? If it's a file then they should all be files
if (dt.items[0].kind !== 'file')
return false;
}
return true;
};
let end_drop = () => {
if (dropzone_class !== null) {
element.classList.remove(dropzone_class);
}
};
// TODO should have a filter function for when a drag is valid but i forget which of these
// should have that
element.addEventListener('dragenter', ev => {
if (! is_valid(ev))
return;
ev.stopPropagation();
ev.preventDefault();
if (dropzone_class !== null) {
element.classList.add(dropzone_class);
}
});
element.addEventListener('dragover', ev => {
if (! is_valid(ev))
return;
ev.stopPropagation();
ev.preventDefault();
});
element.addEventListener('dragleave', ev => {
if (ev.relatedTarget && element.contains(ev.relatedTarget))
return;
end_drop();
});
element.addEventListener('drop', ev => {
if (! is_valid(ev))
return;
ev.stopPropagation();
ev.preventDefault();
end_drop();
on_drop(ev);
});
}
export function promise_event(element, success_event, failure_event) {
let resolve, reject;
let promise = new Promise((res, rej) => {
@ -36,21 +106,21 @@ export function promise_event(element, success_event, failure_event) {
reject = rej;
});
let success_handler = e => {
let success_handler = ev => {
element.removeEventListener(success_event, success_handler);
if (failure_event) {
element.removeEventListener(failure_event, failure_handler);
}
resolve(e);
resolve(ev);
};
let failure_handler = e => {
let failure_handler = ev => {
element.removeEventListener(success_event, success_handler);
if (failure_event) {
element.removeEventListener(failure_event, failure_handler);
}
reject(e);
reject(ev);
};
element.addEventListener(success_event, success_handler);
@ -61,6 +131,7 @@ export function promise_event(element, success_event, failure_event) {
return promise;
}
export async function fetch(url) {
let xhr = new XMLHttpRequest;
let promise = promise_event(xhr, 'load', 'error');
@ -68,9 +139,19 @@ export async function fetch(url) {
xhr.responseType = 'arraybuffer';
xhr.send();
await promise;
if (xhr.status !== 200)
throw new Error(`Failed to load ${url} -- ${xhr.status} ${xhr.statusText}`);
return xhr.response;
}
export function string_from_buffer_ascii(buf, start = 0, len) {
if (ArrayBuffer.isView(buf)) {
start += buf.byteOffset;
buf = buf.buffer;
}
return String.fromCharCode.apply(null, new Uint8Array(buf, start, len));
}
// Cast a line through a grid and yield every cell it touches
export function* walk_grid(x0, y0, x1, y1) {
// TODO if the ray starts outside the grid (extremely unlikely), we should
@ -171,3 +252,101 @@ export function* walk_grid(x0, y0, x1, y1) {
}
}
}
// Root class to indirect over where we might get files from
// - a pool of uploaded in-memory files
// - a single uploaded zip file
// - a local directory provided via the webkit Entry api
// - HTTP (but only for files we choose ourselves, not arbitrary ones, due to CORS)
// Note that where possible, these classes lowercase all filenames, in keeping with C2G's implicit
// requirement that filenames are case-insensitive :/
class FileSource {
constructor() {}
// Get a file's contents as an ArrayBuffer
async get(path) {}
}
// Files we have had uploaded one at a time (note that each upload becomes its own source)
export class FileFileSource extends FileSource {
constructor(files) {
super();
this.files = {};
for (let file of files) {
this.files[(file.webkitRelativePath ?? file.name).toLowerCase()] = file;
}
}
get(path) {
let file = this.files[path.toLowerCase()];
if (file) {
return file.arrayBuffer();
}
else {
return Promise.reject(new Error(`No such file was provided: ${path}`));
}
}
}
// Regular HTTP fetch
export class HTTPFileSource extends FileSource {
// Should be given a URL object as a root
constructor(root) {
super();
this.root = root;
}
get(path) {
let url = new URL(path, this.root);
return fetch(url);
}
}
// WebKit Entry interface
// XXX this does not appear to work if you drag in a link to a directory but that is probably beyond
// my powers to fix
export class EntryFileSource extends FileSource {
constructor(entries) {
super();
this.files = {};
let file_count = 0;
let read_directory = async (directory_entry, dir_prefix) => {
let reader = directory_entry.createReader();
let all_entries = [];
while (true) {
let entries = await new Promise((res, rej) => reader.readEntries(res, rej));
all_entries.push.apply(all_entries, entries);
if (entries.length === 0)
break;
}
await handle_entries(all_entries, dir_prefix);
};
let handle_entries = (entries, dir_prefix) => {
file_count += entries.length;
if (file_count > 4096)
throw new LLError("Found way too many files; did you drag in the wrong directory?");
let dir_promises = [];
for (let entry of entries) {
if (entry.isDirectory) {
dir_promises.push(read_directory(entry, dir_prefix + entry.name + '/'));
}
else {
this.files[(dir_prefix + entry.name).toLowerCase()] = entry;
}
}
return Promise.all(dir_promises);
};
this._loaded_promise = handle_entries(entries, '');
}
async get(path) {
let entry = this.files[path.toLowerCase()];
if (! entry)
throw new LLError(`No such file in local directory: ${path}`);
let file = await new Promise((res, rej) => entry.file(res, rej));
return await file.arrayBuffer();
}
}

View File

@ -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;
}