diff --git a/js/defs.js b/js/defs.js index 1159af4..afb6397 100644 --- a/js/defs.js +++ b/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, diff --git a/js/format-base.js b/js/format-base.js index e071a31..c4c08f2 100644 --- a/js/format-base.js +++ b/js/format-base.js @@ -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 { diff --git a/js/format-c2g.js b/js/format-c2g.js index 50bb6d5..f323d4b 100644 --- a/js/format-c2g.js +++ b/js/format-c2g.js @@ -3,87 +3,98 @@ 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; - - // 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]; +export function decode_replay(bytes) { + if (bytes instanceof ArrayBuffer) { + bytes = new Uint8Array(bytes); } - decompress() { - let duration = 0; - let l = this.bytes.length; - if (l % 2 === 0) { - l--; - } - for (let p = 3; p < l; p += 2) { - let delay = this.bytes[p]; - if (delay === 0xff) - break; - duration += delay; - } - duration = Math.floor(duration / 3); + // byte 0 is unknown, always 0? + // Force floor seed can apparently be anything; my best guess, based on the Desert Oasis + // replay, is that it's just incremented and allowed to overflow, so taking it mod 4 gives + // the correct starting direction + let initial_force_floor_direction = DIRECTION_ORDER[bytes[1] % 4]; + let blob_seed = bytes[2]; - let inputs = new Uint8Array(duration); - let i = 0; - let t = 0; - let input = 0; - for (let p = 3; p < l; p += 2) { - // The first byte measures how long the /previous/ input remains - // valid, so yield that first. Note that this is measured in 60Hz - // frames, so we need to convert to 20Hz tics by subtracting 3 - // frames at a time. - let delay = this.bytes[p]; - if (delay === 0xff) - break; + // Add up the total length + let duration = 0; + let l = bytes.length ?? bytes.byteLength; + if (l % 2 === 0) { + l--; + } + for (let p = 3; p < l; p += 2) { + let delay = bytes[p]; + if (delay === 0xff) + break; + duration += delay; + } + duration = Math.floor(duration / 3) + 1; // leave room for final input - t += delay; - while (t >= 3) { - t -= 3; - inputs[i] = input; - i++; + // Inflate the replay into an array of byte-per-tic + let inputs = new Uint8Array(duration); + let i = 0; + let t = 0; + let input = 0; + for (let p = 3; p < l; p += 2) { + // The first byte measures how long the /previous/ input remains + // valid, so yield that first. Note that this is measured in 60Hz + // frames, so we need to convert to 20Hz tics by subtracting 3 + // frames at a time. + let delay = bytes[p]; + if (delay === 0xff) + break; + + t += delay; + while (t >= 3) { + t -= 3; + inputs[i] = input; + i++; + } + + input = bytes[p + 1]; + let is_player_2 = ((input & 0x80) !== 0); + // TODO handle player 2 + if (is_player_2) + continue; + } + inputs[i] = input; + + return new format_base.Replay(initial_force_floor_direction, blob_seed, inputs); +} + +export function encode_replay(replay, stored_level = null) { + let out = new Uint8Array(1024); + out[0] = 0; + out[1] = DIRECTIONS[replay.initial_force_floor_direction].index; + out[2] = replay.blob_seed; + let p = 3; + let prev_input = null; + let count = 0; + for (let i = 0; i < replay.duration; i++) { + if (p >= out.length - 4) { + let new_out = new Uint8Array(Math.floor(out.length * 1.5)); + for (let j = 0; j < out.length; j++) { + new_out[j] = out[j]; } - - input = this.bytes[p + 1]; - let is_player_2 = ((input & 0x80) !== 0); - // TODO handle player 2 - if (is_player_2) - continue; + out = new_out; } - // 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]; - } - else { - // Last input is implicitly repeated indefinitely - input = this.final_input; - } - return new Set(Object.entries(CC2_DEMO_INPUT_MASK).filter(([action, bit]) => input & bit).map(([action, bit]) => action)); - }, - }; + 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 { + count += 3; + } } + 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(); diff --git a/js/main-base.js b/js/main-base.js index 4b8319f..d02ca3c 100644 --- a/js/main-base.js +++ b/js/main-base.js @@ -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)); } diff --git a/js/main-editor.js b/js/main-editor.js index 4e326ab..3b0f00a 100644 --- a/js/main-editor.js +++ b/js/main-editor.js @@ -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'); diff --git a/js/main.js b/js/main.js index 564709a..2beba25 100644 --- a/js/main.js +++ b/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(); - } - else { - this.play_demo(); - } - }); - this._update_replay_button_enabled(); - debug_el.querySelector('.-replay-columns .-buttons').append( - this.debug.replay_button, + // 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 { + 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(); + }); + }), + // 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; - // FIXME should probably start playback on first real input - this.set_state('playing'); - this.root.classList.add('--replay'); + install_replay(replay, slot, record = false) { + if (! this.debug.enabled) + return; - 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.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.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"); } } diff --git a/js/tiletypes.js b/js/tiletypes.js index 574ddf6..00d9fd0 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -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)) diff --git a/js/util.js b/js/util.js index 9c345f2..0906656 100644 --- a/js/util.js +++ b/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;