diff --git a/index.html b/index.html index 2b72c4a..c4daf38 100644 --- a/index.html +++ b/index.html @@ -40,6 +40,7 @@
+

Lexy's Labyrinth

@@ -58,21 +59,20 @@

Other levels

-

You can play CHIPS.DAT from the original Microsoft version, any custom levels you have lying around, or perhaps ones you found on the Bit Busters Club set list!

- - - - -

Supports both the old Microsoft CHIPS.DAT format and the Steam C2M format.

-

Does not yet support the Steam C2G format, so tragically, the original Steam levels can only be played one at a time. This should be fixed soon!

- +

You can also drag and drop files or directories into this window.

+ + + + +

If you still have the original Microsoft "BOWEP" game lying around, you can play the Chip's Challenge 1 levels by loading CHIPS.DAT.

If you own the Steam versions of Chip's Challenge 1 (free!) or Chip's Challenge 2 ($5 last I checked), you can play those too, even on Linux or Mac:

  1. Right-click the game in Steam and choose Properties. On the Local Files tab, click Browse local files.
  2. Open the data folder, then games.
  3. -
  4. You should see either a cc1 or cc2 folder. Drag it into this window.
  5. +
  6. You should see either a cc1 or cc2 folder. Drag it into this window, or load it with the button above.
