explodorama / commit
Demo on a CRT, with the top bar, its tour and "What is that?"; EXPLODORAMA!
CHANGELOG.md | 7 +- PLAN.md | 16 +++- README.md | 16 +++- decisions.md | 80 ++++++++++++++++++++ docs/screenshots/demo.png | Bin 151437 -> 1035346 bytes index.html | 7 +- src/main.ts | 178 +++++++++++++++++++++++++++++++++++++++++--- src/render/board.ts | 15 +++- src/sim/demo.ts | 28 ++++++- src/style.css | 162 ++++++++++++++++++++++++++++++++++++++-- src/ui/dialog.ts | 14 +++- static/manifest.webmanifest | 4 +- test/demo.test.ts | 3 + 13 files changed, 494 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d99e422..afdeb28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,9 @@ saved before the change is dropped and the next visit starts a new run. ones), each with a caption and an icon, until you tap. Each scene gets to its point 3 to 6 turns in, and a finger plays every turn on the real action bar — taps with a ripple, a long press for a bomb, a button then a tile, Go — after five opening scenes on the controls alone. The player wears a "You" tag in the demo, and - each scene's finger starts beside them. + each scene's finger starts beside them. The top bar shows in the demo too, with a tour of its + items and a scene on using "What is that?" on an enemy. The demo plays on an old CRT TV, made + with LittleJS post-processing and a screen overlay. - **Installable** as an app (Chrome, Edge, Android, iPhone / iPad), and playable offline once loaded. - **Deployment** to GitLab Pages on every push to the default branch, after the tests and the @@ -76,6 +78,7 @@ saved before the change is dropped and the next visit starts a new run. ### Changed +- The game is now called **EXPLODORAMA!** - One board, 9 × 11 with 20 levels (it was a choice with the original's 31 × 13 and 50 levels), and crates always crumble once the enemies are gone (it was an option). The New run menu has just two modes: Developer and Immortal (nothing kills you, so you can reach the @@ -118,6 +121,8 @@ saved before the change is dropped and the next visit starts a new run. ### Fixed +- The player is drawn a little higher, so their feet never touch the line between tiles. +- A top-bar number bumping at the edge of its row (Turn) is no longer cut off. - Moving off the auto-move path now clears the flag. - Enemies let out by flaming the exit or the gem are no longer killed at once by that flame, which lingers a turn; a new explosion still kills them. diff --git a/PLAN.md b/PLAN.md index fb76eef..82420da 100644 --- a/PLAN.md +++ b/PLAN.md @@ -697,10 +697,22 @@ panel at the bottom (grouped by interactivity: board and buttons, then the words long press, then the bomb's preview; a picked button lights up its tiles, a far tap shows auto-move's way. Turns take about 1–2 s. *Since 2026-10-24 (user request)* the player wears a green **"You"** tag just over their head, always (it wasn't obvious which moving figure is you), and each scene's finger -appears just below-right of the player, where the eye should start. **Five control scenes** come first (step, long-press +appears just below-right of the player, where the eye should start. *Since 2026-10-24 (user +request)* the **top bar shows** in the demo too (scenes start at turn 0 with no coins; rooms have +par 20), and two scenes acted out on a still room follow the control scenes: a **top-bar tour** +(the finger points at each item, ringed, the caption saying what it is) and **"What is that?"** +(? then an enemy, through the real `inspect` and `tapTile`; the dialog opens, the finger +taps Got it; in the demo the dialog opens over the scene only, not as a modal over the page, so +the caption stays undimmed). The demo plays **on a CRT** (user request): LittleJS +post-processing (`PostProcessPlugin`: colour fringes and static) on the board, a CSS layer of scan +lines, a rolling dark bar and sweeping bright lines, vignette, rounded glass and bezel, and every +12–25 s half a second of "vacuum cleaner" interference (tearing and snow; shader values set per frame) over the whole screen part (the HTML top bar and buttons +too), at most 4:3 with black bars; the finger is in front of the glass. **Five control scenes** come first (step, long-press bomb, Bomb button, Wait, auto-move with Go), and again each time the living scenes have all been shown. Captions of Run, Kick, Throw, Teleport, Diagonal and the Detonator say how to do them. -41 kinds, all found on 40 seeds each, the slowest under 0.4 s. Ideas: a gem's choice, and +41 kinds; on 40 seeds each, all but one Nuke scene found (a missing scene is skipped), the +slowest under 0.45 s. *Since 2026-10-24 (user request)* every room has its exit: under a crate, +or in one room in six already uncovered (locked while enemies live). Ideas: a gem's choice, and showing the demo again after the player is idle on the menu. ## 13b. Run options (user request, 2026-10-21; built) diff --git a/README.md b/README.md index 3ef0398..195ba2c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Explodorama +# EXPLODORAMA! **[▶ Play now!](https://pavelsafronov.gitlab.io/explodorama/)** — in the browser, on a phone or a computer. @@ -31,9 +31,17 @@ where it lands), a long press for a bomb (a ring closes in, the bomb's blast is bomb goes down when the finger lifts), a button and then a tile (Bomb then a tile; Move then two tiles away for a Run), Wait, and Go. The player wears a green **"You"** tag over their head throughout, since it isn't otherwise obvious which of the moving figures is you, and the finger appears beside the player at the start of each scene, so -your eye starts where the action is. It starts with five scenes about the controls alone: -stepping, a bomb by long press, a bomb with the Bomb button, waiting, and auto-move (tap a far -tile, then Go). The rest show every feature on the +your eye starts where the action is. The top bar shows throughout, as in play (each scene starts at turn 0 with no coins). It starts +with scenes about the controls alone: stepping, a bomb by long press, a bomb with the Bomb button, +waiting, auto-move (tap a far tile, then Go), **a tour of the top bar** (the finger points at +each item, ringed, while the caption says what it is: lives, level, enemies left, coins, turn +and par, bombs / blast / fuse, and the power-ups and charges you hold), and **"What is that?"** +(the finger taps **?**, then an enemy; the real info dialog opens over the scene, leaving the +caption undimmed, and the finger taps Got it). The whole demo plays **on an old CRT TV**, as if +someone were playing on a touch-screen television: scan lines, a vignette, colour fringes and +faint static, a dark bar rolling down the screen and bright lines sweeping across it, and now and +then a burst of interference, as if someone switched a vacuum cleaner on nearby, rounded glass in a dark bezel, at most 4:3 wide with black bars beside it on a wide +screen — and the finger in front of the glass. The rest show every feature on the board: bombs and their countdown, watching the enemies' arrows to bomb where they're going, a narrow escape, chain reactions, lingering flame, the three enemy types, drops, the gem, every power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick — a tap on a bomb next to diff --git a/decisions.md b/decisions.md index 7375301..c9010a2 100644 --- a/decisions.md +++ b/decisions.md @@ -1214,3 +1214,83 @@ immediately clear which of the moving figures is the player. above the finger, so a tap on the tile over the player doesn't hide it. Only in the demo; in play, the action bar's Move button and the tapping make it clear soon enough, and the tag would cover the tile above. Checked in a browser frame by frame. + +### 2026-10-24 — The top bar in the demo, its tour, "What is that?"; the player stands higher; bumps no longer clipped + +User request: add the top bar to the demos, describe its items, and show how to use **?** to +learn about enemies. Also: draw the player a few pixels higher, so its legs never touch the line +between tiles; and (spotted meanwhile) the Turn item, bumping when a turn passes, was cut off by +the bar's edge. + +- **The top bar shows in the demo**, as in play; the board, the action bar under it and the + caption panel fit below it. Each scene now starts at turn 0 with no coins (a scene is the end of + a longer game: the counter would have opened at, say, 14). Safe because a scene starts with no + bomb out, and the turn number only matters relative to a bomb's (`placedTurn`). Demo rooms have + par 20 instead of a dummy 99, which the bar would show. +- **The top-bar tour**, after the control scenes: the finger points (no tap: each item would open + a dialog, twice as long) at lives, level, enemies left, coins, turn and par, bombs / blast / + fuse, and the row of power-ups and charges, each ringed in yellow for 2.6 s while the caption + says what it is; the last says that in a game a tap on any item reads more. The room is a + still one (no turns), given Kick and 3 Pierce so the last row has something to show. +- **"What is that?"**, next: the finger taps **?** and then an enemy, through the real `inspect` + and `tapTile`, so the real info dialog opens; 3.2 s later it taps Got it. A tap that ends the + demo closes the dialog too. In the demo the dialog isn't a modal (user request, same day: the + modal dimmed the caption panel as well): `setDialogInDemo` opens it with `show()`, centred over + the scene above the panel and dimming only that (a wide box-shadow), with the panel above it. + That also lets the finger go over it; a modal sits in the page's top layer, above any z-index. +- **The tour's ring moves with the finger** (user request, same day: it changed before the finger + got there): it changes over half-way along the glide, about where the finger crosses from one + item to the next, and for the first item once the finger arrives; the caption with it. +- **The player stands 0.07 of a tile higher** (`PLAYER_LIFT` in board.ts): the sprites' feet are at + their very bottom edge, and the idle bob dipped them below it. Walking, teleporting and the + move preview use the same lift; the decoy is drawn smaller, so it needed none. +- **Top-bar bumps aren't clipped**: a row that can scroll sideways also clips, so an item bumping + (scale 1.3) at a row's edge — Turn, first in its row — was cut off. Each row has 6 / 16 px of + room inside, given back with negative margins; checked on a phone and a wide screen that the + layout is otherwise unchanged. + +### 2026-10-24 — The demo on a CRT; EXPLODORAMA! + +User request: "try out LittleJS post-processing: can we make the demo appear on a CRT TV with +lines … like someone is actually playing on a touch-capable CRT"; then, on a wide desktop, limit +its width with black bars beside it. And the app is now called **EXPLODORAMA!**. + +- **Two layers**, because the board is a WebGL canvas but the top bar, the action bar, the "You" + tag and the dialog are HTML, which no shader reaches. LittleJS's `PostProcessPlugin` shades the + board with a CRT's signal: colour fringes toward the edges (`postProcessChromatic`) and faint + static (`postProcessNoise`). It is a pass-through in play (`setShaderCode()` with nothing) and + the CRT only while the demo runs. A layer over the whole screen part, `#demo-crt`, adds what + belongs to the tube: scan lines 3 px apart, a vignette, a faint glare, and the glass's rounded + corners in a dark bezel, with a slight flicker (none with reduced motion). Then (user request, + same day) what moves on an old TV: a soft dark bar rolling down every 7 s, as a TV filmed by a + camera shows, and two thin bright lines sweeping down, out of step (3.4 s and 5.3 s); all + hidden with reduced motion. And (user request, same day) **interference**, as when a vacuum + cleaner is switched on nearby: every 12–25 s (the first after 8 s), for 0.5 s (1.6 s at + first, too distracting), the picture + tears — its lines jump sideways, some far, in a custom post-process piece before the colour + fringes, driven by a shader value, `interference`, set each frame and fluttering like a motor — + the static gets ten times louder (`staticNoise`), and an SVG-noise snow jumps over the whole + screen, top bar and buttons included; then it stops at once. None with reduced motion. The finger is in + front of the glass (above it), the ripples on the screen (below it); the caption panel is not + on the TV. +- **No glow, no bulge.** LittleJS's TV effect glows from black up, and even a glow only past 0.8 + brightness washed the pale board out (the sky measured 243 green instead of 227); without it + the colours match play. A bulged screen would bend the board but not the HTML over it, and the + finger would miss the tiles it points at. +- **At most 4:3**: on a wide screen the TV is as wide as 4:3 of its height and centred, with black + bars either side (a huge box-shadow); the top bar narrows to it. On a phone it is the full width. +- **EXPLODORAMA!**, all caps with an exclamation mark: the page title, the home-screen name + (manifest and Apple's), the demo's title and the README's heading. The package name stays + `explodorama`, and the docs still write Explodorama in running text. + +### 2026-10-24 — Every demo room has its exit + +User request: most demo rooms had no door, which made captions that mention it (the enemies +counter: "once they're all gone, the exit opens") strange. Every room now gets one, as every +level does (`placeDoor`): under a crate five rooms in six, otherwise already uncovered and +locked while enemies live (open once they're gone); never under the gem's crate or on a drop, +and at least 3 steps from the player. The exit scenes still place theirs themselves. Since the +exit is part of the rules (flame on an uncovered one lets enemies out; once open, walking in +clears the level), the random games play with it; a probe over 40 seeds of every kind found all +but one Nuke scene (a missing scene is skipped, and the tests' seeds all pass), the slowest +0.44 s. At a scene's start, 79% of the doors are still covered. diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png index c468499..01cfb6e 100644 Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ diff --git a/index.html b/index.html index 9a9ea15..e9cab75 100644 --- a/index.html +++ b/index.html @@ -3,13 +3,13 @@ <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" /> -<title>Explodorama</title> +<title>EXPLODORAMA!</title> <meta name="description" content="A turn-based, mobile-first, roguelike bomb-laying game. Every bomb counts down with your moves." /> <meta name="theme-color" content="#353541" /> <link rel="icon" type="image/png" href="/assets/kenney/bomb.png" /> <meta name="mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" /> -<meta name="apple-mobile-web-app-title" content="Explodorama" /> +<meta name="apple-mobile-web-app-title" content="EXPLODORAMA!" /> <link rel="manifest" href="/manifest.webmanifest" /> <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" /> <link rel="stylesheet" href="/src/style.css" /> @@ -55,12 +55,13 @@ </div> </dialog> <div id="demo" hidden aria-live="polite"> - <h1>Explodorama</h1> + <h1>EXPLODORAMA!</h1> <p id="demo-caption"></p> <p class="demo-hint">Tap anywhere to play</p> </div> <div id="demo-finger" hidden aria-hidden="true"></div> <div id="demo-you" hidden aria-hidden="true"></div> +<div id="demo-crt" hidden aria-hidden="true"><i class="crt-roll"></i><i class="crt-line"></i><i class="crt-line"></i></div> <script type="module" src="/src/main.ts"></script> </body> </html> diff --git a/src/main.ts b/src/main.ts index 8f968fa..3798d3a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,11 @@ import { setGLEnable, setShowSplashScreen, setTilesPixelated, + PostProcessPlugin, + postProcess, + postProcessChromatic, + postProcessEffects, + postProcessNoise, time, vec2, worldToScreen, @@ -22,12 +27,13 @@ import { enemyAt } from './sim/enemies' import { autoMoveAction, playerPath, safeStep as isSafeStep } from './sim/path' import { predictDanger, type Danger } from './sim/danger' import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record' -import { cellAt, eq, inBounds, isBurning, newRun } from './sim/state' +import { cellAt, eq, inBounds, isBurning, newRun, PAR_BONUS } from './sim/state' +import { LAST_LEVEL } from './sim/levelgen' import { step } from './sim/turn' import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type Dir, type GameEvent, type GameState, type RunOptions, type Vec } from './sim/types' import { openAlmanac } from './ui/almanac' import { createControls } from './ui/controls' -import { dialogOpen, openDialog, type DialogButton } from './ui/dialog' +import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog' import { actionInfo, chargeInfo, hudInfo, modeLines, tileInfo, type Info } from './ui/info' import { openOffer } from './ui/offer' import { openScores, recordScore } from './ui/scores' @@ -39,6 +45,7 @@ import { spriteUrl } from './render/sprites' import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png' import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png' import handIcon from '../assets/kenney-ui-rpg/cursorHand_beige.svg' +import questionIcon from '../assets/kenney-icons/question.png' // `?seed=123&level=4` gives a reproducible board (screenshots, bug reports). Otherwise a random // seed — chosen here, never inside the sim. @@ -595,13 +602,65 @@ let demoSeed = Number(params.get('seed')) || randomSeed() // a scene where the player lives, then one where they die (user request, 2026-10-23: alternate // them). In their usual order with `?seed`, for a test URL. Once the living ones have all been // shown, the controls come again. -const demoQueues: Record<'controls' | 'living' | 'dying', SceneKind[]> = { controls: [...CONTROL_KINDS], living: [], dying: [] } +/** + * Besides the scenes made in the sim, two acted out on a still room (user request, 2026-10-24): + * a tour of the top bar, and "What is that?" on an enemy. They come after the controls' scenes. + */ +type DemoItem = SceneKind | 'topBar' | 'whatIsThat' +const CONTROL_ITEMS: readonly DemoItem[] = [...CONTROL_KINDS, 'topBar', 'whatIsThat'] +const demoQueues: { controls: DemoItem[]; living: SceneKind[]; dying: SceneKind[] } = { controls: [...CONTROL_ITEMS], living: [], dying: [] } let demoDying = false const demoPanel = document.getElementById('demo')! const demoCaption = document.getElementById('demo-caption')! const actionBar = document.getElementById('actions')! const finger = document.getElementById('demo-finger')! const youTag = document.getElementById('demo-you')! +/** + * The demo plays on an old CRT, as if someone were playing on a touch-screen TV (user request, + * 2026-10-24). Two layers: LittleJS's post-processing shades the board (the canvas) with a CRT's + * signal — colour fringes, a little static — and #demo-crt lies over the whole + * screen part, board, top bar and buttons alike (they are HTML, which no shader reaches): scan + * lines, a vignette, the tube's rounded glass and its bezel. The finger is in front of the glass. + */ +const crtScreen = document.getElementById('demo-crt')! +/** The CRT's widest shape, width to height: an old TV's 4:3. */ +const TV_ASPECT = 4 / 3 +// Colour fringes and faint static. No glow: the board is pale and bright all over, and any glow +// (postProcessTV's, or one only past 0.8) washed it out. First, interference (`vacuum` below): +// with `interference` above 0 the lines of the picture jump sideways, some far, and it darkens a +// little; the static is `staticNoise`, louder meanwhile. +const CRT_SHADER = postProcessEffects( + ` // interference + float _torn = step(.75, fract(sin(floor(p.y / 4.) * 12.9898 + floor(iTime * 24.)) * 43758.5453)); + uv.x += interference * (_torn * .03 + .006 * sin(uv.y * 90. + iTime * 70.)); + c = texture(iChannel0, uv); + c.rgb *= 1. - .12 * interference;`, + postProcessChromatic(0.0025), + postProcessNoise('staticNoise', 1), +) +/** + * Now and then, as if someone switched a vacuum cleaner on in the next room and off again (user + * request, 2026-10-24): for half a second (1.6 s was too distracting) the picture tears and + * fills with static, the HTML parts of the screen too (`.interference`, style.css). Every 12 to + * 25 s, the first after 8 s. + */ +const VACUUM_SECONDS = 0.5 +let vacuumAt = Infinity + +/** Called every frame of the demo: the interference's strength now, for the shader and the screen. */ +function vacuum(): void { + if (vacuumAt === Infinity) vacuumAt = time + 8 + if (time > vacuumAt + VACUUM_SECONDS) vacuumAt = time + 12 + Math.random() * 13 + // Not with reduced motion: it flickers hard. + const on = time >= vacuumAt && !matchMedia('(prefers-reduced-motion: reduce)').matches + // A motor's buzz isn't steady: the strength flutters while it runs. + const strength = on ? 0.6 + 0.4 * Math.random() : 0 + if (postProcess) { + postProcess.values.interference = strength + postProcess.values.staticNoise = 0.03 + 0.3 * strength + } + crtScreen.classList.toggle('interference', on) +} finger.innerHTML = `<img src="${handIcon}" alt="" draggable="false">` /** * In the demo the action bar sits right under the board, as wide as it, and the caption panel @@ -640,14 +699,14 @@ const SCENE_ICONS: Record<SceneKind, string> = { } /** The next kind of scene: the controls' queue first, then living and dying ones in turn. */ -function nextKind(): SceneKind { +function nextKind(): DemoItem { if (demoQueues.controls.length) return demoQueues.controls.shift()! const which = demoDying ? 'dying' : 'living' demoDying = !demoDying const all = which === 'dying' ? DYING_KINDS : LIVING_KINDS if (!demoQueues[which].length) demoQueues[which] = params.has('seed') ? [...all] : [...all].sort(() => Math.random() - 0.5) const kind = demoQueues[which].shift()! - if (which === 'living' && !demoQueues.living.length) demoQueues.controls = [...CONTROL_KINDS] + if (which === 'living' && !demoQueues.living.length) demoQueues.controls = [...CONTROL_ITEMS] return kind } @@ -655,7 +714,9 @@ function nextKind(): SceneKind { function nextScene(): void { if (!demo) return for (let tries = 0; tries < SCENE_KINDS.length; tries++) { - const scene = generateScene(nextKind(), demoSeed++) + const item = nextKind() + // The still scenes play on the room of a sim scene, without its turns. + const scene = generateScene(item === 'topBar' ? 'tapMove' : item === 'whatIsThat' ? 'enemies' : item, demoSeed++) if (!scene) continue resetView() hover = null @@ -665,10 +726,15 @@ function nextScene(): void { demo.beats = [] demo.at = time + DEMO_LEAD_SECONDS state = scene.start - const icon = SCENE_ICONS[scene.kind] - demoCaption.innerHTML = - `<img class="demo-icon${MONO_ICONS.has(icon) ? ' mono' : ''}" src="${spriteUrl(icon)}" alt="" draggable="false">` + - `<span>${SCENES[scene.kind].caption}</span>` + if (item === 'topBar' || item === 'whatIsThat') { + demo.scene = { ...scene, actions: [], gestures: [] } + // 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 }, boosts: { ...state.boosts, pierce: 3 } } + demo.beats = item === 'topBar' ? topBarTour() : whatIsThat() + } else { + const icon = SCENE_ICONS[item] + setCaption(spriteUrl(icon), MONO_ICONS.has(icon), SCENES[item].caption) + } afterChange() // The finger comes back beside the player, where the eye should be (user request, 2026-10-24). demo.rest = true @@ -676,6 +742,77 @@ function nextScene(): void { } } +/** The demo's caption: an icon (an image URL; `mono` for a one-colour ink icon) and the words. */ +function setCaption(icon: string, mono: boolean, text: string): void { + demoCaption.innerHTML = `<img class="demo-icon${mono ? ' mono' : ''}" src="${icon}" alt="" draggable="false"><span>${text}</span>` +} + +/** The top bar's items the tour points at, in order: the item, its icon, and what to say. */ +const TOUR: { selector: string; icon: string; text: string }[] = [ + { selector: '#hud-lives .hud-item', icon: 'hud_heart', text: 'Your lives. Lose one and the level starts over; lose them all and the run ends.' }, + { selector: '#hud-level .hud-item', icon: 'flag_green_a', text: `The level you’re on, out of ${LAST_LEVEL}.` }, + { selector: '#hud-enemies .hud-item', icon: 'slime_normal_rest', text: 'Enemies left. Once they’re all gone, the exit opens.' }, + { selector: '#hud-coins .hud-item', icon: 'hud_coin', text: 'Your coins.' }, + { selector: '#hud-turn .hud-item', icon: 'icon_wait', text: `Turns taken, and the level’s par: clear it within par for ${PAR_BONUS} bonus coins.` }, + { selector: '#hud-stats', icon: 'bomb', text: 'How many bombs you can have out at once, how far their blast reaches, and how many turns their fuse lasts.' }, + { selector: '#hud-extras', icon: POWER_UP_ICONS.kick, text: 'The power-ups and charges you have. In a game, tap anything up here to read more.' }, +] +const TOUR_SECONDS = 2.6 +const INFO_SECONDS = 3.2 + +/** + * The top bar's tour: the finger points at each item in turn (no tap: each would open a dialog), + * the item ringed, the caption saying what it is. The ring moves with the finger (user request, + * 2026-10-24: it used to jump ahead): it changes over half-way along the glide, about where the + * finger crosses from one item to the next; for the first item, once the finger is there. + */ +function topBarTour(): Beat[] { + const beats: Beat[] = TOUR.flatMap(({ selector, icon, text }, i) => { + const el = () => document.querySelector<HTMLElement>(selector)! + const glide = i === 0 ? FINGER_MOVE_SECONDS : FINGER_MOVE_SECONDS / 2 + return [ + { + run: () => { + const r = el().getBoundingClientRect() + placeFinger(vec2(r.left + r.width / 2, r.bottom - 4)) + }, + then: glide, + }, + { run: () => (spotlight(el()), setCaption(spriteUrl(icon), MONO_ICONS.has(icon), text)), then: TOUR_SECONDS - glide }, + ] + }) + setCaption(spriteUrl(TOUR[0]!.icon), false, 'The top bar.') + return [...beats, { run: () => (spotlight(null), finger.classList.add('away')), then: 0.6 }] +} + +/** Rings one element of the page for the tour, or (null) none. */ +function spotlight(el: HTMLElement | null): void { + for (const lit of document.querySelectorAll('.demo-spot')) lit.classList.remove('demo-spot') + el?.classList.add('demo-spot') +} + +/** + * "What is that?": the finger taps the ? button, then an enemy, and the real info dialog opens + * (main.ts `inspect`, `tapTile`); after a while it taps Got it. In the demo the dialog opens over + * the scene only (dialog.ts `setDialogInDemo`). + */ +function whatIsThat(): Beat[] { + setCaption(questionIcon, true, 'Not sure what something is? Tap ?, then tap it: an enemy, a tile, a bomb, anything.') + const button = document.getElementById('info-button')! + const enemy = state.enemies[0]!.pos + return [ + { run: () => placeFinger(centreOf(button)), then: FINGER_MOVE_SECONDS }, + { run: fingerDown, then: TAP_SECONDS }, + { run: () => (fingerUp(), inspect()), then: PICKED_SECONDS }, + { run: () => placeFinger(tilePoint(enemy)), then: FINGER_MOVE_SECONDS }, + { run: fingerDown, then: TAP_SECONDS }, + { run: () => (fingerUp(), tapTile(enemy)), then: INFO_SECONDS }, + { run: () => placeFinger(centreOf(document.querySelector('#dialog-buttons button:last-child')!)), then: FINGER_MOVE_SECONDS }, + { run: fingerDown, then: TAP_SECONDS }, + { run: () => (fingerUp(), closeDialog(), finger.classList.add('away')), then: DEMO_HOLD_SECONDS }, + ] +} + function startDemo(): void { demo = { scene: null!, next: 0, at: 0, real: state, beats: [], rest: false } document.body.classList.add('demo') @@ -683,6 +820,9 @@ function startDemo(): void { finger.hidden = false finger.classList.add('away') youTag.hidden = false + crtScreen.hidden = false + setDialogInDemo(true) + postProcess?.setShaderCode(CRT_SHADER) nextScene() if (!demo.scene) endDemo() } @@ -819,6 +959,13 @@ function endDemo(): void { demoPanel.hidden = true finger.hidden = true youTag.hidden = true + if (dialogOpen()) closeDialog() + setDialogInDemo(false) + crtScreen.hidden = true + crtScreen.classList.remove('interference') + vacuumAt = Infinity + postProcess?.setShaderCode() + spotlight(null) for (const mark of document.querySelectorAll('.demo-ripple, .demo-ring')) mark.remove() for (const b of actionBar.querySelectorAll('.pressed')) b.classList.remove('pressed') actionBar.style.left = actionBar.style.width = actionBar.style.top = '' @@ -899,6 +1046,8 @@ function guarded(where: string, frame: () => void): () => void { await engineInit( () => { setShowSplashScreen(false) + // Post-processing: a pass-through in play, the CRT in the demo. + new PostProcessPlugin(demo ? CRT_SHADER : undefined).values = { interference: 0, staticNoise: 0.03 } setCanvasClearColor(BACKGROUND) // Pressing shows the preview on the tile under the finger; releasing commits to it. // Sliding to another tile before letting go changes the choice. Holding still on a tile @@ -933,9 +1082,16 @@ await engineInit( // In the demo the board and the action bar under it fit above the caption panel, with room // between them and it. const panel = demoPanel.getBoundingClientRect().height - updateCamera(state, demo ? { top: 0, bottom: panel + DEMO_PANEL_GAP + DEMO_GAP + controls.insets().bottom } : controls.insets(), snapCamera) + updateCamera(state, demo ? { top: controls.insets().top, bottom: panel + DEMO_PANEL_GAP + DEMO_GAP + controls.insets().bottom } : controls.insets(), snapCamera) if (demo) placeDemoBar() + // The caption panel's height, for what sits above it (style.css: the CRT screen, the dialog), + // and the TV's sides: at most 4:3 wide, black bars beside it on a wide screen (user request, + // 2026-10-24: it looked very wide on a desktop). + document.body.style.setProperty('--demo-panel', `${panel}px`) + const tv = Math.min(innerWidth, (innerHeight - panel) * TV_ASPECT) + document.body.style.setProperty('--tv-left', `${(innerWidth - tv) / 2}px`) placeDemoMarks() + if (demo) vacuum() snapCamera = false flames = flames.filter((f) => time - f.born < FLAME_SECONDS) pruneEffects(fx, time) diff --git a/src/render/board.ts b/src/render/board.ts index 3680806..966d80b 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -226,7 +226,7 @@ export function drawBoard(view: View): void { if (s.shield) drawCircle(at, 0.88 * appearScale(fx, key.shield, time), C.clear, 0.06, C.shield) if (s.star > 0) drawCircle(at, 1.0 + 0.06 * Math.sin(time * 8), C.clear, 0.08, C.star) if (after && !eq(after.player, s.player)) { - drawTile(toWorld(s, after.player), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft) + drawTile(toWorld(s, after.player).add(PLAYER_LIFT), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft) } drawPlayer(s, view.playerMove, view.playerPose, view.facingLeft) } @@ -240,6 +240,13 @@ export function playerPos(s: GameState, move: PlayerMove | null): Vector2 { return toWorld(s, move.from).lerp(to, t / WALK_SECONDS) } +/** + * The player stands a little above their tile's centre: the sprite's feet are at its very bottom, + * and would touch the line between tiles, below it at the bottom of the idle bob (user request, + * 2026-10-24). 0.07 of a tile: still 0.045 clear at the bob's lowest. + */ +const PLAYER_LIFT = vec2(0, 0.07) + /** * The player: first their move this turn — a hop from tile to tile with walking frames, or for a * teleport shrinking away and growing back — then, once it is done, their pose: the hit pose when @@ -247,17 +254,17 @@ export function playerPos(s: GameState, move: PlayerMove | null): Vector2 { * with a glance at the camera now and then. Nothing can skip the move (user request, 2026-10-23). */ function drawPlayer(s: GameState, move: PlayerMove | null, pose: PlayerPose, facingLeft: boolean): void { - const to = toWorld(s, s.player) + const to = toWorld(s, s.player).add(PLAYER_LIFT) const t = move ? time - move.at : Infinity if (move && t < moveSeconds(move)) { if (move.teleport) { const out = t < TELEPORT_SECONDS const k = out ? 1 - t / TELEPORT_SECONDS : (t - TELEPORT_SECONDS) / TELEPORT_SECONDS - drawTile(out ? toWorld(s, move.from) : to, vec2(k), sprite('character_green_front'), C.white, (1 - k) * 2, facingLeft) + drawTile(out ? toWorld(s, move.from).add(PLAYER_LIFT) : to, vec2(k), sprite('character_green_front'), C.white, (1 - k) * 2, facingLeft) return } const k = t / WALK_SECONDS - const pos = toWorld(s, move.from).lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) + const pos = toWorld(s, move.from).add(PLAYER_LIFT).lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) drawTile(pos, vec2(1), sprite(k < 0.5 ? 'character_green_walk_a' : 'character_green_walk_b'), C.white, 0, facingLeft) return } diff --git a/src/sim/demo.ts b/src/sim/demo.ts index b23f44d..0b8917a 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -122,6 +122,8 @@ const ROOMS = 8 /** A scene's moment comes this many turns in, at the least and at the most (user request, 2026-10-24). */ export const MIN_STEPS = 3 export const MAX_STEPS = 6 +/** A room's par, as a level of about its size would have (the top bar shows it). */ +const DEMO_PAR = 20 const kills = (t: Turn) => t.events.filter((e): e is Extract<GameEvent, { type: 'enemyDied' }> => e.type === 'enemyDied') const explosions = (t: Turn) => t.events.filter((e): e is Extract<GameEvent, { type: 'exploded' }> => e.type === 'exploded') @@ -481,6 +483,8 @@ export function showsAtEnd(scene: Scene): boolean { * 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. + * The top bar shows during the demo, so the scene starts at turn 0 with no coins, as if fresh; + * with no bomb out, nothing depends on the turn number. */ function ending(kind: SceneKind, game: GameTurn[]): Scene | null { const room = game[0]!.prev @@ -490,7 +494,7 @@ function ending(kind: SceneKind, game: GameTurn[]): Scene | null { if (s.bombs.length || s.burning.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 scene = { kind, start: s, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture) } + const scene = { kind, start: { ...s, turn: 0, coins: 0 }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture) } if (showsAtEnd(scene)) return scene } return null @@ -513,7 +517,7 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState { const s: GameState = { seed: rng.int(2 ** 31), options: { ...DEFAULT_OPTIONS }, level: 1, turn: 0, width: WIDTH, height: HEIGHT, cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing', - enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 99, coins: 0, + enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: DEMO_PAR, coins: 0, boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [], powers: noPowers(), star: 0, gems: [], offer: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31), @@ -525,10 +529,30 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState { s.enemies.push({ id: s.nextEnemyId++, kind: enemy, pos, dir: 'left', plan: [], spawned: false, sheltered: false }) } SCENES[kind].prepare?.(s, rng) + if (s.exit.x < 0) placeDoor(s, rng) planIntents(s) return s } +/** + * Every room has its exit, as every level does (user request, 2026-10-24: captions talk about it, + * and most rooms had none): mostly under a crate, as in play; one room in six with it already + * uncovered, still locked while enemies live. Not under the gem's crate, not on a drop, and at + * least 3 steps from the player. + */ +function placeDoor(s: GameState, rng: Rng): void { + const tiles = (cell: Cell) => + s.cells.flatMap((c, i) => { + const p = { x: i % s.width, y: Math.floor(i / s.width) } + const free = !s.enemies.some((e) => at(e.pos, p)) && !s.drops.some((d) => at(d.pos, p)) && !s.gems.some((g) => at(g.pos, p)) + return c === cell && free && steps(p, s.player) >= 3 ? [p] : [] + }) + const crates = tiles('block') + const floor = tiles('floor') + const pick = crates.length && (rng.next() >= 1 / 6 || !floor.length) ? crates : floor + if (pick.length) s.exit = pick[rng.int(pick.length)]! +} + /** Free floor tiles `n` steps from the player, with no enemy. */ function tilesAt(s: GameState, n: number): Vec[] { return s.cells.flatMap((c, i) => { diff --git a/src/style.css b/src/style.css index 49d6757..d3c872f 100644 --- a/src/style.css +++ b/src/style.css @@ -73,6 +73,11 @@ img { min-height: 30px; overflow-x: auto; scrollbar-width: none; + /* A row that scrolls also clips, so an item bumping (scale 1.3, below) at its edge was cut off + (user report, 2026-10-24: Turn, first in its row). Room for it inside the row, given back + with negative margins so nothing moves. */ + padding: 6px 16px; + margin: -6px -16px; } .hud-row::-webkit-scrollbar { @@ -768,17 +773,30 @@ img { } /* The demo before the game (main.ts): the board alone, a caption panel at the bottom. */ -body.demo #hud, body.demo #banner { display: none; } +/* The top bar is on the TV too, as wide as it (main.ts). */ +body.demo #hud { + left: var(--tv-left, 0px); + right: var(--tv-left, 0px); +} + +/* The top bar's tour in the demo (main.ts): the item it is about, ringed. */ +.demo-spot { + border-radius: 10px; + outline: 3px solid #ffc83d; + outline-offset: 2px; +} + #demo { position: fixed; left: 0; right: 0; bottom: 0; - z-index: 2; + /* Above the CRT screen and the demo's dialog (below): the words are not on the TV. */ + z-index: 10; padding: 12px 16px calc(env(safe-area-inset-bottom) + 16px); background: var(--ink); color: #fff; @@ -859,12 +877,13 @@ body.demo #actions { } /* The demo's finger (Kenney's pointing hand). Its box sits on the point it touches; the image - is shifted so the fingertip, not the corner, is there. */ + is shifted so the fingertip, not the corner, is there. In front of everything on the CRT, + glass included: it is a real finger on the screen. */ #demo-finger { position: fixed; left: 0; top: 0; - z-index: 4; + z-index: 9; pointer-events: none; transition: opacity 0.3s; } @@ -940,13 +959,14 @@ body.demo #actions { } /* Where the finger taps, a ripple spreads; while it holds, a ring closes in (main.ts sets its - time, --hold, to the long press's). Each sits on the point, drawn by its ::before. */ + time, --hold, to the long press's). Each sits on the point, drawn by its ::before; on the + screen, so above its dialog and under the glass. */ .demo-ripple, .demo-ring { position: fixed; left: 0; top: 0; - z-index: 3; + z-index: 7; pointer-events: none; } @@ -1062,3 +1082,133 @@ body.demo #actions { #dialog-body .score-meta b { color: #e8253f; } + +/* The demo's dialog (dialog.ts `setDialogInDemo`): not a modal over the page, but over the + demo's screen only — centred above the caption panel, which stays undimmed (it is above it). */ +#dialog.in-demo { + position: fixed; + inset: 0 0 var(--demo-panel, 0px); + z-index: 6; + height: fit-content; + margin: auto; + box-shadow: 0 0 0 100vmax rgb(53 53 65 / 0.6); + animation: fade-in 0.18s ease-out; +} + +/* The demo's CRT (main.ts): over the screen part — board, top bar, buttons — under the caption + panel. Scan lines a few pixels apart, a vignette, the tube's rounded glass with a faint glare, + and the bezel round it; a slight flicker. */ +#demo-crt { + position: fixed; + inset: 0 var(--tv-left, 0px) var(--demo-panel, 0px); + z-index: 8; + overflow: hidden; + /* Black bars either side of a TV narrower than the screen (main.ts: at most 4:3). */ + box-shadow: 0 0 0 100vmax #000; + pointer-events: none; + background: + repeating-linear-gradient(to bottom, rgb(0 0 0 / 0.18) 0 1px, transparent 1px 3px), + radial-gradient(ellipse at center, transparent 58%, rgb(0 0 0 / 0.42) 100%); + animation: crt-flicker 0.15s steps(2) infinite; +} + +#demo-crt[hidden] { + display: none; +} + +/* The bezel: everything outside the glass's rounded corners, in the dark plastic of an old set, + with the glass's edge shaded in. Above the rolling bar and lines below. */ +#demo-crt::before { + content: ''; + position: absolute; + z-index: 1; + inset: 6px; + border-radius: 28px / 22px; + box-shadow: + 0 0 0 60px #1e1e27, + inset 0 0 24px 4px rgb(0 0 0 / 0.55); +} + +/* A faint glare on the glass, top left. */ +#demo-crt::after { + content: ''; + position: absolute; + inset: 6px; + border-radius: 28px / 22px; + background: linear-gradient(150deg, rgb(255 255 255 / 0.1), transparent 35%); +} + +@keyframes crt-flicker { + 50% { + opacity: 0.93; + } +} + +/* What moves on the glass (user request, 2026-10-24): a soft dark bar rolling slowly down, as a + TV filmed by a camera shows, and two thin bright lines sweeping down faster, out of step. */ +#demo-crt > i { + position: absolute; + left: 0; + right: 0; + top: 0; +} + +.crt-roll { + height: 28%; + background: linear-gradient(transparent, rgb(0 0 0 / 0.14) 45%, rgb(0 0 0 / 0.14) 55%, transparent); + animation: crt-sweep 7s linear infinite; +} + +.crt-line { + height: 4px; + background: linear-gradient(transparent, rgb(255 255 255 / 0.28), transparent); + animation: crt-sweep 3.4s linear infinite; +} + +.crt-line + .crt-line { + animation-duration: 5.3s; + animation-delay: -2.1s; +} + +/* From just above the screen to just below it (the screen is the parent, 100% tall). */ +@keyframes crt-sweep { + from { + top: -30%; + } + to { + top: 100%; + } +} + +/* Interference (main.ts `vacuum`): snow over the whole screen, the HTML parts included (the + shader tears and snows only the board), jumping every frame. */ +#demo-crt.interference { + background: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1.4 -.55'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"), + repeating-linear-gradient(to bottom, rgb(0 0 0 / 0.18) 0 1px, transparent 1px 3px), + radial-gradient(ellipse at center, transparent 58%, rgb(0 0 0 / 0.42) 100%); + animation: crt-snow 0.2s steps(4) infinite; +} + +@keyframes crt-snow { + 25% { + background-position: 37px 91px, 0 0, 0 0; + } + 50% { + background-position: 113px 23px, 0 0, 0 0; + } + 75% { + background-position: 71px 140px, 0 0, 0 0; + } +} + +@media (prefers-reduced-motion: reduce) { + #demo-crt, + #demo-crt.interference { + animation: none; + } + + #demo-crt > i { + display: none; + } +} diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts index 34f808c..724c2ba 100644 --- a/src/ui/dialog.ts +++ b/src/ui/dialog.ts @@ -36,6 +36,16 @@ const panel = dialog.querySelector<HTMLElement>('.panel')! const CLOSE_MS = 150 let closing: { timer: number; then?: () => void } | null = null let required = false +let inDemo = false + +/** + * In the demo (main.ts) the dialog opens over the demo's scene only, not as a modal over the + * whole page: the caption panel below stays undimmed (user request, 2026-10-24). Shown with + * `show()`, placed and dimmed by style.css (`.in-demo`), so the demo's finger can go over it. + */ +export function setDialogInDemo(on: boolean): void { + inDemo = on +} /** Close with a short shrink-and-fade, then run `then` (a button's action). */ export function closeDialog(then?: () => void): void { @@ -95,7 +105,9 @@ export function openDialog(content: DialogContent): void { }), ) if (dialog.open) dialog.close() - dialog.showModal() + dialog.classList.toggle('in-demo', inDemo) + if (inDemo) dialog.show() + else dialog.showModal() // Focus the panel, not the first button, so nothing looks pre-selected; Tab still reaches the buttons. panel.focus() } diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest index eb91195..41cc9a6 100644 --- a/static/manifest.webmanifest +++ b/static/manifest.webmanifest @@ -1,6 +1,6 @@ { - "name": "Explodorama", - "short_name": "Explodorama", + "name": "EXPLODORAMA!", + "short_name": "EXPLODORAMA!", "description": "A turn-based, mobile-first, roguelike bomb-laying game. Every bomb counts down with your moves.", "start_url": "./", "scope": "./", diff --git a/test/demo.test.ts b/test/demo.test.ts index 7b6cde3..8cb309e 100644 --- a/test/demo.test.ts +++ b/test/demo.test.ts @@ -39,6 +39,9 @@ describe('demo scenes', () => { // Calm: the scene's own bombs are placed in it, so the countdown is seen from the start. expect(scene.start.bombs).toEqual([]) expect(scene.start.burning).toEqual([]) + // The top bar shows in the demo: each scene starts as if fresh. + expect(scene.start.turn).toBe(0) + expect(scene.start.coins).toBe(0) // Alive all along; at the end, dead in a scene about a way to die, alive in the rest. let s = scene.start let destination: Vec | null = null