Initial commit: a game that plays through some of CCLP1

This commit is contained in:
Eevee (Evelyn Woods) 2020-08-28 04:02:03 -06:00
commit 3084ca7b49
11 changed files with 1571 additions and 0 deletions

37
README.md Normal file
View File

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

11
index.html Normal file
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf8">
<title>Lexy's Labyrinth</title>
<link rel="stylesheet" type="text/css" href="style.css">
<script type="module" src="js/main.js"></script>
</head>
<body>
</body>
</html>

192
js/format-c2m.js Normal file
View File

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

227
js/format-dat.js Normal file
View File

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

34
js/format-util.js Normal file
View File

@ -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 = [];
}
}

562
js/main.js Normal file
View File

@ -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 = `
<main>
<div class="level"><!-- level canvas and any overlays go here --></div>
<div class="meta"></div>
<div class="hint"></div>
<div class="chips"></div>
<div class="time"></div>
<div class="inventory"></div>
<div class="bummer"></div>
</main>
`;
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();

154
js/tileset.js Normal file
View File

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

275
js/tiletypes.js Normal file
View File

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

BIN
levels/CCLP1.ccl Normal file

Binary file not shown.

79
style.css Normal file
View File

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

BIN
tileset-tworld.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB