Add support for demos, terrible UI for it, and a clumsy pause button

This commit is contained in:
Eevee (Evelyn Woods) 2020-08-31 08:40:44 -06:00
parent 101b68c017
commit b871181bf4
3 changed files with 409 additions and 61 deletions

View File

@ -1,6 +1,91 @@
import * as util from './format-util.js'; import * as util from './format-util.js';
import TILE_TYPES from './tiletypes.js'; import TILE_TYPES from './tiletypes.js';
const CC2_DEMO_INPUT_MASK = {
drop: 0x01,
down: 0x02,
left: 0x04,
right: 0x08,
up: 0x10,
swap: 0x20,
cycle: 0x40,
};
class CC2Demo {
constructor(buf) {
this.buf = buf;
this.bytes = new Uint8Array(buf);
// byte 0 is unknown, always 0?
this.force_floor_seed = this.bytes[1];
this.blob_seed = this.bytes[2];
let l = this.bytes.length;
if (l % 2 === 0) {
l--;
}
for (let p = 3; p < l; p += 2) {
let delay = this.bytes[p];
let input_mask = this.bytes[p + 1];
let input = new Set;
if ((input_mask & 0x80) !== 0) {
input.add('p2');
}
for (let [action, bit] of Object.entries(CC2_DEMO_INPUT_MASK)) {
if ((input_mask & bit) !== 0) {
input.add(action);
}
}
console.log('demo step', delay, input);
}
}
*[Symbol.iterator]() {
let l = this.bytes.length;
if (l % 2 === 0) {
l--;
// TODO assert last byte is terminating 0xff
}
let input = new Set;
let t = 0;
// 47 left 33 down means
// LLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD
// | * * * | * * * | * * * | * * * | * * * | * * * | * *
// | * * * | * * * | * * * | * * * | * * * | * * * | * *
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.
t += this.bytes[p];
// t >= 4: almost right, desyncs just before yellow door
// t >= 3: skips a move, then desyncs trying to leave red door room
// t >= 2: skips a move, also desyncs before yellow door
// t >= 1: same as 2
// t >= 0: same as 3
while (t > 0) {
t -= 3;
console.log(t, input);
yield input;
}
let input_mask = this.bytes[p + 1];
let is_player_2 = ((input_mask & 0x80) !== 0);
// TODO handle player 2
for (let [action, bit] of Object.entries(CC2_DEMO_INPUT_MASK)) {
if ((input_mask & bit) === 0) {
input.delete(action);
}
else {
input.add(action);
}
}
}
}
}
// TODO assert that direction + next match the tile types // TODO assert that direction + next match the tile types
const TILE_ENCODING = { const TILE_ENCODING = {
0x01: 'floor', 0x01: 'floor',
@ -405,9 +490,13 @@ export function parse_level(buf) {
} }
else if (section_type === 'KEY ') { else if (section_type === 'KEY ') {
} }
else if (section_type === 'REPL') { else if (section_type === 'REPL' || section_type === 'PRPL') {
// "Replay", i.e. demo solution
let data = section_buf;
if (section_type === 'PRPL') {
data = decompress(data);
} }
else if (section_type === 'PRPL') { level.demo = new CC2Demo(data);
} }
else if (section_type === 'RDNY') { else if (section_type === 'RDNY') {
} }

View File

@ -282,8 +282,9 @@ class Level {
advance_tic(player_direction) { advance_tic(player_direction) {
if (this.state !== 'playing') { if (this.state !== 'playing') {
console.warn(`Level.advance_tic() called when state is ${this.state}`); // FIXME this breaks the step buttons; maybe pausing should be in game only
return; //console.warn(`Level.advance_tic() called when state is ${this.state}`);
//return;
} }
// XXX this entire turn order is rather different in ms rules // XXX this entire turn order is rather different in ms rules
@ -327,6 +328,7 @@ class Level {
} }
else if (actor === this.player) { else if (actor === this.player) {
if (player_direction) { if (player_direction) {
console.log('--- player moving', player_direction);
direction_preference = [player_direction]; direction_preference = [player_direction];
actor.last_move_was_force = false; actor.last_move_was_force = false;
} }
@ -522,26 +524,82 @@ class Level {
} }
} }
// TODO:
// - some kinda visual theme i guess lol
// - level /number/
// - level password, if any
// - set name
// - timer!
// - intro splash with list of available levels
// - button: quit to splash
// - button: options
// - implement winning and show score for this level
// - show current score so far
const GAME_UI_HTML = ` const GAME_UI_HTML = `
<main> <main>
<div class="level"><!-- level canvas and any overlays go here --></div> <div class="level"><!-- level canvas and any overlays go here --></div>
<div class="bummer"></div>
<div class="meta"></div> <div class="meta"></div>
<div class="nav"> <div class="nav">
<button class="nav-prev" type="button">«</button> <button class="nav-prev" type="button">«</button>
<button class="nav-browse" type="button">Choose level...</button> <button class="nav-browse" type="button">Level select</button>
<button class="nav-next" type="button">»</button> <button class="nav-next" type="button">»</button>
</div> </div>
<div class="hint"></div> <div class="hint"></div>
<div class="chips"></div> <div class="chips"></div>
<div class="time"></div> <div class="time"></div>
<div class="inventory"></div> <div class="inventory"></div>
<div class="bummer"></div> <div class="controls">
<button class="control-pause" type="button">Pause</button>
<button class="control-restart" type="button">Restart</button>
<button class="control-undo" type="button">Undo</button>
<button class="control-rewind" type="button">Rewind</button>
</div>
<div class="demo">
<h2>Solution demo available</h2>
<div class="demo-controls">
<button class="demo-play" type="button">Restart and play</button>
<button class="demo-step-1" type="button">Step 1 tic</button>
<button class="demo-step-4" type="button">Step 1 move</button>
<button class="demo-step-20" type="button">Step 1 second</button>
</div>
<div class="demo-scrubber"></div>
<div class="input"></div>
</div>
</main> </main>
`; `;
const ACTION_LABELS = {
up: '⬆️\ufe0f',
down: '⬇️\ufe0f',
left: '⬅️\ufe0f',
right: '➡️\ufe0f',
drop: '🚮',
cycle: '🔄',
swap: '👫',
};
const ACTION_DIRECTIONS = {
up: 'north',
down: 'south',
left: 'west',
right: 'east',
};
class Game { class Game {
constructor(stored_game, tileset) { constructor(stored_game, tileset) {
this.stored_game = stored_game; this.stored_game = stored_game;
this.tileset = tileset; this.tileset = tileset;
this.key_mapping = {
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down',
w: 'up',
a: 'left',
s: 'down',
d: 'right',
q: 'drop',
e: 'cycle',
c: 'swap',
};
// TODO obey level options; allow overriding // TODO obey level options; allow overriding
this.viewport_size_x = 9; this.viewport_size_x = 9;
@ -559,6 +617,8 @@ class Game {
this.time_el = this.container.querySelector('.time'); this.time_el = this.container.querySelector('.time');
this.inventory_el = this.container.querySelector('.inventory'); this.inventory_el = this.container.querySelector('.inventory');
this.bummer_el = this.container.querySelector('.bummer'); this.bummer_el = this.container.querySelector('.bummer');
this.input_el = this.container.querySelector('.input');
this.demo_el = this.container.querySelector('.demo');
// Populate navigation // Populate navigation
this.nav_prev_button = this.nav_el.querySelector('.nav-prev'); this.nav_prev_button = this.nav_el.querySelector('.nav-prev');
@ -576,6 +636,27 @@ class Game {
} }
}); });
// Bind buttons
this.container.querySelector('.controls .control-pause').addEventListener('click', ev => {
if (this.level.state === 'playing') {
this.level.state = 'paused';
}
else if (this.level.state === 'paused') {
this.level.state = 'playing';
}
ev.target.blur();
});
// Demo playback
this.container.querySelector('.demo .demo-step-1').addEventListener('click', ev => {
this.advance_by(1);
});
this.container.querySelector('.demo .demo-step-4').addEventListener('click', ev => {
this.advance_by(4);
});
this.container.querySelector('.demo .demo-step-20').addEventListener('click', ev => {
this.advance_by(20);
});
// Populate inventory // Populate inventory
this._inventory_tiles = {}; this._inventory_tiles = {};
let floor_tile = this.render_inventory_tile('floor'); let floor_tile = this.render_inventory_tile('floor');
@ -599,48 +680,83 @@ class Game {
this.next_player_move = null; this.next_player_move = null;
this.player_used_move = false; this.player_used_move = false;
let key_target = document.body; let key_target = document.body;
this.previous_input = new Set; // actions that were held last tic
this.previous_action = null; // last direction we were moving, if any
this.current_keys = new Set; // keys that are currently held
// TODO this could all probably be more rigorous but it's fine for now // TODO this could all probably be more rigorous but it's fine for now
key_target.addEventListener('keydown', ev => { key_target.addEventListener('keydown', ev => {
let direction; if (this.key_mapping[ev.key]) {
if (ev.key === 'ArrowDown') { this.current_keys.add(ev.key);
direction = 'south';
}
else if (ev.key === 'ArrowUp') {
direction = 'north';
}
else if (ev.key === 'ArrowLeft') {
direction = 'west';
}
else if (ev.key === 'ArrowRight') {
direction = 'east';
}
if (! direction)
return;
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
}
last_key = ev.key;
this.pending_player_move = direction;
this.next_player_move = direction;
this.player_used_move = false;
}); });
key_target.addEventListener('keyup', ev => { key_target.addEventListener('keyup', ev => {
if (ev.key === last_key) { if (this.key_mapping[ev.key]) {
last_key = null; this.current_keys.delete(ev.key);
this.pending_player_move = null; ev.stopPropagation();
if (this.player_used_move) { ev.preventDefault();
this.next_player_move = null;
}
} }
}); });
// Populate demo scrubber
let scrubber_el = this.container.querySelector('.demo-scrubber');
let scrubber_elements = {};
for (let [action, label] of Object.entries(ACTION_LABELS)) {
let el = mk('li');
scrubber_el.append(el);
scrubber_elements[action] = el;
}
this.demo_scrubber_marker = mk('div.demo-scrubber-marker');
scrubber_el.append(this.demo_scrubber_marker);
// Populate input debugger
this.input_el = this.container.querySelector('.input');
this.input_action_elements = {};
for (let [action, label] of Object.entries(ACTION_LABELS)) {
let el = mk('span.input-action', {'data-action': action}, label);
this.input_el.append(el);
this.input_action_elements[action] = el;
}
// Done with UI, now we can load a level // Done with UI, now we can load a level
this.load_level(0); this.load_level(0);
this.redraw(); this.redraw();
// Fill in the scrubber
if (false && this.level.stored_level.demo) {
let input_starts = {};
for (let action of Object.keys(ACTION_LABELS)) {
input_starts[action] = null;
}
let t = 0;
for (let input of this.level.stored_level.demo) {
for (let [action, t0] of Object.entries(input_starts)) {
if (input.has(action)) {
if (t0 === null) {
input_starts[action] = t;
}
}
else if (t0 !== null) {
let bar = mk('span.demo-scrubber-bar');
bar.style.setProperty('--start-time', t0);
bar.style.setProperty('--end-time', t);
scrubber_elements[action].append(bar);
input_starts[action] = null;
}
}
t += 1;
}
this.demo = this.level.stored_level.demo[Symbol.iterator]();
}
else {
// TODO update these, as appropriate, when loading a level
this.input_el.style.display = 'none';
this.demo_el.style.display = 'none';
}
this.frame = 0; this.frame = 0;
this.tick++; this.tic = 0;
requestAnimationFrame(this.do_frame.bind(this)); requestAnimationFrame(this.do_frame.bind(this));
} }
@ -657,18 +773,80 @@ class Game {
this.update_ui(); this.update_ui();
} }
get_input() {
if (this.demo) {
let step = this.demo.next();
if (step.done) {
return new Set;
}
else {
return step.value;
}
}
else {
// Convert input keys to actions. This is only done now
// because there might be multiple keys bound to one
// action, and it still counts as pressed as long as at
// least one key is held
let input = new Set;
for (let key of this.current_keys) {
input.add(this.key_mapping[key]);
}
return input;
}
}
advance_by(tics) {
for (let i = 0; i < tics; i++) {
let input = this.get_input();
let current_input = input;
if (! input.has('up') && ! input.has('down') && ! input.has('left') && ! input.has('right')) {
//input = this.previous_input;
}
// Choose the movement direction based on the held keys. A
// newly pressed action takes priority; in the case of a tie,
// um, XXX ????
let chosen_action = null;
let any_action = null;
for (let action of ['up', 'down', 'left', 'right']) {
if (input.has(action)) {
if (this.previous_input.has(action)) {
chosen_action = action;
}
any_action = action;
}
}
if (! chosen_action) {
// No keys are new, so check whether we were previously
// holding a key and are still doing it
if (this.previous_action && input.has(this.previous_action)) {
chosen_action = this.previous_action;
}
else {
// No dice, so use an arbitrary action
chosen_action = any_action;
}
}
let player_move = chosen_action ? ACTION_DIRECTIONS[chosen_action] : null;
this.previous_action = chosen_action;
this.previous_input = current_input;
this.level.advance_tic(player_move);
this.tic++;
}
this.redraw();
this.update_ui();
}
do_frame() { do_frame() {
if (this.level.state === 'playing') { if (this.level.state === 'playing') {
this.frame++; this.frame++;
if (this.frame % 3 === 0) { if (this.frame % 3 === 0) {
this.level.advance_tic(this.next_player_move); this.advance_by(1);
this.next_player_move = this.pending_player_move;
this.player_used_move = true;
this.redraw();
} }
this.frame %= 60; this.frame %= 60;
this.update_ui();
} }
requestAnimationFrame(this.do_frame.bind(this)); requestAnimationFrame(this.do_frame.bind(this));
@ -702,6 +880,15 @@ class Game {
this.inventory_el.append(mk('img', {src: this.render_inventory_tile(name)})); this.inventory_el.append(mk('img', {src: this.render_inventory_tile(name)}));
} }
} }
if (this.demo) {
this.demo_scrubber_marker.style.setProperty('--time', this.tic);
this.demo_scrubber_marker.scrollIntoView({inline: 'center'});
}
for (let action of Object.keys(ACTION_LABELS)) {
this.input_action_elements[action].classList.toggle('--pressed', this.previous_input.has(action));
}
} }
redraw() { redraw() {

104
style.css
View File

@ -10,7 +10,7 @@ body {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background: #606060; background: #404040;
} }
main { main {
display: grid; display: grid;
@ -21,6 +21,8 @@ main {
"level time" min-content "level time" min-content
"level hint" 1fr "level hint" 1fr
"level inventory" min-content "level inventory" min-content
"controls controls"
"demo demo"
/ min-content 12em / min-content 12em
; ;
gap: 1em; gap: 1em;
@ -32,6 +34,10 @@ main {
--scale: 2; --scale: 2;
} }
button {
font-size: inherit;
}
.level { .level {
grid-area: level; grid-area: level;
@ -41,6 +47,25 @@ main {
display: block; display: block;
width: calc(9 * var(--tile-width) * var(--scale)); width: calc(9 * var(--tile-width) * var(--scale));
} }
.bummer {
grid-area: level;
display: flex;
justify-content: center;
align-items: center;
z-index: 99;
font-size: 48px;
padding: 25%;
background: #0009;
color: white;
text-align: center;
font-weight: bold;
text-shadow: 0 2px 1px black;
}
.bummer:empty {
display: none;
}
.meta { .meta {
grid-area: meta; grid-area: meta;
@ -85,22 +110,69 @@ main {
.inventory img { .inventory img {
width: calc(2 * var(--tile-width)); width: calc(2 * var(--tile-width));
} }
.bummer { .controls {
grid-area: level; grid-area: controls;
}
.demo {
grid-area: demo;
}
display: flex; .demo-scrubber {
justify-content: center; position: relative;
align-items: center; overflow-x: auto;
padding: 0;
margin: 1em 0;
list-style: none;
}
.demo-scrubber li {
position: relative;
height: 1em;
margin: 2px 0;
background: #303030;
}
.demo-scrubber li .demo-scrubber-bar {
position: absolute;
height: 1em;
left: calc(var(--start-time) * 3px);
width: calc((var(--end-time) - var(--start-time)) * 3px);
background: #606060;
}
.demo-scrubber .demo-scrubber-marker {
position: absolute;
top: 0;
bottom: 0;
left: calc(var(--time) * 3px);
width: 2px;
margin-left: -1px;
background: darkred;
--time: 0;
}
z-index: 99; /* Debug stuff */
font-size: 48px; .input {
padding: 25%; display: grid;
background: #0009; grid:
"drop up cycle" 1.5em
"left swap right" 1.5em
". down . " 1.5em
/ 1.5em 1.5em 1.5em
;
gap: 0.5em;
}
.input-action {
padding: 0.25em;
line-height: 1;
color: #fff4;
background: #202020;
}
.input-action[data-action=up] { grid-area: up; }
.input-action[data-action=down] { grid-area: down; }
.input-action[data-action=left] { grid-area: left; }
.input-action[data-action=right] { grid-area: right; }
.input-action[data-action=swap] { grid-area: swap; }
.input-action[data-action=cycle] { grid-area: cycle; }
.input-action[data-action=drop] { grid-area: drop; }
.input-action.--pressed {
color: white; color: white;
text-align: center; background: hsl(215, 75%, 25%);
font-weight: bold;
text-shadow: 0 2px 1px black;
}
.bummer:empty {
display: none;
} }