- -->
diff --git a/js/format-util.js b/js/format-base.js similarity index 54% rename from js/format-util.js rename to js/format-base.js index b9524e7..2932ffc 100644 --- a/js/format-util.js +++ b/js/format-base.js @@ -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); } } diff --git a/js/format-c2m.js b/js/format-c2g.js similarity index 58% rename from js/format-c2m.js rename to js/format-c2g.js index 1b9fb54..4eb8aaf 100644 --- a/js/format-c2m.js +++ b/js/format-c2g.js @@ -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; +} diff --git a/js/format-dat.js b/js/format-dat.js index eb93717..b44d56d 100644 --- a/js/format-dat.js +++ b/js/format-dat.js @@ -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', @@ -118,21 +119,71 @@ const TILE_ENCODING = { 0x6e: ['player', 'south'], 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; diff --git a/js/main-editor.js b/js/main-editor.js index 041db97..f3fda1a 100644 --- a/js/main-editor.js +++ b/js/main-editor.js @@ -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 diff --git a/js/main.js b/js/main.js index c40b899..e98c532 100644 --- a/js/main.js +++ b/js/main.js @@ -1,9 +1,9 @@ // TODO bugs and quirks i'm aware of: // - steam: if a player character starts on a force floor they won't be able to make any voluntary movements until they are no longer on a force floor import { DIRECTIONS, TICS_PER_SECOND } from './defs.js'; -import * as c2m from './format-c2m.js'; +import * as c2g from './format-c2g.js'; import * as dat from './format-dat.js'; -import * as format_util from './format-util.js'; +import * as format_util from './format-base.js'; import { Level } from './game.js'; import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay } from './main-base.js'; import { Editor } from './main-editor.js'; @@ -11,7 +11,8 @@ import CanvasRenderer from './renderer-canvas.js'; import SOUNDTRACK from './soundtrack.js'; import { Tileset, CC2_TILESET_LAYOUT, LL_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT } from './tileset.js'; import TILE_TYPES from './tiletypes.js'; -import { random_choice, mk, mk_svg, promise_event, fetch } from './util.js'; +import { random_choice, mk, mk_svg, promise_event } from './util.js'; +import * as util from './util.js'; const PAGE_TITLE = "Lexy's Labyrinth"; @@ -167,7 +168,7 @@ class SFXPlayer { } async init_sound(name, path) { - let buf = await fetch(path); + let buf = await util.fetch(path); let audiobuf = await this.ctx.decodeAudioData(buf); this.sounds[name] = { buf: buf, @@ -1253,24 +1254,60 @@ class Splash extends PrimaryView { mk('span.-score', score), ); button.addEventListener('click', ev => { - this.fetch_pack(packdef.path, packdef.title); + this.conductor.fetch_pack(packdef.path, packdef.title); }); pack_list.append(button); } - // Bind to file upload control - let upload_el = this.root.querySelector('#splash-upload'); - // Clear it out in case of refresh - upload_el.value = ''; - this.root.querySelector('#splash-upload-button').addEventListener('click', ev => { - upload_el.click(); + // File loading: allow providing either a single file, multiple files, OR an entire + // directory (via the hokey WebKit Entry interface) + let upload_file_el = this.root.querySelector('#splash-upload-file'); + let upload_dir_el = this.root.querySelector('#splash-upload-dir'); + // Clear out the file controls in case of refresh + upload_file_el.value = ''; + upload_dir_el.value = ''; + this.root.querySelector('#splash-upload-file-button').addEventListener('click', ev => { + upload_file_el.click(); }); - upload_el.addEventListener('change', async ev => { + this.root.querySelector('#splash-upload-dir-button').addEventListener('click', ev => { + upload_dir_el.click(); + }); + upload_file_el.addEventListener('change', async ev => { + if (upload_file_el.files.length === 0) + return; + + // TODO throw up a 'loading' overlay + // FIXME handle multiple files! but if there's only one, explicitly load /that/ one let file = ev.target.files[0]; let buf = await file.arrayBuffer(); - this.load_file(buf, this.extract_identifier_from_path(file.name)); - // TODO get title out of C2G when it's supported - this.conductor.level_pack_name_el.textContent = file.name; + await this.conductor.parse_and_load_game(buf, new util.FileFileSource(ev.target.files), file.name); + }); + upload_dir_el.addEventListener('change', async ev => { + // TODO throw up a 'loading' overlay + // The directory selector populates 'files' with every single file, recursively, which + // is kind of wild but also /much/ easier to deal with + let files = upload_dir_el.files; + if (files.length > 4096) + throw new util.LLError("Got way too many files; did you upload the right directory?"); + + await this.search_multi_source(new util.FileFileSource(files)); + }); + // Allow loading a local directory onto us, via the WebKit + // file entry interface + // TODO? this always takes a moment to register, not sure why... + // FIXME as written this won't correctly handle CCLs + util.handle_drop(this.root, { + require_file: true, + dropzone_class: '--drag-hover', + on_drop: async ev => { + // TODO for now, always use the entry interface, but if these are all files then + // they can just be loaded normally + let entries = []; + for (let item of ev.dataTransfer.items) { + entries.push(item.webkitGetAsEntry()); + } + await this.search_multi_source(new util.EntryFileSource(entries)); + }, }); // Bind to "create level" button @@ -1287,53 +1324,38 @@ class Splash extends PrimaryView { // FIXME definitely gonna need a name here chief let stored_game = new format_util.StoredGame(null); - stored_game.levels.push(stored_level); + stored_game.level_metadata.push({ + stored_level: stored_level, + }); this.conductor.load_game(stored_game); this.conductor.switch_to_editor(); }); } - extract_identifier_from_path(path) { - let ident = path.match(/^(?:.*\/)?[.]*([^.]+)(?:[.]|$)/)[1]; - if (ident) { - return ident.toLowerCase(); + // 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; } - else { - return null; - } - } - // 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"); } } diff --git a/js/util.js b/js/util.js index 2c5277a..dd762be 100644 --- a/js/util.js +++ b/js/util.js @@ -1,7 +1,13 @@ +// Base class for custom errors +export class LLError extends Error {} + +// Random choice export function random_choice(list) { return list[Math.floor(Math.random() * list.length)]; } + +// DOM stuff function _mk(el, children) { if (children.length > 0) { if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") { @@ -29,6 +35,70 @@ export function mk_svg(tag_selector, ...children) { return _mk(el, children); } +export function handle_drop(element, options) { + let dropzone_class = options.dropzone_class ?? null; + let on_drop = options.on_drop; + + let require_file = options.require_file ?? false; + let is_valid = ev => { + // TODO this requires files, should make some args for this + if (options.require_file) { + let dt = ev.dataTransfer; + if (! dt || dt.items.length === 0) + return false; + + // Only test the first item I guess? If it's a file then they should all be files + if (dt.items[0].kind !== 'file') + return false; + } + + return true; + }; + + let end_drop = () => { + if (dropzone_class !== null) { + element.classList.remove(dropzone_class); + } + }; + + // TODO should have a filter function for when a drag is valid but i forget which of these + // should have that + element.addEventListener('dragenter', ev => { + if (! is_valid(ev)) + return; + + ev.stopPropagation(); + ev.preventDefault(); + + if (dropzone_class !== null) { + element.classList.add(dropzone_class); + } + }); + element.addEventListener('dragover', ev => { + if (! is_valid(ev)) + return; + + ev.stopPropagation(); + ev.preventDefault(); + }); + element.addEventListener('dragleave', ev => { + if (ev.relatedTarget && element.contains(ev.relatedTarget)) + return; + + end_drop(); + }); + element.addEventListener('drop', ev => { + if (! is_valid(ev)) + return; + + ev.stopPropagation(); + ev.preventDefault(); + + end_drop(); + on_drop(ev); + }); +} + export function promise_event(element, success_event, failure_event) { let resolve, reject; let promise = new Promise((res, rej) => { @@ -36,21 +106,21 @@ export function promise_event(element, success_event, failure_event) { reject = rej; }); - let success_handler = e => { + let success_handler = ev => { element.removeEventListener(success_event, success_handler); if (failure_event) { element.removeEventListener(failure_event, failure_handler); } - resolve(e); + resolve(ev); }; - let failure_handler = e => { + let failure_handler = ev => { element.removeEventListener(success_event, success_handler); if (failure_event) { element.removeEventListener(failure_event, failure_handler); } - reject(e); + reject(ev); }; element.addEventListener(success_event, success_handler); @@ -61,6 +131,7 @@ export function promise_event(element, success_event, failure_event) { return promise; } + export async function fetch(url) { let xhr = new XMLHttpRequest; let promise = promise_event(xhr, 'load', 'error'); @@ -68,9 +139,19 @@ export async function fetch(url) { xhr.responseType = 'arraybuffer'; xhr.send(); await promise; + if (xhr.status !== 200) + throw new Error(`Failed to load ${url} -- ${xhr.status} ${xhr.statusText}`); return xhr.response; } +export function string_from_buffer_ascii(buf, start = 0, len) { + if (ArrayBuffer.isView(buf)) { + start += buf.byteOffset; + buf = buf.buffer; + } + return String.fromCharCode.apply(null, new Uint8Array(buf, start, len)); +} + // Cast a line through a grid and yield every cell it touches export function* walk_grid(x0, y0, x1, y1) { // TODO if the ray starts outside the grid (extremely unlikely), we should @@ -171,3 +252,101 @@ export function* walk_grid(x0, y0, x1, y1) { } } } + +// Root class to indirect over where we might get files from +// - a pool of uploaded in-memory files +// - a single uploaded zip file +// - a local directory provided via the webkit Entry api +// - HTTP (but only for files we choose ourselves, not arbitrary ones, due to CORS) +// Note that where possible, these classes lowercase all filenames, in keeping with C2G's implicit +// requirement that filenames are case-insensitive :/ +class FileSource { + constructor() {} + + // Get a file's contents as an ArrayBuffer + async get(path) {} +} +// Files we have had uploaded one at a time (note that each upload becomes its own source) +export class FileFileSource extends FileSource { + constructor(files) { + super(); + this.files = {}; + for (let file of files) { + this.files[(file.webkitRelativePath ?? file.name).toLowerCase()] = file; + } + } + + get(path) { + let file = this.files[path.toLowerCase()]; + if (file) { + return file.arrayBuffer(); + } + else { + return Promise.reject(new Error(`No such file was provided: ${path}`)); + } + } +} +// Regular HTTP fetch +export class HTTPFileSource extends FileSource { + // Should be given a URL object as a root + constructor(root) { + super(); + this.root = root; + } + + get(path) { + let url = new URL(path, this.root); + return fetch(url); + } +} +// WebKit Entry interface +// XXX this does not appear to work if you drag in a link to a directory but that is probably beyond +// my powers to fix +export class EntryFileSource extends FileSource { + constructor(entries) { + super(); + this.files = {}; + let file_count = 0; + + let read_directory = async (directory_entry, dir_prefix) => { + let reader = directory_entry.createReader(); + let all_entries = []; + while (true) { + let entries = await new Promise((res, rej) => reader.readEntries(res, rej)); + all_entries.push.apply(all_entries, entries); + if (entries.length === 0) + break; + } + + await handle_entries(all_entries, dir_prefix); + }; + let handle_entries = (entries, dir_prefix) => { + file_count += entries.length; + if (file_count > 4096) + throw new LLError("Found way too many files; did you drag in the wrong directory?"); + + let dir_promises = []; + for (let entry of entries) { + if (entry.isDirectory) { + dir_promises.push(read_directory(entry, dir_prefix + entry.name + '/')); + } + else { + this.files[(dir_prefix + entry.name).toLowerCase()] = entry; + } + } + + return Promise.all(dir_promises); + }; + + this._loaded_promise = handle_entries(entries, ''); + } + + async get(path) { + let entry = this.files[path.toLowerCase()]; + if (! entry) + throw new LLError(`No such file in local directory: ${path}`); + + let file = await new Promise((res, rej) => entry.file(res, rej)); + return await file.arrayBuffer(); + } +} diff --git a/style.css b/style.css index a7c6ef3..3ab80f1 100644 --- a/style.css +++ b/style.css @@ -82,6 +82,9 @@ p:first-child { p:last-child { margin-bottom: 0; } +code { + color: #c0c0e0; +} a { color: #c0c0c0; @@ -188,6 +191,10 @@ table.level-browser tr.--unvisited { color: #606060; font-style: italic; } +table.level-browser tr.--error { + color: #600000; + font-style: italic; +} table.level-browser tbody tr { cursor: pointer; } @@ -350,10 +357,31 @@ body[data-mode=player] #editor-play { ; gap: 1em; + position: relative; padding: 1em 10%; margin: auto; overflow: auto; } +#splash > .drag-overlay { + display: none; + justify-content: center; + align-items: center; + + font-size: 10vmin; + position: absolute; + top: 0; + bottom: 0; + left: 1rem; + right: 1rem; + background: #fff2; + border: 0.25rem dashed white; + border-radius: 1rem; + text-shadow: 0 1px 5px black; + text-align: center; +} +#splash.--drag-hover > .drag-overlay { + display: flex; +} #splash > header { grid-area: header; @@ -387,7 +415,8 @@ body[data-mode=player] #editor-play { #splash > #splash-upload-levels { grid-area: upload; } -#splash-upload { +#splash-upload-file, +#splash-upload-dir { /* Hide the file upload control, which is ugly */ display: none; }