2232 lines
65 KiB
JavaScript
2232 lines
65 KiB
JavaScript
import { DIRECTIONS, DIRECTION_ORDER, LAYERS } from './defs.js';
|
|
import * as format_base from './format-base.js';
|
|
import TILE_TYPES from './tiletypes.js';
|
|
import * as util from './util.js';
|
|
|
|
export function decode_replay(bytes) {
|
|
if (bytes instanceof ArrayBuffer) {
|
|
bytes = new Uint8Array(bytes);
|
|
}
|
|
|
|
// byte 0 is unknown, always 0?
|
|
// Force floor seed can apparently be anything; my best guess, based on the Desert Oasis
|
|
// replay, is that it's just incremented and allowed to overflow, so taking it mod 4 gives
|
|
// the correct starting direction
|
|
let initial_force_floor_direction = DIRECTION_ORDER[bytes[1] % 4];
|
|
let blob_seed = bytes[2];
|
|
|
|
// Add up the total length
|
|
let duration = 0;
|
|
let l = bytes.length ?? bytes.byteLength;
|
|
if (l % 2 === 0) {
|
|
l--;
|
|
}
|
|
for (let p = 3; p < l; p += 2) {
|
|
let delay = bytes[p];
|
|
if (delay === 0xff)
|
|
break;
|
|
duration += delay;
|
|
}
|
|
duration = Math.floor(duration / 3) + 1; // leave room for final input
|
|
|
|
// Inflate the replay into an array of byte-per-tic
|
|
let inputs = new Uint8Array(duration);
|
|
let i = 0;
|
|
let t = 0;
|
|
let input = 0;
|
|
for (let p = 3; p < l; p += 2) {
|
|
// The first byte measures how long the /previous/ input remains
|
|
// valid, so yield that first. Note that this is measured in 60Hz
|
|
// frames, so we need to convert to 20Hz tics by subtracting 3
|
|
// frames at a time.
|
|
let delay = bytes[p];
|
|
if (delay === 0xff)
|
|
break;
|
|
|
|
t += delay;
|
|
while (t >= 3) {
|
|
t -= 3;
|
|
inputs[i] = input;
|
|
i++;
|
|
}
|
|
|
|
input = bytes[p + 1];
|
|
let is_player_2 = ((input & 0x80) !== 0);
|
|
// TODO handle player 2
|
|
if (is_player_2)
|
|
continue;
|
|
}
|
|
inputs[i] = input;
|
|
|
|
return new format_base.Replay(initial_force_floor_direction, blob_seed, inputs);
|
|
}
|
|
|
|
export function encode_replay(replay, stored_level = null) {
|
|
let out = new Uint8Array(1024);
|
|
out[0] = 0;
|
|
out[1] = DIRECTIONS[replay.initial_force_floor_direction].index;
|
|
out[2] = replay.blob_seed;
|
|
let p = 3;
|
|
let prev_input = null;
|
|
let count = 0;
|
|
for (let i = 0; i < replay.duration; i++) {
|
|
if (p >= out.length - 4) {
|
|
let new_out = new Uint8Array(Math.floor(out.length * 1.5));
|
|
for (let j = 0; j < out.length; j++) {
|
|
new_out[j] = out[j];
|
|
}
|
|
out = new_out;
|
|
}
|
|
|
|
let input = replay.inputs[i];
|
|
if (input !== prev_input || count >= 252 - 2) {
|
|
out[p] = count;
|
|
out[p + 1] = input;
|
|
p += 2;
|
|
count = 3;
|
|
prev_input = input;
|
|
}
|
|
else {
|
|
count += 3;
|
|
}
|
|
}
|
|
out[p] = 0xff;
|
|
p += 1;
|
|
out[p] = 0x00;
|
|
p += 1;
|
|
out = out.subarray(0, p);
|
|
// TODO stick it on the level if given?
|
|
return out;
|
|
}
|
|
|
|
|
|
let modifier_wire = {
|
|
decode(tile, modifier) {
|
|
tile.wire_directions = modifier & 0x0f;
|
|
// TODO wait, what happens if you use wire tunnels on steel or something other than floor?
|
|
tile.wire_tunnel_directions = (modifier & 0xf0) >> 4;
|
|
},
|
|
encode(tile) {
|
|
return tile.wire_directions | (tile.wire_tunnel_directions << 4);
|
|
},
|
|
};
|
|
|
|
let modifier_color = {
|
|
_order: ['red', 'blue', 'yellow', 'green'],
|
|
decode(tile, modifier) {
|
|
tile.color = this._order[modifier % 4];
|
|
},
|
|
encode(tile) {
|
|
return this._order.indexOf(tile.color);
|
|
},
|
|
};
|
|
|
|
let arg_direction = {
|
|
size: 1,
|
|
decode(tile, dirbyte) {
|
|
let direction = DIRECTION_ORDER[dirbyte & 0x03];
|
|
tile.direction = direction;
|
|
},
|
|
encode(tile) {
|
|
return {north: 0, east: 1, south: 2, west: 3}[tile.direction];
|
|
},
|
|
};
|
|
|
|
// TODO assert that direction + next match the tile types
|
|
const TILE_ENCODING = {
|
|
0x01: {
|
|
name: 'floor',
|
|
modifier: modifier_wire,
|
|
},
|
|
0x02: {
|
|
name: 'wall',
|
|
},
|
|
0x03: {
|
|
name: 'ice',
|
|
},
|
|
0x04: {
|
|
name: 'ice_sw',
|
|
},
|
|
0x05: {
|
|
name: 'ice_nw',
|
|
},
|
|
0x06: {
|
|
name: 'ice_ne',
|
|
},
|
|
0x07: {
|
|
name: 'ice_se',
|
|
},
|
|
0x08: {
|
|
name: 'water',
|
|
},
|
|
0x09: {
|
|
name: 'fire',
|
|
},
|
|
0x0a: {
|
|
name: 'force_floor_n',
|
|
},
|
|
0x0b: {
|
|
name: 'force_floor_e',
|
|
},
|
|
0x0c: {
|
|
name: 'force_floor_s',
|
|
},
|
|
0x0d: {
|
|
name: 'force_floor_w',
|
|
},
|
|
0x0e: {
|
|
name: 'green_wall',
|
|
},
|
|
0x0f: {
|
|
name: 'green_floor',
|
|
},
|
|
0x10: {
|
|
name: 'teleport_red',
|
|
modifier: modifier_wire,
|
|
},
|
|
0x11: {
|
|
name: 'teleport_blue',
|
|
modifier: modifier_wire,
|
|
},
|
|
0x12: {
|
|
name: 'teleport_yellow',
|
|
},
|
|
0x13: {
|
|
name: 'teleport_green',
|
|
},
|
|
0x14: {
|
|
name: 'exit',
|
|
},
|
|
0x15: {
|
|
name: 'slime',
|
|
},
|
|
0x16: {
|
|
name: 'player',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x17: {
|
|
name: 'dirt_block',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x18: {
|
|
name: 'walker',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x19: {
|
|
name: 'glider',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x1a: {
|
|
name: 'ice_block',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x1b: {
|
|
// CC1 south thin wall
|
|
name: 'thin_walls',
|
|
has_next: true,
|
|
modifier: {
|
|
dummy: true,
|
|
decode(tile, mod) {
|
|
tile.edges = DIRECTIONS['south'].bit;
|
|
},
|
|
encode(tile) {
|
|
return 0;
|
|
},
|
|
},
|
|
},
|
|
0x1c: {
|
|
// CC1 east thin wall
|
|
name: 'thin_walls',
|
|
has_next: true,
|
|
modifier: {
|
|
dummy: true,
|
|
decode(tile, mod) {
|
|
tile.edges = DIRECTIONS['east'].bit;
|
|
},
|
|
encode(tile) {
|
|
return 0;
|
|
},
|
|
},
|
|
},
|
|
0x1d: {
|
|
// CC1 southeast thin wall
|
|
name: 'thin_walls',
|
|
has_next: true,
|
|
modifier: {
|
|
dummy: true,
|
|
decode(tile, mod) {
|
|
tile.edges = DIRECTIONS['south'].bit | DIRECTIONS['east'].bit;
|
|
},
|
|
encode(tile) {
|
|
return 0;
|
|
},
|
|
},
|
|
},
|
|
0x1e: {
|
|
name: 'gravel',
|
|
},
|
|
0x1f: {
|
|
name: 'button_green',
|
|
},
|
|
0x20: {
|
|
name: 'button_blue',
|
|
},
|
|
0x21: {
|
|
name: 'tank_blue',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x22: {
|
|
name: 'door_red',
|
|
},
|
|
0x23: {
|
|
name: 'door_blue',
|
|
},
|
|
0x24: {
|
|
name: 'door_yellow',
|
|
},
|
|
0x25: {
|
|
name: 'door_green',
|
|
},
|
|
0x26: {
|
|
name: 'key_red',
|
|
has_next: true,
|
|
},
|
|
0x27: {
|
|
name: 'key_blue',
|
|
has_next: true,
|
|
},
|
|
0x28: {
|
|
name: 'key_yellow',
|
|
has_next: true,
|
|
},
|
|
0x29: {
|
|
name: 'key_green',
|
|
has_next: true,
|
|
},
|
|
0x2a: {
|
|
name: 'chip',
|
|
has_next: true,
|
|
},
|
|
0x2b: {
|
|
name: 'chip_extra',
|
|
has_next: true,
|
|
},
|
|
0x2c: {
|
|
name: 'socket',
|
|
},
|
|
0x2d: {
|
|
name: 'popwall',
|
|
},
|
|
0x2e: {
|
|
name: 'wall_appearing',
|
|
},
|
|
0x2f: {
|
|
name: 'wall_invisible',
|
|
},
|
|
0x30: {
|
|
name: 'fake_wall',
|
|
},
|
|
0x31: {
|
|
name: 'fake_floor',
|
|
},
|
|
0x32: {
|
|
name: 'dirt',
|
|
},
|
|
0x33: {
|
|
name: 'bug',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x34: {
|
|
name: 'paramecium',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x35: {
|
|
name: 'ball',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x36: {
|
|
name: 'blob',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x37: {
|
|
name: 'teeth',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x38: {
|
|
name: 'fireball',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x39: {
|
|
name: 'button_red',
|
|
},
|
|
0x3a: {
|
|
name: 'button_brown',
|
|
},
|
|
0x3b: {
|
|
name: 'cleats',
|
|
has_next: true,
|
|
},
|
|
0x3c: {
|
|
name: 'suction_boots',
|
|
has_next: true,
|
|
},
|
|
0x3d: {
|
|
name: 'fire_boots',
|
|
has_next: true,
|
|
},
|
|
0x3e: {
|
|
name: 'flippers',
|
|
has_next: true,
|
|
},
|
|
0x3f: {
|
|
name: 'thief_tools',
|
|
},
|
|
0x40: {
|
|
name: 'bomb',
|
|
has_next: true,
|
|
},
|
|
0x41: {
|
|
name: 'trap',
|
|
// Not actually a modifier, just using this for hax
|
|
// FIXME round-trip this, maybe expose it in the editor (sigh)
|
|
modifier: {
|
|
dummy: true,
|
|
decode(tile, mod) {
|
|
tile._initially_open = true;
|
|
},
|
|
encode(tile) {
|
|
return 0;
|
|
},
|
|
},
|
|
},
|
|
0x42: {
|
|
name: 'trap',
|
|
},
|
|
0x43: {
|
|
name: 'cloner',
|
|
},
|
|
0x44: {
|
|
name: 'cloner',
|
|
modifier: {
|
|
decode(tile, mod) {
|
|
tile.arrows = mod;
|
|
},
|
|
encode(tile) {
|
|
return tile.arrows;
|
|
},
|
|
},
|
|
},
|
|
0x45: {
|
|
name: 'hint',
|
|
},
|
|
0x46: {
|
|
name: 'force_floor_all',
|
|
},
|
|
0x47: {
|
|
name: 'button_gray',
|
|
},
|
|
0x48: {
|
|
name: 'swivel_sw',
|
|
dummy_terrain: 'swivel_floor',
|
|
},
|
|
0x49: {
|
|
name: 'swivel_nw',
|
|
dummy_terrain: 'swivel_floor',
|
|
},
|
|
0x4a: {
|
|
name: 'swivel_ne',
|
|
dummy_terrain: 'swivel_floor',
|
|
},
|
|
0x4b: {
|
|
name: 'swivel_se',
|
|
dummy_terrain: 'swivel_floor',
|
|
},
|
|
0x4c: {
|
|
name: 'stopwatch_bonus',
|
|
has_next: true,
|
|
},
|
|
0x4d: {
|
|
name: 'stopwatch_toggle',
|
|
has_next: true,
|
|
},
|
|
0x4e: {
|
|
name: 'transmogrifier',
|
|
modifier: modifier_wire,
|
|
},
|
|
0x4f: {
|
|
name: 'railroad',
|
|
modifier: {
|
|
_parts: ['ne', 'se', 'sw', 'ne', 'ew', 'ns'],
|
|
decode(tile, mask) {
|
|
// Leave the track parts alone as a bitmask; the type has a list of them
|
|
tile.tracks = mask & 0x3f;
|
|
// Check for a switch, which is a bit number in the above mask
|
|
if (mask & 0x40) {
|
|
tile.track_switch = (mask >> 8) & 0x0f;
|
|
}
|
|
else {
|
|
tile.track_switch = null;
|
|
}
|
|
// Initial actor facing is in the highest nybble
|
|
tile.entered_direction = DIRECTION_ORDER[(mask >> 12) & 0x03];
|
|
},
|
|
encode(tile) {
|
|
let ret = tile.tracks & 0x3f;
|
|
if (tile.track_switch !== null) {
|
|
ret |= 0x40;
|
|
ret |= tile.track_switch << 8;
|
|
}
|
|
if (tile.entered_direction) {
|
|
ret |= DIRECTION_ORDER.indexOf(tile.entered_direction) << 12;
|
|
}
|
|
return ret;
|
|
},
|
|
},
|
|
},
|
|
0x50: {
|
|
name: 'steel',
|
|
modifier: modifier_wire,
|
|
},
|
|
0x51: {
|
|
name: 'dynamite',
|
|
has_next: true,
|
|
},
|
|
0x52: {
|
|
name: 'helmet',
|
|
has_next: true,
|
|
},
|
|
0x56: {
|
|
name: 'player2',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x57: {
|
|
name: 'teeth_timid',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x58: {
|
|
// TODO??? unused in main levels -- name: '',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
error: "Explosion animation is not implemented, sorry!",
|
|
},
|
|
0x59: {
|
|
name: 'hiking_boots',
|
|
has_next: true,
|
|
},
|
|
0x5a: {
|
|
name: 'no_player2_sign',
|
|
},
|
|
0x5b: {
|
|
name: 'no_player1_sign',
|
|
},
|
|
0x5c: {
|
|
name: 'logic_gate',
|
|
modifier: {
|
|
decode(tile, modifier) {
|
|
if (modifier >= 0x1e && modifier <= 0x27) {
|
|
// Counter, which can't be rotated
|
|
tile.direction = 'north';
|
|
tile.gate_type = 'counter';
|
|
tile.memory = modifier - 0x1e;
|
|
}
|
|
else {
|
|
tile.direction = DIRECTION_ORDER[modifier & 0x03];
|
|
let type = modifier >> 2;
|
|
if (type < 7) {
|
|
tile.gate_type = ['not', 'and', 'or', 'xor', 'latch-cw', 'nand', 'diode'][type];
|
|
}
|
|
else if (type === 16) {
|
|
tile.gate_type = 'latch-ccw';
|
|
}
|
|
else {
|
|
tile.gate_type = 'bogus';
|
|
}
|
|
}
|
|
},
|
|
encode(tile) {
|
|
let direction_offset = DIRECTIONS[tile.direction].index;
|
|
if (tile.gate_type === 'not') {
|
|
return 0 + direction_offset;
|
|
}
|
|
else if (tile.gate_type === 'and') {
|
|
return 4 + direction_offset;
|
|
}
|
|
else if (tile.gate_type === 'or') {
|
|
return 8 + direction_offset;
|
|
}
|
|
else if (tile.gate_type === 'xor') {
|
|
return 12 + direction_offset;
|
|
}
|
|
else if (tile.gate_type === 'latch-cw') {
|
|
return 16 + direction_offset;
|
|
}
|
|
else if (tile.gate_type === 'nand') {
|
|
return 20 + direction_offset;
|
|
}
|
|
else if (tile.gate_type === 'diode') {
|
|
return 24 + direction_offset;
|
|
}
|
|
else if (tile.gate_type === 'counter') {
|
|
return 30 + tile.memory;
|
|
}
|
|
else if (tile.gate_type === 'latch-ccw') {
|
|
return 64 + direction_offset;
|
|
}
|
|
else {
|
|
return 0xff;
|
|
}
|
|
},
|
|
},
|
|
},
|
|
0x5e: {
|
|
name: 'button_pink',
|
|
modifier: modifier_wire,
|
|
},
|
|
0x5f: {
|
|
name: 'flame_jet_off',
|
|
},
|
|
0x60: {
|
|
name: 'flame_jet_on',
|
|
},
|
|
0x61: {
|
|
name: 'button_orange',
|
|
},
|
|
0x62: {
|
|
name: 'lightning_bolt',
|
|
has_next: true,
|
|
},
|
|
0x63: {
|
|
name: 'tank_yellow',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x64: {
|
|
name: 'button_yellow',
|
|
},
|
|
0x65: {
|
|
name: 'doppelganger1',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x66: {
|
|
name: 'doppelganger2',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x68: {
|
|
name: 'bowling_ball',
|
|
has_next: true,
|
|
},
|
|
0x69: {
|
|
name: 'rover',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x6a: {
|
|
name: 'stopwatch_penalty',
|
|
has_next: true,
|
|
},
|
|
0x6b: {
|
|
name: ['floor_custom_green', 'floor_custom_pink', 'floor_custom_yellow', 'floor_custom_blue'],
|
|
},
|
|
0x6d: {
|
|
// TODO oh this one is probably gonna be hard
|
|
name: '#thinwall/canopy',
|
|
has_next: true,
|
|
},
|
|
0x6f: {
|
|
name: 'railroad_sign',
|
|
has_next: true,
|
|
},
|
|
0x70: {
|
|
name: ['wall_custom_green', 'wall_custom_pink', 'wall_custom_yellow', 'wall_custom_blue'],
|
|
},
|
|
0x71: {
|
|
name: 'floor_letter',
|
|
modifier: {
|
|
decode(tile, ascii_code) {
|
|
if (ascii_code < 28 || ascii_code >= 96) {
|
|
// Invalid
|
|
tile.overlaid_glyph = "?";
|
|
}
|
|
else if (ascii_code < 32) {
|
|
// Arrows are stored goofily
|
|
tile.overlaid_glyph = ["⬆", "➡", "⬇", "⬅"][ascii_code - 28];
|
|
}
|
|
else {
|
|
tile.overlaid_glyph = String.fromCharCode(ascii_code);
|
|
}
|
|
},
|
|
encode(tile) {
|
|
let arrow_index = ["⬆", "➡", "⬇", "⬅"].indexOf(tile.overlaid_glyph);
|
|
if (arrow_index >= 0) {
|
|
return arrow_index + 28;
|
|
}
|
|
|
|
return tile.overlaid_glyph.charCodeAt(0);
|
|
},
|
|
},
|
|
},
|
|
0x72: {
|
|
name: 'purple_floor',
|
|
},
|
|
0x73: {
|
|
name: 'purple_wall',
|
|
},
|
|
0x76: {
|
|
name: '#mod8',
|
|
},
|
|
0x77: {
|
|
name: '#mod16',
|
|
},
|
|
0x78: {
|
|
name: '#mod32',
|
|
},
|
|
0x7a: {
|
|
name: 'score_10',
|
|
has_next: true,
|
|
},
|
|
0x7b: {
|
|
name: 'score_100',
|
|
has_next: true,
|
|
},
|
|
0x7c: {
|
|
name: 'score_1000',
|
|
has_next: true,
|
|
},
|
|
0x7d: {
|
|
name: 'popdown_wall',
|
|
},
|
|
0x7e: {
|
|
name: 'popdown_floor',
|
|
},
|
|
0x7f: {
|
|
name: 'no_sign',
|
|
has_next: true,
|
|
},
|
|
0x80: {
|
|
name: 'score_2x',
|
|
has_next: true,
|
|
},
|
|
0x81: {
|
|
name: 'frame_block',
|
|
has_next: true,
|
|
extra_args: [
|
|
arg_direction,
|
|
{
|
|
size: 1,
|
|
decode(tile, mask) {
|
|
let arrows = new Set;
|
|
for (let [direction, info] of Object.entries(DIRECTIONS)) {
|
|
if (mask & info.bit) {
|
|
arrows.add(direction);
|
|
}
|
|
}
|
|
tile.arrows = arrows;
|
|
},
|
|
encode(tile) {
|
|
let bits = 0;
|
|
for (let direction of tile.arrows) {
|
|
bits |= DIRECTIONS[direction].bit;
|
|
}
|
|
return bits;
|
|
},
|
|
},
|
|
],
|
|
},
|
|
0x82: {
|
|
name: 'floor_mimic',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x83: {
|
|
name: 'green_bomb',
|
|
has_next: true,
|
|
},
|
|
0x84: {
|
|
name: 'green_chip',
|
|
has_next: true,
|
|
},
|
|
0x87: {
|
|
name: 'button_black',
|
|
modifier: modifier_wire,
|
|
},
|
|
0x88: {
|
|
name: 'light_switch_off',
|
|
modifier: modifier_wire,
|
|
},
|
|
0x89: {
|
|
name: 'light_switch_on',
|
|
modifier: modifier_wire,
|
|
},
|
|
0x8a: {
|
|
name: 'thief_keys',
|
|
},
|
|
0x8b: {
|
|
name: 'ghost',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
0x8c: {
|
|
name: 'foil',
|
|
has_next: true,
|
|
},
|
|
0x8d: {
|
|
name: 'turtle',
|
|
},
|
|
0x8e: {
|
|
name: 'xray_eye',
|
|
has_next: true,
|
|
},
|
|
0x8f: {
|
|
name: 'bribe',
|
|
has_next: true,
|
|
},
|
|
0x90: {
|
|
name: 'speed_boots',
|
|
has_next: true,
|
|
},
|
|
0x92: {
|
|
name: 'hook',
|
|
has_next: true,
|
|
},
|
|
|
|
// ------------------------------------------------------------------------------------------------
|
|
// LL-specific tiles
|
|
0xd0: {
|
|
name: 'electrified_floor',
|
|
is_extension: true,
|
|
},
|
|
0xd1: {
|
|
name: 'hole',
|
|
is_extension: true,
|
|
},
|
|
0xd2: {
|
|
name: 'cracked_floor',
|
|
is_extension: true,
|
|
},
|
|
0xd3: {
|
|
name: 'cracked_ice',
|
|
is_extension: true,
|
|
},
|
|
0xd4: {
|
|
name: 'score_5x',
|
|
has_next: true,
|
|
is_extension: true,
|
|
},
|
|
0xd5: {
|
|
name: 'spikes',
|
|
is_extension: true,
|
|
},
|
|
0xd6: {
|
|
name: 'boulder',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
},
|
|
// 0xd7
|
|
0xd8: {
|
|
name: 'dash_floor',
|
|
is_extension: true,
|
|
},
|
|
0xd9: {
|
|
name: 'teleport_blue_exit',
|
|
modifier: modifier_wire,
|
|
is_extension: true,
|
|
},
|
|
0xda: {
|
|
name: 'glass_block',
|
|
has_next: true,
|
|
extra_args: [arg_direction],
|
|
is_extension: true,
|
|
},
|
|
0xe0: {
|
|
name: 'gift_bow',
|
|
has_next: true,
|
|
is_extension: true,
|
|
},
|
|
0xe1: {
|
|
name: 'circuit_block',
|
|
has_next: true,
|
|
modifier: modifier_wire,
|
|
extra_args: [arg_direction],
|
|
is_extension: true,
|
|
},
|
|
0xe2: {
|
|
name: 'skeleton_key',
|
|
has_next: true,
|
|
is_extension: true,
|
|
},
|
|
0xe3: {
|
|
name: 'gate_red',
|
|
has_next: true,
|
|
is_extension: true,
|
|
},
|
|
0xe4: {
|
|
name: 'gate_blue',
|
|
has_next: true,
|
|
is_extension: true,
|
|
},
|
|
0xe5: {
|
|
name: 'gate_yellow',
|
|
has_next: true,
|
|
is_extension: true,
|
|
},
|
|
0xe6: {
|
|
name: 'gate_green',
|
|
has_next: true,
|
|
is_extension: true,
|
|
},
|
|
0xe7: {
|
|
name: 'sand',
|
|
is_extension: true,
|
|
},
|
|
0xed: {
|
|
name: 'ankh',
|
|
has_next: true,
|
|
is_extension: true,
|
|
},
|
|
0xef: {
|
|
name: 'turntable_cw',
|
|
modifier: modifier_wire,
|
|
is_extension: true,
|
|
},
|
|
0xf0: {
|
|
name: 'turntable_ccw',
|
|
modifier: modifier_wire,
|
|
is_extension: true,
|
|
},
|
|
0xf1: {
|
|
name: 'sokoban_block',
|
|
has_next: true,
|
|
modifier: modifier_color,
|
|
extra_args: [arg_direction],
|
|
is_extension: true,
|
|
},
|
|
0xf2: {
|
|
name: 'sokoban_button',
|
|
modifier: modifier_color,
|
|
is_extension: true,
|
|
},
|
|
0xf3: {
|
|
name: 'sokoban_wall',
|
|
modifier: modifier_color,
|
|
is_extension: true,
|
|
},
|
|
0xf4: {
|
|
name: 'one_way_walls',
|
|
has_next: true,
|
|
is_extension: true,
|
|
extra_args: [
|
|
{
|
|
size: 1,
|
|
decode(tile, mask) {
|
|
tile.edges = mask;
|
|
},
|
|
encode(tile) {
|
|
return tile.edges;
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const REVERSE_TILE_ENCODING = {};
|
|
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
|
|
spec.tile_byte = tile_byte;
|
|
|
|
if (spec.name instanceof Array) {
|
|
// Custom floor/wall
|
|
for (let [i, name] of spec.name.entries()) {
|
|
// Copy the spec with a hardcoded modifier
|
|
let new_spec = Object.assign({}, spec);
|
|
new_spec.name = name;
|
|
new_spec.modifier = {
|
|
encode(tile) {
|
|
return i;
|
|
},
|
|
};
|
|
REVERSE_TILE_ENCODING[name] = new_spec;
|
|
}
|
|
}
|
|
else {
|
|
REVERSE_TILE_ENCODING[spec.name] = spec;
|
|
}
|
|
}
|
|
|
|
// Read 1, 2, or 4 bytes from a DataView
|
|
function read_n_bytes(view, start, n) {
|
|
if (n === 1) {
|
|
return view.getUint8(start, true);
|
|
}
|
|
else if (n === 2) {
|
|
return view.getUint16(start, true);
|
|
}
|
|
else if (n === 4) {
|
|
return view.getUint32(start, true);
|
|
}
|
|
else {
|
|
throw new Error(`Can't read ${n} bytes`);
|
|
}
|
|
}
|
|
|
|
// Decompress the little ad-hoc compression scheme used for both map data and solution playback
|
|
function decompress(bytes) {
|
|
let decompressed_length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(0, true);
|
|
let outbytes = new Uint8Array(decompressed_length);
|
|
let p = 2;
|
|
let q = 0;
|
|
while (p < bytes.length) {
|
|
let len = bytes[p];
|
|
p++;
|
|
if (len < 0x80) {
|
|
// Data block
|
|
outbytes.set(new Uint8Array(bytes.buffer, bytes.byteOffset + 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 outbytes;
|
|
}
|
|
|
|
// Iterates over a C2M file and yields: [section type, uint8 array view of the section]
|
|
function* read_c2m_sections(buf) {
|
|
let full_view = new DataView(buf);
|
|
let next_section_start = 0;
|
|
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, 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 util.LLError(`Section at byte ${section_start} of type '${section_type}' extends ${buf.length - next_section_start} bytes past the end of the file`);
|
|
|
|
// This chunk marks the end of the file, full stop; a lot of canonical files have garbage
|
|
// newlines afterwards and will fail to continue to parse beyond this point
|
|
if (section_type === 'END ')
|
|
return;
|
|
|
|
yield [section_type, new Uint8Array(buf, section_start + 8, section_length)];
|
|
}
|
|
}
|
|
|
|
export function parse_level_metadata(buf) {
|
|
let meta = {
|
|
title: null,
|
|
};
|
|
for (let [type, bytes] of read_c2m_sections(buf)) {
|
|
if (type === 'TITL') {
|
|
meta.title = util.string_from_buffer_ascii(bytes, 0, bytes.length - 1).replace(/\r\n/g, "\n");
|
|
// TODO anything else we want for now?
|
|
break;
|
|
}
|
|
}
|
|
return meta;
|
|
}
|
|
|
|
export function parse_level(buf, number = 1) {
|
|
if (ArrayBuffer.isView(buf)) {
|
|
buf = buf.buffer;
|
|
}
|
|
|
|
let level = new format_base.StoredLevel(number);
|
|
level.format = 'c2m';
|
|
level.uses_ll_extensions = false; // we'll update this if it changes
|
|
let default_hint = '';
|
|
let extra_hints = [];
|
|
let hint_tiles = [];
|
|
for (let [type, bytes] of read_c2m_sections(buf)) {
|
|
if (type === 'CC2M' || type === 'LOCK' || type === 'VERS' ||
|
|
type === 'TITL' || type === 'AUTH' ||
|
|
type === 'CLUE' || type === 'NOTE')
|
|
{
|
|
// These are all singular strings (with a terminating NUL, for some reason)
|
|
// XXX character encoding?? seems to be latin1, ugh
|
|
let str = util.string_from_buffer_ascii(bytes, 0, bytes.length - 1).replace(/\r\n/g, "\n");
|
|
|
|
// TODO store more of this, at least for idempotence, maybe
|
|
if (type === 'CC2M') {
|
|
// File version, doesn't seem interesting
|
|
}
|
|
else if (type === 'LOCK') {
|
|
// Unclear, seems to be a comment about the editor...?
|
|
}
|
|
else if (type === 'VERS') {
|
|
// Editor version which created this level
|
|
}
|
|
else if (type === 'TITL') {
|
|
// Level title
|
|
level.title = str;
|
|
}
|
|
else if (type === 'AUTH') {
|
|
// Author's name
|
|
level.author = str;
|
|
}
|
|
else if (type === 'CLUE') {
|
|
// Level hint
|
|
default_hint = str;
|
|
}
|
|
else if (type === 'NOTE') {
|
|
// Author's comments... but might also include tags delimiting special blocks, most
|
|
// notably for storing multiple hints. Note that this parsing might lose data in
|
|
// two cases: if other text is in the same line as the tag (which still counts!),
|
|
// it's silently ignored; if there are more [CLUE] blocks than the level has hints,
|
|
// the extras are silently dropped, because hint text is a tile prop in LL.
|
|
let parts = str.split(/\n?^.*\[(CLUE|JETLIFE|COM)\].*$\n?/mg);
|
|
level.comment = parts[0];
|
|
extra_hints = [];
|
|
for (let i = 1; i < parts.length; i += 2) {
|
|
let type = parts[i];
|
|
let text = parts[i + 1];
|
|
if (type === 'CLUE') {
|
|
extra_hints.push(text);
|
|
}
|
|
// TODO do something with COM (c2g commands) and JETLIFE (easter egg, make flame
|
|
// jets propagate like game of life)
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let view = new DataView(buf, bytes.byteOffset, bytes.byteLength);
|
|
|
|
if (type === 'OPTN') {
|
|
// Level options, which may be truncated at any point
|
|
// TODO implement most of these
|
|
level.time_limit = view.getUint16(0, true);
|
|
|
|
// TODO 0 - 10x10, 1 - 9x9, 2 - split, otherwise unknown which needs handling
|
|
// FIXME does this default to 0 if no OPTN block is present?
|
|
let viewport = 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 (view.byteLength <= 3)
|
|
continue;
|
|
//options.has_solution = view.getUint8(3, true);
|
|
|
|
if (view.byteLength <= 4)
|
|
continue;
|
|
//options.show_map_in_editor = view.getUint8(4, true);
|
|
|
|
if (view.byteLength <= 5)
|
|
continue;
|
|
//options.is_editable = view.getUint8(5, true);
|
|
|
|
if (view.byteLength <= 6)
|
|
continue;
|
|
//options.solution_hash = format_base.string_from_buffer_ascii(buf.slice(
|
|
//section_start + 6, section_start + 22));
|
|
|
|
if (view.byteLength <= 22)
|
|
continue;
|
|
level.hide_logic = !! view.getUint8(22, true);
|
|
|
|
if (view.byteLength <= 23)
|
|
continue;
|
|
level.use_cc1_boots = !! view.getUint8(23, true);
|
|
|
|
if (view.byteLength <= 24)
|
|
continue;
|
|
level.blob_behavior = view.getUint8(24, true);
|
|
}
|
|
else if (type === 'MAP ' || type === 'PACK') {
|
|
if (type === 'PACK') {
|
|
bytes = decompress(bytes);
|
|
}
|
|
let map_view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
let width = bytes[0];
|
|
let height = bytes[1];
|
|
level.size_x = width;
|
|
level.size_y = height;
|
|
let p = 2;
|
|
|
|
let n;
|
|
|
|
function read_spec() {
|
|
let tile_byte = bytes[p];
|
|
p++;
|
|
if (tile_byte === undefined)
|
|
throw new util.LLError(`Read past end of file in cell ${n}`);
|
|
|
|
let spec = TILE_ENCODING[tile_byte];
|
|
if (! spec)
|
|
throw new util.LLError(`Invalid tile type 0x${tile_byte.toString(16)}`);
|
|
if (spec.error)
|
|
throw new util.LLError(spec.error);
|
|
|
|
return spec;
|
|
}
|
|
|
|
for (n = 0; n < width * height; n++) {
|
|
let cell = new format_base.StoredCell;
|
|
while (true) {
|
|
let spec = read_spec();
|
|
|
|
// Deal with modifiers
|
|
let modifier = 0; // defaults to zero
|
|
if (spec.name === '#mod8' || spec.name === '#mod16' || spec.name === '#mod32') {
|
|
if (spec.name === '#mod8') {
|
|
modifier = bytes[p];
|
|
p++;
|
|
}
|
|
else if (spec.name === '#mod16') {
|
|
modifier = map_view.getUint16(p, true);
|
|
p += 2;
|
|
}
|
|
else if (spec.name === '#mod32') {
|
|
modifier = map_view.getUint32(p, true);
|
|
p += 4;
|
|
}
|
|
spec = read_spec();
|
|
if (! spec.modifier && ! (spec.name instanceof Array)) {
|
|
console.warn("Got unexpected modifier for tile:", spec.name);
|
|
}
|
|
}
|
|
|
|
if (spec.is_extension) {
|
|
level.uses_ll_extensions = true;
|
|
}
|
|
|
|
let name = spec.name;
|
|
|
|
// Make a tile template, possibly dealing with some special cases
|
|
if (name === '#thinwall/canopy') {
|
|
// Thin walls and the canopy are combined into a single byte for some
|
|
// reason; split them apart here. Which ones we get is determined by a
|
|
// bitmask
|
|
let mask = bytes[p];
|
|
p++;
|
|
if (mask & 0x10) {
|
|
let type = TILE_TYPES['canopy'];
|
|
cell[type.layer] = {type};
|
|
}
|
|
if (mask & 0x0f) {
|
|
let type = TILE_TYPES['thin_walls'];
|
|
cell[type.layer] = {type, edges: mask & 0x0f};
|
|
}
|
|
// Skip the rest of the loop. That means we don't handle any of the other
|
|
// special behavior below, but neither thin walls nor canopies should use
|
|
// any of it, so that's fine
|
|
continue;
|
|
}
|
|
else if (name instanceof Array) {
|
|
// Custom floors and walls are one of several options, chosen by modifier
|
|
name = name[modifier % name.length];
|
|
}
|
|
|
|
let type = TILE_TYPES[name];
|
|
if (!type) console.error(name, spec);
|
|
let tile = {type};
|
|
cell[type.layer] = tile;
|
|
if (spec.modifier) {
|
|
spec.modifier.decode(tile, modifier);
|
|
}
|
|
|
|
// Swivels come with their own specific flooring
|
|
// TODO this should go on the bottom
|
|
// TODO we should sort and also only allow one thing per layer
|
|
if (spec.dummy_terrain) {
|
|
let type = TILE_TYPES[spec.dummy_terrain];
|
|
cell[type.layer] = {type};
|
|
}
|
|
|
|
if (type.is_hint) {
|
|
// Remember all the hint tiles (in reading order) so we can map extra hints
|
|
// to them later. Don't do it now, since the format doesn't technically
|
|
// guarantee that the metadata sections appear before the map data!
|
|
hint_tiles.push(tile);
|
|
}
|
|
|
|
// Handle extra arguments
|
|
if (spec.extra_args) {
|
|
for (let argspec of spec.extra_args) {
|
|
let arg = read_n_bytes(map_view, p, argspec.size);
|
|
p += argspec.size;
|
|
argspec.decode(tile, arg);
|
|
}
|
|
}
|
|
|
|
if (! spec.has_next)
|
|
break;
|
|
}
|
|
level.linear_cells.push(cell);
|
|
}
|
|
}
|
|
else if (type === 'KEY ') {
|
|
}
|
|
else if (type === 'REPL' || type === 'PRPL') {
|
|
// "Replay", i.e. demo solution
|
|
if (type === 'PRPL') {
|
|
// TODO is even this necessary though
|
|
bytes = decompress(bytes);
|
|
}
|
|
level._replay_data = bytes;
|
|
level._replay_decoder = decode_replay;
|
|
}
|
|
else if (type === 'RDNY') {
|
|
}
|
|
// TODO LL custom chunks, should distinguish somehow
|
|
else if (type === 'LXCM') {
|
|
// Camera regions
|
|
if (bytes.length % 4 !== 0)
|
|
throw new Error(`Expected LXCM chunk to be a multiple of 4 bytes; got ${bytes.length}`);
|
|
|
|
let p = 0;
|
|
while (p < bytes.length) {
|
|
let x = bytes[p + 0];
|
|
let y = bytes[p + 1];
|
|
let w = bytes[p + 2];
|
|
let h = bytes[p + 3];
|
|
// TODO validate? must be smaller than map?
|
|
level.camera_regions.push(new DOMRect(x, y, w, h));
|
|
p += 4;
|
|
}
|
|
}
|
|
else if (type === 'LXCX') {
|
|
// Custom connections, like MSCC (but more! maybe)
|
|
if (bytes.length % 4 !== 0)
|
|
throw new Error(`Expected LXCX chunk to be a multiple of 4 bytes; got ${bytes.length}`);
|
|
|
|
level.has_custom_connections = true;
|
|
let p = 0;
|
|
while (p < bytes.length) {
|
|
let src = view.getUint16(p, true);
|
|
let dest = view.getUint16(p + 2, true);
|
|
level.custom_connections[src] = dest;
|
|
p += 4;
|
|
}
|
|
}
|
|
else {
|
|
// console.warn(`Unrecognized section type '${type}' at offset ${bytes.byteOffset}`, view);
|
|
// TODO save it, persist when editing level
|
|
}
|
|
}
|
|
|
|
// Connect extra hints
|
|
for (let [i, tile] of hint_tiles.entries()) {
|
|
if (i < extra_hints.length) {
|
|
tile.hint_text = extra_hints[i];
|
|
}
|
|
else {
|
|
// Fall back to regular hint
|
|
tile.hint_text = default_hint;
|
|
}
|
|
}
|
|
|
|
return level;
|
|
}
|
|
|
|
// This thin wrapper is passed to StoredGame as the parser function
|
|
function _parse_level_from_stored_meta(meta) {
|
|
return parse_level(meta.bytes, meta.number);
|
|
}
|
|
|
|
|
|
// Write 1, 2, or 4 bytes to a DataView
|
|
function write_n_bytes(view, start, n, value) {
|
|
if (n === 1) {
|
|
view.setUint8(start, value, true);
|
|
}
|
|
else if (n === 2) {
|
|
view.setUint16(start, value, true);
|
|
}
|
|
else if (n === 4) {
|
|
view.setUint32(start, value, true);
|
|
}
|
|
else {
|
|
throw new Error(`Can't write ${n} bytes`);
|
|
}
|
|
}
|
|
|
|
|
|
// Compress map data or a replay, using an LZ77-esque scheme
|
|
function compress(buf) {
|
|
let bytes = new Uint8Array(buf);
|
|
// Can't be longer than the original; if it is, don't bother compressing!
|
|
let outbytes = new Uint8Array(buf.byteLength);
|
|
// First two bytes are uncompressed size
|
|
new DataView(outbytes.buffer).setUint16(0, buf.byteLength, true);
|
|
let p = 0;
|
|
let q = 2;
|
|
let pending_data_length = 0;
|
|
while (p < buf.byteLength) {
|
|
// Look back through the window (the previous 255 bytes, since that's the furthest back we
|
|
// can look) for a match that matches as much of the upcoming data as possible
|
|
let best_start = null;
|
|
let best_length = 0;
|
|
for (let b = Math.max(0, p - 255); b < p; b++) {
|
|
if (bytes[b] !== bytes[p])
|
|
continue;
|
|
|
|
// First byte matches; let's keep going and see how much else does, up to 127 max
|
|
let length = 1;
|
|
while (length < 127 && b + length < buf.byteLength) {
|
|
if (bytes[b + length] === bytes[p + length]) {
|
|
length++;
|
|
}
|
|
else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (length > best_length) {
|
|
best_start = b;
|
|
best_length = length;
|
|
}
|
|
}
|
|
|
|
// If we found a match that's worth copying (i.e. shorter than just writing a data block),
|
|
// then do so
|
|
let do_copy = (best_length > 3);
|
|
|
|
// If we're not copying, add this byte to a pending data block /now/, so the next block can
|
|
// catch it if it happens to be the last byte
|
|
if (! do_copy) {
|
|
pending_data_length += 1;
|
|
p++;
|
|
}
|
|
|
|
// Write out any pending data block if necessary -- i.e. if we're about to write a copy
|
|
// block, if we're at the max size of a data block, or if this is the end of the data
|
|
if (pending_data_length > 0 &&
|
|
(do_copy || pending_data_length === 127 || p >= buf.byteLength))
|
|
{
|
|
outbytes[q] = pending_data_length;
|
|
q++;
|
|
for (let i = p - pending_data_length; i < p; i++) {
|
|
outbytes[q] = bytes[i];
|
|
q++;
|
|
}
|
|
pending_data_length = 0;
|
|
}
|
|
|
|
// Finally, do a copy
|
|
if (do_copy) {
|
|
outbytes[q] = 0x80 + best_length;
|
|
outbytes[q + 1] = p - best_start;
|
|
q += 2;
|
|
// Update p, noting that we might've done a copy into the future
|
|
p += best_length;
|
|
}
|
|
|
|
// If we ever exceed the uncompressed length, don't even bother
|
|
if (q > buf.byteLength) {
|
|
return null;
|
|
}
|
|
}
|
|
return outbytes.subarray(0, q);
|
|
}
|
|
|
|
class C2M {
|
|
constructor() {
|
|
this._sections = []; // array of [name, arraybuffer]
|
|
}
|
|
|
|
add_section(name, buf) {
|
|
if (name.length !== 4) {
|
|
throw new Error(`Section names must be four characters, not '${name}'`);
|
|
}
|
|
|
|
if (typeof buf === 'string' || buf instanceof String) {
|
|
// FIXME encode as latin1, maybe with some kludge for anything that doesn't fit
|
|
let str = buf;
|
|
// C2M also includes the trailing NUL
|
|
buf = new ArrayBuffer(str.length + 1);
|
|
let array = new Uint8Array(buf);
|
|
for (let i = 0, l = str.length; i < l; i++) {
|
|
array[i] = str.charCodeAt(i);
|
|
}
|
|
}
|
|
|
|
this._sections.push([name, buf]);
|
|
}
|
|
|
|
serialize() {
|
|
let parts = [];
|
|
let total_length = 0;
|
|
for (let [name, buf] of this._sections) {
|
|
total_length += buf.byteLength + 8;
|
|
}
|
|
|
|
let ret = new ArrayBuffer(total_length);
|
|
let array = new Uint8Array(ret);
|
|
let view = new DataView(ret);
|
|
let p = 0;
|
|
for (let [name, buf] of this._sections) {
|
|
// Write the header
|
|
for (let i = 0; i < 4; i++) {
|
|
view.setUint8(p + i, name.charCodeAt(i));
|
|
}
|
|
view.setUint32(p + 4, buf.byteLength, true);
|
|
p += 8;
|
|
|
|
// Copy in the section contents
|
|
array.set(new Uint8Array(buf), p);
|
|
p += buf.byteLength;
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
}
|
|
|
|
export function synthesize_level(stored_level) {
|
|
let c2m = new C2M;
|
|
c2m.add_section('CC2M', '7'); // latest version
|
|
// TODO add in a VERS (editor version) section? some other indication LL produced it? not for
|
|
// url sharing though, should make that as small as possible
|
|
|
|
if (stored_level.title) {
|
|
c2m.add_section('TITL', stored_level.title);
|
|
}
|
|
if (stored_level.author) {
|
|
c2m.add_section('AUTH', stored_level.author);
|
|
}
|
|
|
|
// Options block
|
|
let options = new Uint8Array(25); // max possible size
|
|
let options_length = 0;
|
|
new DataView(options.buffer).setUint16(0, stored_level.time_limit, true);
|
|
if (stored_level.viewport_size === 10) {
|
|
options[2] = 0;
|
|
}
|
|
else if (stored_level.viewport_size === 9) {
|
|
options[2] = 1;
|
|
options_length = 3;
|
|
}
|
|
if (stored_level.hide_logic) {
|
|
options[22] = 1;
|
|
options_length = 23;
|
|
}
|
|
if (stored_level.use_cc1_boots) {
|
|
options[23] = 1;
|
|
options_length = 24;
|
|
}
|
|
if (stored_level.blob_behavior !== 0) {
|
|
options[24] = stored_level.blob_behavior;
|
|
options_length = 25;
|
|
}
|
|
// TODO split
|
|
if (options_length > 0) {
|
|
c2m.add_section('OPTN', options.slice(0, options_length));
|
|
}
|
|
|
|
// Store camera regions
|
|
// TODO LL feature, should be distinguished somehow
|
|
if (stored_level.camera_regions.length > 0) {
|
|
let bytes = new Uint8Array(4 * stored_level.camera_regions.length);
|
|
let p = 0;
|
|
for (let region of stored_level.camera_regions) {
|
|
bytes[p + 0] = region.x;
|
|
bytes[p + 1] = region.y;
|
|
bytes[p + 2] = region.width;
|
|
bytes[p + 3] = region.height;
|
|
p += 4;
|
|
}
|
|
c2m.add_section('LXCM', bytes.buffer);
|
|
}
|
|
|
|
// Store MSCC-like custom connections
|
|
// TODO LL feature, should be distinguished somehow
|
|
let num_connections = Object.keys(stored_level.custom_connections).length;
|
|
if (num_connections > 0) {
|
|
let buf = new ArrayBuffer(4 * num_connections);
|
|
let view = new DataView(buf);
|
|
let p = 0;
|
|
for (let [src, dest] of Object.entries(stored_level.custom_connections)) {
|
|
view.setUint16(p + 0, src, true);
|
|
view.setUint16(p + 2, dest, true);
|
|
p += 4;
|
|
}
|
|
c2m.add_section('LXCX', buf);
|
|
}
|
|
|
|
let map_bytes = new Uint8Array(1024);
|
|
let map_view = new DataView(map_bytes.buffer);
|
|
map_bytes[0] = stored_level.size_x;
|
|
map_bytes[1] = stored_level.size_y;
|
|
let hints = [];
|
|
let p = 2;
|
|
for (let cell of stored_level.linear_cells) {
|
|
// If we're in danger of running out of room in our buffer, add another kilobyte and copy
|
|
if (p >= map_bytes.length - 64) {
|
|
let new_bytes = new Uint8Array(map_bytes.length + 1024);
|
|
for (let i = 0; i < map_bytes.length; i++) {
|
|
new_bytes[i] = map_bytes[i];
|
|
}
|
|
map_bytes = new_bytes;
|
|
map_view = new DataView(map_bytes.buffer);
|
|
}
|
|
|
|
let dummy_terrain_tile = null;
|
|
let handled_thin_walls = false;
|
|
for (let i = LAYERS.MAX - 1; i >= 0; i--) {
|
|
let tile = cell[i];
|
|
if (! tile)
|
|
continue;
|
|
|
|
if (tile.type.name === 'canopy' || tile.type.name === 'thin_walls') {
|
|
// These two tiles are encoded together despite being on different layers. If we
|
|
// see the canopy first, then find the thin wall tile (if any) and set a flag so we
|
|
// don't try to encode it again
|
|
if (handled_thin_walls)
|
|
continue;
|
|
handled_thin_walls = true;
|
|
|
|
let canopy, thin_walls;
|
|
if (tile.type.name === 'canopy') {
|
|
canopy = tile;
|
|
thin_walls = cell[LAYERS.thin_wall];
|
|
}
|
|
else {
|
|
thin_walls = tile;
|
|
}
|
|
|
|
let arg = 0;
|
|
if (canopy) {
|
|
arg |= 0x10;
|
|
}
|
|
if (thin_walls) {
|
|
arg |= thin_walls.edges;
|
|
}
|
|
|
|
map_bytes[p] = REVERSE_TILE_ENCODING['#thinwall/canopy'].tile_byte;
|
|
map_bytes[p + 1] = arg;
|
|
p += 2;
|
|
continue;
|
|
}
|
|
|
|
let spec = REVERSE_TILE_ENCODING[tile.type.name];
|
|
|
|
// Handle the swivel, a tile that draws as an overlay but is stored like terrain. In a
|
|
// level, it has two parts: the swivel itself, and a dummy swivel_floor terrain which is
|
|
// unencodable. To encode that, we notice when we hit a swivel (which happens first),
|
|
// save it until we reach the terrain layer, and then sub it in instead.
|
|
// TODO if i follow in tyler's footsteps and give swivel its own layer then i'll need to
|
|
// complicate this somewhat
|
|
if (tile.type.layer === LAYERS.terrain && dummy_terrain_tile) {
|
|
tile = dummy_terrain_tile;
|
|
spec = REVERSE_TILE_ENCODING[tile.type.name];
|
|
}
|
|
else if (spec.dummy_terrain) {
|
|
// This is a swivel, a tile that draws as an overlay but is stored like terrain, so
|
|
// wait until we hit terrain and store it then
|
|
dummy_terrain_tile = tile;
|
|
continue;
|
|
}
|
|
|
|
if (spec.modifier) {
|
|
let mod = spec.modifier.encode(tile);
|
|
if (mod === 0) {
|
|
// Zero is optional; do nothing
|
|
}
|
|
else if (mod < 256) {
|
|
// Encode in one byte
|
|
map_bytes[p] = REVERSE_TILE_ENCODING['#mod8'].tile_byte;
|
|
map_bytes[p + 1] = mod;
|
|
p += 2;
|
|
}
|
|
else if (mod < 65536) {
|
|
// Encode in two bytes
|
|
map_bytes[p] = REVERSE_TILE_ENCODING['#mod16'].tile_byte;
|
|
map_view.setUint16(p + 1, mod, true);
|
|
p += 3;
|
|
}
|
|
else {
|
|
// Encode in four (!) bytes
|
|
map_bytes[p] = REVERSE_TILE_ENCODING['#mod32'].tile_byte;
|
|
map_view.setUint16(p + 1, mod, true);
|
|
p += 5;
|
|
}
|
|
}
|
|
|
|
map_bytes[p] = spec.tile_byte;
|
|
p++;
|
|
|
|
if (spec.extra_args) {
|
|
for (let argspec of spec.extra_args) {
|
|
let arg = argspec.encode(tile);
|
|
write_n_bytes(map_view, p, argspec.size, arg);
|
|
p += argspec.size;
|
|
}
|
|
}
|
|
|
|
if (tile.type.name === 'hint') {
|
|
hints.push(tile.hint_text);
|
|
}
|
|
|
|
// TODO assert that the bottom tile has no next, and all the others do
|
|
}
|
|
}
|
|
map_bytes = map_bytes.subarray(0, p);
|
|
|
|
let comment = stored_level.comment;
|
|
if (hints.length) {
|
|
// Collect hints first so we can put them in the comment field
|
|
// FIXME this does not respect global hint, but then, neither does the editor.
|
|
hints = hints.map(hint => hint ?? '');
|
|
hints.push('');
|
|
hints.unshift('');
|
|
// Must use Windows linebreaks here 🙄
|
|
comment += hints.join('\r\n[CLUE]\r\n');
|
|
}
|
|
// TODO support COM and JETLIFE
|
|
c2m.add_section('NOTE', comment);
|
|
|
|
let compressed_map = compress(map_bytes);
|
|
if (compressed_map) {
|
|
c2m.add_section('PACK', compressed_map);
|
|
}
|
|
else {
|
|
c2m.add_section('MAP ', map_bytes);
|
|
}
|
|
|
|
// Add the replay, if any
|
|
if (stored_level.has_replay) {
|
|
let replay_bytes;
|
|
if (stored_level._replay_data && stored_level._replay_decoder === decode_replay) {
|
|
replay_bytes = stored_level._replay_data;
|
|
}
|
|
else {
|
|
replay_bytes = encode_replay(stored_level.replay, stored_level);
|
|
}
|
|
|
|
let compressed_replay = compress(replay_bytes);
|
|
if (compressed_replay) {
|
|
c2m.add_section('PRPL', compressed_replay);
|
|
}
|
|
else {
|
|
c2m.add_section('REPL', replay_bytes);
|
|
}
|
|
}
|
|
|
|
c2m.add_section('END ', '');
|
|
|
|
return c2m.serialize();
|
|
}
|
|
|
|
|
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
// C2G, the text format that stitches levels together into a game
|
|
|
|
// NOTE: C2G is surprisingly complicated for a game layout format, and most of its features are not
|
|
// currently supported. Most of them have also never been used in practice, so that's fine.
|
|
|
|
// TODO this is not quite right yet; the architect has more specific lexing documentation
|
|
|
|
// Split a statement into a number of tokens. This is, thankfully, relatively easy, due to the
|
|
// minimal syntax and the lack of string escapes (so we don't have to check for " vs \" vs \\").
|
|
// The tokens seem to be one of:
|
|
// - a bareword (could be a variable or keyword)
|
|
// - an operator
|
|
// - a literal number
|
|
// - a quoted string
|
|
// - a label
|
|
// - a comment
|
|
// And that's it! So here's a regex to find all of them, and then we just use matchAll.
|
|
const TOKENIZE_RX = RegExp(
|
|
// Eat any leading horizontal whitespace
|
|
'[ \\t]*(?:' +
|
|
// 1: Catch newlines as their own thing, since they are (sigh) important, sometimes
|
|
'(\\n)' +
|
|
// 2: Comments are preceded by ; or // for some reason and run to the end of the line
|
|
'|(?:;|//)(.*)' +
|
|
// 3: Strings are double-quoted (only!) and contain no escapes
|
|
'|"([^"]*?)"' +
|
|
// 4: Labels are indicated by a #, including when used with 'goto'
|
|
// (the exact set of allowed characters is unclear and i'm fudging it here)
|
|
'|#(\\w+)' +
|
|
// 5: Only decimal integers are allowed
|
|
'|(\\d+)' +
|
|
// 6: Operators are part of a fixed set
|
|
'|(==|<=|>=|!=|&&|\\|\\||[-+*/<>=&|&^])' +
|
|
// 7: Barewords appear to allow literally fucking anything as long as they start with a
|
|
// letter -- the official playcc2 contains `really?'"` as an accidental unquoted string and
|
|
// it's accepted but ignored, so I can only assume it's treated as a variable
|
|
// TODO i really don't like this, it's beyond error-prone
|
|
'|([a-zA-Z]\\S*)' +
|
|
// 8: Anything else is an error
|
|
'|(\\S+)' +
|
|
')', 'g');
|
|
const DIRECTIVES = {
|
|
// Important stuff
|
|
'chdir': ['string'],
|
|
'do': 'statement', // special
|
|
'game': ['string'],
|
|
'goto': ['label'],
|
|
'map': ['string'],
|
|
'music': ['string'],
|
|
'script': 'script', // special
|
|
// Weird stuff
|
|
'edit': [],
|
|
// Seemingly unused, or at least not understood
|
|
'art': ['string'],
|
|
'chain': ['string'],
|
|
'dlc': ['string'],
|
|
'end': [],
|
|
'main': [], // allegedly jumps to playcc2.c2g??
|
|
'wav': ['string'],
|
|
};
|
|
const OPERATORS = {
|
|
'==': {
|
|
argc: 2,
|
|
},
|
|
'<=': {
|
|
},
|
|
'>=': {
|
|
},
|
|
'!=': {
|
|
},
|
|
'<': {
|
|
},
|
|
'>': {
|
|
},
|
|
'=': {
|
|
},
|
|
'*': {
|
|
},
|
|
'/': {
|
|
},
|
|
'+': {
|
|
},
|
|
'-': {
|
|
},
|
|
'&&': {
|
|
},
|
|
'||': {
|
|
},
|
|
'&': {
|
|
},
|
|
'|': {
|
|
},
|
|
'%': {
|
|
},
|
|
'^': {
|
|
},
|
|
};
|
|
|
|
function* tokenize(statement) {
|
|
for (let match of statement.matchAll(TOKENIZE_RX)) {
|
|
if (match[1] !== undefined) {
|
|
// Newline(s)
|
|
yield {type: 'newline'};
|
|
}
|
|
else if (match[2] !== undefined) {
|
|
// Comment, do nothing
|
|
}
|
|
else if (match[3] !== undefined) {
|
|
// String
|
|
yield {type: 'string', value: match[3]};
|
|
}
|
|
else if (match[4] !== undefined) {
|
|
// Label
|
|
yield {type: 'label', value: match[4].toLowerCase()};
|
|
}
|
|
else if (match[5] !== undefined) {
|
|
// Number
|
|
yield {type: 'number', value: parseInt(match[5], 10)};
|
|
}
|
|
else if (match[6] !== undefined) {
|
|
// Operator
|
|
yield {type: 'op', value: match[6]};
|
|
}
|
|
else if (match[7] !== undefined) {
|
|
// Bareword; either a directive or a variable name
|
|
let word = match[7].toLowerCase();
|
|
if (DIRECTIVES[word] !== undefined) {
|
|
yield {type: 'directive', value: word};
|
|
}
|
|
else {
|
|
yield {type: 'variable', value: word};
|
|
}
|
|
}
|
|
else {
|
|
yield {type: 'error', value: match[8]};
|
|
}
|
|
}
|
|
}
|
|
|
|
class ParseError extends Error {
|
|
constructor(message, parser) {
|
|
super(`${message} at line ${parser.lineno}`);
|
|
}
|
|
}
|
|
|
|
class Parser {
|
|
constructor(string) {
|
|
this.string = string;
|
|
this.lexer = tokenize(string);
|
|
this.lineno = 1;
|
|
this.done = false;
|
|
this._peek = null;
|
|
}
|
|
|
|
peek() {
|
|
if (this._peek === null) {
|
|
let next = this.lexer.next();
|
|
if (! next.done) {
|
|
this._peek = next.value;
|
|
if (this._peek.type === 'error')
|
|
throw new ParseError(`Bad syntax: ${this._peek.value}`, this);
|
|
}
|
|
}
|
|
|
|
return this._peek;
|
|
}
|
|
|
|
advance() {
|
|
if (this.done)
|
|
return null;
|
|
|
|
let token;
|
|
if (this._peek !== null) {
|
|
token = this._peek;
|
|
this._peek = null;
|
|
}
|
|
else {
|
|
let next = this.lexer.next();
|
|
if (next.done) {
|
|
this.done = true;
|
|
return null;
|
|
}
|
|
|
|
token = next.value;
|
|
if (token.type === 'error')
|
|
throw new ParseError(`Bad syntax: ${token.value}`, this);
|
|
}
|
|
|
|
if (token && token.type === 'newline') {
|
|
this.lineno++;
|
|
}
|
|
return token;
|
|
}
|
|
|
|
advance_ignore_newlines() {
|
|
if (this.done)
|
|
return null;
|
|
|
|
let token = this.advance();
|
|
while (token && token.type === 'newline') {
|
|
token = this.advance();
|
|
}
|
|
|
|
return token;
|
|
}
|
|
|
|
parse_statement() {
|
|
let token = this.advance_ignore_newlines();
|
|
if (! token)
|
|
return null;
|
|
|
|
// Check for a directive and handle it separately
|
|
if (token.type === 'directive') {
|
|
return this.parse_directive(token.value);
|
|
}
|
|
|
|
// A string (outside of a script block) doesn't seem to do anything?
|
|
if (token.type === 'string') {
|
|
return {
|
|
kind: 'noop',
|
|
tokens: [token],
|
|
};
|
|
}
|
|
|
|
// A lone label is a label declaration
|
|
if (token.type === 'label') {
|
|
return {
|
|
kind: 'label',
|
|
name: token.value,
|
|
};
|
|
}
|
|
|
|
// An operator is not a valid start; this uses RPN so values must come first
|
|
if (token.type === 'op')
|
|
throw new ParseError(`Unexpected operator: ${token.value}`, this);
|
|
|
|
// Otherwise (number, bareword presumed to be a variable), we have an RPN expression; keep
|
|
// consuming tokens until we finish the expression
|
|
let branches = [token];
|
|
while (true) {
|
|
let next = this.peek();
|
|
if (! next) {
|
|
break;
|
|
}
|
|
else if (next.type === 'number' || next.type === 'variable') {
|
|
let token = this.advance();
|
|
branches.push(token);
|
|
}
|
|
else if (next.type === 'op') {
|
|
let token = this.advance();
|
|
if (! token || token.type === 'newline')
|
|
break;
|
|
|
|
// All operators are binary, so pop the last two expressions
|
|
if (branches.length < 2)
|
|
throw new ParseError(`Not enough arguments for operator: ${token.value}`, this);
|
|
let a = branches.pop();
|
|
let b = branches.pop();
|
|
branches.push({
|
|
op: token.value,
|
|
left: a,
|
|
right: b,
|
|
});
|
|
|
|
// TODO return now if we just did an =?
|
|
}
|
|
else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
kind: 'expression',
|
|
trees: branches,
|
|
};
|
|
}
|
|
|
|
parse_directive(name) {
|
|
let argspec = DIRECTIVES[name];
|
|
if (argspec === 'statement') {
|
|
// TODO implement this for real
|
|
// eat the rest of the line for now
|
|
while (true) {
|
|
let token = this.advance();
|
|
if (! token || token.type === 'newline') {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else if (argspec === 'script') {
|
|
// Script mode; expect a newline, then sequences of [string, values..., newline]
|
|
let lines = [];
|
|
let newline = this.advance();
|
|
if (newline && newline.type !== 'newline')
|
|
throw new ParseError(`Expected a newline after 'script' directive`, this);
|
|
while (true) {
|
|
let next = this.peek();
|
|
while (next && next.type === 'newline') {
|
|
this.advance();
|
|
next = this.peek();
|
|
}
|
|
if (! next)
|
|
break;
|
|
|
|
// If this is a string, we're still in script mode; eat the whole line
|
|
if (next.type === 'string') {
|
|
let string = this.advance();
|
|
let args = [];
|
|
// TODO can args be expressions??
|
|
while (true) {
|
|
let arg = this.advance();
|
|
if (! arg || arg.type === 'newline') {
|
|
break;
|
|
}
|
|
else if (arg.type === 'number' || arg.type === 'variable') {
|
|
args.push(arg);
|
|
}
|
|
else {
|
|
throw new ParseError(`Unexpected ${arg.type} token found in script mode: ${arg.value}`, this);
|
|
}
|
|
}
|
|
lines.push({
|
|
string: string,
|
|
args: args,
|
|
});
|
|
}
|
|
// If not a string, script mode is over
|
|
else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
kind: 'script',
|
|
lines: lines,
|
|
};
|
|
}
|
|
else {
|
|
// Normal arguments
|
|
let args = [];
|
|
for (let argtype of argspec) {
|
|
let token = this.advance();
|
|
if (! token || token.type === 'newline') {
|
|
// If we're cut off early, the whole directive is ignored
|
|
return {
|
|
kind: 'noop',
|
|
directive: name,
|
|
tokens: args,
|
|
};
|
|
}
|
|
else if (token.type === argtype) {
|
|
args.push(token);
|
|
}
|
|
else {
|
|
throw new ParseError(`Directive ${name} expected a ${argtype} token but got ${token.type}`, this);
|
|
}
|
|
}
|
|
return {
|
|
kind: 'directive',
|
|
name: name,
|
|
args: args,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// C2G is a Chip's Challenge 2 format that describes the structure of a level set, which is helpful
|
|
// since CC2 levels are all stored in separate files
|
|
// XXX observations i have made about this hell format:
|
|
// - newlines are optional, except after: do, map, script, goto
|
|
// - `1 level = music "+Intro"` crashes the game
|
|
// - `map\n"path"` is completely ignored, and in fact newlines between a directive and its arguments
|
|
// in general seem to separate them
|
|
const MAX_SIMULTANEOUS_REQUESTS = 5;
|
|
/*async*/ export function parse_game(buf, source, base_path) {
|
|
// TODO maybe do something with this later
|
|
let warn = () => {};
|
|
|
|
let resolve;
|
|
let promise = new Promise((res, rej) => { resolve = res });
|
|
|
|
let game = new format_base.StoredGame(undefined, _parse_level_from_stored_meta);
|
|
let parser;
|
|
let active_map_fetches = new Set;
|
|
let pending_map_fetches = [];
|
|
let _fetch_map = (path, n) => {
|
|
let promise = source.get(base_path + '/' + path);
|
|
active_map_fetches.add(promise);
|
|
|
|
let meta = {
|
|
// TODO this will not always fly, the slot is not the same as the number
|
|
index: n - 1,
|
|
number: n,
|
|
};
|
|
game.level_metadata[meta.index] = meta;
|
|
|
|
promise.then(buf => {
|
|
meta.bytes = new Uint8Array(buf);
|
|
Object.assign(meta, parse_level_metadata(buf));
|
|
})
|
|
.then(null, err => {
|
|
// TODO should have: what level, what file, position, etc attached to errors
|
|
console.error(err);
|
|
meta.error = err;
|
|
})
|
|
.then(() => {
|
|
// Always remove our promise and start a new map load if any are waiting
|
|
active_map_fetches.delete(promise);
|
|
if (active_map_fetches.size < MAX_SIMULTANEOUS_REQUESTS && pending_map_fetches.length > 0) {
|
|
_fetch_map(...pending_map_fetches.shift());
|
|
}
|
|
else if (active_map_fetches.size === 0 && pending_map_fetches.length === 0 && parser.done) {
|
|
// FIXME this is a bit of a mess
|
|
resolve(game);
|
|
}
|
|
});
|
|
};
|
|
let fetch_map = (path, n) => {
|
|
if (active_map_fetches.size >= MAX_SIMULTANEOUS_REQUESTS) {
|
|
pending_map_fetches.push([path, n]);
|
|
return;
|
|
}
|
|
|
|
_fetch_map(path, n);
|
|
};
|
|
|
|
// FIXME and right off the bat we have an Issue: this is a text format so i want a string, not
|
|
// an arraybuffer!
|
|
let contents = util.string_from_buffer_ascii(buf);
|
|
parser = new Parser(contents);
|
|
let statements = [];
|
|
let level_number = 1;
|
|
while (! parser.done) {
|
|
let stmt = parser.parse_statement();
|
|
if (stmt === null)
|
|
break;
|
|
|
|
// TODO search 'do' as well
|
|
if (stmt.kind === 'directive' && stmt.name === 'map') {
|
|
let path = stmt.args[0].value;
|
|
path = path.replace(/\\/, '/');
|
|
fetch_map(path, level_number);
|
|
level_number++;
|
|
}
|
|
else if (stmt.kind === 'directive' && stmt.name === 'game') {
|
|
// TODO apparently cc2 lets you change this mid-game and will then use a different save
|
|
// slot (?!), but i can't even consider that until i actually execute these things in
|
|
// order
|
|
if (game.identifier === undefined) {
|
|
let title = stmt.args[0].value;
|
|
game.identifier = title;
|
|
game.title = title;
|
|
}
|
|
}
|
|
statements.push(stmt);
|
|
}
|
|
|
|
// FIXME grody
|
|
if (active_map_fetches.size === 0 && pending_map_fetches.length === 0) {
|
|
resolve(game);
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
// Individual levels don't make sense on their own, but we can wrap them in a dummy one-level game
|
|
export function wrap_individual_level(buf) {
|
|
let game = new format_base.StoredGame(undefined, _parse_level_from_stored_meta);
|
|
let meta = {
|
|
index: 0,
|
|
number: 1,
|
|
bytes: new Uint8Array(buf),
|
|
};
|
|
try {
|
|
Object.assign(meta, parse_level_metadata(buf));
|
|
}
|
|
catch (e) {
|
|
meta.error = e;
|
|
}
|
|
game.level_metadata.push(meta);
|
|
return game;
|
|
}
|