Initial commit: a game that plays through some of CCLP1
This commit is contained in:
commit
3084ca7b49
37
README.md
Normal file
37
README.md
Normal 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
11
index.html
Normal 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
192
js/format-c2m.js
Normal 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
227
js/format-dat.js
Normal 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
34
js/format-util.js
Normal 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
562
js/main.js
Normal 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
154
js/tileset.js
Normal 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
275
js/tiletypes.js
Normal 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
BIN
levels/CCLP1.ccl
Normal file
Binary file not shown.
79
style.css
Normal file
79
style.css
Normal 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
BIN
tileset-tworld.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
Loading…
Reference in New Issue
Block a user