commit 3084ca7b49e456dd490c891fc048936578391bb3 Author: Eevee (Evelyn Woods) Date: Fri Aug 28 04:02:03 2020 -0600 Initial commit: a game that plays through some of CCLP1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..80458bb --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Lexy's Labyrinth + +This is a web implementation of a puzzle game that bears a _striking_ similarity to [Chip's Challenge](https://wiki.bitbusters.club/Chip%27s_Challenge) and its [sequel](https://wiki.bitbusters.club/Chip%27s_Challenge_2), but is legally distinct, and also free! + +It is a work in progress and also might be abandoned and forgotten at any time. + +## Play online + +Give it a try, I guess! [https://c.eev.ee/lexys-labyrinth/](https://c.eev.ee/lexys-labyrinth/) + +## Current status + +- Game runs, plays, kills you +- Support for ~60% of Chip's Challenge 1 objects +- Support for MS Chip's Challenge .DAT files and Steam Chip's Challenge .C2M files + +### Planned features + +- Support for all of the nonsense in Chip's Challenge 2 +- Allow playing the original commercial levels by dragging the data files in from your own computer +- Support various sets of bugs from various implementations +- Undo moves +- Play the game turn-based instead of realtime (i.e., nothing moves until Chip does) +- Record and play back demos +- Mouse and touchscreen support +- Outright cheat in a variety of ways + +### Noble aspirations + +- Level editor, slash convertor +- New exclusive puzzle elements?? Embrace extend extinguish baby + +## Special thanks + +- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord +- Everyone who worked on [Chip's Challenge Level Pack 1](https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_1), the default set of levels +- The [Tile World](https://wiki.bitbusters.club/Tile_World) tileset currently used by default, created by Anders Kaseorg diff --git a/index.html b/index.html new file mode 100644 index 0000000..672d007 --- /dev/null +++ b/index.html @@ -0,0 +1,11 @@ + + + + + Lexy's Labyrinth + + + + + + diff --git a/js/format-c2m.js b/js/format-c2m.js new file mode 100644 index 0000000..e833d2c --- /dev/null +++ b/js/format-c2m.js @@ -0,0 +1,192 @@ +import * as util from './format-util.js'; +import { TILE_TYPES, CC2_TILE_TYPES } from './tiletypes.js'; + +// 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); + let p = 2; + let q = 0; + while (p < buf.byteLength) { + let len = bytes[p]; + p++; + if (len < 0x80) { + // Data block + outbytes.set(new Uint8Array(buf.slice(p, p + len)), q); + p += len; + q += len; + } + else { + // Back-reference block + len -= 0x80; + let offset = bytes[p]; + p++; + // Can't use set + slice here because the copy can overlap and that + // doesn't work so great, so just do a regular loop and let the JIT + // deal with it + let start = q - offset; + for (let i = 0; i < len; i++) { + outbytes[q] = outbytes[start + i]; + q++; + } + } + } + if (q !== decompressed_length) + throw new Error(`Expected to decode ${decompressed_length} bytes but got ${q} instead`); + return out; +} + +export function parse_level(buf) { + let level = new util.StoredLevel; + let full_view = new DataView(buf); + let next_section_start = 0; + 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_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`); + + if (section_type === 'CC2M' || section_type === 'LOCK' || section_type === 'TITL' || section_type === 'AUTH' || section_type === 'VERS' || section_type === 'CLUE' || section_type === 'NOTE') { + // These are all singular strings (with a terminating NUL, for some reason) + // XXX character encoding?? + // FIXME assign to appropriate fields + let field = section_type; + if (section_type === 'TITL') { + field = 'title'; + } + else if (section_type === 'AUTH') { + field = 'author'; + } + /* + else if (section_type === 'CLUE') { + field = 'hint'; + } + */ + level[field] = util.string_from_buffer_ascii(buf.slice(section_start + 8, next_section_start - 1)).replace(/\r\n/g, "\n"); + continue; + } + + let section_buf = buf.slice(section_start + 8, next_section_start); + let section_view = new DataView(buf, section_start + 8, section_length); + + if (section_type === 'OPTN') { + // Level options, which may be truncated at any point + // TODO implement most of these + level.time_limit = section_view.getUint16(0, true); + + // TODO 0 - 10x10, 1 - 9x9, 2 - split, otherwise unknown which needs handling + let viewport = section_view.getUint8(2, true); + if (viewport === 0) { + level.viewport_size = 10; + } + else if (viewport === 1) { + level.viewport_size = 9; + } + else if (viewport === 2) { + // FIXME this is split + level.viewport_size = 10; + } + else { + throw new Error(`Unrecognized viewport size option ${viewport}`); + } + + if (section_view.byteLength <= 3) + continue; + //options.has_solution = section_view.getUint8(3, true); + + if (section_view.byteLength <= 4) + continue; + //options.show_map_in_editor = section_view.getUint8(4, true); + + if (section_view.byteLength <= 5) + continue; + //options.is_editable = section_view.getUint8(5, true); + + if (section_view.byteLength <= 6) + continue; + //options.solution_hash = util.string_from_buffer_ascii(buf.slice( + //section_start + 6, section_start + 22)); + + if (section_view.byteLength <= 22) + continue; + //options.hide_logic = section_view.getUint8(22, true); + + if (section_view.byteLength <= 23) + continue; + level.use_cc1_boots = section_view.getUint8(23, true); + + if (section_view.byteLength <= 24) + continue; + //level.blob_behavior = section_view.getUint8(24, true); + } + else if (section_type === 'MAP ' || section_type === 'PACK') { + let data = section_buf; + if (section_type === 'PACK') { + data = decompress(data); + } + let bytes = new Uint8Array(data); + let width = bytes[0]; + let height = bytes[1]; + level.size_x = width; + level.size_y = height; + let p = 2; + for (let n = 0; n < width * height; n++) { + let cell = new util.StoredCell; + while (true) { + let tile_byte = bytes[p]; + p++; + let tile_name = CC2_TILE_TYPES[tile_byte]; + if (! tile_name) + throw new Error(`Unrecognized tile type 0x${tile_byte.toString(16)}`); + + let tile = {name: tile_name}; + cell.push(tile); + let tiledef = TILE_TYPES[tile_name]; + if (tiledef.is_required_chip) { + level.chips_required++; + } + if (tiledef.is_player) { + // TODO handle multiple starts + level.player_start_x = n % width; + level.player_start_y = Math.floor(n / width); + } + if (tiledef.has_direction) { + let dirbyte = bytes[p]; + p++; + let direction = ['north', 'east', 'south', 'west'][dirbyte]; + if (! direction) { + console.warn(`'${tile_name}' tile at ${n % width}, ${Math.floor(n / width)} has bogus direction byte ${dirbyte}; defaulting to south`); + direction = 'south'; + } + tile.direction = direction; + } + if (! tiledef.is_top_layer) + break; + } + level.linear_cells.push(cell); + } + } + else if (section_type === 'KEY ') { + } + else if (section_type === 'REPL') { + } + else if (section_type === 'PRPL') { + } + else if (section_type === 'RDNY') { + } + else if (section_type === 'END ') { + } + else { + console.warn(`Unrecognized section type '${section_type}' at offset ${section_start}`); + // TODO save it, persist when editing level + } + } + console.log(level); + return level; +} diff --git a/js/format-dat.js b/js/format-dat.js new file mode 100644 index 0000000..e2301a7 --- /dev/null +++ b/js/format-dat.js @@ -0,0 +1,227 @@ +import * as util from './format-util.js'; +import { TILE_TYPES, CC2_TILE_TYPES } from './tiletypes.js'; + +const CC1_TILE_ENCODING = { + 0x00: 'floor', + 0x01: 'wall', + 0x02: 'chip', + 0x03: 'water', + 0x04: 'fire', + // invis wall + // thin walls... + 0x0a: 'dirt_block', + 0x0b: 'dirt', + 0x0c: 'ice', + 0x0d: 'force_floor_s', + // cloners + 0x12: 'force_floor_n', + 0x13: 'force_floor_e', + 0x14: 'force_floor_w', + 0x15: 'exit', + 0x16: 'door_blue', + 0x17: 'door_red', + 0x18: 'door_green', + 0x19: 'door_yellow', + 0x1a: 'ice_se', + 0x1b: 'ice_sw', + 0x1c: 'ice_nw', + 0x1d: 'ice_nw', + // fake blocks + // 0x20 unused + // thief + 0x22: 'socket', + // green button + // red button + // green tile + // more buttons, teleports, bombs, traps + 0x2f: 'clue', + + 0x33: 'player_drowned', + 0x34: 'player_burned', + //0x35: player_burned, XXX is this burned off a tile or? + // 0x36 - 0x38 unused + //0x39: exit_player, + 0x3a: 'exit', + 0x3b: 'exit', // i think this is for the second frame of the exit animation? + // FIXME??? 0x3c - 0x3f are player swimming! + 0x40: ['bug', 'north'], + 0x41: ['bug', 'west'], + 0x42: ['bug', 'south'], + 0x43: ['bug', 'east'], + + 0x64: 'key_blue', + 0x65: 'key_red', + 0x66: 'key_green', + 0x67: 'key_yellow', + 0x68: 'flippers', + 0x69: 'fire_boots', + 0x6a: 'cleats', + 0x6b: 'suction_boots', + 0x6c: ['player', 'north'], + 0x6d: ['player', 'west'], + 0x6e: ['player', 'south'], + 0x6f: ['player', 'east'], +}; + +function parse_level(buf) { + let level = new util.StoredLevel; + // 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.use_cc1_boots = true; + + let view = new DataView(buf); + let bytes = new Uint8Array(buf); + console.log(bytes); + + // Header + let level_number = view.getUint16(0, true); + level.time_limit = view.getUint16(2, true); + level.chips_required = view.getUint16(4, true); + + // Map layout + let unknown = view.getUint16(6, true); + // 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; + let c = 0; + let end = p + layer_length; + while (p < end) { + let tile_byte = bytes[p]; + p++; + let count = 1; + if (tile_byte === 0xff) { + // RLE: 0xff, count, tile + count = bytes[p]; + tile_byte = bytes[p + 1]; + p += 2; + } + + let name = CC1_TILE_ENCODING[tile_byte]; + // TODO could be more forgiving for goofy levels doing goofy things + if (! name) + // TODO doesn't say what level or where in the file, come on + throw new Error(`Invalid tile byte: 0x${tile_byte.toString(16)}`); + + let direction; + if (name instanceof Array) { + [name, direction] = name; + } + let tile_type = TILE_TYPES[name]; + + let tile = {name: name, direction: direction}; + for (let i = 0; i < count; i++) { + if (c >= 1024) + throw new Error("Too many cells found"); + + let cell = level.linear_cells[c]; + c++; + + // FIXME not entirely sure how to handle floor, to be honest; should it just be blank, and blank cells get drawn as floor? eugh but then it would be drawn under floor tiles too... + if (name === 'floor' && cell.length > 0) { + continue; + } + + cell.push({name, direction}); + } + } + if (c !== 1024) + throw new Error(`Expected 1024 cells (32x32 map); found ${c}`); + } + + // Optional metadata fields + let meta_length = view.getUint16(p, true); + p += 2; + let end = p + meta_length; + while (p < meta_length) { + // Common header + let field_type = view.getUint16(p, true); + let field_length = view.getUint16(p + 2, true); + p += 4; + if (field_type === 0x01) { + // Level time; unnecessary since it's already in the level header + // TODO check, compare, warn? + } + else if (field_type === 0x02) { + // Chips; unnecessary since it's already in the level header + // TODO check, compare, warn? + } + else if (field_type === 0x03) { + // Title, including trailing NUL + level.title = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1)); + } + else if (field_type === 0x04) { + // Trap linkages + // TODO read this + // TODO under lynx rules these aren't even used, and they cause bugs in mscc1! + } + else if (field_type === 0x05) { + // Trap linkages + // TODO read this + // TODO under lynx rules these aren't even used, and they cause bugs in mscc1! + } + 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); + } + 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)); + } + else if (field_type === 0x08) { + // Password, but not encoded + // TODO ??? + } + else if (field_type === 0x0a) { + // Initial actor order + // TODO ??? should i... trust this... + } + p += field_length; + } + + return level; +} + +export function parse_game(buf) { + let game = new util.StoredGame; + + let full_view = new DataView(buf); + let magic = full_view.getUint32(0, true); + if (magic === 0x0002aaac) { + // OK + // TODO probably use ms rules + } + else if (magic === 0x0102aaac) { + // OK + // TODO tile world convention, use lynx rules + } + else { + throw new Error(`Unrecognized magic number ${magic.toString(16)}`); + } + + let level_count = full_view.getUint16(4, true); + + // And now, the levels + let p = 6; + for (let l = 1; l <= level_count; l++) { + console.log('level', l); + let length = full_view.getUint16(p, true); + let level_buf = buf.slice(p + 2, p + 2 + length); + p += 2 + length; + + let level = parse_level(level_buf); + game.levels.push(level); + break; + } + + return game; +} diff --git a/js/format-util.js b/js/format-util.js new file mode 100644 index 0000000..71c39f5 --- /dev/null +++ b/js/format-util.js @@ -0,0 +1,34 @@ +export function string_from_buffer_ascii(buf) { + return String.fromCharCode.apply(null, new Uint8Array(buf)); +} + +export class StoredCell extends Array { +} + +export class StoredLevel { + constructor() { + this.title = ''; + this.password = null; + this.chips_required = 0; + this.time_limit = 0; + this.viewport_size = 9; + this.extra_chunks = []; + this.use_cc1_boots = false; + + this.size_x = 0; + this.size_y = 0; + this.linear_cells = []; + + this.player_start_x = 0; + this.player_start_y = 0; + } + + check() { + } +} + +export class StoredGame { + constructor() { + this.levels = []; + } +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..afc4cbb --- /dev/null +++ b/js/main.js @@ -0,0 +1,562 @@ +// 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 * as c2m from './format-c2m.js'; +import * as dat from './format-dat.js'; +import { TILE_TYPES, CC2_TILE_TYPES } from './tiletypes.js'; +import { Tileset, CC2_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT } from './tileset.js'; + +function mk(tag_selector, ...children) { + let [tag, ...classes] = tag_selector.split('.'); + let el = document.createElement(tag); + el.classList = classes.join(' '); + if (children.length > 0) { + if (!(children[0] instanceof Node) && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") { + let [attrs] = children.splice(0, 1); + for (let [key, value] of Object.entries(attrs)) { + el.setAttribute(key, value); + } + } + el.append(...children); + } + return el; +} + +function promise_event(element, success_event, failure_event) { + let resolve, reject; + let promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + let success_handler = e => { + element.removeEventListener(success_event, success_handler); + if (failure_event) { + element.removeEventListener(failure_event, failure_handler); + } + + resolve(e); + }; + let failure_handler = e => { + element.removeEventListener(success_event, success_handler); + if (failure_event) { + element.removeEventListener(failure_event, failure_handler); + } + + reject(e); + }; + + element.addEventListener(success_event, success_handler); + if (failure_event) { + element.addEventListener(failure_event, failure_handler); + } + + return promise; +} + + +class Tile { + constructor(type, x, y, direction = null) { + this.type = type; + this.x = x; + this.y = y; + this.direction = direction; + if (type.has_direction && ! direction) { + this.direction = 'south'; + } + + this.is_sliding = false; + + if (type.has_inventory) { + this.inventory = {}; + } + } + + static from_template(tile_template, x, y) { + return new this(TILE_TYPES[tile_template.name], x, y, tile_template.direction); + } + + ignores(name) { + if (this.type.ignores && this.type.ignores.has(name)) + return true; + + for (let [item, count] of Object.entries(this.inventory)) { + if (count === 0) + continue; + + let item_type = TILE_TYPES[item]; + if (item_type.item_ignores && item_type.item_ignores.has(name)) + return true; + } + + return false; + } + + become(name) { + this.type = TILE_TYPES[name]; + // TODO adjust anything else? + } + + destroy() { + this.doomed = true; + } + + // Inventory stuff + give_item(name) { + this.inventory[name] = (this.inventory[name] ?? 0) + 1; + } + + take_item(name) { + if (this.inventory[name] && this.inventory[name] >= 1) { + if (!(this.type.infinite_items && this.type.infinite_items[name])) { + this.inventory[name]--; + } + return true; + } + else { + return false; + } + } +} + +class Cell extends Array { + constructor() { + super(); + this.is_dirty = false; + } + + _add(tile) { + this.push(tile); + } + + // DO NOT use me to remove a tile permanently, only to move it! + // Should only be called from Level, which handles some bookkeeping! + _remove(tile) { + let layer = this.indexOf(tile); + if (layer < 0) + throw new Error("Asked to remove tile that doesn't seem to exist"); + + this.splice(layer, 1); + } + + each(f) { + for (let i = this.length - 1; i >= 0; i--) { + if (f(this[i]) === false) + break; + } + this._gc(); + } + + _gc() { + let p = 0; + for (let i = 0, l = this.length; i < l; i++) { + let cell = this[i]; + if (! cell.doomed) { + if (p !== i) { + this[p] = cell; + } + p++; + } + } + this.length = p; + } +} + +const DIRECTIONS = { + north: { + movement: [0, -1], + left: 'west', + right: 'east', + }, + south: { + movement: [0, 1], + left: 'east', + right: 'west', + }, + west: { + movement: [-1, 0], + left: 'south', + right: 'north', + }, + east: { + movement: [1, 0], + left: 'north', + right: 'south', + }, +}; + +class Level { + constructor(stored_level) { + this.stored_level = stored_level; + this.width = stored_level.size_x; + this.height = stored_level.size_y; + this.restart(); + + // playing: normal play + // success: has been won + // failure: died + // paused: paused + this.state = 'playing'; + } + + restart() { + this.cells = []; + this.player = null; + this.actors = []; + this.chips_remaining = this.stored_level.chips_required; + + let n = 0; + for (let y = 0; y < this.height; y++) { + let row = []; + this.cells.push(row); + for (let x = 0; x < this.width; x++) { + let cell = new Cell; + row.push(cell); + + let template_cell = this.stored_level.linear_cells[n]; + n++; + + for (let template_tile of template_cell) { + let tile = Tile.from_template(template_tile, x, y); + if (tile.type.is_player) { + // TODO handle multiple players, also chip and melinda both + // TODO complain if no chip + this.player = tile; + } + if (tile.type.is_actor) { + this.actors.push(tile); + } + cell.push(tile); + } + // Make the bottom tile be /first/ + cell.reverse(); + } + } + } + + halftic() { + if (this.state !== 'playing') { + console.warn(`Level.halftic() called when state is ${this.state}`); + return; + } + + for (let actor of this.actors) { + if (actor.is_sliding) { + // TODO do we stop sliding if we hit something, too? + this.attempt_step(actor, actor.direction); + } + + if (this.state === 'success' || this.state === 'failure') + break; + } + } + + advance(player_direction) { + if (this.state !== 'playing') { + console.warn(`Level.advance() called when state is ${this.state}`); + return; + } + + for (let actor of this.actors) { + // TODO skip doomed? strip them out? hm + if (actor === this.player) { + if (player_direction) { + actor.direction = player_direction; + this.attempt_step(actor, player_direction); + } + } + else { + // bug behavior: always try turning as left as possible, and + // fall back to less-left turns when that fails + let direction = DIRECTIONS[actor.direction].left; + for (let i = 0; i < 4; i++) { + if (this.attempt_step(actor, direction)) { + actor.direction = direction; + break; + } + direction = DIRECTIONS[direction].right; + } + } + + // TODO do i need to do this more aggressively? + if (this.state === 'success' || this.state === 'failure') + break; + } + } + + fail(message) { + this.state = 'failure'; + this.fail_message = message; + } + + attempt_step(actor, direction) { + let move = DIRECTIONS[direction].movement; + let goal_x = actor.x + move[0]; + let goal_y = actor.y + move[1]; + let goal_cell = this.cells[goal_y][goal_x]; + + let blocks; + goal_cell.each(tile => { + if (tile !== actor && tile.type.blocks) { + if (actor.type.pushes && actor.type.pushes[tile.type.name]) { + if (this.attempt_step(tile, direction)) + // It moved out of the way! + return; + } + if (tile.type.on_bump) { + tile.type.on_bump(tile, this, actor); + if (! tile.type.blocks) + // It became something non-blocking! + return; + } + blocks = true; + // XXX should i break here, or bump everything? + return false; + } + }); + + if (blocks) + return false; + + // We're clear! + this.move_to(actor, goal_x, goal_y); + return true; + } + + move_to(actor, x, y) { + if (x === actor.x && y === actor.y) + return; + + let goal_cell = this.cells[y][x]; + let original_cell = this.cells[actor.y][actor.x]; + original_cell._remove(actor); + actor.is_sliding = false; + goal_cell._add(actor); + actor.x = x; + actor.y = y; + + original_cell.is_dirty = true; + goal_cell.is_dirty = true; + + // Step on all the tiles in the new cell + goal_cell.each(tile => { + if (tile === actor) + return; + if (actor.ignores(tile.type.name)) + return; + if (tile.type.is_item && actor.type.has_inventory) { + actor.give_item(tile.type.name); + tile.destroy(); + } + else if (tile.type.on_arrive) { + tile.type.on_arrive(tile, this, actor); + } + }); + } + + collect_chip() { + if (this.chips_remaining > 0) { + this.chips_remaining--; + } + } + + // TODO make a set of primitives for actually altering the level that also + // record how to undo themselves +} + +const GAME_UI_HTML = ` +
+
+
+
+
+
+
+
+
+`; +class Game { + constructor(tileset, level) { + this.tileset = tileset; + + // TODO obey level options; allow overriding + this.camera_size_x = 9; + this.camera_size_y = 9; + + this.container = document.body; + this.container.innerHTML = GAME_UI_HTML; + this.level_el = this.container.querySelector('.level'); + this.meta_el = this.container.querySelector('.meta'); + this.hint_el = this.container.querySelector('.hint'); + this.chips_el = this.container.querySelector('.chips'); + this.time_el = this.container.querySelector('.time'); + this.inventory_el = this.container.querySelector('.inventory'); + this.bummer_el = this.container.querySelector('.bummer'); + + this.load_level(level); + + this.level_canvas = mk('canvas', {width: tileset.size_x * this.camera_size_x, height: tileset.size_y * this.camera_size_y}); + this.level_el.append(this.level_canvas); + this.level_canvas.setAttribute('tabindex', '-1'); + + let last_key; + this.pending_player_move = null; + this.next_player_move = null; + this.player_used_move = false; + let key_target = this.container; + // TODO this could all probably be more rigorous but it's fine for now + key_target.addEventListener('keydown', ev => { + let direction; + if (ev.key === 'ArrowDown') { + direction = 'south'; + } + else if (ev.key === 'ArrowUp') { + direction = 'north'; + } + else if (ev.key === 'ArrowLeft') { + direction = 'west'; + } + else if (ev.key === 'ArrowRight') { + direction = 'east'; + } + + if (! direction) + return; + ev.stopPropagation(); + ev.preventDefault(); + + last_key = ev.key; + this.pending_player_move = direction; + this.next_player_move = direction; + this.player_used_move = false; + }); + key_target.addEventListener('keyup', ev => { + if (ev.key === last_key) { + last_key = null; + this.pending_player_move = null; + if (this.player_used_move) { + this.next_player_move = null; + } + } + }); + + this.redraw(); + + this.frame = 0; + this.tick++; + requestAnimationFrame(this.do_frame.bind(this)); + } + + load_level(level) { + this.level = level; + // FIXME do better + this.meta_el.textContent = this.level.stored_level.title; + this.update_ui(); + } + + do_frame() { + if (this.level.state === 'playing') { + this.frame++; + if (this.frame % 6 === 0) { + this.level.halftic(); + } + if (this.frame % 12 === 0) { + this.level.advance(this.next_player_move); + this.next_player_move = this.pending_player_move; + this.player_used_move = true; + } + if (this.frame % 6 === 0) { + this.redraw(); + } + this.frame %= 60; + + this.update_ui(); + } + + requestAnimationFrame(this.do_frame.bind(this)); + } + + update_ui() { + this.chips_el.textContent = this.level.chips_remaining; + + if (this.level.state === 'failure') { + this.bummer_el.textContent = this.level.fail_message; + } + else { + this.bummer_el.textContent = ''; + } + } + + redraw() { + let ctx = this.level_canvas.getContext('2d'); + ctx.clearRect(0, 0, this.level_canvas.width, this.level_canvas.height); + + let camera_x = this.level.player.x - (this.camera_size_x - 1) / 2; + let camera_y = this.level.player.y - (this.camera_size_y - 1) / 2; + for (let dx = 0; dx < this.camera_size_x; dx++) { + for (let dy = 0; dy < this.camera_size_y; dy++) { + let cell = this.level.cells[dy + camera_y][dx + camera_x]; + /* + if (! cell.is_dirty) + continue; + */ + cell.is_dirty = false; + + for (let tile of cell) { + if (! tile.doomed) { + this.tileset.draw(tile, ctx, dx, dy); + } + } + } + } + } +} + +async function load_level(url) { + let xhr = new XMLHttpRequest; + let promise = promise_event(xhr, 'load', 'error'); + xhr.open('GET', url); + xhr.responseType = 'arraybuffer'; + xhr.send(); + await promise; + let data = xhr.response; + return c2m.parse(data); +} + +async function load_game(url) { + let xhr = new XMLHttpRequest; + let promise = promise_event(xhr, 'load', 'error'); + xhr.open('GET', url); + xhr.responseType = 'arraybuffer'; + xhr.send(); + await promise; + let data = xhr.response; + return dat.parse_game(data); +} + + + +async function main() { + //let game = new Game; + let tiles = new Image(); + //tiles.src = 'tileset-ms.png'; + tiles.src = 'tileset-tworld.png'; + //tiles.src = 'tileset-lexy.png'; + //await promise_event(tiles, 'load', 'error'); + await tiles.decode(); + //let tileset = new Tileset(tiles, CC2_TILESET_LAYOUT, TILE_SIZE_X, TILE_SIZE_Y); + let tileset = new Tileset(tiles, TILE_WORLD_TILESET_LAYOUT, 48, 48); + + let level_file = '001-020/map001.c2m'; + if (location.search) { + level_file = '001-020/' + location.search.substring(1); + } + // TODO error handling, yadda + //let stored_level = await load_level(level_file); + // TODO also support tile world's DAC when reading from local?? + // TODO ah, there's more metadata in CCX, crapola + let stored_game = await load_game('levels/CCLP1.ccl'); + let level = new Level(stored_game.levels[0]); + let game = new Game(tileset, level); +} + +main(); diff --git a/js/tileset.js b/js/tileset.js new file mode 100644 index 0000000..721b719 --- /dev/null +++ b/js/tileset.js @@ -0,0 +1,154 @@ +export const CC2_TILESET_LAYOUT = { + floor: [0, 2], + wall: [1, 2], + ice: [10, 1], + ice_sw: [12, 1], + ice_nw: [14, 1], + ice_ne: [13, 1], + ice_se: [11, 1], + water: [ + [12, 24], + [13, 24], + [14, 24], + [15, 24], + ], + fire: [ + [12, 29], + [13, 29], + [14, 29], + [15, 29], + ], + force_floor_n: [[0, 19], [0, 20]], + force_floor_e: [[2, 19], [2, 20]], + force_floor_s: [[1, 19], [1, 20]], + force_floor_w: [[3, 19], [3, 20]], + + exit: [ + [6, 2], + [7, 2], + [8, 2], + [9, 2], + ], + + // TODO moving + swimming + pushing animations + player: { + north: [0, 22], + south: [0, 23], + west: [8, 23], + east: [8, 22], + }, + // TODO these shouldn't loop + player_drowned: [[4, 5], [5, 5], [6, 5], [7, 5]], + player_burned: [[0, 5], [1, 5], [2, 5], [3, 5]], + dirt_block: [8, 1], + + door_red: [0, 1], + door_blue: [1, 1], + door_yellow: [2, 1], + door_green: [3, 1], + key_red: [4, 1], + key_blue: [5, 1], + key_yellow: [6, 1], + key_green: [7, 1], + chip: [11, 3], + chip_extra: [10, 3], + socket: [4, 2], + + dirt: [4, 31], + bug: { + north: [[0, 7], [1, 7], [2, 7], [3, 7]], + east: [[4, 7], [5, 7], [6, 7], [7, 7]], + south: [[8, 7], [9, 7], [10, 7], [11, 7]], + west: [[12, 7], [13, 7], [14, 7], [15, 7]], + }, + + cleats: [2, 6], + suction_boots: [3, 6], + fire_boots: [1, 6], + flippers: [0, 6], + + clue: [5, 2], +}; + +export const TILE_WORLD_TILESET_LAYOUT = { + floor: [0, 0], + wall: [0, 1], + ice: [0, 12], + ice_sw: [1, 13], + ice_nw: [1, 10], + ice_ne: [1, 11], + ice_se: [1, 12], + water: [0, 3], + fire: [0, 4], + force_floor_n: [1, 2], + force_floor_e: [1, 3], + force_floor_s: [0, 13], + force_floor_w: [1, 4], + + exit: [[3, 10], [3, 11]], + + player: { + north: [6, 12], + south: [6, 14], + west: [6, 13], + east: [6, 15], + }, + player_drowned: [3, 3], + player_burned: [3, 4], + // TODO the tileset has several of these...? why? + dirt_block: [0, 10], + + door_red: [1, 7], + door_blue: [1, 6], + door_yellow: [1, 9], + door_green: [1, 8], + key_red: [6, 5], + key_blue: [6, 4], + key_yellow: [6, 7], + key_green: [6, 6], + chip: [0, 2], + // XXX can't use for cc2 levels, need to specify that somehow + //chip_extra: [10, 3], + socket: [2, 2], + + dirt: [0, 11], + bug: { + north: [4, 0], + east: [4, 3], + south: [4, 2], + west: [4, 1], + }, + + cleats: [6, 10], + suction_boots: [6, 11], + fire_boots: [6, 9], + flippers: [6, 8], + + clue: [2, 15], +}; + +export class Tileset { + constructor(image, layout, size_x, size_y) { + this.image = image; + this.layout = layout; + this.size_x = size_x; + this.size_y = size_y; + } + + draw(tile, ctx, x, y) { + let drawspec = this.layout[tile.type.name]; + let coords = drawspec; + if (!(coords instanceof Array)) { + // Must be an object of directions + coords = coords[tile.direction ?? 'south']; + } + if (coords[0] instanceof Array) { + coords = coords[0]; + } + + ctx.drawImage( + this.image, + coords[0] * this.size_x, coords[1] * this.size_y, this.size_x, this.size_y, + x * this.size_x, y * this.size_y, this.size_x, this.size_y); + } +} diff --git a/js/tiletypes.js b/js/tiletypes.js new file mode 100644 index 0000000..278c442 --- /dev/null +++ b/js/tiletypes.js @@ -0,0 +1,275 @@ +export const TILE_TYPES = { + floor: { + cc2_byte: 0x01, + }, + wall: { + cc2_byte: 0x02, + blocks: true, + }, + ice: { + cc2_byte: 0x03, + }, + ice_sw: { + cc2_byte: 0x04, + thin_walls: { + south: true, + west: true, + }, + }, + ice_nw: { + cc2_byte: 0x05, + thin_walls: { + north: true, + west: true, + }, + }, + ice_ne: { + cc2_byte: 0x06, + thin_walls: { + north: true, + east: true, + }, + }, + ice_se: { + cc2_byte: 0x07, + thin_walls: { + south: true, + east: true, + }, + }, + water: { + cc2_byte: 0x08, + on_arrive(me, level, other) { + // TODO cc1 allows items under water, i think; water was on the upper layer + if (other.type.name == 'dirt_block') { + other.destroy(); + me.become('dirt'); + } + else if (other.type.is_player) { + level.fail("Oops! You can't swim without flippers!"); + other.become('player_drowned'); + } + else { + other.destroy(); + } + } + }, + fire: { + cc2_byte: 0x09, + on_arrive(me, level, other) { + if (other.type.is_player) { + level.fail("Oops! You can't walk on fire without fire boots!"); + other.become('player_burned'); + } + else { + other.destroy(); + } + } + }, + force_floor_n: { + cc2_byte: 0x0a, + on_arrive(me, level, other) { + other.direction = 'north'; + other.is_sliding = true; + } + }, + force_floor_e: { + cc2_byte: 0x0b, + on_arrive(me, level, other) { + other.direction = 'east'; + other.is_sliding = true; + } + }, + force_floor_s: { + cc2_byte: 0x0c, + on_arrive(me, level, other) { + other.direction = 'south'; + other.is_sliding = true; + } + }, + force_floor_w: { + cc2_byte: 0x0d, + on_arrive(me, level, other) { + other.direction = 'west'; + other.is_sliding = true; + } + }, + + exit: { + cc2_byte: 0x14, + }, + + player: { + cc2_byte: 0x16, + is_actor: true, + is_player: true, + has_inventory: true, + has_direction: true, + is_top_layer: true, + pushes: { + dirt_block: true, + }, + infinite_items: { + key_green: true, + }, + }, + player_drowned: { + cc2_byte: null, + }, + player_burned: { + cc2_byte: null, + }, + dirt_block: { + cc2_byte: 0x17, + blocks: true, + has_direction: true, + is_top_layer: true, + }, + + door_red: { + cc2_byte: 0x22, + blocks: true, + on_bump(me, level, other) { + if (other.type.has_inventory && other.take_item('key_red')) { + me.type = TILE_TYPES.floor; + } + } + }, + door_blue: { + cc2_byte: 0x23, + blocks: true, + on_bump(me, level, other) { + if (other.type.has_inventory && other.take_item('key_blue')) { + me.type = TILE_TYPES.floor; + } + } + }, + door_yellow: { + cc2_byte: 0x24, + blocks: true, + on_bump(me, level, other) { + if (other.type.has_inventory && other.take_item('key_yellow')) { + me.type = TILE_TYPES.floor; + } + } + }, + door_green: { + cc2_byte: 0x25, + blocks: true, + on_bump(me, level, other) { + if (other.type.has_inventory && other.take_item('key_green')) { + me.type = TILE_TYPES.floor; + } + } + }, + key_red: { + cc2_byte: 0x26, + is_top_layer: true, + is_item: true, + }, + key_blue: { + cc2_byte: 0x27, + is_top_layer: true, + is_item: true, + }, + key_yellow: { + cc2_byte: 0x28, + is_top_layer: true, + is_item: true, + }, + key_green: { + cc2_byte: 0x29, + is_top_layer: true, + is_item: true, + }, + chip: { + cc2_byte: 0x2a, + is_top_layer: true, + is_chip: true, + is_required_chip: true, + on_arrive(me, level, other) { + if (other.type.is_player) { + level.collect_chip(); + me.destroy(); + } + } + }, + chip_extra: { + cc2_byte: 0x2b, + is_chip: true, + is_top_layer: true, + }, + socket: { + cc2_byte: 0x2c, + blocks: true, + on_bump(me, level, other) { + if (other.type.is_player && level.chips_remaining === 0) { + me.type = TILE_TYPES.floor; + } + } + }, + + dirt: { + cc2_byte: 0x32, + // TODO block monsters, and melinda only without the hiking boots + on_arrive(me, level, other) { + me.become('floor'); + } + }, + bug: { + cc2_byte: 0x33, + is_actor: true, + has_direction: true, + is_top_layer: true, + }, + + cleats: { + cc2_byte: 0x3b, + is_top_layer: true, + is_item: true, + item_ignores: new Set(['ice']), + }, + suction_boots: { + cc2_byte: 0x3c, + is_top_layer: true, + is_item: true, + item_ignores: new Set([ + 'force_floor_n', + 'force_floor_s', + 'force_floor_e', + 'force_floor_w', + ]), + }, + fire_boots: { + cc2_byte: 0x3d, + is_top_layer: true, + is_item: true, + item_ignores: new Set(['fire']), + }, + flippers: { + cc2_byte: 0x3e, + is_top_layer: true, + is_item: true, + item_ignores: new Set(['water']), + }, + + clue: { + cc2_byte: 0x45, + }, +}; + + +export const CC2_TILE_TYPES = new Array(256); +CC2_TILE_TYPES.fill(null); +for (let [name, tiledef] of Object.entries(TILE_TYPES)) { + tiledef.name = name; + + if (tiledef.cc2_byte === null) + continue; + + let existing = CC2_TILE_TYPES[tiledef.cc2_byte]; + if (existing) + throw new Error(`Duplicate CC2 byte: ${tiledef.cc2_byte} is both '${existing}' and '${name}'`); + + CC2_TILE_TYPES[tiledef.cc2_byte] = name; +} diff --git a/levels/CCLP1.ccl b/levels/CCLP1.ccl new file mode 100644 index 0000000..8358a97 Binary files /dev/null and b/levels/CCLP1.ccl differ diff --git a/style.css b/style.css new file mode 100644 index 0000000..b79f906 --- /dev/null +++ b/style.css @@ -0,0 +1,79 @@ +html { + height: 100%; +} +body { + font-size: 24px; + min-height: 100%; + margin: 0; + + display: flex; + justify-content: center; + align-items: center; + + background: #606060; +} +main { + display: grid; + grid: + "level meta" min-content + "level chips" min-content + "level time" min-content + "level hint" 1fr + "level inventory" min-content + / min-content 12em + ; + gap: 1em; +} + +.level { + grid-area: level; + + position: relative; +} +.level canvas { + display: block; + width: calc(9 * 32px * 2); + width: calc(9 * 48px * 2); + image-rendering: optimizeSpeed; +} +.meta { + grid-area: meta; + + color: yellow; + background: black; + text-align: center; +} +.chips { + grid-area: chips; + + color: yellow; + background: black; +} +.time { + grid-area: time; +} +.hint { + grid-area: hint; +} +.inventory { + grid-area: inventory; +} +.bummer { + grid-area: level; + + display: flex; + justify-content: center; + align-items: center; + + z-index: 99; + font-size: 48px; + padding: 25%; + background: #0009; + color: white; + text-align: center; + font-weight: bold; + text-shadow: 0 2px 1px black; +} +.bummer:empty { + display: none; +} diff --git a/tileset-tworld.png b/tileset-tworld.png new file mode 100644 index 0000000..fb0acc0 Binary files /dev/null and b/tileset-tworld.png differ