Add support for recording replays, with a bunch of refactoring along the way
This commit is contained in:
parent
85a81878cc
commit
1c9dee1213
10
js/defs.js
10
js/defs.js
@ -41,6 +41,16 @@ export const DIRECTIONS = {
|
|||||||
// Should match the bit ordering above, and CC2's order
|
// Should match the bit ordering above, and CC2's order
|
||||||
export const DIRECTION_ORDER = ['north', 'east', 'south', 'west'];
|
export const DIRECTION_ORDER = ['north', 'east', 'south', 'west'];
|
||||||
|
|
||||||
|
export const INPUT_BITS = {
|
||||||
|
drop: 0x01,
|
||||||
|
down: 0x02,
|
||||||
|
left: 0x04,
|
||||||
|
right: 0x08,
|
||||||
|
up: 0x10,
|
||||||
|
swap: 0x20,
|
||||||
|
cycle: 0x40,
|
||||||
|
};
|
||||||
|
|
||||||
// TODO cc2 order is: swivel, thinwalls, canopy (and yes you can have them all in the same tile)
|
// TODO cc2 order is: swivel, thinwalls, canopy (and yes you can have them all in the same tile)
|
||||||
export const DRAW_LAYERS = {
|
export const DRAW_LAYERS = {
|
||||||
terrain: 0,
|
terrain: 0,
|
||||||
|
|||||||
@ -3,6 +3,51 @@ import * as util from './util.js';
|
|||||||
export class StoredCell extends Array {
|
export class StoredCell extends Array {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Replay {
|
||||||
|
constructor(initial_force_floor_direction, blob_seed, inputs = null) {
|
||||||
|
this.initial_force_floor_direction = initial_force_floor_direction;
|
||||||
|
this.blob_seed = blob_seed;
|
||||||
|
this.inputs = inputs ?? new Uint8Array;
|
||||||
|
this.duration = this.inputs.length;
|
||||||
|
this.cursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(t) {
|
||||||
|
if (this.duration <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
else if (t < this.duration) {
|
||||||
|
return this.inputs[t];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Last input is implicitly repeated indefinitely
|
||||||
|
return this.inputs[this.duration - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(t, input) {
|
||||||
|
if (t >= this.inputs.length) {
|
||||||
|
let new_inputs = new Uint8Array(this.inputs.length + 1024);
|
||||||
|
for (let i = 0; i < this.inputs.length; i++) {
|
||||||
|
new_inputs[i] = this.inputs[i];
|
||||||
|
}
|
||||||
|
this.inputs = new_inputs;
|
||||||
|
}
|
||||||
|
this.inputs[t] = input;
|
||||||
|
if (t >= this.duration) {
|
||||||
|
this.duration = t + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clone() {
|
||||||
|
let new_inputs = new Uint8Array(this.duration);
|
||||||
|
for (let i = 0; i < this.duration; i++) {
|
||||||
|
new_inputs[i] = this.inputs[i];
|
||||||
|
}
|
||||||
|
return new this.constructor(this.initial_force_floor_direction, this.blob_seed, new_inputs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class StoredLevel {
|
export class StoredLevel {
|
||||||
constructor(number) {
|
constructor(number) {
|
||||||
// TODO still not sure this belongs here
|
// TODO still not sure this belongs here
|
||||||
@ -22,6 +67,12 @@ export class StoredLevel {
|
|||||||
// 2 - extra random (like deterministic, but initial seed is "actually" random)
|
// 2 - extra random (like deterministic, but initial seed is "actually" random)
|
||||||
this.blob_behavior = 1;
|
this.blob_behavior = 1;
|
||||||
|
|
||||||
|
// Lazy-loading that allows for checking existence (see methods below)
|
||||||
|
// TODO this needs a better interface, these get accessed too much atm
|
||||||
|
this._replay = null;
|
||||||
|
this._replay_data = null;
|
||||||
|
this._replay_decoder = null;
|
||||||
|
|
||||||
this.size_x = 0;
|
this.size_x = 0;
|
||||||
this.size_y = 0;
|
this.size_y = 0;
|
||||||
this.linear_cells = [];
|
this.linear_cells = [];
|
||||||
@ -47,6 +98,17 @@ export class StoredLevel {
|
|||||||
|
|
||||||
check() {
|
check() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get has_replay() {
|
||||||
|
return this._replay || (this._replay_data && this._replay_decoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
get replay() {
|
||||||
|
if (! this._replay) {
|
||||||
|
this._replay = this._replay_decoder(this._replay_data);
|
||||||
|
}
|
||||||
|
return this._replay;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StoredPack {
|
export class StoredPack {
|
||||||
|
|||||||
110
js/format-c2g.js
110
js/format-c2g.js
@ -3,42 +3,33 @@ import * as format_base from './format-base.js';
|
|||||||
import TILE_TYPES from './tiletypes.js';
|
import TILE_TYPES from './tiletypes.js';
|
||||||
import * as util from './util.js';
|
import * as util from './util.js';
|
||||||
|
|
||||||
const CC2_DEMO_INPUT_MASK = {
|
export function decode_replay(bytes) {
|
||||||
drop: 0x01,
|
if (bytes instanceof ArrayBuffer) {
|
||||||
down: 0x02,
|
bytes = new Uint8Array(bytes);
|
||||||
left: 0x04,
|
}
|
||||||
right: 0x08,
|
|
||||||
up: 0x10,
|
|
||||||
swap: 0x20,
|
|
||||||
cycle: 0x40,
|
|
||||||
};
|
|
||||||
|
|
||||||
class CC2Demo {
|
|
||||||
constructor(bytes) {
|
|
||||||
this.bytes = bytes;
|
|
||||||
|
|
||||||
// byte 0 is unknown, always 0?
|
// byte 0 is unknown, always 0?
|
||||||
// Force floor seed can apparently be anything; my best guess, based on the Desert Oasis
|
// 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
|
// replay, is that it's just incremented and allowed to overflow, so taking it mod 4 gives
|
||||||
// the correct starting direction
|
// the correct starting direction
|
||||||
this.initial_force_floor_direction = ['north', 'east', 'south', 'west'][this.bytes[1] % 4];
|
let initial_force_floor_direction = DIRECTION_ORDER[bytes[1] % 4];
|
||||||
this.blob_seed = this.bytes[2];
|
let blob_seed = bytes[2];
|
||||||
}
|
|
||||||
|
|
||||||
decompress() {
|
// Add up the total length
|
||||||
let duration = 0;
|
let duration = 0;
|
||||||
let l = this.bytes.length;
|
let l = bytes.length ?? bytes.byteLength;
|
||||||
if (l % 2 === 0) {
|
if (l % 2 === 0) {
|
||||||
l--;
|
l--;
|
||||||
}
|
}
|
||||||
for (let p = 3; p < l; p += 2) {
|
for (let p = 3; p < l; p += 2) {
|
||||||
let delay = this.bytes[p];
|
let delay = bytes[p];
|
||||||
if (delay === 0xff)
|
if (delay === 0xff)
|
||||||
break;
|
break;
|
||||||
duration += delay;
|
duration += delay;
|
||||||
}
|
}
|
||||||
duration = Math.floor(duration / 3);
|
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 inputs = new Uint8Array(duration);
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let t = 0;
|
let t = 0;
|
||||||
@ -48,7 +39,7 @@ class CC2Demo {
|
|||||||
// valid, so yield that first. Note that this is measured in 60Hz
|
// 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, so we need to convert to 20Hz tics by subtracting 3
|
||||||
// frames at a time.
|
// frames at a time.
|
||||||
let delay = this.bytes[p];
|
let delay = bytes[p];
|
||||||
if (delay === 0xff)
|
if (delay === 0xff)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -59,31 +50,51 @@ class CC2Demo {
|
|||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
input = this.bytes[p + 1];
|
input = bytes[p + 1];
|
||||||
let is_player_2 = ((input & 0x80) !== 0);
|
let is_player_2 = ((input & 0x80) !== 0);
|
||||||
// TODO handle player 2
|
// TODO handle player 2
|
||||||
if (is_player_2)
|
if (is_player_2)
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
inputs[i] = input;
|
||||||
|
|
||||||
// TODO maybe turn this into, like, a type, or something
|
return new format_base.Replay(initial_force_floor_direction, blob_seed, inputs);
|
||||||
return {
|
}
|
||||||
inputs: inputs,
|
|
||||||
final_input: input,
|
export function encode_replay(replay, stored_level = null) {
|
||||||
length: duration,
|
let out = new Uint8Array(1024);
|
||||||
get(t) {
|
out[0] = 0;
|
||||||
let input;
|
out[1] = DIRECTIONS[replay.initial_force_floor_direction].index;
|
||||||
if (t < this.inputs.length) {
|
out[2] = replay.blob_seed;
|
||||||
input = this.inputs[t];
|
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) {
|
||||||
|
out[p] = count;
|
||||||
|
out[p + 1] = input;
|
||||||
|
p += 2;
|
||||||
|
count = 3;
|
||||||
|
prev_input = input;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Last input is implicitly repeated indefinitely
|
count += 3;
|
||||||
input = this.final_input;
|
|
||||||
}
|
}
|
||||||
return new Set(Object.entries(CC2_DEMO_INPUT_MASK).filter(([action, bit]) => input & bit).map(([action, bit]) => action));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
out[p] = 0xff;
|
||||||
|
p += 1;
|
||||||
|
out = out.subarray(0, p);
|
||||||
|
// TODO stick it on the level if given?
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -101,7 +112,7 @@ let modifier_wire = {
|
|||||||
let arg_direction = {
|
let arg_direction = {
|
||||||
size: 1,
|
size: 1,
|
||||||
decode(tile, dirbyte) {
|
decode(tile, dirbyte) {
|
||||||
let direction = ['north', 'east', 'south', 'west'][dirbyte & 0x03];
|
let direction = DIRECTION_ORDER[dirbyte & 0x03];
|
||||||
tile.direction = direction;
|
tile.direction = direction;
|
||||||
},
|
},
|
||||||
encode(tile) {
|
encode(tile) {
|
||||||
@ -472,7 +483,7 @@ const TILE_ENCODING = {
|
|||||||
tile.memory = modifier - 0x1e;
|
tile.memory = modifier - 0x1e;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tile.direction = ['north', 'east', 'south', 'west'][modifier & 0x03];
|
tile.direction = DIRECTION_ORDER[modifier & 0x03];
|
||||||
let type = modifier >> 2;
|
let type = modifier >> 2;
|
||||||
if (type < 6) {
|
if (type < 6) {
|
||||||
tile.gate_type = ['not', 'and', 'or', 'xor', 'latch-cw', 'nand'][type];
|
tile.gate_type = ['not', 'and', 'or', 'xor', 'latch-cw', 'nand'][type];
|
||||||
@ -1088,9 +1099,11 @@ export function parse_level(buf, number = 1) {
|
|||||||
else if (type === 'REPL' || type === 'PRPL') {
|
else if (type === 'REPL' || type === 'PRPL') {
|
||||||
// "Replay", i.e. demo solution
|
// "Replay", i.e. demo solution
|
||||||
if (type === 'PRPL') {
|
if (type === 'PRPL') {
|
||||||
|
// TODO is even this necessary though
|
||||||
bytes = decompress(bytes);
|
bytes = decompress(bytes);
|
||||||
}
|
}
|
||||||
level.demo = new CC2Demo(bytes);
|
level._replay_data = bytes;
|
||||||
|
level._replay_decoder = decode_replay;
|
||||||
}
|
}
|
||||||
else if (type === 'RDNY') {
|
else if (type === 'RDNY') {
|
||||||
}
|
}
|
||||||
@ -1423,6 +1436,25 @@ export function synthesize_level(stored_level) {
|
|||||||
c2m.add_section('MAP ', map_bytes);
|
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 ', '');
|
c2m.add_section('END ', '');
|
||||||
|
|
||||||
return c2m.serialize();
|
return c2m.serialize();
|
||||||
|
|||||||
@ -120,6 +120,17 @@ export class ConfirmOverlay extends DialogOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function flash_button(button) {
|
||||||
|
button.classList.add('--button-glow-ok');
|
||||||
|
window.setTimeout(() => {
|
||||||
|
button.classList.add('--button-glow');
|
||||||
|
button.classList.remove('--button-glow-ok');
|
||||||
|
}, 10);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
button.classList.remove('--button-glow');
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
export function load_json_from_storage(key) {
|
export function load_json_from_storage(key) {
|
||||||
return JSON.parse(window.localStorage.getItem(key));
|
return JSON.parse(window.localStorage.getItem(key));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
|
|||||||
import { TILES_WITH_PROPS } from './editor-tile-overlays.js';
|
import { TILES_WITH_PROPS } from './editor-tile-overlays.js';
|
||||||
import * as format_base from './format-base.js';
|
import * as format_base from './format-base.js';
|
||||||
import * as c2g from './format-c2g.js';
|
import * as c2g from './format-c2g.js';
|
||||||
import { PrimaryView, TransientOverlay, DialogOverlay, load_json_from_storage, save_json_to_storage } from './main-base.js';
|
import { PrimaryView, TransientOverlay, DialogOverlay, flash_button, load_json_from_storage, save_json_to_storage } from './main-base.js';
|
||||||
import CanvasRenderer from './renderer-canvas.js';
|
import CanvasRenderer from './renderer-canvas.js';
|
||||||
import TILE_TYPES from './tiletypes.js';
|
import TILE_TYPES from './tiletypes.js';
|
||||||
import { SVG_NS, mk, mk_svg, string_from_buffer_ascii, bytestring_to_buffer, walk_grid } from './util.js';
|
import { SVG_NS, mk, mk_svg, string_from_buffer_ascii, bytestring_to_buffer, walk_grid } from './util.js';
|
||||||
@ -267,8 +267,8 @@ class EditorShareOverlay extends DialogOverlay {
|
|||||||
this.main.append(mk('p.editor-share-url', {}, url));
|
this.main.append(mk('p.editor-share-url', {}, url));
|
||||||
let copy_button = mk('button', {type: 'button'}, "Copy to clipboard");
|
let copy_button = mk('button', {type: 'button'}, "Copy to clipboard");
|
||||||
copy_button.addEventListener('click', ev => {
|
copy_button.addEventListener('click', ev => {
|
||||||
|
flash_button(ev.target);
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
// TODO feedback?
|
|
||||||
});
|
});
|
||||||
this.main.append(copy_button);
|
this.main.append(copy_button);
|
||||||
|
|
||||||
@ -1643,11 +1643,7 @@ export class Editor extends PrimaryView {
|
|||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
});
|
});
|
||||||
_make_button("Share", ev => {
|
_make_button("Share", ev => {
|
||||||
let buf = c2g.synthesize_level(this.stored_level);
|
let data = util.b64encode(c2g.synthesize_level(this.stored_level));
|
||||||
// FIXME Not ideal, but btoa() wants a string rather than any of the myriad binary types
|
|
||||||
let stringy_buf = Array.from(new Uint8Array(buf)).map(n => String.fromCharCode(n)).join('');
|
|
||||||
// Make URL-safe and strip trailing padding
|
|
||||||
let data = btoa(stringy_buf).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
|
|
||||||
let url = new URL(location);
|
let url = new URL(location);
|
||||||
url.searchParams.delete('level');
|
url.searchParams.delete('level');
|
||||||
url.searchParams.delete('setpath');
|
url.searchParams.delete('setpath');
|
||||||
|
|||||||
246
js/main.js
246
js/main.js
@ -1,11 +1,11 @@
|
|||||||
// TODO bugs and quirks i'm aware of:
|
// 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
|
// - 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 { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
|
import { DIRECTIONS, INPUT_BITS, TICS_PER_SECOND } from './defs.js';
|
||||||
import * as c2g from './format-c2g.js';
|
import * as c2g from './format-c2g.js';
|
||||||
import * as dat from './format-dat.js';
|
import * as dat from './format-dat.js';
|
||||||
import * as format_base from './format-base.js';
|
import * as format_base from './format-base.js';
|
||||||
import { Level } from './game.js';
|
import { Level } from './game.js';
|
||||||
import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay } from './main-base.js';
|
import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay, flash_button } from './main-base.js';
|
||||||
import { Editor } from './main-editor.js';
|
import { Editor } from './main-editor.js';
|
||||||
import CanvasRenderer from './renderer-canvas.js';
|
import CanvasRenderer from './renderer-canvas.js';
|
||||||
import SOUNDTRACK from './soundtrack.js';
|
import SOUNDTRACK from './soundtrack.js';
|
||||||
@ -15,6 +15,13 @@ import { random_choice, mk, mk_svg, promise_event } from './util.js';
|
|||||||
import * as util from './util.js';
|
import * as util from './util.js';
|
||||||
|
|
||||||
const PAGE_TITLE = "Lexy's Labyrinth";
|
const PAGE_TITLE = "Lexy's Labyrinth";
|
||||||
|
// This prefix is LLDEMO in base64, used to be somewhat confident that a string is a valid demo
|
||||||
|
// (it's 6 characters so it becomes exactly 8 base64 chars with no leftovers to entangle)
|
||||||
|
const REPLAY_PREFIX = "TExERU1P";
|
||||||
|
|
||||||
|
function format_replay_duration(t) {
|
||||||
|
return `${t} tics (${util.format_duration(t / TICS_PER_SECOND)})`;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO:
|
// TODO:
|
||||||
// - level password, if any
|
// - level password, if any
|
||||||
@ -275,7 +282,6 @@ class Player extends PrimaryView {
|
|||||||
this.time_el = this.root.querySelector('.time output');
|
this.time_el = this.root.querySelector('.time output');
|
||||||
this.bonus_el = this.root.querySelector('.bonus output');
|
this.bonus_el = this.root.querySelector('.bonus output');
|
||||||
this.inventory_el = this.root.querySelector('.inventory');
|
this.inventory_el = this.root.querySelector('.inventory');
|
||||||
this.demo_el = this.root.querySelector('.demo');
|
|
||||||
|
|
||||||
this.music_el = this.root.querySelector('#player-music');
|
this.music_el = this.root.querySelector('#player-music');
|
||||||
this.music_audio_el = this.music_el.querySelector('audio');
|
this.music_audio_el = this.music_el.querySelector('audio');
|
||||||
@ -732,30 +738,132 @@ class Player extends PrimaryView {
|
|||||||
this.debug.input_els[action] = el;
|
this.debug.input_els[action] = el;
|
||||||
input_el.append(el);
|
input_el.append(el);
|
||||||
}
|
}
|
||||||
// Add a button to view a replay
|
// There are two replay slots: the (read-only) one baked into the level, and the one you are
|
||||||
this.debug.replay_button = make_button("run level's replay", () => {
|
// editing. You can also transfer them back and forth.
|
||||||
if (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding') {
|
// This is the level slot
|
||||||
new ConfirmOverlay(this.conductor, "Restart this level and watch the replay?", () => {
|
let extra_replay_elements = [];
|
||||||
this.play_demo();
|
extra_replay_elements.push(mk('hr'));
|
||||||
}).open();
|
this.debug.replay_level_label = mk('p', this.level && this.level.stored_level.has_replay ? "available" : "none");
|
||||||
|
extra_replay_elements.push(mk('div.-replay-available', mk('h4', "From level:"), this.debug.replay_level_label));
|
||||||
|
this.debug.replay_level_buttons = [
|
||||||
|
make_button("Play", () => {
|
||||||
|
if (! this.level.stored_level.has_replay)
|
||||||
|
return;
|
||||||
|
this.confirm_game_interruption("Restart this level to watch the level's built-in replay?", () => {
|
||||||
|
this.restart_level();
|
||||||
|
let replay = this.level.stored_level.replay;
|
||||||
|
this.install_replay(replay, 'level');
|
||||||
|
this.debug.replay_level_label.textContent = format_replay_duration(replay.duration);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
make_button("Edit", () => {
|
||||||
|
if (! this.level.stored_level.has_replay)
|
||||||
|
return;
|
||||||
|
this.debug.custom_replay = this.level.stored_level.replay;
|
||||||
|
this._update_replay_ui();
|
||||||
|
this.debug.replay_custom_label.textContent = format_replay_duration(this.debug.custom_replay.duration);
|
||||||
|
}),
|
||||||
|
make_button("Copy", ev => {
|
||||||
|
let stored_level = this.level.stored_level;
|
||||||
|
if (! stored_level.has_replay)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
if (stored_level._replay_decoder === c2g.decode_replay) {
|
||||||
|
// No need to decode it just to encode it again
|
||||||
|
data = stored_level._replay_data;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.play_demo();
|
data = c2g.encode_replay(stored_level.replay);
|
||||||
}
|
}
|
||||||
|
// This prefix is LLDEMO in base64 (it's 6 characters so it becomes exactly 8 base64
|
||||||
|
// chars and won't entangle with any other characters)
|
||||||
|
navigator.clipboard.writeText(REPLAY_PREFIX + util.b64encode(data));
|
||||||
|
flash_button(ev.target);
|
||||||
|
}),
|
||||||
|
// TODO delete
|
||||||
|
// TODO download entire demo as a file (???)
|
||||||
|
];
|
||||||
|
extra_replay_elements.push(mk('div.-buttons', ...this.debug.replay_level_buttons));
|
||||||
|
// This is the custom slot, which has rather a few more buttons
|
||||||
|
extra_replay_elements.push(mk('hr'));
|
||||||
|
this.debug.replay_custom_label = mk('p', "none");
|
||||||
|
extra_replay_elements.push(mk('div.-replay-available', mk('h4', "Custom:"), this.debug.replay_custom_label));
|
||||||
|
extra_replay_elements.push(mk('div.-buttons',
|
||||||
|
make_button("Record new", () => {
|
||||||
|
this.confirm_game_interruption("Restart this level to record a replay?", () => {
|
||||||
|
this.restart_level();
|
||||||
|
let replay = new format_base.Replay(
|
||||||
|
this.level.force_floor_direction, this.level._blob_modifier);
|
||||||
|
this.install_replay(replay, 'custom', true);
|
||||||
|
this.debug.custom_replay = replay;
|
||||||
|
this.debug.replay_custom_label.textContent = format_replay_duration(replay.duration);
|
||||||
|
this._update_replay_ui();
|
||||||
});
|
});
|
||||||
this._update_replay_button_enabled();
|
}),
|
||||||
debug_el.querySelector('.-replay-columns .-buttons').append(
|
// TODO load from a file?
|
||||||
this.debug.replay_button,
|
make_button("Paste", async ev => {
|
||||||
|
// FIXME firefox doesn't let this fly; provide a textbox instead
|
||||||
|
let string = await navigator.clipboard.readText();
|
||||||
|
if (string.substring(0, REPLAY_PREFIX.length) !== REPLAY_PREFIX) {
|
||||||
|
alert("Not a valid replay string, sorry!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let replay = c2g.decode_replay(util.b64decode(string.substring(REPLAY_PREFIX.length)));
|
||||||
|
this.debug.custom_replay = replay;
|
||||||
|
this.debug.replay_custom_label.textContent = format_replay_duration(replay.duration);
|
||||||
|
this._update_replay_ui();
|
||||||
|
flash_button(ev.target);
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
let row1 = [
|
||||||
|
make_button("Play", () => {
|
||||||
|
if (! this.debug.custom_replay)
|
||||||
|
return;
|
||||||
|
this.confirm_game_interruption("Restart this level to watch your custom replay?", () => {
|
||||||
|
this.restart_level();
|
||||||
|
let replay = this.debug.custom_replay;
|
||||||
|
this.install_replay(replay, 'custom');
|
||||||
|
this.debug.replay_custom_label.textContent = format_replay_duration(replay.duration);
|
||||||
|
});
|
||||||
|
}),
|
||||||
/*
|
/*
|
||||||
mk('button', {disabled: 'disabled'}, "load external replay"),
|
make_button("Record from here", () => {
|
||||||
mk('button', {disabled: 'disabled'}, "regain control"),
|
// TODO this feels poorly thought out i guess
|
||||||
mk('button', {disabled: 'disabled'}, "record new replay"),
|
}),
|
||||||
mk('button', {disabled: 'disabled'}, "record from here"),
|
|
||||||
mk('button', {disabled: 'disabled'}, "browse/edit manually"),
|
|
||||||
*/
|
*/
|
||||||
);
|
];
|
||||||
|
let row2 = [
|
||||||
|
make_button("Save to level", () => {
|
||||||
|
if (! this.debug.custom_replay)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.level.stored_level._replay = this.debug.custom_replay.clone();
|
||||||
|
this.level.stored_level._replay_data = null;
|
||||||
|
this.level.stored_level._replay_decoder = null;
|
||||||
|
this.debug.replay_level_label.textContent = format_replay_duration(this.debug.custom_replay.duration);
|
||||||
|
this._update_replay_ui();
|
||||||
|
}),
|
||||||
|
make_button("Copy", ev => {
|
||||||
|
if (! this.debug.custom_replay)
|
||||||
|
return;
|
||||||
|
|
||||||
|
let data = c2g.encode_replay(this.debug.custom_replay);
|
||||||
|
navigator.clipboard.writeText(REPLAY_PREFIX + util.b64encode(data));
|
||||||
|
flash_button(ev.target);
|
||||||
|
}),
|
||||||
|
// TODO download?
|
||||||
|
];
|
||||||
|
extra_replay_elements.push(mk('div.-buttons', ...row1));
|
||||||
|
extra_replay_elements.push(mk('div.-buttons', ...row2));
|
||||||
|
this.debug.replay_custom_buttons = [...row1, ...row2];
|
||||||
|
// XXX this is an experimental API but it's been supported by The Two Browsers for ages
|
||||||
|
debug_el.querySelector('.-replay-columns').after(...extra_replay_elements);
|
||||||
|
this._update_replay_ui();
|
||||||
|
|
||||||
// Progress bar and whatnot
|
// Progress bar and whatnot
|
||||||
let replay_playback_el = debug_el.querySelector('#player-debug-replay-playback');
|
let replay_playback_el = debug_el.querySelector('.-replay-status > .-playback');
|
||||||
this.debug.replay_playback_el = replay_playback_el;
|
this.debug.replay_playback_el = replay_playback_el;
|
||||||
this.debug.replay_progress_el = replay_playback_el.querySelector('progress');
|
this.debug.replay_progress_el = replay_playback_el.querySelector('progress');
|
||||||
this.debug.replay_percent_el = replay_playback_el.querySelector('output');
|
this.debug.replay_percent_el = replay_playback_el.querySelector('output');
|
||||||
@ -810,9 +918,18 @@ class Player extends PrimaryView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_update_replay_button_enabled() {
|
_update_replay_ui() {
|
||||||
if (this.debug.replay_button) {
|
if (! this.debug.enabled)
|
||||||
this.debug.replay_button.disabled = ! (this.level && this.level.stored_level.demo);
|
return;
|
||||||
|
|
||||||
|
let has_level_replay = (this.level && this.level.stored_level.has_replay);
|
||||||
|
for (let button of this.debug.replay_level_buttons) {
|
||||||
|
button.disabled = ! has_level_replay;
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_custom_replay = !! this.debug.custom_replay;
|
||||||
|
for (let button of this.debug.replay_custom_buttons) {
|
||||||
|
button.disabled = ! has_custom_replay;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -852,7 +969,10 @@ class Player extends PrimaryView {
|
|||||||
this.change_music(this.conductor.level_index % SOUNDTRACK.length);
|
this.change_music(this.conductor.level_index % SOUNDTRACK.length);
|
||||||
this._clear_state();
|
this._clear_state();
|
||||||
|
|
||||||
this._update_replay_button_enabled();
|
this._update_replay_ui();
|
||||||
|
if (this.debug.enabled) {
|
||||||
|
this.debug.replay_level_label.textContent = this.level.stored_level.has_replay ? "available" : "none";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update_viewport_size() {
|
update_viewport_size() {
|
||||||
@ -897,7 +1017,6 @@ class Player extends PrimaryView {
|
|||||||
|
|
||||||
this.turn_mode = this.turn_based_checkbox.checked ? 1 : 0;
|
this.turn_mode = this.turn_based_checkbox.checked ? 1 : 0;
|
||||||
this.last_advance = 0;
|
this.last_advance = 0;
|
||||||
this.demo_faucet = null;
|
|
||||||
this.current_keyring = {};
|
this.current_keyring = {};
|
||||||
this.current_toolbelt = [];
|
this.current_toolbelt = [];
|
||||||
|
|
||||||
@ -905,9 +1024,16 @@ class Player extends PrimaryView {
|
|||||||
this.time_el.classList.remove('--frozen');
|
this.time_el.classList.remove('--frozen');
|
||||||
this.time_el.classList.remove('--danger');
|
this.time_el.classList.remove('--danger');
|
||||||
this.time_el.classList.remove('--warning');
|
this.time_el.classList.remove('--warning');
|
||||||
this.root.classList.remove('--replay');
|
this.root.classList.remove('--replay-playback');
|
||||||
|
this.root.classList.remove('--replay-recording');
|
||||||
this.root.classList.remove('--bonus-visible');
|
this.root.classList.remove('--bonus-visible');
|
||||||
|
|
||||||
|
if (this.debug.enabled) {
|
||||||
|
this.debug.replay = null;
|
||||||
|
this.debug.replay_slot = null;
|
||||||
|
this.debug.replay_recording = false;
|
||||||
|
}
|
||||||
|
|
||||||
this.update_ui();
|
this.update_ui();
|
||||||
// Force a redraw, which won't happen on its own since the game isn't running
|
// Force a redraw, which won't happen on its own since the game isn't running
|
||||||
this._redraw();
|
this._redraw();
|
||||||
@ -917,28 +1043,34 @@ class Player extends PrimaryView {
|
|||||||
new LevelBrowserOverlay(this.conductor).open();
|
new LevelBrowserOverlay(this.conductor).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
play_demo() {
|
install_replay(replay, slot, record = false) {
|
||||||
this.restart_level();
|
if (! this.debug.enabled)
|
||||||
let demo = this.level.stored_level.demo;
|
return;
|
||||||
this.demo_faucet = demo.decompress();
|
|
||||||
this.level.force_floor_direction = demo.initial_force_floor_direction;
|
this.debug.replay = replay;
|
||||||
this.level._blob_modifier = demo.blob_seed;
|
this.debug.replay_slot = slot;
|
||||||
|
this.debug.replay_recording = record;
|
||||||
|
this.debug.replay_playback_el.style.display = '';
|
||||||
|
let t = replay.duration;
|
||||||
|
this.debug.replay_progress_el.setAttribute('max', t);
|
||||||
|
this.debug.replay_duration_el.textContent = format_replay_duration(t);
|
||||||
|
|
||||||
|
if (! record) {
|
||||||
|
this.level.force_floor_direction = replay.initial_force_floor_direction;
|
||||||
|
this.level._blob_modifier = replay.blob_seed;
|
||||||
// FIXME should probably start playback on first real input
|
// FIXME should probably start playback on first real input
|
||||||
this.set_state('playing');
|
this.set_state('playing');
|
||||||
this.root.classList.add('--replay');
|
|
||||||
|
|
||||||
if (this.debug.enabled) {
|
|
||||||
this.debug.replay_playback_el.style.display = '';
|
|
||||||
let t = this.demo_faucet.length;
|
|
||||||
this.debug.replay_progress_el.setAttribute('max', t);
|
|
||||||
this.debug.replay_duration_el.textContent = `${t} tics (${util.format_duration(t / TICS_PER_SECOND)})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.root.classList.toggle('--replay-playback', ! record);
|
||||||
|
this.root.classList.toggle('--replay-recording', record);
|
||||||
}
|
}
|
||||||
|
|
||||||
get_input() {
|
get_input() {
|
||||||
let input;
|
let input;
|
||||||
if (this.demo_faucet) {
|
if (this.debug && this.debug.replay && ! this.debug.replay_recording) {
|
||||||
input = this.demo_faucet.get(this.level.tic_counter);
|
let mask = this.debug.replay.get(this.level.tic_counter);
|
||||||
|
input = new Set(Object.entries(INPUT_BITS).filter(([action, bit]) => mask & bit).map(([action, bit]) => action));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Convert input keys to actions. This is only done now
|
// Convert input keys to actions. This is only done now
|
||||||
@ -971,6 +1103,16 @@ class Player extends PrimaryView {
|
|||||||
for (let i = 0; i < tics; i++) {
|
for (let i = 0; i < tics; i++) {
|
||||||
let input = this.get_input();
|
let input = this.get_input();
|
||||||
|
|
||||||
|
if (this.debug && this.debug.replay && this.debug.replay_recording) {
|
||||||
|
let input_mask = 0;
|
||||||
|
for (let [action, bit] of Object.entries(INPUT_BITS)) {
|
||||||
|
if (input.has(action)) {
|
||||||
|
input_mask |= bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.debug.replay.set(this.level.tic_counter, input_mask);
|
||||||
|
}
|
||||||
|
|
||||||
// Replica of CC2 input handling, based on experimentation
|
// Replica of CC2 input handling, based on experimentation
|
||||||
let primary_action = null, secondary_action = null;
|
let primary_action = null, secondary_action = null;
|
||||||
let current_action = DIRECTIONS[this.level.player.direction].action;
|
let current_action = DIRECTIONS[this.level.player.direction].action;
|
||||||
@ -1057,7 +1199,11 @@ class Player extends PrimaryView {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update_ui();
|
this.update_ui();
|
||||||
|
if (this.debug && this.debug.replay && this.debug.replay_recording) {
|
||||||
|
this.debug.replay_custom_label.textContent = format_replay_duration(this.debug.custom_replay.duration);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main driver of the level; advances by one tic, then schedules itself to
|
// Main driver of the level; advances by one tic, then schedules itself to
|
||||||
@ -1230,9 +1376,9 @@ class Player extends PrimaryView {
|
|||||||
this.debug.time_moves_el.textContent = `${Math.floor(t/4)}`;
|
this.debug.time_moves_el.textContent = `${Math.floor(t/4)}`;
|
||||||
this.debug.time_secs_el.textContent = (t / 20).toFixed(2);
|
this.debug.time_secs_el.textContent = (t / 20).toFixed(2);
|
||||||
|
|
||||||
if (this.demo_faucet) {
|
if (this.debug.replay) {
|
||||||
this.debug.replay_progress_el.setAttribute('value', t);
|
this.debug.replay_progress_el.setAttribute('value', t);
|
||||||
this.debug.replay_percent_el.textContent = `${Math.floor((t + 1) / this.demo_faucet.length * 100)}%`;
|
this.debug.replay_percent_el.textContent = `${Math.floor((t + 1) / this.debug.replay.duration * 100)}%`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1450,6 +1596,16 @@ class Player extends PrimaryView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confirm_game_interruption(question, action) {
|
||||||
|
if (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding') {
|
||||||
|
new ConfirmOverlay(this.conductor, "Restart this level and watch the replay?", action)
|
||||||
|
.open();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Music stuff
|
// Music stuff
|
||||||
|
|
||||||
change_music(index) {
|
change_music(index) {
|
||||||
@ -2380,9 +2536,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
else if (b64level) {
|
else if (b64level) {
|
||||||
// TODO all the more important to show errors!!
|
// TODO all the more important to show errors!!
|
||||||
// FIXME Not ideal, but atob() returns a string rather than any of the myriad binary types
|
let buf = util.b64decode(b64level);
|
||||||
let stringy_buf = atob(b64level.replace(/-/g, '+').replace(/_/g, '/'));
|
|
||||||
let buf = Uint8Array.from(stringy_buf, c => c.charCodeAt(0)).buffer;
|
|
||||||
await conductor.parse_and_load_game(buf, null, 'shared.c2m', null, "Shared level");
|
await conductor.parse_and_load_game(buf, null, 'shared.c2m', null, "Shared level");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1141,7 +1141,6 @@ const TILE_TYPES = {
|
|||||||
let found = [];
|
let found = [];
|
||||||
let seen = new Set;
|
let seen = new Set;
|
||||||
while (seeds.length > 0) {
|
while (seeds.length > 0) {
|
||||||
console.log(seeds);
|
|
||||||
let next_seeds = [];
|
let next_seeds = [];
|
||||||
for (let cell of seeds) {
|
for (let cell of seeds) {
|
||||||
if (seen.has(cell))
|
if (seen.has(cell))
|
||||||
|
|||||||
12
js/util.js
12
js/util.js
@ -162,6 +162,18 @@ export function bytestring_to_buffer(bytestring) {
|
|||||||
return Uint8Array.from(bytestring, c => c.charCodeAt(0)).buffer;
|
return Uint8Array.from(bytestring, c => c.charCodeAt(0)).buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function b64encode(value) {
|
||||||
|
if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
|
||||||
|
value = string_from_buffer_ascii(value);
|
||||||
|
}
|
||||||
|
// Make URL-safe and strip trailing padding
|
||||||
|
return btoa(value).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64decode(data) {
|
||||||
|
return bytestring_to_buffer(atob(data.replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
}
|
||||||
|
|
||||||
export function format_duration(seconds, places = 0) {
|
export function format_duration(seconds, places = 0) {
|
||||||
let mins = Math.floor(seconds / 60);
|
let mins = Math.floor(seconds / 60);
|
||||||
let secs = seconds % 60;
|
let secs = seconds % 60;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user