Add support for recording replays, with a bunch of refactoring along the way

This commit is contained in:
Eevee (Evelyn Woods) 2020-12-13 20:36:12 -07:00
parent 85a81878cc
commit 1c9dee1213
8 changed files with 411 additions and 135 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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