explodorama / commit
Rubble demo scene and a timeline bar on every scene (0.2.0 work)
CHANGELOG.md | 1 + README.md | 4 +-- decisions.md | 11 ++++++++ index.html | 1 + src/changelog.ts | 8 ++++++ src/main.ts | 10 +++++++- src/sim/demo.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++---------- src/style.css | 39 +++++++++++++++++++++++++++++ src/ui/clip.ts | 16 ++++++++++-- src/ui/info.ts | 2 +- src/ui/timeline.ts | 50 +++++++++++++++++++++++++++++++++++++ test/demo.test.ts | 15 +++++++++-- 12 files changed, 209 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df15f22..0405ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ saved before the change is dropped and the next visit starts a new run. ### Changed +- **A demo scene for rubble, and a timeline on every scene** (user request, 2026-10-31): the new `rubble` scene starts with a pillar already cracked and plays until the ceiling falls, then two turns of waiting to see the rubble (`SceneDef.maxSteps` and `tail`; the Acid scene ended when the pillar cracked, which is why the Almanac's Rubble clip never showed any). A scene now records its `moment`. A bar under the demo's caption and under each dialog's clip (`ui/timeline.ts`) fills a turn at a time, with a mark at the moment. - **Two copies of the game** (user request, 2026-10-31): the released one (built from a git tag, `/explodorama/`) and a **next** copy built from `main` (`/explodorama-next/`, deployed with `fw deploy --as explodorama-next`; tab title "EXPLODORAMA! (next)"). Each has its own saves, options, scores and last-played version (`explodorama-next.*` keys), so a rules change on `main` never drops a released run. The released game's menu has a **Try it early** link and its Changes dialog a **Coming next** teaser (read from the next copy's `next.json`); the next copy's Changes dialog lists **Not released yet**. A run finished on the next copy is marked **Early adopter** on its board and counted in one shared key (`explodorama.earlyRuns`) the released copy shows in its menu. Developer mode is separate and works on either. - **A bomb two of your actions could handle asks which** (user request, 2026-10-31): with Kick and Bomb Pass, a tap or arrow key on a bomb next to you offers Kick or Walk onto it (the Detonator's Set it off too). Bomb Pass alone now walks onto the bomb on a tap, which was refused before. - **A tap on a crate walks to the tile beside it** (2026-10-31); with Run, a crate two tiles away did nothing. diff --git a/README.md b/README.md index bab4242..354ccb5 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ and a seed gives the same dungeon only within one rules version. the real rules, each with a caption and an icon. The screen reads top to bottom: the title **EXPLODORAMA!**, the TV with the scene, then the caption; both the title and the caption go once the game starts. Each scene gets to its point quickly: what it is -about happens 3 to 6 turns in, starting from a calm board (no bomb or flame yet). **A finger shows +about happens 3 to 6 turns in (more for a moment that takes longer to come, like rubble, with a turn or two after it), starting from a calm board (no bomb or flame yet). **A finger shows the controls** as it plays every turn, on the real action bar (it sits right under the board, apart from the caption at the bottom: what you play on, then what explains it): a tap on a tile (with a ripple where it lands), a long press for a bomb (a ring closes in, the bomb's blast is previewed, the @@ -78,7 +78,7 @@ Coin bag only change numbers, so they have no scene; nor does the shop. Every ot own bomb, one bomb setting off another, touching an enemy, and a couple of funny ones (the Detonator next to you, a Run into an enemy). The scenes play **in a fixed order** and loop — the controls first, then the rest, with a way to die after every four — each made new every -time. The panel shows which one it is (**12 / 47**); **◀ ▶** (or the **← →** keys) go to the +time. The panel shows which one it is (**12 / 56**), with a bar under it that fills as the scene plays and a yellow mark where its moment comes (the same bar sits under every clip in a dialog); **◀ ▶** (or the **← →** keys) go to the one before or after, and **↺** back to the first; **the dice button** plays them in a random order instead (lit while on; kept for next time). The demo carries on where it was the next time it plays, after a reload too. It is view-only; **click or tap anywhere else to play** (a key does nothing: Ctrl or Shift used to start the game), and that tap does nothing diff --git a/decisions.md b/decisions.md index 4209c14..d043af0 100644 --- a/decisions.md +++ b/decisions.md @@ -2292,3 +2292,14 @@ early", the Changes dialog "Coming next" (from the next copy's `next.json`), sco next copy are marked "Early adopter" and counted. Rejected: giving the Developer rainbow gem on the next copy (scores would stop being comparable; Developer stays a separate option for either copy). Scores are per copy, as storage is. + +### 2026-10-31 — Scripted-or-found demo scenes: found, with a push; timeline bar + +User question: should demos be scripted rather than found by random play? Kept: scenes are found by +random play on a random room (varied, and always by the real rules), but a scene can now **start +from a set-up state** (`prepare`: rubble starts with a pillar already cracked), **ask for a longer +run** (`maxSteps`) and **play a tail** after its moment (`tail`: waiting turns, while safe). Scripting +a whole sequence (Acid, then a second bomb, then waiting) was not needed once the set-up did the +first half. The Almanac's Rubble clip now plays the `rubble` scene, not `acid`. A timeline bar +(`ui/timeline.ts`) under the demo caption and every clip shows turns played, with a mark at the +scene's `moment`; chosen: normal speed, turn-by-turn fill, on both. diff --git a/index.html b/index.html index 3f607a5..c570cc0 100644 --- a/index.html +++ b/index.html @@ -59,6 +59,7 @@ <h1 id="demo-title" hidden>EXPLODORAMA!</h1> <div id="demo" hidden aria-live="polite"> <p id="demo-caption"></p> + <div id="demo-time"></div> <div class="demo-nav"> <button id="demo-prev" type="button" aria-label="Previous scene"></button> <span id="demo-count"></span> diff --git a/src/changelog.ts b/src/changelog.ts index b05795a..4f24886 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -48,6 +48,14 @@ export const RELEASES: Release[] = [ /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */ export const UNRELEASED: Change[] = [ + { + kind: 'added', + icon: 'rock', + title: 'A demo of rubble forming; a time bar on every demo', + text: ['The Rubble entry in the Almanac now shows a cracked pillar being broken and the ceiling coming down, with a couple of turns after it. Every demo scene and clip has a bar under it showing how much is left, with a mark where the important moment comes.'], + clip: 'rubble', + date: '2026-10-31', + }, { kind: 'added', icon: 'bomb_active', diff --git a/src/main.ts b/src/main.ts index ef49c1f..88d8a0f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,7 @@ import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info' import { openChargeOffer } from './ui/chargeOffer' import { openTapChoice } from './ui/tapChoice' +import { bindTimeline } from './ui/timeline' import { openOffer } from './ui/offer' import { showShop, updateShop } from './ui/shop' import { shopAfter } from './sim/shop' @@ -896,6 +897,8 @@ const viewMask = document.getElementById('view-mask')! /** The demo's title, on top above the TV (user request, 2026-10-25: title, TV, caption). */ const demoTitle = document.getElementById('demo-title')! const demoCount = document.getElementById('demo-count')! +/** The bar of the scene's turns, with its moment marked (ui/timeline.ts). */ +const demoTime = bindTimeline(document.getElementById('demo-time')!) // The demo's buttons: the scene before, the scene after, and back to the first. document.getElementById('demo-prev')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, -1))) document.getElementById('demo-next')!.addEventListener('click', () => demo && skipTo(adjacentScene(demo.index, 1))) @@ -995,7 +998,7 @@ const SCENE_ICONS: Record<SceneKind, string> = { dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest', dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run, rpg: POWER_UP_ICONS.rpg, proximity: POWER_UP_ICONS.proximity, slide: POWER_UP_ICONS.slide, icbm: CHARGE_ICONS.icbm, - acid: CHARGE_ICONS.acid, + acid: CHARGE_ICONS.acid, rubble: 'rock', bush: 'grass_a', bushFire: 'grass_a', metal: 'block_metal', oil: 'oil', spikes: 'spikes', barrel: 'block_strong_danger', } @@ -1044,7 +1047,9 @@ function nextScene(): void { // The tour's room holds a power-up and a charge, so the top bar's last row has something to show. if (item === 'topBar') state = { ...state, powers: { ...state.powers, kick: true }, charge: { kind: 'pierce', left: 3 } } demo.beats = item === 'topBar' ? topBarTour() : whatIsThat() + demoTime.set(0, 0) } else { + demoTime.set(0, scene.actions.length, scene.moment) const icon = SCENE_ICONS[item] setCaption(spriteUrl(icon), MONO_ICONS.has(icon), SCENES[item].caption) } @@ -1153,6 +1158,8 @@ function demoTick(): void { if (!demo.scene || demo.next >= demo.scene.actions.length) return nextScene() const i = demo.next++ demo.beats = beatsFor(demo.scene.gestures[i]!, demo.scene.actions[i]!, demo.next === demo.scene.actions.length) + // The bar fills over the turn's beats, so it moves all the time rather than a turn at a time. + demoTime.set(demo.next, demo.scene.actions.length, demo.scene.moment, demo.beats.reduce((sum, b) => sum + b.then, 0)) } const beat = demo.beats.shift()! beat.run() @@ -1295,6 +1302,7 @@ function skipTo(index: number): void { demo.beats = [] demo.scene = null demo.at = 0 + demoTime.set(0, 0) if (dialogOpen()) closeDialog() spotlight(null) fingerUp() diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 47c86d2..eb2ebf7 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -1,6 +1,6 @@ import { ACTIONS, isAvailable, isValidTarget } from './actions' import { isDeadly, predictDanger } from './danger' -import { blastTiles } from './explosions' +import { blastTiles, COLLAPSE_TURNS } from './explosions' import { planIntents } from './enemies' import { Rng } from './rng' import { autoMoveAction, playerPath, safeStep } from './path' @@ -30,7 +30,7 @@ import { * the shop and the exit) — Heart and Coin bag only change numbers, so they have none. * Since 2026-10-24 (user request) they also show the controls: each turn comes with the gesture * that plays it (a tap, a long press, a button then a tile), which the UI acts out with a finger, - * and a few scenes are about the controls alone. And each scene's moment comes 3 to 6 turns in: + * and a few scenes are about the controls alone. And each scene's moment comes 3 to 6 turns in (`maxSteps` asks for more, `tail` for turns after it): * the scene is the last few turns of a longer game, from a calm moment (no bomb, no flame). */ export type SceneKind = @@ -39,7 +39,7 @@ export type SceneKind = | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate' | 'rpg' | 'proximity' | 'slide' | 'icbm' | 'wallPass' | 'bombPass' | 'flamePass' | 'star' - | 'pierce' | 'power' | 'nuke' | 'acid' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap' + | 'pierce' | 'power' | 'nuke' | 'acid' | 'rubble' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap' | 'exit' | 'doorBlast' | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal' | 'bush' | 'bushFire' | 'metal' | 'oil' | 'spikes' | 'barrel' @@ -72,6 +72,8 @@ export interface Scene { actions: Action[] /** How each action is played, one per action. */ gestures: Gesture[] + /** The turn (index in `actions`) the scene is about; any after it are the aftermath (`SceneDef.tail`). Last if absent. */ + moment?: number } /** What a scene's game has done so far, for moments that need an earlier turn (a charge picked up). */ @@ -119,6 +121,10 @@ interface SceneDef { steer?: (s: GameState, rng: Rng, walking: Walking) => Play | null /** Bombs placed with the Bomb button and a tap, not a long press. */ bombButton?: boolean + /** More turns to a scene than MAX_STEPS, for a moment that takes a while to come (rubble). */ + maxSteps?: number + /** Turns of waiting played after the moment, as far as they are safe, to see its result (user request, 2026-10-31). */ + tail?: number /** A scene about the sell-or-swap choice: turns that ask it are kept (elsewhere they are skipped). */ choice?: boolean } @@ -381,6 +387,14 @@ export const SCENES: Record<SceneKind, SceneDef> = { prepare: (s, rng) => ((s.stats.fire = 2), dropping('acid', 1)(s, rng)), shows: (t, seen) => seen.picked.has('acid') && t.events.some((e) => e.type === 'pillarCracked'), }, + rubble: { + caption: `Rubble: the next blast breaks a cracked pillar, and ${COLLAPSE_TURNS} turns later the ceiling round it comes down for good.`, + enemies: ['blob'], + maxSteps: 12, + tail: 2, + prepare: crackedPillar, + shows: (t) => t.events.some((e) => e.type === 'ceilingFell'), + }, shield: { caption: 'Shield: while you carry it, it absorbs the next hit.', enemies: ['seeker', 'seeker'], @@ -599,7 +613,7 @@ export function generateScene(kind: SceneKind, seed: number): Scene | null { return null } -/** Replays a scene: its turns, and whether its last turn is its moment (for tests, and `ending`). */ +/** Replays a scene: its turns, and whether its moment turn shows it (for tests, and `ending`). */ export function replayScene(scene: Scene): { turns: Turn[]; shows: boolean } { let s = scene.start const seen = newSeen() @@ -610,19 +624,19 @@ export function replayScene(scene: Scene): { turns: Turn[]; shows: boolean } { const turn = { prev: s, action, gesture: scene.gestures[i]!, events: r.events, next: r.state } remember(turn, seen) turns.push(turn) - last = SCENES[scene.kind].shows(turn, seen) + if (i === (scene.moment ?? scene.actions.length - 1)) last = SCENES[scene.kind].shows(turn, seen) s = r.state } return { turns, shows: last } } -/** Whether a scene's last turn is its moment, replaying it from the start (for tests). */ +/** Whether a scene's moment turn shows its moment, replaying it from the start (for tests). */ export function showsAtEnd(scene: Scene): boolean { return replayScene(scene).shows } /** - * The scene in a game that shows its moment: its last 3 to 6 turns (as few as do), starting + * The scene in a game that shows its moment: the turns up to its moment, 3 to 6 of them (`maxSteps` allows more; as few as do), then `tail` turns of waiting, starting * from a calm moment — no bomb or flame on the board, no drop but those the room started with * (a coin or charge left by an enemy killed before the scene would be a mystery), auto-move not * under way — and still showing the moment when played from there. Null if no such stretch. @@ -631,16 +645,27 @@ export function showsAtEnd(scene: Scene): boolean { */ function ending(kind: SceneKind, game: GameTurn[]): Scene | null { const room = game[0]!.prev - for (let n = MIN_STEPS; n <= Math.min(MAX_STEPS, game.length); n++) { + const def = SCENES[kind] + for (let n = MIN_STEPS; n <= Math.min(def.maxSteps ?? MAX_STEPS, game.length); n++) { const first = game[game.length - n]! const s = first.prev - if (s.bombs.length || s.burning.length || first.gesture.kind === 'go' || first.walking) continue + if (s.bombs.length || s.burning.length || s.collapsing.length || first.gesture.kind === 'go' || first.walking) continue if (s.drops.some((d) => !room.drops.some((r) => at(r.pos, d.pos) && r.kind === d.kind))) continue - const turns = game.slice(-n) + const turns: Play[] = game.slice(-n) // Spikes keep their rhythm across the reset to turn 0. const spikes = s.spikes.map((sp) => ({ ...sp, phase: (sp.phase + s.turn) % 2 })) - const scene = { kind, start: { ...s, turn: 0, coins: 0, spikes }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture) } - if (showsAtEnd(scene)) return scene + const scene: Scene = { kind, start: { ...s, turn: 0, coins: 0, spikes }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture), moment: n - 1 } + if (!showsAtEnd(scene)) continue + // The aftermath: a few turns of waiting, while that is safe, to see what the moment did. + let after = game[game.length - 1]!.next + for (let i = 0; i < (def.tail ?? 0) && after.status === 'playing' && !after.offer; i++) { + const play = playOf(def, after, { id: 'wait' }) + if (!play || isDeadly(after, play.action)) break + scene.actions.push(play.action) + scene.gestures.push(play.gesture) + after = step(after, play.action).state + } + return scene } return null } @@ -698,6 +723,28 @@ function placeDoor(s: GameState, rng: Rng): void { if (pick.length) s.exit = pick[rng.int(pick.length)]! } +/** + * Rubble's room: one pillar already cracked (as Acid leaves it), the player a few steps from it with + * a blast long enough to reach it from beside it. + */ +function crackedPillar(s: GameState, rng: Rng): void { + const pillars = s.cells.flatMap((c, i) => { + const p = { x: i % s.width, y: Math.floor(i / s.width) } + return c === 'wall' && !(p.x === 0 || p.y === 0 || p.x === s.width - 1 || p.y === s.height - 1) ? [i] : [] + }) + const pillar = pillars[rng.int(pillars.length)]! + s.cracked.push(pillar) + const at2 = { x: pillar % s.width, y: Math.floor(pillar / s.width) } + const near = s.cells.flatMap((c, i) => { + const p = { x: i % s.width, y: Math.floor(i / s.width) } + const d = steps(p, at2) + return c === 'floor' && d >= 2 && d <= 3 && !s.enemies.some((e) => at(e.pos, p)) ? [p] : [] + }) + if (near.length) s.player = { ...near[rng.int(near.length)]! } + s.lastSeen = { ...s.player } + s.stats.fire = 2 +} + /** Free floor tiles `n` steps from the player, with no enemy. */ function tilesAt(s: GameState, n: number): Vec[] { return s.cells.flatMap((c, i) => { @@ -841,7 +888,7 @@ function reasonable(kind: SceneKind, t: Turn): boolean { const uncovered = (p: Vec) => s.cells[idx(s, p)] === 'floor' && (at(p, s.exit) || s.gems.some((g) => at(g.pos, p))) if (punishing && tiles.some(uncovered)) return false return tiles.some( - (p) => s.cells[idx(s, p)] === 'block' || s.bombs.some((b) => at(b.pos, p)) || s.enemies.some((e) => at(e.pos, p) || e.plan.some((q) => at(q, p))), + (p) => s.cells[idx(s, p)] === 'block' || s.cracked.includes(idx(s, p)) || s.bombs.some((b) => at(b.pos, p)) || s.enemies.some((e) => at(e.pos, p) || e.plan.some((q) => at(q, p))), ) } diff --git a/src/style.css b/src/style.css index aea8589..20d0146 100644 --- a/src/style.css +++ b/src/style.css @@ -1638,3 +1638,42 @@ canvas.clip[hidden] { font-size: 11px; color: var(--muted); } + +/* The bar of a scene's turns (ui/timeline.ts): filling as it plays, with a mark where its moment + is, lit once that has passed. In the demo's panel and under each dialog's clip. */ +.timeline { + position: relative; + height: 6px; + margin: 8px auto 0; + max-width: 260px; + border-radius: 3px; + background: rgb(128 128 128 / 0.3); +} + +.timeline i { + display: block; + height: 100%; + border-radius: 3px; + background: var(--accent); + transition: width 0.5s linear; +} + +.timeline b { + position: absolute; + top: -4px; + width: 4px; + height: 14px; + margin-left: -2px; + border-radius: 2px; + background: #ffc83d; + box-shadow: 0 0 0 1px rgb(53 53 65 / 0.7); + opacity: 0.8; +} + +.timeline b.passed { + opacity: 1; +} + +#demo .timeline { + margin-top: 10px; +} diff --git a/src/ui/clip.ts b/src/ui/clip.ts index d5d258d..467b040 100644 --- a/src/ui/clip.ts +++ b/src/ui/clip.ts @@ -7,6 +7,7 @@ import { step } from '../sim/turn' import type { Action, ActionId, GameState, Vec } from '../sim/types' import handIcon from '../../assets/kenney-ui-rpg/cursorHand_beige.svg' import { makeScene } from './scenes' +import { timelineAfter, type Timeline } from './timeline' /** * A dialog's clip (PLAN.md §13c, user request, 2026-10-24): a small looping scene of what the @@ -30,6 +31,8 @@ interface Clip { scene: Scene | null upcoming: Scene | null state: GameState | null + /** Its bar of turns left (ui/timeline.ts); none for a fixed scene (the player's own last turns). */ + timeline: Timeline | null /** The next turn to play, and when (LittleJS `time`). */ next: number at: number @@ -82,6 +85,8 @@ export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene, frame.append(canvas) } for (const old of frame.querySelectorAll('.clip-finger, .demo-ripple, .demo-ring')) old.remove() + // A canvas started again (the shop's and the offer's cards) keeps one bar, not one more each time. + if (frame.nextElementSibling?.classList.contains('timeline')) frame.nextElementSibling.remove() let finger: HTMLElement | null = null if (kind && FINGER_KINDS.has(kind)) { finger = document.createElement('div') @@ -91,6 +96,7 @@ export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene, } const c: Clip = { canvas, kind, fixed: kind ? null : (source as Scene), focus, seed: Math.floor(Math.random() * 2 ** 31), scene: null, upcoming: null, state: null, + timeline: kind ? timelineAfter(frame) : null, next: 0, at: 0, anim: newAnimation(), finger, beats: [], selected: null, hover: null, armed: null, } clips = [...clips.filter((o) => o.canvas !== canvas), c] @@ -100,6 +106,8 @@ export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene, /** Stops the clip in `canvas`, if one plays there. */ export function stopClip(canvas: HTMLCanvasElement): void { + const bar = canvas.parentElement?.nextElementSibling + if (bar?.classList.contains('timeline')) bar.remove() clips = clips.filter((o) => o.canvas !== canvas) } @@ -125,6 +133,7 @@ function tick(c: Clip): void { c.upcoming = null c.state = c.scene.start c.next = 0 + c.timeline?.set(0, c.scene.actions.length, c.scene.moment) c.at = time + LEAD_SECONDS c.anim = newAnimation() if (matchMedia('(prefers-reduced-motion: reduce)').matches) return still(c) @@ -157,16 +166,19 @@ function tick(c: Clip): void { play(c, action) c.at = time + (last ? HOLD_SECONDS : STEP_SECONDS) } + // The bar fills over the turn: as long as its beats, or until the next turn. + c.timeline?.set(c.next, c.scene.actions.length, c.scene.moment, c.finger ? c.beats.reduce((sum, b) => sum + b.then, 0) : last ? HOLD_SECONDS : STEP_SECONDS) } /** * With reduced motion, a clip is a still (user's choice, 2026-10-24: "whatever is clearer"): the - * scene's moment, just after it — the flame still on the board, the player's pose — held, with - * no finger and no next loop. + * scene's end (its moment, and any aftermath turns after it) — the flame still on the board, the + * player's pose — held, with no finger and no next loop. The bar shows it full. */ function still(c: Clip): void { for (const action of c.scene!.actions) c.state = step(c.state!, action).state c.next = c.scene!.actions.length + c.timeline?.set(c.next, c.next, c.scene!.moment, 0) c.at = Infinity c.finger?.classList.add('away') } diff --git a/src/ui/info.ts b/src/ui/info.ts index 544c7f9..93d7d5f 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -310,7 +310,7 @@ export const BOARD_TEXT: Record<BoardThing, { icon: string; name: string; text: icon: 'rock', name: 'Rubble', text: [`A fallen ceiling: Acid cracked a pillar, a later blast broke it, and ${COLLAPSE_TURNS} turns on the ceiling round it came down. Nothing gets through rubble, not even flame, and nothing clears it.`], - clip: 'acid', + clip: 'rubble', }, } diff --git a/src/ui/timeline.ts b/src/ui/timeline.ts new file mode 100644 index 0000000..0814e1d --- /dev/null +++ b/src/ui/timeline.ts @@ -0,0 +1,50 @@ +/** + * The bar under a scene that shows how much of it is left, with a mark where the moment it is + * about comes (user request, 2026-10-31): in the demo's panel and under each dialog's clip. It + * fills a turn at a time; the mark lights once the moment has passed, and is left out when the moment is the last turn. + */ +export interface Timeline { + /** + * `done` of `total` turns played; the scene's moment is turn `moment` (0-based; the last if undefined). + * Hidden with no turns. The fill moves to its new width over `seconds` (how long the turn about to + * play takes), so it keeps moving rather than stepping a turn at a time. + */ + set(done: number, total: number, moment?: number, seconds?: number): void +} + +export function bindTimeline(el: HTMLElement): Timeline { + el.classList.add('timeline') + const fill = document.createElement('i') + const mark = document.createElement('b') + el.replaceChildren(fill, mark) + return { + set(done, total, moment, seconds = 0.5) { + el.hidden = total <= 0 + if (total <= 0) return + const width = `${(Math.min(done, total) / total) * 100}%` + if (done === 0) { + // A new loop: jump back to empty at once, rather than draining from full. + fill.style.transition = 'none' + fill.style.width = width + void fill.offsetWidth + fill.style.transition = '' + } else { + fill.style.transitionDuration = `${seconds}s` + fill.style.width = width + } + // The mark says where the moment is; at the very end it says nothing, so it isn't shown. + const at = moment ?? total - 1 + mark.hidden = at >= total - 1 + mark.style.left = `${((at + 1) / total) * 100}%` + // Lit once the turn after the moment has begun, i.e. the moment has shown. + mark.classList.toggle('passed', done > at + 1) + }, + } +} + +/** A timeline in a new element placed after `anchor`. */ +export function timelineAfter(anchor: Element): Timeline { + const el = document.createElement('div') + anchor.after(el) + return bindTimeline(el) +} diff --git a/test/demo.test.ts b/test/demo.test.ts index b0b1e29..307d316 100644 --- a/test/demo.test.ts +++ b/test/demo.test.ts @@ -39,8 +39,11 @@ describe('demo scenes', () => { for (let seed = 1; seed <= 6; seed++) { const scene = generateScene(kind, seed)! expect(scene).not.toBeNull() - expect(scene.actions.length).toBeGreaterThanOrEqual(MIN_STEPS) - expect(scene.actions.length).toBeLessThanOrEqual(MAX_STEPS) + // The moment is MIN-MAX turns in (a scene may ask for more); a few turns of aftermath may follow it. + const moment = scene.moment! + expect(moment + 1).toBeGreaterThanOrEqual(MIN_STEPS) + expect(moment + 1).toBeLessThanOrEqual(SCENES[kind].maxSteps ?? MAX_STEPS) + expect(scene.actions.length - (moment + 1)).toBeLessThanOrEqual(SCENES[kind].tail ?? 0) expect(scene.gestures.length).toBe(scene.actions.length) // Calm: the scene's own bombs are placed in it, so the countdown is seen from the start. expect(scene.start.bombs).toEqual([]) @@ -73,6 +76,14 @@ describe('demo scenes', () => { }) } + it('rubble: the ceiling falls at the moment, and the aftermath shows it', () => { + const scene = generateScene('rubble', 3)! + expect(scene.start.cracked.length).toBe(1) + const { turns } = replayScene(scene) + expect(turns[scene.moment!]!.events.some((e) => e.type === 'ceilingFell')).toBe(true) + expect(turns[turns.length - 1]!.next.cells.includes('rubble')).toBe(true) + }) + it('the same seed gives the same scene', () => { expect(generateScene('kill', 7)).toEqual(generateScene('kill', 7)) })