Lexy's Labyrinth
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:
- Right-click the game in Steam and choose Properties. On the Local Files tab, click Browse local files.
- Open the
datafolder, thengames.
- - You should see either a
cc1orcc2folder. Drag it into this window.
+ - You should see either a
cc1orcc2folder. Drag it into this window, or load it with the button above.
Welcome to Lexy's Labyrinth, an exciting old-school tile-based puzzle adventure that is compatible with — but legally distinct from! — Chip's Challenge and its long-awaited sequel Chip's Challenge 2.
@@ -1613,7 +1666,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) { @@ -1637,10 +1690,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), @@ -1649,7 +1710,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 => { @@ -1658,8 +1729,9 @@ class LevelBrowserOverlay extends DialogOverlay { return; let index = parseInt(tr.getAttribute('data-index'), 10); - this.conductor.change_level(index); - this.close(); + if (this.conductor.change_level(index)) { + this.close(); + } }); this.add_button("nevermind", ev => { @@ -1718,7 +1790,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(); @@ -1786,6 +1858,13 @@ class Conductor { if (identifier !== null) { // TODO again, enforce something about the shape here this.current_pack_savefile = JSON.parse(window.localStorage.getItem(STORAGE_PACK_PREFIX + identifier)); + if (this.current_pack_savefile.total_score === null) { + // Fix some NaNs that slipped in + this.current_pack_savefile.total_score = this.current_pack_savefile.scorecards + .map(scorecard => scorecard ? scorecard.score : 0) + .reduce((a, b) => a + b, 0); + this.save_savefile(); + } } if (! this.current_pack_savefile) { this.current_pack_savefile = { @@ -1800,12 +1879,20 @@ class Conductor { this.player.load_game(stored_game); this.editor.load_game(stored_game); - this.change_level(0); + return this.change_level(0); } change_level(level_index) { + // FIXME handle errors here + try { + this.stored_level = this.stored_game.load_level(level_index); + } + catch (e) { + new LevelErrorOverlay(this, e).open(); + return false; + } + this.level_index = level_index; - this.stored_level = this.stored_game.levels[level_index]; // FIXME do better this.level_name_el.textContent = `Level ${level_index + 1} — ${this.stored_level.title}`; @@ -1815,12 +1902,13 @@ class Conductor { this.player.load_level(this.stored_level); this.editor.load_level(this.stored_level); + return true; } 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() { @@ -1845,6 +1933,68 @@ 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"); + } + if (this.load_game(stored_game, identifier)) { + this.switch_to_player(); + } + } } @@ -1891,14 +2041,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/tiletypes.js b/js/tiletypes.js index 719c8a3..3e55c8b 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -882,7 +882,8 @@ const TILE_TYPES = { level.sfx.play_once('button-press', me.cell); // Move all yellow tanks one tile in the direction of the pressing actor - for (let actor of level.actors) { + for (let i = level.actors.length - 1; i >= 0; i--) { + let actor = level.actors[i]; // TODO generify somehow?? if (actor.type.name === 'tank_yellow') { level.attempt_step(actor, other.direction); 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..efcaba5 100644 --- a/style.css +++ b/style.css @@ -82,6 +82,12 @@ p:first-child { p:last-child { margin-bottom: 0; } +pre { + white-space: pre-wrap; +} +code { + color: #c0c0e0; +} a { color: #c0c0c0; @@ -154,6 +160,11 @@ a:active { overflow: auto; padding: 1em; } +.dialog pre.error { + color: #400000; + background: #f0d0d0; + padding: 0.5em 1em; +} /* Individual overlays */ table.level-browser { @@ -188,6 +199,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 +365,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 +423,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; }