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
|
||||
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)
|
||||
export const DRAW_LAYERS = {
|
||||
terrain: 0,
|
||||
|
||||
@ -3,6 +3,51 @@ import * as util from './util.js';
|
||||
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 {
|
||||
constructor(number) {
|
||||
// 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)
|
||||
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_y = 0;
|
||||
this.linear_cells = [];
|
||||
@ -47,6 +98,17 @@ export class StoredLevel {
|
||||
|
||||
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 {
|
||||
|
||||
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 * as util from './util.js';
|
||||
|
||||
const CC2_DEMO_INPUT_MASK = {
|
||||
drop: 0x01,
|
||||
down: 0x02,
|
||||
left: 0x04,
|
||||
right: 0x08,
|
||||
up: 0x10,
|
||||
swap: 0x20,
|
||||
cycle: 0x40,
|
||||
};
|
||||
|
||||
class CC2Demo {
|
||||
constructor(bytes) {
|
||||
this.bytes = bytes;
|
||||
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
|
||||
this.initial_force_floor_direction = ['north', 'east', 'south', 'west'][this.bytes[1] % 4];
|
||||
this.blob_seed = this.bytes[2];
|
||||
}
|
||||
let initial_force_floor_direction = DIRECTION_ORDER[bytes[1] % 4];
|
||||
let blob_seed = bytes[2];
|
||||
|
||||
decompress() {
|
||||
// Add up the total length
|
||||
let duration = 0;
|
||||
let l = this.bytes.length;
|
||||
let l = bytes.length ?? bytes.byteLength;
|
||||
if (l % 2 === 0) {
|
||||
l--;
|
||||
}
|
||||
for (let p = 3; p < l; p += 2) {
|
||||
let delay = this.bytes[p];
|
||||
let delay = bytes[p];
|
||||
if (delay === 0xff)
|
||||
break;
|
||||
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 i = 0;
|
||||
let t = 0;
|
||||
@ -48,7 +39,7 @@ class CC2Demo {
|
||||
// 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 = this.bytes[p];
|
||||
let delay = bytes[p];
|
||||
if (delay === 0xff)
|
||||
break;
|
||||
|
||||
@ -59,31 +50,51 @@ class CC2Demo {
|
||||
i++;
|
||||
}
|
||||
|
||||
input = this.bytes[p + 1];
|
||||
input = bytes[p + 1];
|
||||
let is_player_2 = ((input & 0x80) !== 0);
|
||||
// TODO handle player 2
|
||||
if (is_player_2)
|
||||
continue;
|
||||
}
|
||||
inputs[i] = input;
|
||||
|
||||
// TODO maybe turn this into, like, a type, or something
|
||||
return {
|
||||
inputs: inputs,
|
||||
final_input: input,
|
||||
length: duration,
|
||||
get(t) {
|
||||
let input;
|
||||
if (t < this.inputs.length) {
|
||||
input = this.inputs[t];
|
||||
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) {
|
||||
out[p] = count;
|
||||
out[p + 1] = input;
|
||||
p += 2;
|
||||
count = 3;
|
||||
prev_input = input;
|
||||
}
|
||||
else {
|
||||
// Last input is implicitly repeated indefinitely
|
||||
input = this.final_input;
|
||||
count += 3;
|
||||
}
|
||||
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 = {
|
||||
size: 1,
|
||||
decode(tile, dirbyte) {
|
||||
let direction = ['north', 'east', 'south', 'west'][dirbyte & 0x03];
|
||||
let direction = DIRECTION_ORDER[dirbyte & 0x03];
|
||||
tile.direction = direction;
|
||||
},
|
||||
encode(tile) {
|
||||
@ -472,7 +483,7 @@ const TILE_ENCODING = {
|
||||
tile.memory = modifier - 0x1e;
|
||||
}
|
||||
else {
|
||||
tile.direction = ['north', 'east', 'south', 'west'][modifier & 0x03];
|
||||
tile.direction = DIRECTION_ORDER[modifier & 0x03];
|
||||
let type = modifier >> 2;
|
||||
if (type < 6) {
|
||||
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') {
|
||||
// "Replay", i.e. demo solution
|
||||
if (type === 'PRPL') {
|
||||
// TODO is even this necessary though
|
||||
bytes = decompress(bytes);
|
||||
}
|
||||
level.demo = new CC2Demo(bytes);
|
||||
level._replay_data = bytes;
|
||||
level._replay_decoder = decode_replay;
|
||||
}
|
||||
else if (type === 'RDNY') {
|
||||
}
|
||||
@ -1423,6 +1436,25 @@ export function synthesize_level(stored_level) {
|
||||
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();
|
||||
|
||||
@ -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) {
|
||||
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 * as format_base from './format-base.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 TILE_TYPES from './tiletypes.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));
|
||||
let copy_button = mk('button', {type: 'button'}, "Copy to clipboard");
|
||||
copy_button.addEventListener('click', ev => {
|
||||
flash_button(ev.target);
|
||||
navigator.clipboard.writeText(url);
|
||||
// TODO feedback?
|
||||
});
|
||||
this.main.append(copy_button);
|
||||
|
||||
@ -1643,11 +1643,7 @@ export class Editor extends PrimaryView {
|
||||
}, 60 * 1000);
|
||||
});
|
||||
_make_button("Share", ev => {
|
||||
let buf = 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 data = util.b64encode(c2g.synthesize_level(this.stored_level));
|
||||
let url = new URL(location);
|
||||
url.searchParams.delete('level');
|
||||
url.searchParams.delete('setpath');
|
||||
|
||||
246
js/main.js
246
js/main.js
@ -1,11 +1,11 @@
|
||||
// TODO bugs and quirks i'm aware of:
|
||||
// - steam: if a player character starts on a force floor they won't be able to make any voluntary movements until they are no longer on a force floor
|
||||
import { 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 dat from './format-dat.js';
|
||||
import * as format_base from './format-base.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 CanvasRenderer from './renderer-canvas.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';
|
||||
|
||||
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:
|
||||
// - level password, if any
|
||||
@ -275,7 +282,6 @@ class Player extends PrimaryView {
|
||||
this.time_el = this.root.querySelector('.time output');
|
||||
this.bonus_el = this.root.querySelector('.bonus output');
|
||||
this.inventory_el = this.root.querySelector('.inventory');
|
||||
this.demo_el = this.root.querySelector('.demo');
|
||||
|
||||
this.music_el = this.root.querySelector('#player-music');
|
||||
this.music_audio_el = this.music_el.querySelector('audio');
|
||||
@ -732,30 +738,132 @@ class Player extends PrimaryView {
|
||||
this.debug.input_els[action] = el;
|
||||
input_el.append(el);
|
||||
}
|
||||
// Add a button to view a replay
|
||||
this.debug.replay_button = make_button("run level's replay", () => {
|
||||
if (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding') {
|
||||
new ConfirmOverlay(this.conductor, "Restart this level and watch the replay?", () => {
|
||||
this.play_demo();
|
||||
}).open();
|
||||
// There are two replay slots: the (read-only) one baked into the level, and the one you are
|
||||
// editing. You can also transfer them back and forth.
|
||||
// This is the level slot
|
||||
let extra_replay_elements = [];
|
||||
extra_replay_elements.push(mk('hr'));
|
||||
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 {
|
||||
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(
|
||||
this.debug.replay_button,
|
||||
}),
|
||||
// TODO load from a file?
|
||||
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"),
|
||||
mk('button', {disabled: 'disabled'}, "regain control"),
|
||||
mk('button', {disabled: 'disabled'}, "record new replay"),
|
||||
mk('button', {disabled: 'disabled'}, "record from here"),
|
||||
mk('button', {disabled: 'disabled'}, "browse/edit manually"),
|
||||
make_button("Record from here", () => {
|
||||
// TODO this feels poorly thought out i guess
|
||||
}),
|
||||
*/
|
||||
);
|
||||
];
|
||||
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
|
||||
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_progress_el = replay_playback_el.querySelector('progress');
|
||||
this.debug.replay_percent_el = replay_playback_el.querySelector('output');
|
||||
@ -810,9 +918,18 @@ class Player extends PrimaryView {
|
||||
}
|
||||
}
|
||||
|
||||
_update_replay_button_enabled() {
|
||||
if (this.debug.replay_button) {
|
||||
this.debug.replay_button.disabled = ! (this.level && this.level.stored_level.demo);
|
||||
_update_replay_ui() {
|
||||
if (! this.debug.enabled)
|
||||
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._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() {
|
||||
@ -897,7 +1017,6 @@ class Player extends PrimaryView {
|
||||
|
||||
this.turn_mode = this.turn_based_checkbox.checked ? 1 : 0;
|
||||
this.last_advance = 0;
|
||||
this.demo_faucet = null;
|
||||
this.current_keyring = {};
|
||||
this.current_toolbelt = [];
|
||||
|
||||
@ -905,9 +1024,16 @@ class Player extends PrimaryView {
|
||||
this.time_el.classList.remove('--frozen');
|
||||
this.time_el.classList.remove('--danger');
|
||||
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');
|
||||
|
||||
if (this.debug.enabled) {
|
||||
this.debug.replay = null;
|
||||
this.debug.replay_slot = null;
|
||||
this.debug.replay_recording = false;
|
||||
}
|
||||
|
||||
this.update_ui();
|
||||
// Force a redraw, which won't happen on its own since the game isn't running
|
||||
this._redraw();
|
||||
@ -917,28 +1043,34 @@ class Player extends PrimaryView {
|
||||
new LevelBrowserOverlay(this.conductor).open();
|
||||
}
|
||||
|
||||
play_demo() {
|
||||
this.restart_level();
|
||||
let demo = this.level.stored_level.demo;
|
||||
this.demo_faucet = demo.decompress();
|
||||
this.level.force_floor_direction = demo.initial_force_floor_direction;
|
||||
this.level._blob_modifier = demo.blob_seed;
|
||||
install_replay(replay, slot, record = false) {
|
||||
if (! this.debug.enabled)
|
||||
return;
|
||||
|
||||
this.debug.replay = replay;
|
||||
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
|
||||
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() {
|
||||
let input;
|
||||
if (this.demo_faucet) {
|
||||
input = this.demo_faucet.get(this.level.tic_counter);
|
||||
if (this.debug && this.debug.replay && ! this.debug.replay_recording) {
|
||||
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 {
|
||||
// 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++) {
|
||||
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
|
||||
let primary_action = null, secondary_action = null;
|
||||
let current_action = DIRECTIONS[this.level.player.direction].action;
|
||||
@ -1057,7 +1199,11 @@ class Player extends PrimaryView {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@ -1230,9 +1376,9 @@ class Player extends PrimaryView {
|
||||
this.debug.time_moves_el.textContent = `${Math.floor(t/4)}`;
|
||||
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_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
|
||||
|
||||
change_music(index) {
|
||||
@ -2380,9 +2536,7 @@ async function main() {
|
||||
}
|
||||
else if (b64level) {
|
||||
// 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 stringy_buf = atob(b64level.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
let buf = Uint8Array.from(stringy_buf, c => c.charCodeAt(0)).buffer;
|
||||
let buf = util.b64decode(b64level);
|
||||
await conductor.parse_and_load_game(buf, null, 'shared.c2m', null, "Shared level");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1141,7 +1141,6 @@ const TILE_TYPES = {
|
||||
let found = [];
|
||||
let seen = new Set;
|
||||
while (seeds.length > 0) {
|
||||
console.log(seeds);
|
||||
let next_seeds = [];
|
||||
for (let cell of seeds) {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
let mins = Math.floor(seconds / 60);
|
||||
let secs = seconds % 60;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user