explodorama / commit
"What is that?" button, auto-walk, animations for everything
PLAN.md | 37 +++++-- README.md | 30 ++++-- assets/kenney-icons/license.txt | 14 +++ assets/kenney-icons/question.png | Bin 0 -> 1711 bytes decisions.md | 82 ++++++++++++++ docs/screenshots/auto-move.png | Bin 249559 -> 247225 bytes docs/screenshots/bomb-picked.png | Bin 249435 -> 247215 bytes docs/screenshots/charges.png | Bin 277032 -> 274858 bytes docs/screenshots/countdown.png | Bin 253982 -> 252035 bytes docs/screenshots/death.png | Bin 199246 -> 196151 bytes docs/screenshots/desktop.png | Bin 598296 -> 599115 bytes docs/screenshots/dev.png | Bin 267516 -> 265446 bytes docs/screenshots/enemies.png | Bin 252762 -> 250737 bytes docs/screenshots/info-enemy.png | Bin 191188 -> 189332 bytes docs/screenshots/info-hud.png | Bin 181634 -> 179736 bytes docs/screenshots/info-mode.png | Bin 0 -> 246730 bytes docs/screenshots/lingering.png | Bin 258424 -> 251484 bytes docs/screenshots/long-press.png | Bin 252346 -> 250372 bytes docs/screenshots/menu.png | Bin 189637 -> 187294 bytes docs/screenshots/new-run.png | Bin 193766 -> 191939 bytes docs/screenshots/pierce-decoy.png | Bin 284698 -> 282447 bytes docs/screenshots/power-up.png | Bin 261868 -> 254037 bytes docs/screenshots/run.png | Bin 239526 -> 237669 bytes docs/screenshots/scrolled.png | Bin 259762 -> 257554 bytes docs/screenshots/small-desktop.png | Bin 280692 -> 281514 bytes docs/screenshots/small.png | Bin 194117 -> 192002 bytes docs/screenshots/start.png | Bin 248312 -> 246163 bytes src/main.ts | 97 ++++++++++++----- src/render/board.ts | 122 ++++++++++++++++----- src/render/effects.ts | 215 +++++++++++++++++++++++++++++++++++++ src/style.css | 108 ++++++++++++++++++- src/ui/controls.ts | 71 +++++++++--- src/ui/dialog.ts | 42 ++++++-- tools/screenshots.mjs | 9 +- 34 files changed, 724 insertions(+), 103 deletions(-) diff --git a/PLAN.md b/PLAN.md index 07a5083..b320692 100644 --- a/PLAN.md +++ b/PLAN.md @@ -98,12 +98,15 @@ Starting values can be raised by Workshop tiers (§7). | **Place bomb** | On any **adjacent** free tile (orthogonal). Limited by bomb capacity. | Valid tiles. Hovering/pressing one shows the faded **blast cross** and the starting timer. | | **Wait** | Do nothing; the world advances one turn. | Nothing (or a subtle pulse on the player). | -**Info and menu (user request, 2026-10-21; built):** tapping anything that isn't an action shows -a dialog explaining it (`ui/info.ts`): enemies (their next moves in words), bombs, burning -flame, walls and crates, the exit, the power-up, drops, and every top-bar item. Enemies, bombs and -burning tiles always show info, even next to the player (so a tap never walks into an enemy). -Walls and crates clear the auto-move flag if there is one, otherwise show info. Things the player -can walk to get a **Walk there** button. A **menu** button (top right; Esc with nothing to +**Info and menu (user request, 2026-10-21; built; changed 2026-10-22):** a dialog explains a +tile (`ui/info.ts`): enemies (their next moves in words), bombs, burning flame, walls and crates, +the exit, the power-up, drops. *Since 2026-10-22 (user request)* a plain tap no longer shows it: +the **?** button beside Wait (key **?**) turns on "What is that?" for one tap on the board, like +picking an action; tapping **?** again, picking an action, or Esc turns it off. Without it, a tap +on an enemy, a bomb or burning flame does nothing (never a move into it), a wall or crate only +clears the auto-move flag, and any other tile the player can walk to — including the exit, the +power-up and drops — sets the auto-move destination. Things the player can walk to get a +**Walk there** button in their dialog. Top-bar items still explain themselves on a plain tap. A **menu** button (top right; Esc with nothing to cancel) shows level and seed, credits Kenney, and offers **New run** behind a confirmation. **Touch shortcuts (user request, 2026-10-20; built):** @@ -119,10 +122,14 @@ cancel) shows level and seed, credits Kenney, and offers **New run** behind a co **Moving anywhere off the path clears the flag** ("changed my mind", user request, 2026-10-21): any move, run, diagonal step or teleport that doesn't land on the path. **Tapping the flag also takes one step** (user request, 2026-10-20). Tapping another far tile - moves the flag. Recomputed every turn; cleared on arrival, when unreachable, on Esc, by tapping + moves the flag; so does tapping the exit, the power-up or a drop (since 2026-10-22). Recomputed every turn; cleared on arrival, when unreachable, on Esc, by tapping a wall or crate, or on death / level change. Neither Go nor the flag takes a known-deadly step (onto an enemy, onto an enemy's intent tile, or into flame this turn): the next step shows red and Go falls back to picking Move. + **With no enemies left, auto-move walks by itself** (user request, 2026-10-22): one step 1 s + after the last action, until the flag is reached — not while an action is picked, "What is + that?" is on or a dialog is open. **With Skates, a straight 2-tile stretch is one Run** + (same request), if both tiles pass the safety check; still recorded as an ordinary Run. ### 3.2 Abilities (permanent once gained — no charges) @@ -473,6 +480,10 @@ plan of black Icongeek26 outline icons from Flaticon over a plain drawn board. **Kenney Future** font for titles) and the **UI Pack: RPG Expansion** parchment panel, as CSS 9-slice `border-image`s. Files in `assets/kenney-ui/` (pack colour folders kept) and `assets/kenney-ui-rpg/`, each with its licence. Credited with the platformer pack. +- **Icons (2026-10-22):** Kenney's **Game Icons** pack (CC0) for plain UI symbols the other packs + lack: `question` (black, 2x) on the "What is that?" button, inverted to white while it is on. + Files in `assets/kenney-icons/` with the pack's licence, unchanged, under the pack's names. + Credited with the others. Sprite assignments (✅ = drawn now; the rest are copied and waiting for their feature): @@ -494,7 +505,7 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f | ✅ auto-move flag | `flag_green_a` | | ✅ intent arrows | drawn red lines with a head, not sprites | | ✅ power-ups | `gem_blue` Bomb+, `gem_red` Fire+, `gem_yellow` Fuse+, `gem_green` Skates (and the Run button), `lever` Detonator (and Detonate), `bush` Wall Pass, `ladder_top` Bomb Pass, `torch_off` Flame Pass, `star` Star (and a gold ring), `heart` Heart, `hud_coin` Coin bag; on a yellow glow. HUD: `torch_on_a` fuse, small pass icons. | -| ✅ Blob | `slime_normal_rest` (nowhere to go), `_walk_a` / `_walk_b` alternating; mirrored when heading right. `_flat` spare for a death animation. | +| ✅ Blob | `slime_normal_rest` (nowhere to go), `_walk_a` / `_walk_b` alternating; mirrored when heading right. `_flat` spare (the death animation flips `_rest` over instead, the same for every enemy). | | ✅ Seeker | `fly_rest`, `fly_a`, `fly_b`, as the Blob | | ✅ Drifter | `saw_rest`, `saw_a`, `saw_b`; spins while moving | | Sprinter | `mouse_rest`, `mouse_walk_a`, `mouse_walk_b` | @@ -585,7 +596,8 @@ URL test parameters `?small`, `?dev`, `?god`, `?crumble` set them for a test boa like `?seed=123&level=1` for reproducible scenes. The user reviews them before any commit. - **Start simple:** labelled shapes until icons arrive; no animation polish until M7. *(The player's animation — idle bob and glance, walking slide, crouch, jump, hit — came early, at - the user's request, 2026-10-21, and enemies got the same hop the same day.)* + the user's request, 2026-10-21, and enemies got the same hop the same day. Everything else + on the board got its animation on 2026-10-22, also at the user's request: decisions.md.)* --- @@ -614,7 +626,8 @@ src/ render/ LittleJS board.ts sprites, highlights, danger overlay, intents sprites.ts which Kenney sprites the board loads (§13) - animate.ts plays events[] as tweens; owns the input lock + effects.ts turns a turn's events into short animations (appear, slide, swell, + go away); no input lock — see decisions.md 2026-10-22 camera.ts ui/ DOM: action bar, HUD, shop, death choice, Workshop, certificate, About audio.ts Kenney pack sound effects (§13); ZzFX for any it lacks @@ -677,7 +690,9 @@ tools/ icons.mjs (PWA icons), screenshots.mjs (Playwright) 7. **M6 – Run loop:** ✅ NES pickups (all 11, one hidden per level, Skates on level 1 or 2), Run and Detonate, the passes and Star, 50-level progression and ending, mid-run save (2026-10-20). ⏳ Shop after each level, Keep/Fresh death choice, curses from level 2. -8. **M7 – Look & feel:** animations, sounds (Kenney pack, §13), PWA icons, remaining +8. **M7 – Look & feel:** ✅ animations (2026-10-22: everything that appears, moves, changes or + goes away on the board, the top bar's numbers, the banner, and dialogs opening and closing); + ⏳ sounds (Kenney pack, §13), PWA icons, remaining enemies and actions, screenshots tool. 9. **M8 – Permanence:** Workshop, save export/import, victory certificate + QR, replay file and verify page. diff --git a/README.md b/README.md index 9b875af..8b2d758 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,11 @@ see [PLAN.md](PLAN.md). ## How to play You are the green astronaut (animated: it bobs while waiting, hops from tile to tile, crouches -to place a bomb and jumps when a level is cleared; enemies hop between tiles too). **Goal of each level:** destroy every enemy, uncover the exit door +to place a bomb and jumps when a level is cleared; enemies hop between tiles too, and flip over +when they die). Everything else on the board moves too: bombs pop in, swell as they count down, +slide when kicked and fly when thrown; crates burst; flame fades out; drops and power-ups rise +away when taken. A number in the top bar bumps when it changes, and dialogs pop up and shrink +away. **Goal of each level:** destroy every enemy, uncover the exit door hidden under one of the crates, and step onto it. Each turn you pick **one** action; then the world takes its turn, in this order: enemies step @@ -28,7 +32,8 @@ where their arrows pointed, bombs count down and explode, and enemies show their | **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. | | **Wait** | Tap **Wait**, or tap **yourself** | Space | Do nothing; the world takes its turn. | -**Auto-move:** tap an empty tile further away. A flag marks it, dots show the way, and the +**Auto-move:** tap any tile further away that you can walk to — empty floor, the exit, the +power-up or a drop. A flag marks it, dots show the way, and the **Move** button becomes **Go**: each press of Go — or each tap on the flag itself — takes **one step** along the way (one turn, as always — the world still moves between steps). The way is worked out again every turn. Tapping another far tile moves the flag there. Auto-move ends when @@ -37,12 +42,20 @@ can no longer be reached, when you tap a wall or crate, or on **Esc**. It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, or into flame this turn. The next step then shows red, and Go / the flag wait for you to deal with it (Go falls back to picking Move, so you can step aside yourself). +**Once every enemy is gone**, the flag needs no more presses: you walk there by yourself, one +step a second (still one turn per step). **With Skates**, a step of auto-move where the way goes +straight on for 2 tiles is a Run instead (one turn for both), if both tiles are safe. -**What is that?** Tap anything that isn't a move to see what it is and what it does: an -**enemy** (with its next moves spelled out), a **bomb**, burning flame, a wall or crate, the exit, -the power-up, a drop — and every item in the **top bar** (lives, level, enemies left, coins, -turn / par, bombs, blast, fuse, passes, Star). For things you can walk to, the dialog has a -**Walk there** button that sets the auto-move flag. Tapping an enemy never walks into it. +Tapping an enemy, a bomb, burning flame, a wall or a crate does nothing (apart from clearing the +auto-move flag, for a wall or crate) — a tap never walks into an enemy. + +**What is that?** Tap the **?** button beside Wait (or press **?**), then tap anything on the board +to see what it is and what it does: an **enemy** (with its next moves spelled out), a **bomb**, +burning flame, a wall or crate, the exit, the power-up, a drop. The **?** turns green while it is +on; it works for one tap, and tapping it again or **Esc** turns it off. For things you can walk +to, the dialog has a **Walk there** button that sets the auto-move flag. The items in the **top +bar** (lives, level, enemies left, coins, turn / par, bombs, blast, fuse, passes, Star) explain +themselves with a plain tap. **Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level and seed, and has **New run**, which asks first, since the current run can't be got back. @@ -237,7 +250,8 @@ Design and decisions: [PLAN.md](PLAN.md), [decisions.md](decisions.md). Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1, [UI Pack](https://kenney.nl/assets/ui-pack) 2.0 (with its Kenney Future font) and -[UI Pack: RPG Expansion](https://kenney.nl/assets/ui-pack-rpg-expansion), all by +[UI Pack: RPG Expansion](https://kenney.nl/assets/ui-pack-rpg-expansion) and +[Game Icons](https://kenney.nl/assets/game-icons), all by [Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/) (public domain). Kenney doesn't require credit, but the pack was chosen deliberately and the game looks the way it does because of it, so it is credited here and in the game (in the menu). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>. diff --git a/assets/kenney-icons/license.txt b/assets/kenney-icons/license.txt new file mode 100644 index 0000000..4914a6d --- /dev/null +++ b/assets/kenney-icons/license.txt @@ -0,0 +1,14 @@ + +############################################################################### + + Game icon pack by Kenney Vleugels (www.kenney.nl) + + ------------------------------ + + License (CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + You may use these graphics in personal and commercial projects. + Credit (Kenney or www.kenney.nl) would be nice but is not mandatory. + +############################################################################### \ No newline at end of file diff --git a/assets/kenney-icons/question.png b/assets/kenney-icons/question.png new file mode 100644 index 0000000..f194eb9 Binary files /dev/null and b/assets/kenney-icons/question.png differ diff --git a/decisions.md b/decisions.md index b9f0115..8571b9b 100644 --- a/decisions.md +++ b/decisions.md @@ -252,3 +252,85 @@ The user found the walls too uniform and asked for the pack's bordered wall tile - **Floors are drawn before walls** (render only): each tile is drawn a hair oversized to hide seams, and a floor drawn after its neighbouring wall painted over that wall's edge, leaving thin light lines through the stone. + +### 2026-10-22 — "What is that?" moves behind a ? button + +The user asked for info to need a dedicated button first, so a plain tap on the board either does +nothing or starts walking. + +- **A plain tap:** the player = Wait; the flag = one step; a tile next to the player = move; + any other tile the player can walk to (now including the exit, the power-up and drops) = set + the auto-move flag; a wall or crate = clear the flag if there is one (kept from 2026-10-21, + since that was how the flag gets cleared by touch); an enemy, a bomb or burning flame = + nothing. +- **The ? button sits in the action bar, beside Wait**, not in the top bar next to the menu: the + top bar's second row on a 390 px phone had no room for a second round button, and the menu + wrapped onto a third row, shrinking the board. In the action bar it is picked like an action + (green while on, key **?**), which is what it is: pick, then tap a tile. Move / Bomb / Wait + give up about 16 px each for it. +- **One tap, then off**, whatever the tap hit (a plain floor tile has nothing to explain, so it + just turns the mode off). Picking an action, the ? again, or Esc also turns it off; turning it + on drops any picked action. While it is on, a long press doesn't arm a bomb. +- **Its icon is the Game Icons pack's `question`** (the user added the pack, CC0), not a "?" typed + in the Kenney Future font, which read poorly. Credited in the README and the menu. +- **No key hints on phones** (user request): at 600 px wide or less, the action buttons drop + their WASD / B / Space / ? labels (the charge buttons already had), and Move / Bomb / Wait put + icon and name side by side. Wider screens keep the hints. +- **Top-bar items keep their plain-tap info**: they are not on the board and do nothing else. + +### 2026-10-22 — Auto-move walks by itself once the enemies are gone, and runs with Skates + +User request, before committing the ? button. + +- **No enemies left: one tap is enough.** A set flag is walked to automatically, one step + `AUTO_STEP_SECONDS` (1 s) after the last action — so the first step comes at once if you + haven't just acted. Each step is still an ordinary turn (bombs tick, crates crumble) and is + recorded as usual. It pauses while an action is picked, "What is that?" is on or a dialog is + open. With enemies on the board nothing changes: Go / the flag, one press per step. +- **An unsafe next step just waits**, as for Go: auto-move never takes it, and nothing changes + until you act (flame from last turn only goes out after your next action), so you Wait or + step aside and the walk carries on. +- **With Skates, auto-move runs** whenever the next two tiles of the way go straight on and Run + is allowed there, if both tiles pass the same safety check as a single step. This applies to + every auto-move step: Go, tapping the flag, and the automatic walk. +- **Checked by driving the game in a browser** (seed 1, enemies removed through a temporary hook + that was taken out again), since this lives in `main.ts`, outside the tested sim. + +### 2026-10-22 — Everything on the board is animated + +The user asked that nothing just appear, disappear or jump any more (enemies vanished, kicked +bombs teleported), with flame fading out over about half a second. + +- **`render/effects.ts`** turns each turn's events, plus the state before and after, into short + animations; `board.ts` draws them. The sim is unchanged (no new events, `SIM_VERSION` stays). +- **What animates, and how:** + - appearing (≈0.22 s, a pop with a slight overshoot): placed bombs (Line's one after another), + drops, enemies spawned from a burned exit or power-up, the decoy, a revealed exit or + power-up, a new shield ring; + - moving: a kicked bomb slides (0.05 s a tile), a thrown bomb flies in an arc (0.25 s), a + teleport shrinks away and grows back (0.15 s each); + - changing: a bomb swells as its number goes down or a Fuse charge changes it; the exit + swells as it opens; Freeze flashes the enemies icy blue (0.6 s); + - going away: an enemy flips over with its eyes shut, hops and falls away fading (0.5 s); + a crate swells, tips and fades (0.4 s), over whatever it uncovered; flame from last turn + fades as it goes out (0.5 s, at the start of that turn); a drop or power-up taken rises and + fades (0.4 s, starting as the player arrives); one burned, or the decoy, shrinks and spins + away; a broken shield ring widens and fades. + - the banner comes in after 0.35 s, so the death or level-cleared jump is seen first; dialogs + pop up over a fading backdrop. Both are off with "reduce motion". +- **Follow-up (user request, same day): the top bar's numbers and the dialog closing.** A + top-bar item that changed (a number, a heart, a pass arriving) is replaced and bumps (scales + to 1.3 and back in 0.3 s); unchanged items are kept as they are, so a bump isn't cut short by + the next update. Dialogs close with a 0.15 s shrink-and-fade — buttons, the close button, the + backdrop and Esc alike — and a button's action runs once it has closed. +- **No input lock** (PLAN.md planned `animate.ts` to own one). The game is turn-based and the + sim has already moved on, so a quick next tap simply starts the next turn's animations over + the end of the last ones; locking input would only slow play down. +- **One death animation for every enemy** rather than the Blob's spare `_flat` sprite, since the + fly and saw have no flat frame. +- **Screenshots wait 0.8 s** after their action (was 0.25 s), so they show the settled board + and the banner. +- **Checked by capturing frame bursts in a browser** (seed 1, Developer mode, a temporary hook + to act directly and to put an enemy in a blast, removed afterwards): bomb pop, kick slide, + throw arc, shield ring, crate burst, enemy death, flame fading, teleport. + diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index 8ce407a..0dca10c 100644 Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index 496cb17..7e78c38 100644 Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png index 4ed832f..4cd2a3f 100644 Binary files a/docs/screenshots/charges.png and b/docs/screenshots/charges.png differ diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png index c6ec468..c21df9e 100644 Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png index ce02f25..cde3447 100644 Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index c226b4c..a1d24ec 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png index 2eb22cf..f85e114 100644 Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index c2b3abb..c1bf4c7 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png index edf0b8a..98e0a97 100644 Binary files a/docs/screenshots/info-enemy.png and b/docs/screenshots/info-enemy.png differ diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png index f8fff59..2de5635 100644 Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png new file mode 100644 index 0000000..a2afdcc Binary files /dev/null and b/docs/screenshots/info-mode.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index c13fb65..bb382d1 100644 Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png index 9c838db..9e9e4da 100644 Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png index 1c26558..0562254 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png index 68ada1b..c323a32 100644 Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png index 05879da..2e5eb8d 100644 Binary files a/docs/screenshots/pierce-decoy.png and b/docs/screenshots/pierce-decoy.png differ diff --git a/docs/screenshots/power-up.png b/docs/screenshots/power-up.png index de6ca69..5feadae 100644 Binary files a/docs/screenshots/power-up.png and b/docs/screenshots/power-up.png differ diff --git a/docs/screenshots/run.png b/docs/screenshots/run.png index c7a8277..97762f7 100644 Binary files a/docs/screenshots/run.png and b/docs/screenshots/run.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index aa02540..314650e 100644 Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ diff --git a/docs/screenshots/small-desktop.png b/docs/screenshots/small-desktop.png index d290ee0..7c67467 100644 Binary files a/docs/screenshots/small-desktop.png and b/docs/screenshots/small-desktop.png differ diff --git a/docs/screenshots/small.png b/docs/screenshots/small.png index 526d025..9312f55 100644 Binary files a/docs/screenshots/small.png and b/docs/screenshots/small.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index 0e08f97..f1f1381 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/src/main.ts b/src/main.ts index b783078..b0802d7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import { vec2, } from 'littlejsengine' import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame, type PlayerAnim } from './render/board' +import { clearEffects, newEffects, pruneEffects, startEffects } from './render/effects' import { updateCamera } from './render/camera' import { TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' @@ -39,11 +40,14 @@ let record: RunRecord let state: GameState let danger: Danger let selected: ActionId | null = null +/** "What is that?" is on: the next tap on the board explains the tile instead of acting. */ +let inspecting = false let hover: Vec | null = null let flames: Flame[] = [] let facingLeft = false let playerAnim: PlayerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 } const enemySteps = new Map<number, { from: Vec; at: number }>() +const fx = newEffects() let snapCamera = true /** Auto-move: a far tile the player tapped. Each press of Move takes one step along `path` toward it. */ let destination: Vec | null = null @@ -53,11 +57,16 @@ let armed: Vec | null = null let pressTimer: number | undefined let pressTile: Vec | null = null const LONG_PRESS_MS = 450 +/** With every enemy gone, auto-move walks by itself: one step this long after the last action. */ +const AUTO_STEP_SECONDS = 1 +let lastActionAt = -Infinity function startRun(seed: number, options: RunOptions): void { playerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 } enemySteps.clear() + clearEffects(fx) selected = null + inspecting = false destination = null flames = [] snapCamera = true @@ -134,8 +143,10 @@ function randomSeed(): number { const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'teleport']) function act(action: Action): void { + lastActionAt = time if (MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null const result = step(state, action) + startEffects(fx, state, result.state, result.events, time) state = result.state record.actions.push(action) for (const e of result.events) { @@ -143,10 +154,10 @@ function act(action: Action): void { if (e.type === 'levelRestarted' || e.type === 'levelStarted') snapCamera = true if (e.type === 'levelRestarted' || e.type === 'levelStarted' || e.type === 'levelCleared') destination = null if (e.type === 'moved' && e.to.x !== e.from.x) facingLeft = e.to.x < e.from.x - // A step or run slides; a teleport (further than a run) just appears. + // A step, run or diagonal step slides; a teleport (further than a run) vanishes and reappears. if (e.type === 'moved') { - const far = Math.abs(e.to.x - e.from.x) + Math.abs(e.to.y - e.from.y) > 2 - playerAnim = { kind: far ? 'none' : 'walk', from: e.from, at: time } + const far = action.id === 'teleport' + playerAnim = { kind: far ? 'teleport' : 'walk', from: e.from, at: time } } if (e.type === 'bombPlaced' && playerAnim.kind !== 'walk') playerAnim = { kind: 'duck', from: state.player, at: time } if (e.type === 'levelCleared') playerAnim = { kind: 'jump', from: state.player, at: time } @@ -157,6 +168,7 @@ function act(action: Action): void { if (e.type === 'enemyMoved') enemySteps.set(e.id, { from: e.from, at: time }) } selected = null + inspecting = false save() afterChange() } @@ -168,44 +180,50 @@ function afterChange(): void { if (destination && (eq(state.player, destination) || state.status !== 'playing')) destination = null path = destination ? playerPath(state, destination) : null if (!path) destination = null - controls.update(state, selected, destination !== null) + controls.update(state, selected, destination !== null, inspecting) } /** - * Two taps: pick an action, then a highlighted tile. With nothing picked: + * Two taps: pick an action, then a highlighted tile. With "What is that?" on: explain the tile. + * With nothing picked: * - the player: Wait; - * - an enemy, a bomb, or flame still burning: what it is (info), never a move into it; * - the auto-move flag: one step toward it (like Go), with auto-move's safety check; * - a tile next to the player: move there; - * - a wall or crate: clears the flag if there is one, otherwise what it is; - * - the exit, the power-up, a drop or the decoy further away: what it is, with "Walk there"; - * - any other reachable tile further away: set it as the auto-move destination. + * - any other tile you can walk to: set it as the auto-move destination; + * - a wall or crate, or any floor you can't walk to: clears the flag if there is one; + * - an enemy, a bomb, or flame still burning: nothing, never a move into it. */ function tapTile(p: Vec): void { if (state.status !== 'playing') return - if (selected) { + if (inspecting) { + inspecting = false + afterChange() + showTileInfo(p) + } else if (selected) { if (isValidTarget(state, selected, p)) act({ id: selected, target: p }) else { selected = null afterChange() } } else if (eq(p, state.player)) pick('wait') - else if (enemyAt(state, p) || bombAt(state, p) || isBurning(state, p)) showTileInfo(p) + else if (enemyAt(state, p) || bombAt(state, p) || isBurning(state, p)) return else if (destination && eq(destination, p)) stepTowardDestination() else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p }) - else if (cellAt(state, p) !== 'floor') { - if (destination) { - destination = null - afterChange() - } else showTileInfo(p) - } else if (tileInfo(state, p)) showTileInfo(p) - else if (playerPath(state, p)) setDestination(p) + else if (cellAt(state, p) === 'floor' && playerPath(state, p)) setDestination(p) else if (destination) { destination = null afterChange() } } +/** The "What is that?" button: on, or off again (dropping any action picked). */ +function inspect(): void { + if (state.status !== 'playing') return + inspecting = !inspecting + selected = null + afterChange() +} + function setDestination(p: Vec): void { destination = p afterChange() @@ -237,7 +255,7 @@ function showMenu(): void { `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` + modeLines(state).map((l) => `<p class="mode">${l}</p>`).join('') + `<p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` + - `New Platformer Pack, UI Pack and UI Pack: RPG Expansion (CC0). Thank you!</p>`, + `New Platformer Pack, UI Pack, UI Pack: RPG Expansion and Game Icons (CC0). Thank you!</p>`, buttons: [ { label: 'Resume', kind: 'primary', icon: playIcon }, { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: newRunDialog }, @@ -296,16 +314,34 @@ function safeStep(p: Vec): boolean { return !enemyAt(state, p) && !state.enemies.some((e) => e.plan[0] && eq(e.plan[0], p)) && !danger.now.has(idx(state, p)) } -/** One step of auto-move, if the next step is safe. True if it stepped. */ +/** + * One step of auto-move, if the next step is safe. True if it stepped. With Skates, a path + * going straight on for 2 tiles is taken as one Run, if both tiles are safe. + */ function stepTowardDestination(): boolean { const next = path?.[0] - if (!next || !safeStep(next) || !isValidTarget(state, 'move', next)) return false + if (!next || !safeStep(next)) return false + const after = path?.[1] + const straight = after && after.x - next.x === next.x - state.player.x && after.y - next.y === next.y - state.player.y + if (straight && isAvailable(state, 'run') && isValidTarget(state, 'run', after) && safeStep(after)) { + act({ id: 'run', target: after }) + return true + } + if (!isValidTarget(state, 'move', next)) return false act({ id: 'move', target: next }) return true } +/** Once every enemy is gone, the flag needs no more taps: auto-move steps on its own. */ +function autoStep(): void { + if (state.status !== 'playing' || !destination || state.enemies.length > 0) return + if (selected || inspecting || dialogOpen() || time - lastActionAt < AUTO_STEP_SECONDS) return + stepTowardDestination() +} + function pick(id: ActionId): void { if (!isAvailable(state, id)) return + inspecting = false if (id === 'move' && destination && !selected && stepTowardDestination()) return if (!ACTIONS[id].needsTarget) act({ id }) else { @@ -333,6 +369,7 @@ const controls = createControls({ else if (state.status === 'gameOver' || state.status === 'won') newRunDialog() }, info: (item) => showInfo(hudInfo(item, state)), + inspect, menu: showMenu, }) @@ -361,12 +398,16 @@ window.addEventListener('keydown', (e) => { if (isValidTarget(state, 'diagonal', p)) act({ id: 'diagonal', target: p }) } else if (d) direction(d) else if (e.code === 'Space') pick('wait') + else if (e.key === '?' || e.code === 'Slash') inspect() else if (ACTION_KEYS[e.code]) pick(ACTION_KEYS[e.code]!) else if (e.code === 'Escape') { - // Cancels what is picked, or the flag; with nothing to cancel, opens the menu. - if (!selected && !destination) return showMenu() - selected = null - destination = null + // Cancels "What is that?", what is picked, or the flag; with nothing to cancel, opens the menu. + if (!inspecting && !selected && !destination) return showMenu() + if (inspecting) inspecting = false + else { + selected = null + destination = null + } afterChange() } else if (e.code === 'Enter') controls.pressBanner() }) @@ -376,7 +417,7 @@ function startPress(p: Vec | null): void { clearTimeout(pressTimer) armed = null pressTile = p - if (!p || selected || state.status !== 'playing' || !isValidTarget(state, 'bomb', p)) return + if (!p || selected || inspecting || state.status !== 'playing' || !isValidTarget(state, 'bomb', p)) return pressTimer = window.setTimeout(() => { armed = p navigator.vibrate?.(15) @@ -436,11 +477,13 @@ await engineInit( updateCamera(state, controls.insets(), snapCamera) snapCamera = false flames = flames.filter((f) => time - f.born < FLAME_SECONDS) + pruneEffects(fx, time) + autoStep() }, () => {}, () => drawBoard({ - state, danger, selected, hover, flames, facingLeft, destination, path, armed, playerAnim, enemySteps, + state, danger, selected, hover, flames, facingLeft, destination, path, armed, playerAnim, enemySteps, fx, pathBlocked: !!path?.[0] && !safeStep(path[0]), }), () => {}, diff --git a/src/render/board.ts b/src/render/board.ts index b35da5d..a84b126 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -5,7 +5,8 @@ import { blastTiles } from '../sim/explosions' import { dirBetween } from '../sim/enemies' import { cellAt, cloneState, eq, exitOpen, idx } from '../sim/state' import type { ActionId, Bomb, Enemy, GameState, Vec } from '../sim/types' -import { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, POWER_UP_ICONS, sprite, wallSprite, type BoardSprite } from './sprites' +import { appearScale, dropSprite, FREEZE_SECONDS, GONE_SECONDS, key, progress, pulseScale, type Effects, type Gone } from './effects' +import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, POWER_UP_ICONS, sprite, wallSprite, type BoardSprite } from './sprites' /** * The one place where sim tiles meet LittleJS world space. The sim's y grows downward @@ -45,14 +46,17 @@ export interface View { playerAnim: PlayerAnim /** Each enemy's latest step, by id: where from, and when (LittleJS `time`). */ enemySteps: ReadonlyMap<number, { from: Vec; at: number }> + /** What the last turns changed, still playing out (render/effects.ts). */ + fx: Effects } /** * walk: slides from `from` to the player's tile on walking frames. duck: crouches (placing a - * bomb). jump: jumps for joy (level cleared), until the next level. none: idle. + * bomb). jump: jumps for joy (level cleared), until the next level. teleport: shrinks away at + * `from` and grows back at the new tile. none: idle. */ export interface PlayerAnim { - kind: 'none' | 'walk' | 'duck' | 'jump' + kind: 'none' | 'walk' | 'duck' | 'jump' | 'teleport' from: Vec /** LittleJS `time` when it started. */ at: number @@ -61,6 +65,8 @@ export interface PlayerAnim { /** Seconds a step's slide takes, and a crouch. */ export const WALK_SECONDS = 0.16 const DUCK_SECONDS = 0.22 +/** Each half of a teleport: vanishing, then appearing. */ +const TELEPORT_SECONDS = 0.15 export const FLAME_SECONDS = 0.45 @@ -94,7 +100,7 @@ export const BACKGROUND: Color = C.sky export function drawBoard(view: View): void { - const { state: s, danger, selected, hover } = view + const { state: s, danger, selected, hover, fx } = view // A hair over a tile, so neighbouring walls overlap and no anti-aliased seam shows between them. const wallSize = vec2(1.02) const tileSize = vec2(1) @@ -130,13 +136,18 @@ export function drawBoard(view: View): void { drawRect(toWorld(s, { x, y }).add(vec2(dx * reach, dy * reach)), vec2(outline), C.stoneOutline) } } - if (cellAt(s, s.exit) === 'floor') drawExit(toWorld(s, s.exit), exitOpen(s)) + if (cellAt(s, s.exit) === 'floor') { + drawExit(toWorld(s, s.exit), exitOpen(s), appearScale(fx, key.exit, time) * pulseScale(fx, key.exit, time, 0.25)) + } // The level's power-up, once its crate is gone: on a soft glow, so it stands out from drops. if (s.powerUp && cellAt(s, s.powerUp.pos) === 'floor') { const pos = toWorld(s, s.powerUp.pos) - drawCircle(pos, 0.86, C.powerUpGlow) - drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7), sprite(POWER_UP_ICONS[s.powerUp.kind])) + const k = appearScale(fx, key.powerUp, time) + drawCircle(pos, 0.86 * k, C.powerUpGlow) + drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7 * k), sprite(POWER_UP_ICONS[s.powerUp.kind])) } + // A destroyed crate bursts over whatever it uncovered. + for (const g of fx.gone) if (g.kind === 'crate') drawGone(s, g) for (const i of danger.next) if (!danger.now.has(i)) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNext) for (const i of danger.now) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNow) @@ -171,6 +182,7 @@ export function drawBoard(view: View): void { drawRect(pos, vec2(0.94), C.burning) drawTile(pos, vec2(0.55 + 0.05 * Math.sin(time * 9 + i)), sprite('fireball'), C.white.scale(1, 0.8), time * 4 + i) } + for (const g of fx.gone) if (g.kind === 'flame') drawGone(s, g) for (const f of view.flames) { const fade = 1 - (time - f.born) / FLAME_SECONDS @@ -185,21 +197,23 @@ export function drawBoard(view: View): void { for (const b of s.bombs) { // The last turn before it goes off, it flashes. const hot = b.timer <= 1 && Math.sin(time * 14) > 0 - drawBomb(toWorld(s, b.pos), b.timer, hot ? 'bomb_active' : 'bomb', C.white, b.special) + const k = key.bomb(b.pos) + drawBomb(bombPos(s, fx, b.pos), b.timer, hot ? 'bomb_active' : 'bomb', C.white, b.special, appearScale(fx, k, time) * pulseScale(fx, k, time)) } for (const b of changedBombs) drawBomb(toWorld(s, b.pos), b.timer, 'bomb', C.ghost, b.special) - if (s.decoy) drawDecoy(toWorld(s, s.decoy.pos), C.white) + if (s.decoy) drawDecoy(toWorld(s, s.decoy.pos), C.white, appearScale(fx, key.decoy, time)) if (after?.decoy && !s.decoy) drawDecoy(toWorld(s, after.decoy.pos), C.ghost) // Drops bob gently, so they read as things to pick up rather than part of the floor. for (const [i, d] of s.drops.entries()) { const pos = toWorld(s, d.pos).add(vec2(0, 0.05 * Math.sin(time * 3 + i))) - drawTile(pos, vec2(0.62), sprite(d.kind === 'coins' ? coinSprite(d.amount) : CHARGE_ICONS[d.kind])) + drawTile(pos, vec2(0.62 * appearScale(fx, key.drop(d.pos), time)), sprite(dropSprite(d))) } - for (const e of s.enemies) drawEnemy(s, e, view.enemySteps.get(e.id)) + for (const e of s.enemies) drawEnemy(s, e, view.enemySteps.get(e.id), fx) + for (const g of fx.gone) if (g.kind !== 'crate' && g.kind !== 'flame') drawGone(s, g) - if (s.shield) drawCircle(toWorld(s, s.player), 1.08, C.clear, 0.07, C.shield) + if (s.shield) drawCircle(toWorld(s, s.player), 1.08 * appearScale(fx, key.shield, time), C.clear, 0.07, C.shield) // A Star: a pulsing gold ring for as long as it lasts. if (s.star > 0) drawCircle(toWorld(s, s.player), 1.0 + 0.06 * Math.sin(time * 8), C.clear, 0.08, C.star) if (after && !eq(after.player, s.player)) { @@ -221,6 +235,12 @@ function drawPlayer(s: GameState, anim: PlayerAnim, facingLeft: boolean): void { if (s.status === 'dead' || s.status === 'gameOver') { frame = 'character_green_hit' pos = to.add(vec2(0.03 * Math.sin(time * 40) * Math.max(0, 1 - t * 2), 0)) + } else if (anim.kind === 'teleport' && t < 2 * TELEPORT_SECONDS) { + // Shrinks away where it was, then grows back where it lands. + const out = t < TELEPORT_SECONDS + const k = out ? 1 - t / TELEPORT_SECONDS : (t - TELEPORT_SECONDS) / TELEPORT_SECONDS + drawTile(out ? toWorld(s, anim.from) : to, vec2(k), sprite('character_green_front'), C.white, (1 - k) * 2, facingLeft) + return } else if (anim.kind === 'jump') { frame = 'character_green_jump' pos = to.add(vec2(0, 0.18 * Math.abs(Math.sin(time * 6)))) @@ -240,10 +260,55 @@ function drawPlayer(s: GameState, anim: PlayerAnim, facingLeft: boolean): void { } /** The pack's door is two tiles tall; it is drawn narrower so both halves fit about one tile. */ -function drawExit(pos: Vector2, open: boolean): void { - const half = vec2(0.56) - drawTile(pos.add(vec2(0, -0.22)), half, sprite(open ? 'door_open' : 'door_closed')) - drawTile(pos.add(vec2(0, 0.34)), half, sprite(open ? 'door_open_top' : 'door_closed_top')) +function drawExit(pos: Vector2, open: boolean, scale = 1): void { + const half = vec2(0.56 * scale) + drawTile(pos.add(vec2(0, -0.22 * scale)), half, sprite(open ? 'door_open' : 'door_closed')) + drawTile(pos.add(vec2(0, 0.34 * scale)), half, sprite(open ? 'door_open_top' : 'door_closed_top')) +} + +/** Where a bomb is drawn: on its tile, or on its way there (kicked: sliding; thrown: in an arc). */ +function bombPos(s: GameState, fx: Effects, p: Vec): Vector2 { + const slide = fx.slides.get(key.bomb(p)) + const to = toWorld(s, p) + if (!slide) return to + const k = progress(slide.at, slide.seconds, time) + const from = toWorld(s, slide.from) + return from.lerp(to, k).add(vec2(0, slide.arc ? 0.6 * Math.sin(k * Math.PI) : 0)) +} + +/** Something on its way out (effects.ts `Gone`), `k` going from 0 to 1 over its time. */ +function drawGone(s: GameState, g: Gone): void { + const k = progress(g.at, GONE_SECONDS[g.kind], time) + const fade = C.white.scale(1, 1 - k) + const pos = toWorld(s, 'pos' in g ? g.pos : g.enemy.pos) + switch (g.kind) { + case 'enemy': { + // Flips over with its eyes shut, hops up, and falls away fading. + const art = ENEMY_SPRITES[g.enemy.kind] + const lift = 0.3 * Math.sin(k * Math.PI) - 0.25 * k + drawTile(pos.add(vec2(-0.09, lift)), vec2(0.8 * (1 - 0.3 * k)), sprite(art.rest), fade, Math.PI * Math.min(1, k * 3)) + break + } + case 'crate': { + // Bursts: swells, tips over and fades. + const tip = (g.pos.x + g.pos.y) % 2 ? 0.5 : -0.5 + drawTile(pos, vec2(1 + 0.3 * k), sprite('block_planks'), fade, tip * k) + break + } + case 'flame': + drawRect(pos, vec2(0.94), C.burning.scale(1, 1 - k)) + drawTile(pos, vec2(0.55 * (1 - k)), sprite('fireball'), C.white.scale(1, 0.8 * (1 - k)), time * 4) + break + case 'taken': + drawTile(pos.add(vec2(0, 0.6 * k)), vec2(g.size * (1 + 0.3 * k)), sprite(g.sprite), fade) + break + case 'burned': + drawTile(pos, vec2(g.size * (1 - k)), sprite(g.sprite), fade, k * 3) + break + case 'shield': + drawCircle(pos, 1.08 + 0.6 * k, C.clear, 0.07, C.shield.scale(1, 1 - k)) + break + } } /** How strongly each planned move's arrow shows: the next one solid, later ones fading. */ @@ -254,7 +319,7 @@ const PLAN_ALPHA = [0.85, 0.55, 0.3] * one per planned move, the next move at the top (the user's choice of layout, decisions.md). * The enemy is drawn a little smaller and to the left to make room. */ -function drawEnemy(s: GameState, e: Enemy, stepped?: { from: Vec; at: number }): void { +function drawEnemy(s: GameState, e: Enemy, stepped: { from: Vec; at: number } | undefined, fx: Effects): void { // A step just taken: hop from the old tile to the new one, like the player. const k = stepped ? (time - stepped.at) / WALK_SECONDS : 1 const pos = k < 1 ? toWorld(s, stepped!.from).lerp(toWorld(s, e.pos), k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) : toWorld(s, e.pos) @@ -264,7 +329,11 @@ function drawEnemy(s: GameState, e: Enemy, stepped?: { from: Vec; at: number }): const angle = art.spins && moving ? time * 3 : 0 const heading = moving ? dirBetween(e.pos, e.plan[0]!) : e.dir const mirror = art.facesLeft && heading === 'right' - drawTile(pos.add(vec2(-0.09, 0)), vec2(0.8), sprite(frame), C.white, angle, mirror) + // Just spawned: pops in. Just frozen: flashes icy blue. + const size = 0.8 * appearScale(fx, key.enemy(e.id), time) + const frost = 1 - progress(fx.frozenAt, FREEZE_SECONDS, time) + const tint = rgb(1 - 0.45 * frost, 1 - 0.15 * frost, 1) + drawTile(pos.add(vec2(-0.09, 0)), vec2(size), sprite(frame), tint, angle, mirror) if (!moving) return const gap = 0.29 @@ -296,21 +365,22 @@ function previewOf(s: GameState, id: ActionId, target: Vec): GameState { } /** The decoy: a pale copy of the player, facing out. */ -function drawDecoy(pos: Vector2, color: Color): void { - drawTile(pos, vec2(0.9), sprite('character_green_front'), color.scale(1, 0.75)) +function drawDecoy(pos: Vector2, color: Color, scale = 1): void { + drawTile(pos, vec2(0.9 * scale), sprite('character_green_front'), color.scale(1, 0.75)) } /** * The bomb sprite with its timer on the body, which sits a little below and right of the * tile's centre. A Pierce or Power bomb wears its charge's icon as a badge at the top left. */ -function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, special?: Bomb['special']): void { - drawTile(pos, vec2(0.9), sprite(body), color) +function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, special?: Bomb['special'], scale = 1): void { + if (scale <= 0) return + drawTile(pos, vec2(0.9 * scale), sprite(body), color) const digits = String(timer) - const w = 0.32 - const start = pos.add(vec2(0.04 - ((digits.length - 1) * w) / 2, -0.06)) - for (let i = 0; i < digits.length; i++) drawTile(start.add(vec2(i * w, 0)), vec2(0.42), digitSprite(Number(digits[i])), color) - if (special) drawTile(pos.add(vec2(-0.28, 0.28)), vec2(0.4), sprite(CHARGE_ICONS[special]), color) + const w = 0.32 * scale + const start = pos.add(vec2(0.04 * scale - ((digits.length - 1) * w) / 2, -0.06 * scale)) + for (let i = 0; i < digits.length; i++) drawTile(start.add(vec2(i * w, 0)), vec2(0.42 * scale), digitSprite(Number(digits[i])), color) + if (special) drawTile(pos.add(vec2(-0.28, 0.28).scale(scale)), vec2(0.4 * scale), sprite(CHARGE_ICONS[special]), color) } function tileOf(s: GameState, i: number): Vec { diff --git a/src/render/effects.ts b/src/render/effects.ts new file mode 100644 index 0000000..1867123 --- /dev/null +++ b/src/render/effects.ts @@ -0,0 +1,215 @@ +import { CHARGE_ICONS, coinSprite, POWER_UP_ICONS, type BoardSprite } from './sprites' +import type { Drop, Enemy, GameEvent, GameState, Vec } from '../sim/types' + +/** + * Short animations started by a turn: things that appear, slide, tick or go away. The sim + * changes the board in one go; these play the change out over a fraction of a second. Times + * are LittleJS `time`, in seconds. + */ + +/** Popping into place (bombs, drops, spawned enemies, the decoy, a revealed exit or power-up). */ +export const APPEAR_SECONDS = 0.22 +/** A bomb's number changing (its countdown, or a Fuse charge): a quick swell. */ +export const PULSE_SECONDS = 0.2 +/** How long each kind of disappearance takes. */ +export const GONE_SECONDS: Record<Gone['kind'], number> = { + enemy: 0.5, + crate: 0.4, + flame: 0.5, + taken: 0.4, + burned: 0.4, + shield: 0.35, +} +/** A kicked bomb slides this long per tile; a thrown one flies for THROW_SECONDS. */ +const KICK_SECONDS_PER_TILE = 0.05 +const THROW_SECONDS = 0.25 +/** Line's bombs pop in one after another, this far apart. */ +const LINE_STAGGER = 0.05 +/** A thing picked up waits this long, for the player to arrive, before it rises away. */ +const TAKE_DELAY = 0.12 +/** Frozen enemies flash icy blue for this long. */ +export const FREEZE_SECONDS = 0.6 + +/** + * Something no longer on the board, still drawn while it goes: an enemy killed, a crate + * destroyed, flame going out, a drop or power-up taken (it rises) or burned (it shrinks), the + * decoy gone, a shield broken. + */ +export type Gone = ( + | { kind: 'enemy'; enemy: Enemy } + | { kind: 'crate'; pos: Vec } + | { kind: 'flame'; pos: Vec } + | { kind: 'taken'; sprite: BoardSprite; pos: Vec; size: number } + | { kind: 'burned'; sprite: BoardSprite; pos: Vec; size: number } + | { kind: 'shield'; pos: Vec } +) & { at: number } + +export interface Slide { + from: Vec + at: number + seconds: number + /** Thrown: flies in an arc instead of sliding along the floor. */ + arc: boolean +} + +export interface Effects { + /** When each thing appeared, by `key`. */ + appeared: Map<string, number> + /** When each bomb's number last changed, by `key.bomb`. */ + pulsed: Map<string, number> + /** Bombs on their way to their tile, by `key.bomb` of where they end up. */ + slides: Map<string, Slide> + gone: Gone[] + frozenAt: number +} + +export const key = { + bomb: (p: Vec) => `bomb:${p.x},${p.y}`, + drop: (p: Vec) => `drop:${p.x},${p.y}`, + enemy: (id: number) => `enemy:${id}`, + decoy: 'decoy', + powerUp: 'powerUp', + exit: 'exit', + shield: 'shield', +} + +export function newEffects(): Effects { + return { appeared: new Map(), pulsed: new Map(), slides: new Map(), gone: [], frozenAt: -Infinity } +} + +/** A new level, or the same one again: nothing from the old board carries over. */ +export function clearEffects(fx: Effects): void { + fx.appeared.clear() + fx.pulsed.clear() + fx.slides.clear() + fx.gone = [] + fx.frozenAt = -Infinity +} + +export function dropSprite(d: Drop): BoardSprite { + return d.kind === 'coins' ? coinSprite(d.amount) : CHARGE_ICONS[d.kind] +} + +/** Start the animations for one turn, from the state before it, the state after, and its events. */ +export function startEffects(fx: Effects, prev: GameState, next: GameState, events: GameEvent[], now: number): void { + if (events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted')) return clearEffects(fx) + // Bombs placed this turn: they pop in (or fly in) rather than swell at their first tick. + const placed = new Set<string>() + for (const e of events) { + switch (e.type) { + case 'bombPlaced': { + const far = Math.abs(e.pos.x - prev.player.x) + Math.abs(e.pos.y - prev.player.y) > 1 + // Thrown 2 tiles: flies from the player. Line's bombs (the first one next to the player) + // pop in along the row, in order. + if (far && placed.size === 0) { + fx.slides.set(key.bomb(e.pos), { from: prev.player, at: now, seconds: THROW_SECONDS, arc: true }) + } else fx.appeared.set(key.bomb(e.pos), now + placed.size * LINE_STAGGER) + placed.add(key.bomb(e.pos)) + break + } + case 'bombKicked': { + const tiles = Math.abs(e.to.x - e.from.x) + Math.abs(e.to.y - e.from.y) + fx.slides.set(key.bomb(e.to), { from: e.from, at: now, seconds: tiles * KICK_SECONDS_PER_TILE + 0.08, arc: false }) + break + } + case 'bombTicked': + if (!placed.has(key.bomb(e.pos))) fx.pulsed.set(key.bomb(e.pos), now) + break + case 'fuseChanged': + fx.pulsed.set(key.bomb(e.pos), now) + break + case 'blockDestroyed': + case 'crateCrumbled': + fx.gone.push({ kind: 'crate', pos: e.pos, at: now }) + break + case 'enemyDied': { + const enemy = prev.enemies.find((o) => o.id === e.id) + if (enemy) fx.gone.push({ kind: 'enemy', enemy: { ...enemy, pos: e.pos }, at: now }) + break + } + case 'dropSpawned': + fx.appeared.set(key.drop(e.drop.pos), now) + break + case 'pickedUp': + fx.gone.push({ kind: 'taken', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: now + TAKE_DELAY }) + break + case 'dropBurned': + fx.gone.push({ kind: 'burned', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: now }) + break + case 'powerUpRevealed': + fx.appeared.set(key.powerUp, now) + break + case 'powerUpTaken': + fx.gone.push({ kind: 'taken', sprite: POWER_UP_ICONS[e.powerUp.kind], pos: e.powerUp.pos, size: 0.7, at: now + TAKE_DELAY }) + break + case 'powerUpBurned': + fx.gone.push({ kind: 'burned', sprite: POWER_UP_ICONS[e.powerUp.kind], pos: e.powerUp.pos, size: 0.7, at: now }) + break + case 'exitRevealed': + fx.appeared.set(key.exit, now) + break + case 'exitOpened': + fx.pulsed.set(key.exit, now) + break + case 'decoyPlaced': + fx.appeared.set(key.decoy, now) + break + case 'decoyGone': + fx.gone.push({ kind: 'burned', sprite: 'character_green_front', pos: e.pos, size: 0.9, at: now }) + break + case 'shieldBroke': + fx.gone.push({ kind: 'shield', pos: e.pos, at: now }) + break + case 'chargeUsed': + if (e.id === 'shield') fx.appeared.set(key.shield, now) + if (e.id === 'freeze') fx.frozenAt = now + break + } + } + // Enemies that weren't there before (spawned from a burned exit or power-up) pop in. + for (const enemy of next.enemies) { + if (!prev.enemies.some((o) => o.id === enemy.id)) fx.appeared.set(key.enemy(enemy.id), now) + } + // Last turn's flame that has now gone out fades. + const burning = new Set(next.burning) + for (const i of prev.burning) { + if (!burning.has(i)) fx.gone.push({ kind: 'flame', pos: { x: i % prev.width, y: Math.floor(i / prev.width) }, at: now }) + } +} + +/** Forget animations that have finished. */ +export function pruneEffects(fx: Effects, now: number): void { + fx.gone = fx.gone.filter((g) => now - g.at < GONE_SECONDS[g.kind]) + for (const map of [fx.appeared, fx.pulsed]) { + for (const [k, at] of map) if (now - at > 1) map.delete(k) + } + for (const [k, s] of fx.slides) if (now - s.at > s.seconds) fx.slides.delete(k) +} + +/** + * A thing's size while it appears: 0 before it starts, swelling a little past full size, then + * settling. 1 when it isn't appearing. + */ +export function appearScale(fx: Effects, k: string, now: number): number { + const at = fx.appeared.get(k) + if (at === undefined) return 1 + const t = (now - at) / APPEAR_SECONDS + if (t < 0) return 0 + if (t >= 1) return 1 + // Ease out with a slight overshoot ("back"). + const c = 1.70158 + return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2 +} + +/** A short swell when a bomb's number (or the exit's state) changes. 1 otherwise. */ +export function pulseScale(fx: Effects, k: string, now: number, amount = 0.15): number { + const at = fx.pulsed.get(k) + if (at === undefined) return 1 + const t = (now - at) / PULSE_SECONDS + return t >= 0 && t < 1 ? 1 + amount * Math.sin(t * Math.PI) : 1 +} + +/** 0 at the start of something's animation, 1 at its end (clamped). */ +export function progress(at: number, seconds: number, now: number): number { + return Math.min(1, Math.max(0, (now - at) / seconds)) +} diff --git a/src/style.css b/src/style.css index bb2d00f..2227884 100644 --- a/src/style.css +++ b/src/style.css @@ -129,8 +129,8 @@ img { } #actions button { - /* Three to a row, so Move / Bomb / Wait fill the first and any charges wrap below. */ - flex: 1 0 calc(33.333% - 6px); + /* Three to a row beside the narrow "?", so Move / Bomb / Wait fill the first and any charges wrap below. */ + flex: 1 0 calc((100% - 72px) / 3); display: grid; grid-template-columns: auto auto; grid-template-rows: auto auto; @@ -156,6 +156,26 @@ img { height: 36px; } +/* "What is that?": pick it, then tap a tile to have it explained. */ +#actions button.inspect { + flex: 0 0 48px; + grid-template-columns: auto; + justify-items: center; + padding: 0; +} + +/* Kenney's Game Icons "question", black; white on the green of a picked button. */ +#actions .inspect .glyph { + width: 26px; + height: 26px; + opacity: 0.85; +} + +#actions .inspect[aria-pressed='true'] .glyph { + filter: invert(1); + opacity: 1; +} + #actions button[hidden] { display: none; } @@ -203,13 +223,17 @@ img { font-weight: 800; } -/* On a phone, four charge buttons share a row: no room for their key hints. */ +/* On a phone there is no keyboard to hint at, and the buttons get the room. */ @media (max-width: 600px) { - #actions button.charge { + #actions button { grid-template-rows: auto; } - #actions button.charge kbd { + #actions .icon { + grid-row: auto; + } + + #actions kbd { display: none; } @@ -262,6 +286,80 @@ img { display: none; } +/* Animations for the HTML parts: the banner comes in after a short pause, so the death or the + level-cleared jump is seen first; dialogs pop up over a fading backdrop. */ +@keyframes fade-in { + from { + opacity: 0; + } +} + +@keyframes pop-in { + from { + opacity: 0; + transform: scale(0.9); + } +} + +#banner:not([hidden]) { + animation: fade-in 0.3s ease-out 0.35s backwards; +} + +#banner:not([hidden]) > * { + animation: pop-in 0.3s ease-out 0.35s backwards; +} + +#dialog[open] .panel { + animation: pop-in 0.18s ease-out; +} + +#dialog[open]::backdrop { + animation: fade-in 0.18s ease-out; +} + +/* Closing (dialog.ts): the panel shrinks away and the backdrop clears, then it closes. */ +@keyframes pop-out { + to { + opacity: 0; + transform: scale(0.9); + } +} + +@keyframes fade-out { + to { + opacity: 0; + } +} + +#dialog.closing .panel { + animation: pop-out 0.15s ease-in forwards; +} + +#dialog.closing::backdrop { + animation: fade-out 0.15s ease-in forwards; +} + +/* A top-bar item that just changed (controls.ts `show`) bumps. */ +@keyframes bump { + 40% { + transform: scale(1.3); + } +} + +#hud .hud-item.bump { + animation: bump 0.3s ease-out; +} + +@media (prefers-reduced-motion: reduce) { + #banner:not([hidden]), + #banner:not([hidden]) > *, + #dialog[open] .panel, + #dialog[open]::backdrop, + #hud .hud-item.bump { + animation: none; + } +} + #banner img { width: 96px; height: 96px; diff --git a/src/ui/controls.ts b/src/ui/controls.ts index 7897099..c25fd09 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -3,6 +3,7 @@ import { PAR_BONUS, START_LIVES } from '../sim/state' import type { ActionId, GameState, PowerUpKind, Powers } from '../sim/types' import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' import type { HudItem } from './info' +import questionIcon from '../../assets/kenney-icons/question.png' /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */ export interface Handlers { @@ -12,6 +13,8 @@ export interface Handlers { banner(): void /** A top-bar item was tapped: show what it means. */ info(item: HudItem): void + /** The "What is that?" button: the next tap on the board explains the tile. */ + inspect(): void menu(): void } @@ -41,6 +44,29 @@ function item(info: HudItem, html: string): string { return `<button type="button" class="hud-item" data-info="${info}">${html}</button>` } +/** + * Fill a top-bar slot, keeping the items that didn't change. An item that did (a number went up + * or down, a heart emptied, a pass arrived) is replaced and bumps (style.css), except on the + * first fill. + */ +function show(slot: HTMLElement, html: string): void { + const template = document.createElement('template') + template.innerHTML = html + const first = !slot.dataset.filled + slot.dataset.filled = 'yes' + const old = new Map([...slot.children].map((c) => [(c as HTMLElement).dataset.was, c])) + slot.replaceChildren( + ...[...template.content.children].map((c) => { + const was = c.outerHTML + const kept = old.get(was) + if (kept) return kept + ;(c as HTMLElement).dataset.was = was + if (!first) c.classList.add('bump') + return c + }), + ) +} + function icon(name: string, className = 'icon'): string { return `<img class="${className}" src="${spriteUrl(name)}" alt="" draggable="false">` } @@ -66,6 +92,15 @@ export function createControls(handlers: Handlers) { buttons.set(id, b) } + // "What is that?" sits beside Wait: picked like an action, then a tap on the board explains the tile. + const inspectButton = document.createElement('button') + inspectButton.type = 'button' + inspectButton.className = 'inspect' + inspectButton.setAttribute('aria-label', 'What is that?') + inspectButton.innerHTML = `<img class="glyph" src="${questionIcon}" alt="" draggable="false"><kbd>?</kbd>` + inspectButton.addEventListener('click', () => handlers.inspect()) + buttons.get('wait')!.after(inspectButton) + const banner = document.getElementById('banner')! const bannerText = document.getElementById('banner-text')! const bannerIcon = document.getElementById('banner-icon') as HTMLImageElement @@ -90,8 +125,10 @@ export function createControls(handlers: Handlers) { } return { - /** `autoMove`: a destination is set, so Move takes one step toward it. */ - update(s: GameState, selected: ActionId | null, autoMove: boolean): void { + /** `autoMove`: a destination is set, so Move takes one step toward it. `inspecting`: "What is that?" is on. */ + update(s: GameState, selected: ActionId | null, autoMove: boolean, inspecting: boolean): void { + inspectButton.setAttribute('aria-pressed', String(inspecting)) + inspectButton.disabled = s.status !== 'playing' for (const [id, b] of buttons) { b.disabled = !isAvailable(s, id) b.setAttribute('aria-pressed', String(selected === id)) @@ -126,29 +163,29 @@ export function createControls(handlers: Handlers) { s.options.god && 'God', s.options.crumble && 'Crumble', ].filter(Boolean) - hud.modes.innerHTML = tags.length - ? item('modes', tags.map((t) => `<span class="tag">${t}</span>`).join('')) - : '' - hud.lives.innerHTML = item('lives', `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`) - hud.level.innerHTML = item('level', icon('flag_green_a') + number(s.level, 'Level ')) - hud.enemies.innerHTML = item('enemies', icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left ')) - hud.coins.innerHTML = item('coins', icon('hud_coin') + number(s.coins, 'Coins ')) + show(hud.modes, tags.length ? item('modes', tags.map((t) => `<span class="tag">${t}</span>`).join('')) : '') + show(hud.lives, item('lives', `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`)) + show(hud.level, item('level', icon('flag_green_a') + number(s.level, 'Level '))) + show(hud.enemies, item('enemies', icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left '))) + show(hud.coins, item('coins', icon('hud_coin') + number(s.coins, 'Coins '))) // Turns taken, and the par to beat for the bonus. - hud.turn.innerHTML = item( - 'turn', - `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + `<small class="par">/${s.par}</small>`, + show( + hud.turn, + item('turn', `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + `<small class="par">/${s.par}</small>`), ) // Bomb limit, blast size and fuse, then a small icon for each pass held and the Star's turns. const passes = PASSES.filter(([p]) => s.powers[p]) .map(([p, kind, name]) => item(p, `<span class="sr-only">${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`)) .join('') const star = s.star > 0 ? item('star', icon('star', 'icon small') + number(s.star, 'Star for ')) : '' - hud.stats.innerHTML = + show( + hud.stats, item('bombs', icon('bomb') + number(s.stats.capacity, 'Bombs ')) + - item('fire', icon('fireball') + number(s.stats.fire, 'Blast ')) + - item('fuse', icon('torch_on_a') + number(s.stats.fuse, 'Fuse ')) + - passes + - star + item('fire', icon('fireball') + number(s.stats.fire, 'Blast ')) + + item('fuse', icon('torch_on_a') + number(s.stats.fuse, 'Fuse ')) + + passes + + star, + ) banner.hidden = s.status === 'playing' if (s.status === 'dead') { diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts index 61527dd..12b2ce1 100644 --- a/src/ui/dialog.ts +++ b/src/ui/dialog.ts @@ -30,13 +30,46 @@ const body = document.getElementById('dialog-body')! const buttons = document.getElementById('dialog-buttons')! const panel = dialog.querySelector<HTMLElement>('.panel')! -document.getElementById('dialog-close')!.addEventListener('click', () => dialog.close()) +/** How long the panel takes to shrink away and the backdrop to clear (style.css `.closing`). */ +const CLOSE_MS = 150 +let closing: { timer: number; then?: () => void } | null = null + +/** Close with a short shrink-and-fade, then run `then` (a button's action). */ +function closeDialog(then?: () => void): void { + if (!dialog.open || closing) return + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { + dialog.close() + then?.() + return + } + dialog.classList.add('closing') + closing = { timer: window.setTimeout(finishClosing, CLOSE_MS), then } +} + +function finishClosing(): void { + if (!closing) return + const { timer, then } = closing + closing = null + clearTimeout(timer) + dialog.classList.remove('closing') + dialog.close() + then?.() +} + +document.getElementById('dialog-close')!.addEventListener('click', () => closeDialog()) // A tap on the dimmed backdrop (the dialog element itself, outside its panel) closes it. dialog.addEventListener('click', (e) => { - if (e.target === dialog) dialog.close() + if (e.target === dialog) closeDialog() +}) +// Esc closes it too, animated like the rest. +dialog.addEventListener('cancel', (e) => { + e.preventDefault() + closeDialog() }) export function openDialog(content: DialogContent): void { + // Still closing the last one: finish that first (and run its button's action). + finishClosing() icon.hidden = !content.icon if (content.icon) icon.src = content.icon title.textContent = content.title @@ -47,10 +80,7 @@ export function openDialog(content: DialogContent): void { el.type = 'button' el.className = `ui-button ${b.kind}` el.innerHTML = (b.icon ? `<img src="${b.icon}" alt="">` : '') + `<span>${b.label}</span>` - el.addEventListener('click', () => { - dialog.close() - b.onClick?.() - }) + el.addEventListener('click', () => closeDialog(b.onClick)) return el }), ) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 801799f..b9c8ded 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -77,8 +77,10 @@ const SHOTS = [ { name: 'small-desktop', viewport: DESKTOP, query: 'small', act: async () => {} }, // Developer mode: every ability and unlimited charges (the ∞ badges); its tag in the top bar. { name: 'dev', query: 'dev', act: async () => {} }, - // Tapping an enemy, or anything in the top bar, explains it. - { name: 'info-enemy', act: tap(4, 5) }, + // "What is that?" (the ? button beside Wait) on, then tapping an enemy explains it; the top bar's items + // explain themselves with a plain tap. + { name: 'info-mode', act: async (page) => { await page.click('.inspect') } }, + { name: 'info-enemy', act: async (page) => { await page.click('.inspect'); await tap(4, 5)(page) } }, { name: 'info-hud', act: async (page) => { await page.click('[data-info=fuse]') } }, // The menu (top right), and the confirmation before abandoning a run. { name: 'menu', act: async (page) => { await page.click('#menu-button') } }, @@ -103,7 +105,8 @@ try { await page.waitForSelector('canvas') await page.waitForTimeout(300) await act(page) - await page.waitForTimeout(250) + // Long enough for the turn's animations to finish and the banner to come in (style.css). + await page.waitForTimeout(800) await page.screenshot({ path: `${OUT}/${name}.png` }) await page.close() console.log(` ${OUT}/${name}.png`)