explodorama / commit
Cheaper shop, coins per level; sounds and vibration; smooth in Android Chrome
CHANGELOG.md | 5 ++ PLAN.md | 47 ++++++++-- README.md | 43 ++++++--- assets/kenney-ui/Sounds/switch-a.mp3 | Bin 0 -> 6097 bytes assets/kenney-ui/Sounds/tap-a.mp3 | Bin 0 -> 3384 bytes assets/kenney/sounds/sfx_bump.mp3 | Bin 0 -> 2390 bytes assets/kenney/sounds/sfx_coin.mp3 | Bin 0 -> 3825 bytes assets/kenney/sounds/sfx_disappear.mp3 | Bin 0 -> 7452 bytes assets/kenney/sounds/sfx_gem.mp3 | Bin 0 -> 4582 bytes assets/kenney/sounds/sfx_hurt.mp3 | Bin 0 -> 2546 bytes assets/kenney/sounds/sfx_jump-high.mp3 | Bin 0 -> 5861 bytes assets/kenney/sounds/sfx_jump.mp3 | Bin 0 -> 2836 bytes assets/kenney/sounds/sfx_magic.mp3 | Bin 0 -> 4660 bytes assets/kenney/sounds/sfx_select.mp3 | Bin 0 -> 2285 bytes assets/kenney/sounds/sfx_throw.mp3 | Bin 0 -> 2782 bytes decisions.md | 96 +++++++++++++++++++- docs/screenshots/almanac-charges.png | Bin 314118 -> 313849 bytes docs/screenshots/almanac-enemies.png | Bin 195175 -> 195003 bytes docs/screenshots/almanac.png | Bin 523978 -> 535107 bytes docs/screenshots/auto-move.png | Bin 217119 -> 214599 bytes docs/screenshots/bomb-picked.png | Bin 216434 -> 213863 bytes docs/screenshots/charges.png | Bin 228670 -> 225516 bytes docs/screenshots/countdown.png | Bin 221112 -> 218619 bytes docs/screenshots/death.png | Bin 235106 -> 232923 bytes docs/screenshots/demo.png | Bin 963970 -> 959912 bytes docs/screenshots/desktop.png | Bin 314015 -> 311441 bytes docs/screenshots/dev-offer.png | Bin 203343 -> 206914 bytes docs/screenshots/dev.png | Bin 219773 -> 217227 bytes docs/screenshots/enemies.png | Bin 220100 -> 217399 bytes docs/screenshots/gem-offer.png | Bin 202477 -> 213284 bytes docs/screenshots/gem.png | Bin 200500 -> 198082 bytes docs/screenshots/info-enemy.png | Bin 143135 -> 142755 bytes docs/screenshots/info-hud.png | Bin 179052 -> 170239 bytes docs/screenshots/info-mode.png | Bin 215594 -> 213090 bytes docs/screenshots/lingering.png | Bin 219582 -> 217061 bytes docs/screenshots/long-press.png | Bin 219831 -> 217236 bytes docs/screenshots/menu.png | Bin 141390 -> 144018 bytes docs/screenshots/move-picked.png | Bin 216563 -> 213922 bytes docs/screenshots/new-run.png | Bin 188971 -> 158203 bytes docs/screenshots/nuke.png | Bin 241416 -> 238012 bytes docs/screenshots/shop.png | Bin 191886 -> 193997 bytes docs/screenshots/start.png | Bin 215894 -> 213224 bytes package-lock.json | 10 +-- package.json | 2 +- src/main.ts | 59 ++++++++++--- src/sim/enemies.ts | 11 ++- src/sim/record.ts | 2 +- src/sim/shop.ts | 16 ++-- src/sim/state.ts | 4 +- src/sim/turn.ts | 6 +- src/sim/types.ts | 2 +- src/style.css | 15 ++++ src/ui/controls.ts | 6 +- src/ui/feedback.ts | 157 +++++++++++++++++++++++++++++++++ src/ui/fps.ts | 52 +++++++++++ src/ui/info.ts | 8 +- test/enemies.test.ts | 10 +-- test/shop.test.ts | 10 +-- 58 files changed, 487 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4923871..7d0e5bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ saved before the change is dropped and the next visit starts a new run. ### Added +- **A frame-rate meter** (`?fps`), and `?gl=0`, `?ratio=1` and `?aa` to find what makes a device slow. +- **Sounds and vibration:** Kenney's sound effects (and a few made with ZzFX) for bombs, blasts, enemies, pickups, the gem, the exit, hits and a cleared level, timed with the animations; buttons click. Phones that can (Android) vibrate for blasts, hits and pickups. Both can be switched off in the menu. The demo and the clips stay silent. +- **Coins for every cleared level** (2, and 5 more within par), coins from enemies more often (about 1 a kill), and **cheaper shop prices** (Bomb +1 4, most abilities 6, Heart 12, charges 3–6, a restock 1): the first shop now buys two to four things. Version 27: runs saved before don't resume. - **The shop**, after every 3rd level: 4 power-ups and charges, priced by rarity, with a restock that gets dearer. Version 26 (with Diagonal widening Teleport): runs saved before don't resume. - **New power-ups: RPG** (fire a bomb as far as it flies), **Proximity alert** (your bombs go off when an enemy is in reach) and **Slide** (slide until a wall, crate or bomb), and a new charge, **ICBM** (3 launches of a bomb that goes off where it lands). With Run, Teleport reaches 4 steps; with Diagonal, its reach is a square. Version 24: runs saved before don't resume. - **The "Ouch!" banner replays your own last turns** before a death. The gem's offer plays a clip of the power-up you look at, and almanac entries open their clip. @@ -131,6 +134,8 @@ saved before the change is dropped and the next visit starts a new run. - Dialogs never run off the bottom of a phone's screen: only their middle scrolls, and their buttons always show. - Long dialogs (the almanac, the rainbow gem's offer) scroll with a finger again. - Every dialog opens scrolled to the top: it used to keep where the last one was scrolled to. +- The game ran at under 1 frame a second in Chrome on Android: the demo's CRT effect copied every frame, in play too. It is now off outside the demo, and copies on the graphics chip in the demo (LittleJS 1.28.0); and WebGL's antialiasing, which held Chrome on Android to 12 fps, is off. +- Developer mode is described in one line in the game. - 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. diff --git a/PLAN.md b/PLAN.md index 2276cee..daea85b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -343,7 +343,8 @@ are in git history and decisions.md.) ## 8. Coins and the in-run shop - **Coin sources:** some enemy types always drop coins, others sometimes; occasional coins under - soft blocks; Coin bag pickups; a **par bonus** for finishing a level within its turn budget. + soft blocks (*not built*); Coin bag pickups; *since 2026-10-24* a **clear bonus** for every + level; a **par bonus** for finishing a level within its turn budget. - *Built 2026-10-24 (the user's choices of the options offered):* **the shop opens every few levels** — after levels 3, 6, 9, 12, 15 and 18 (`SHOP_EVERY` 3, a choice made here; not after the last) — when the player goes on from the level-cleared banner, before the next level @@ -351,10 +352,27 @@ are in git history and decisions.md.) - **It sells power-ups and charges:** `SHOP_SIZE` 4 items, drawn without repeats by the gem's weights (`POWER_UP_WEIGHTS`), charges at weight 7 each; no power-up already held, and no Coin bag (coins for coins). - - **Prices by rarity** (`PRICES`): Bomb +1 / Fire +1 6, Fuse +1 8, most abilities and passes 10, - Slide / RPG / Detonator 12, Flame Pass 14, Teleport / Proximity alert 16, Heart 18, Star 10; - charges 6–9. To tune by playtesting. - - **Restock:** 2 coins, one more each time in the same shop (`rerollPrice`); a new draw of 4. + - **Prices by rarity** (`PRICES`): Bomb +1 / Fire +1 4, Fuse +1 5, most abilities and passes 6, + Slide / RPG / Detonator 8, Flame Pass 9, Teleport / Proximity alert 10, Heart 12, Star 6; + charges 3–6 (Decoy 3; Pierce, Power, Freeze 4; Shield 5; Nuke, ICBM 6). To tune by playtesting. + - **Restock:** 1 coin, one more each time in the same shop (`rerollPrice`); a new draw of 4. + - **The coin budget** (*2026-10-24, user request: make the shop reasonable, "leaning towards + cheap"*). Before, three levels earned about 2 coins from enemies (2 Blobs a level at 20% for + 1.7 coins on average) plus 5 for each level within par: a playtest reached the first shop with + 5, about half the cheapest item. Now each cleared level pays `CLEAR_BONUS` 2 coins (par adds + `PAR_BONUS` 5 on top), and enemies drop coins more often (§10), about 1 coin a kill. Expected + coins per three-level stretch, collecting about 80% of the coin drops: + + | Shop after level | Kills | From kills | Clear bonus | Within par | Total | + |---|---|---|---|---|---| + | 3 | 6 Blobs | ≈ 5 | 6 | + 15 | ≈ 11 – 26 | + | 6 | 8 Blobs | ≈ 7 | 6 | + 15 | ≈ 13 – 28 | + | 9 | 10, Blobs and Seekers | ≈ 10 | 6 | + 15 | ≈ 16 – 31 | + | 12 – 18 | 12 – 15 | ≈ 12 – 15 | 6 | + 15 | ≈ 18 – 36 | + + With the new prices, the least a player brings to the first shop buys two cheap things (a stat + and an ability, or a charge), and par play buys three or four; a Coin bag (10) is a whole + extra item. Later shops are a little richer, as the run gets harder. - Buying (`buy`) and restocking (`reroll`) are actions of the run record that take no turn; a bought item works at once. **Next level** is the `continue` action, as from the banner. - The draws come from the seed, the level and the restock count, so a seed always offers the @@ -408,7 +426,7 @@ contact. Intent shown every turn. Adapted from the NES eight: - Until Reapers exist, "destroyed pickup/exit" spawns a group of the level's tougher types. - Base drop table: ~30–40% action charge, separate coin roll, rare skull from level 2. Tune. *Built (2026-10-20):* per type — Blob 20% coins / 15% charge, Seeker 50% / 25%, Drifter - 20% / 50%; a coin drop is worth 1 (60%), 2 (30%) or 5 (10%). Counts: 3 enemies on level 1, + 20% / 50% (*coins since 2026-10-24, for the shop, §8: Blob 60%, Seeker 80%, Drifter 50%*); a coin drop is worth 1 (60%), 2 (30%) or 5 (10%). Counts: 3 enemies on level 1, +1 every 3 levels, at most 10. New type every 6 levels: Seeker from 7, Drifter from 13. Enemies may share a tile (they don't block each other), but *since 2026-10-23 (user request)* each plans around where the others will be, so they spread out and only share when forced @@ -573,6 +591,17 @@ plan of black Icongeek26 outline icons from Flaticon over a plain drawn board. `jump`, `jump-high`, `magic`, `select`, `throw`). **Decided (user, 2026-10-20): use them** when sound is added (M7), copied into `assets/kenney/` like the sprites; ZzFX only for anything the pack has no sound for (e.g. the explosion). + *Built 2026-10-24 (user request: "add sounds and vibrations (where possible)"):* + `src/ui/feedback.ts`. The 10 effects in `assets/kenney/sounds/` plus the UI Pack's `tap-a` (every + button) and `switch-a` (the gem's choice, a restock) in `assets/kenney-ui/Sounds/`, converted + to `.mp3` (older Safari versions can't decode Ogg Vorbis). ZzFX for the blast, a bomb set down and the + ICBM's launch. Each turn's sounds are timed with its animation: a blast and what it kills when + the blast shows (`fx.blastAt`), a pickup at the end of the walk; a chain is one louder boom. + Vibration (`navigator.vibrate`, so Android only; iOS Safari has no web vibration) for blasts, + hits, a broken shield, pickups and a cleared level. Menu switches **Sound** and **Vibration** + (the latter only where the browser has it), stored in `localStorage` + (`explodorama.feedback`). Only the player's own turns make sound: the demo and the clips are + silent. No music. - **UI (2026-10-21):** dialogs and their buttons use Kenney's **UI Pack 2.0** (rectangle depth buttons, green / red / grey; round grey button for close and the menu; play / repeat icons; the @@ -908,7 +937,9 @@ tools/ compose-icons.sh (composed sprites and the app icons), sc `vite-plugin-qrcode` (scan to open on a phone), `@types/node`. `vite-plugin-pwa` arrives with the icons in M7. - Dependency: **`littlejsengine`**, **pinned to exactly 1.25.0** (the newest release at least two - weeks old on 2026-10-19). Upgrade deliberately. + weeks old on 2026-10-19). Upgrade deliberately. *Upgraded 2026-10-24 to 1.28.0* (the latest, 4 days + old: the user's call, for the speed on Android Chrome; decisions.md: "Android Chrome at half a + frame a second"). - A QR library for the certificate (e.g. `qrcode`, which `~/code/pretty` already uses). - `tsconfig.json`: copy nordic-skating's (strict, `noUncheckedIndexedAccess`, …). - `vite.config.ts`: `base: process.env.BASE || './'`, `publicDir: 'static'`, QR plugin, PWA @@ -947,7 +978,7 @@ tools/ compose-icons.sh (composed sprites and the app icons), sc (2026-10-20). ✅ the shop (2026-10-24, every 3rd level, §8); ⏳ curses from level 2. (The Keep/Fresh death choice was dropped 2026-10-24.) 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); - ✅ installable with offline play (2026-10-23); ✅ the demo before the game (2026-10-23, §13a); ⏳ sounds (Kenney pack, §13), remaining + ✅ installable with offline play (2026-10-23); ✅ the demo before the game (2026-10-23, §13a); ✅ sounds and vibration (2026-10-24, §13), ⏳ remaining enemies and actions, screenshots tool. 9. **M8 – Records:** save export/import, victory certificate + QR, replay file and verify page. (The Workshop was here until 2026-10-24: dropped, §7.) diff --git a/README.md b/README.md index 4c276a3..3f4ed37 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ counts down with *your* moves, so nothing happens until you act — and you can what will burn before you commit. Status: **early development.** The board, bombs, lives, the first three enemy types, coins, all -seven charges that work when picked up, a hidden gem on every level that offers a choice of power-ups, the shop every 3rd level, the exit, the 20-level run with its ending, and saving -mid-run work. Curses, sounds and more enemies are planned — +seven charges that work when picked up, a hidden gem on every level that offers a choice of power-ups, the shop every 3rd level, the exit, the 20-level run with its ending, sounds and vibration, and saving +mid-run work. Curses and more enemies are planned — see [PLAN.md](PLAN.md). -Version: **26**, shown at the bottom of the menu with the commit the game was built from (a link +Version: **27**, shown at the bottom of the menu with the commit the game was built from (a link to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the [source code](https://gitlab.com/PavelSafronov/explodorama). The version is the rules version (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)): it goes up by one whenever a rule @@ -135,7 +135,7 @@ a finger shows how. Enemies have only their picture. With reduced motion turned a still picture of its moment instead. **Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level -and seed, and has the **Almanac** — every power-up, charge and enemy type, with its +and seed, the **Sound** and **Vibration** switches (see *Sound and vibration* below), and has the **Almanac** — every power-up, charge and enemy type, with its picture and what it does, in three tabs; an entry with a green ▶ opens its clip — **High scores**, **Watch the demo**, **Report a bug** (an email to the game's GitLab Service Desk, which needs no GitLab account, filled in with the game's version, your device and the run, so it can be replayed; a long run's record goes on @@ -225,7 +225,7 @@ Level 1 has 2 enemies, with one more every 4 levels (at most 5). Enemies never s ### Drops and coins -A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or a **charge** +A slain enemy often leaves **coins** (bronze = 1, silver = 2, gold = 5: about 1 coin a kill on average) or sometimes a **charge** (which works as soon as you pick it up) on its tile. The last enemy's charge (when no enemies are left after it) is always one that lasts into the next level — Pierce, Power, Nuke or Shield — never a Freeze or Decoy, which would have nothing to work on. And no enemy drops a Decoy, Freeze @@ -243,7 +243,7 @@ open, so pick them up soon. Coins are spent in **the shop** (below). - **Don't blast the uncovered door:** flame on it lets out 3 more enemies (of the level's toughest type). Those drop nothing. They can't be hurt by the flame that let them out (it lingers a turn), but a new explosion kills them as usual. -- **Par:** the top bar shows `turns taken / par`. Finish within par for a bonus of **5 coins**. +- **Clearing a level pays 2 coins.** **Par:** the top bar shows `turns taken / par`. Finish within par for **5 more**. Par is 25 turns plus 8 per enemy on the level. ### Power-ups @@ -295,15 +295,16 @@ and pass you hold. After every **3rd level** (3, 6, 9, 12, 15 and 18), going on from the level-cleared banner opens **the shop** before the next level. It has **4 items**: power-ups (none you already hold, and no Coin bag) and charges, drawn with the gem's rarity weights, each with its **price** — common ones -cheap (Bomb +1 and Fire +1 cost 6), rare ones dear (Teleport and Proximity alert 16, Heart 18), -charges 6–9. Tap an item to select it (a clip above shows it in action), then its **Buy** button +cheap (Bomb +1 and Fire +1 cost 4, most abilities and passes 6), rare ones dear (Teleport and +Proximity alert 10, Heart 12), charges 3–6. Three levels earn about 12 coins, or about 27 when +each is cleared within par, so the first shop buys two cheap things, or three or four. Tap an item to select it (a clip above shows it in action), then its **Buy** button (or, when it's too dear, how many coins you still need); what you buy works at once and lasts for -the rest of the run. **Restock** draws 4 new items for 2 coins, -then 3, 4… in the same shop. **Next level** leaves. Shopping takes no turns. What a shop offers +the rest of the run. **Restock** draws 4 new items for 1 coin, +then 2, 3… in the same shop. **Next level** leaves. Shopping takes no turns. What a shop offers comes from the seed, so the same dungeon offers the same things. Nothing carries over between runs: leftover coins only count on the high-score board. -<img src="docs/screenshots/shop.png" alt="The shop: 5 coins, a clip above the items with their prices, Bomb +1 selected with its own button (1 more coin needed), and Restock and Next level side by side" width="260"> +<img src="docs/screenshots/shop.png" alt="The shop: 9 coins, a clip above the items with their prices, Bomb +1 selected with its own Buy button (4 coins), and Restock and Next level side by side" width="260"> ### Lives @@ -357,6 +358,15 @@ still give (bombs or turns left, or the Shield while it's up). The bar has a fix rows (three on a phone or a narrow screen, one on a wide one), and a row that gets too long scrolls sideways instead of growing, so **the board never moves** as you pick things up. +### Sound and vibration + +Turns make sounds, timed with what you see: a pop when a bomb is set down, a boom when it goes off +(one louder boom for a chain; an ICBM's when it lands), enemies vanishing, coins and charges +picked up, the gem, the exit opening, a hurt, a cleared level; buttons click. Phones that can +vibrate (Android; iPhones can't, from a web page) also buzz for blasts, hits, pickups and a +cleared level. Both can be turned off in the menu, and the setting is remembered in this browser. +The demo and the clips in the dialogs are silent. On an iPhone, the silent switch mutes the game. + ### The board 9 × 11 tiles, fitting the screen whole (no scrolling): a stone outer wall and metal pillars @@ -379,7 +389,7 @@ npm run screenshots # build, then photograph the game into docs/screenshots/ (P URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 13 has all three enemy types); `?charges=2` starts as if 2 of each lasting charge had been picked up -(6 Pierce, Power and Nuke bombs, and a Shield), for trying them out; `?dev` and `?immortal` set the modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). A URL with any of these parameters plays a test board: it doesn't load or overwrite your +(6 Pierce, Power and Nuke bombs, and a Shield), for trying them out; `?dev` and `?immortal` set the modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). `?fps` shows a frame-rate meter in the bottom left corner: frames a second, the slowest frame, the game's own time per frame, and the canvas size. For tracking down slowness on a device: `?gl=0` draws with Canvas 2D instead of WebGL (no demo TV effect, no clips), `?ratio=1` draws at one pixel per CSS pixel, `?aa` turns WebGL's antialiasing on (it is off: it held Android Chrome to 12 fps). These four keep your saved run. A URL with any of these parameters plays a test board: it doesn't load or overwrite your saved run. Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch. @@ -426,8 +436,13 @@ licence file. One exception: the demo's pointing hand, `assets/kenney-ui-rpg/cur is the RPG Expansion's `cursorHand_beige` cut out of the pack's vector sheet (`Vector/uipack_rpg_vector.svg`: just its three shapes, unchanged), since the PNG is only 27 px across. They -are not covered by this project's MIT licence. Sound effects, when the game gets them, will come -from the same pack. +are not covered by this project's MIT licence. + +Sounds: the New Platformer Pack's 10 effects, in [`assets/kenney/sounds/`](assets/kenney/sounds/), +and the UI Pack's `tap-a` and `switch-a`, in [`assets/kenney-ui/Sounds/`](assets/kenney-ui/Sounds/), +under their own names but converted from the packs' `.ogg` to `.mp3`, since older iPhones can't +play Ogg. The blast, a bomb set down and the ICBM's launch are made in code with +[ZzFX](https://github.com/KilledByAPixel/ZzFX), through LittleJS. ## Licence diff --git a/assets/kenney-ui/Sounds/switch-a.mp3 b/assets/kenney-ui/Sounds/switch-a.mp3 new file mode 100644 index 0000000..9c6c43d Binary files /dev/null and b/assets/kenney-ui/Sounds/switch-a.mp3 differ diff --git a/assets/kenney-ui/Sounds/tap-a.mp3 b/assets/kenney-ui/Sounds/tap-a.mp3 new file mode 100644 index 0000000..6bfe969 Binary files /dev/null and b/assets/kenney-ui/Sounds/tap-a.mp3 differ diff --git a/assets/kenney/sounds/sfx_bump.mp3 b/assets/kenney/sounds/sfx_bump.mp3 new file mode 100644 index 0000000..91bc99d Binary files /dev/null and b/assets/kenney/sounds/sfx_bump.mp3 differ diff --git a/assets/kenney/sounds/sfx_coin.mp3 b/assets/kenney/sounds/sfx_coin.mp3 new file mode 100644 index 0000000..0254b3a Binary files /dev/null and b/assets/kenney/sounds/sfx_coin.mp3 differ diff --git a/assets/kenney/sounds/sfx_disappear.mp3 b/assets/kenney/sounds/sfx_disappear.mp3 new file mode 100644 index 0000000..425d7e1 Binary files /dev/null and b/assets/kenney/sounds/sfx_disappear.mp3 differ diff --git a/assets/kenney/sounds/sfx_gem.mp3 b/assets/kenney/sounds/sfx_gem.mp3 new file mode 100644 index 0000000..f00a72e Binary files /dev/null and b/assets/kenney/sounds/sfx_gem.mp3 differ diff --git a/assets/kenney/sounds/sfx_hurt.mp3 b/assets/kenney/sounds/sfx_hurt.mp3 new file mode 100644 index 0000000..a82bccf Binary files /dev/null and b/assets/kenney/sounds/sfx_hurt.mp3 differ diff --git a/assets/kenney/sounds/sfx_jump-high.mp3 b/assets/kenney/sounds/sfx_jump-high.mp3 new file mode 100644 index 0000000..0c60f37 Binary files /dev/null and b/assets/kenney/sounds/sfx_jump-high.mp3 differ diff --git a/assets/kenney/sounds/sfx_jump.mp3 b/assets/kenney/sounds/sfx_jump.mp3 new file mode 100644 index 0000000..f213533 Binary files /dev/null and b/assets/kenney/sounds/sfx_jump.mp3 differ diff --git a/assets/kenney/sounds/sfx_magic.mp3 b/assets/kenney/sounds/sfx_magic.mp3 new file mode 100644 index 0000000..e3d45ee Binary files /dev/null and b/assets/kenney/sounds/sfx_magic.mp3 differ diff --git a/assets/kenney/sounds/sfx_select.mp3 b/assets/kenney/sounds/sfx_select.mp3 new file mode 100644 index 0000000..7252dbc Binary files /dev/null and b/assets/kenney/sounds/sfx_select.mp3 differ diff --git a/assets/kenney/sounds/sfx_throw.mp3 b/assets/kenney/sounds/sfx_throw.mp3 new file mode 100644 index 0000000..0e7b12f Binary files /dev/null and b/assets/kenney/sounds/sfx_throw.mp3 differ diff --git a/decisions.md b/decisions.md index 6470b2c..55d71a2 100644 --- a/decisions.md +++ b/decisions.md @@ -1259,7 +1259,8 @@ its width with black bars beside it. And the app is now called **EXPLODORAMA!**. 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 + the CRT only while the demo runs. (*Since 2026-10-24 it is switched off in play instead — + `postProcess.enabled`, LittleJS 1.28.0 — see "Android Chrome at half a frame a second".*) 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 @@ -1697,3 +1698,96 @@ power-up if the order is weird": with Diagonal, Teleport counts a diagonal step (`teleportSteps`), so its reach is a square, 7 × 7 (9 × 9 with Run too), instead of a diamond. Run gives Teleport length, Diagonal gives it shape. Diagonal's own steps and keys are unchanged. `SIM_VERSION` 26. Test: a tile 3 across and 3 up is out of reach without Diagonal, in with it. + +### 2026-10-24 — Coins and prices for a reasonable shop + +User request: make the shop reasonable, "perhaps leaning towards cheap", working out the coin +gains. Before, a level paid almost nothing but its par bonus: 2 Blobs at a 20% coin chance (1.7 +coins a drop on average) give under 1 coin a level, so three levels earned about 2 coins plus 5 for +each level cleared within par, and a playtest reached the first shop with 5 coins, short of the +cheapest item (6). Three changes, worked out in PLAN.md §8 (the coin budget): + +- **Every cleared level pays 2 coins** (`CLEAR_BONUS`), so even a slow player gets a baseline; par + pays 5 more, as before. +- **Enemies drop coins more often:** Blob 60% (was 20%), Seeker 80% (50%), Drifter 50% (20%), + about 1 coin a kill. Charge chances are unchanged. +- **Prices roughly two thirds of before:** Bomb +1 / Fire +1 4, Fuse +1 5, most abilities and + passes 6, Slide / RPG / Detonator 8, Flame Pass 9, Teleport / Proximity alert 10, Heart 12, Star + 6, charges 3–6; a restock 1 coin, then one more each time. + +The first shop now sees about 11 coins (two cheap things) to about 26 within par (three or four). +The banner says what the level paid: "+2 coins." or "+7 coins, under par!". `SIM_VERSION` 27. + +### 2026-10-24 — Sounds and vibration + +User request: "add sounds and vibrations (where possible)". Built as decided on 2026-10-20: the New +Platformer Pack's 10 effects, plus two from the UI Pack (a current pack, so no new pack was +brought in): `tap-a` for every button, `switch-a` for the gem's choice and a restock. ZzFX, built +into LittleJS, for what the packs lack: the blast, a bomb set down, the ICBM's launch. + +- **MP3, not the packs' Ogg:** older Safari versions can't decode Ogg Vorbis, so iPhones a few years + old would have been silent. Converted with ffmpeg (`-q:a 4`), same names. +- **Timed with the animation:** sounds go off when what they belong to shows — a blast and its + kills at `fx.blastAt` (an ICBM's on landing), a pickup when the walk ends — not when the sim + settles the turn. A chain is one louder boom, not several at once. +- **Vibration where the browser has it:** `navigator.vibrate`, which Android browsers have and + iOS Safari doesn't (no web page can vibrate an iPhone); the menu's switch only shows where it + works. Short buzzes for blasts (longer for chains), a hit, a broken shield, pickups, a cleared + level. +- **Switches in the menu**, applied at once and remembered in this browser; both on by default. +- **Silent demo and clips:** they loop by themselves and would be noise; only turns the player + plays make sound. +- **No music**, and no walking sound (every turn would tick). The ZzFX parameters were chosen + without listening (the shell has no audio): to tune by ear. + +### 2026-10-24 — A one-line Developer description + +User request: with Developer mode on, its long description crowded the dialogs. In the game it now +reads only "A special gem lies next to you on every level." (the New run dialog, and the modes +tag's explanation). The details — every power-up not held and every charge, the level's own gem +still hidden, the red border on achievements — stay in the README. + +### 2026-10-24 — Android Chrome at half a frame a second + +User report: on Android Chrome the game ran at about 0.5 fps, locally and on GitLab Pages, while +Firefox on the same phone ran smoothly. The likely cause: the CRT's `PostProcessPlugin` was on all +the time, a pass-through in play, and every frame it uploaded the whole WebGL canvas into a +texture (`texImage2D` from `glCanvas`). Chrome does that by reading the frame back from the GPU +and sending it up again, which stalls the GPU each frame — at a phone's 3× pixel ratio, ~10 MB a +frame. (Headless Chrome warned "GPU stall due to ReadPixels" too.) + +- **Fix, part 1:** `postProcess.enabled` (LittleJS 1.26.1): the plugin is made once with the CRT + shader and is **enabled only while the demo runs**, so play does no copy at all. +- **Fix, part 2, the demo:** LittleJS 1.27.1 copies the frame on the GPU (`copyTexImage2D`) instead + of uploading the canvas. **Upgraded to the latest, 1.28.0** (2026-10-20, 4 days old), at the + user's request ("let's pick up the latest version … it slows down the entire phone"): an + explicit exception to the two-week pinning rule of 2026-10-19, which otherwise stands. The + breaking changes of 1.26–1.28 (video capture and the Electron example removed; 3D cameras moved + to a plugin) touch nothing here. +- **Checked** by counting calls in Chromium: in play, no canvas copies or readbacks per frame (was + one upload a frame); in the demo, one GPU-side copy a frame and no upload. +- **Still to watch:** a dialog's clip copies its room from the WebGL canvas into its own 2D canvas + with `drawImage` each frame (ui/clip.ts). Chrome may do that on the GPU or by reading back, + depending on the canvas; only while a clip shows. +- Not yet checked on the phone (no Android device here): the user is to confirm. +- **Follow-up (same day):** still slow on the phone with 1.28.0. User request: an FPS count. Added + `ui/fps.ts`, a meter in the bottom left (in `npm run dev`, or `?fps`): frames a second, the + slowest frame, the game's own update-and-draw time per frame (so a slow frame with little game + time points at the browser or GPU), the canvas size and pixel ratio. And three switches to + narrow it down on the device: `?gl=0` (Canvas 2D), `?ratio=1` (no high-density canvas), + `?noaa` (no WebGL antialiasing). In headless Chromium (software GPU) the default ran at about + 44 fps and each switch at 60, so antialiasing is one suspect; the phone will tell. +- **Found (same day):** the user's measurements on their Android phone in Chrome, with the meter: + 12 fps as it was (game code 0.7 ms a frame, so the time was the browser's), **60 with `?noaa`**, + 52 with `?ratio=1`, 60 with `?gl=0`, the same in the demo and in play; Firefox on the same phone, + and Chrome, Firefox and Safari on the desktop, ran at 60 throughout. So it was **WebGL's + multisample antialiasing**, which that phone's Chrome couldn't afford at full resolution. + **Antialiasing is now off** (`glSetAntialias(false)`); `?aa` turns it back on, for comparing. + The board is sprites on a square grid, which antialiasing hardly smooths: the screenshots look + the same. The meter and the switches stay, for the next slow device. +- **After the fix (same day):** smooth on the phone. The meter no longer shows by itself in + `npm run dev` (user request), only with `?fps`. No automatic tuning to the device (user + question, answered): the game draws a few hundred sprites, which any phone of the last years + manages at 60 fps once antialiasing is off; the cost was one setting, not the hardware's + limits. Such tuning (e.g. dropping the pixel ratio after a few slow seconds) is to add only if + a slow device turns up, measured first with `?fps` and the switches. diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png index 5ff7e35..57b96d1 100644 Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png index 3f47be9..fbab680 100644 Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png index 15aef7f..7f6ceac 100644 Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index 7f29757..4edddc0 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 a9fefb5..7072e85 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 b9d20a6..48832e6 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 0ac284f..c841a7b 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 799938e..646bc1b 100644 Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png index 39dc8eb..ce59e00 100644 Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index b919247..53527a9 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png index b35771f..51736fc 100644 Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png index c35b4e0..07535fe 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 6e4d19b..6c979a6 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png index 55212c0..15ca817 100644 Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png index 508a005..830f56d 100644 Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png index 04b4b40..25984c1 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 4706a01..d26da3a 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 index 350d19f..2e5e841 100644 Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index 77624f6..fb203d1 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 67fc5c6..9b344a2 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 731636a..b996e3f 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png index 9591e51..1b67e7f 100644 Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png index 6612a98..737f559 100644 Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png index 2a1bac9..56fd028 100644 Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ diff --git a/docs/screenshots/shop.png b/docs/screenshots/shop.png index 39d07a0..18c41db 100644 Binary files a/docs/screenshots/shop.png and b/docs/screenshots/shop.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index c35fc7f..f8b1a48 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/package-lock.json b/package-lock.json index c9f1a48..82259f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "littlejsengine": "1.25.0" + "littlejsengine": "1.28.0" }, "devDependencies": { "@types/node": "^26.6.5", @@ -1085,12 +1085,12 @@ } }, "node_modules/littlejsengine": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/littlejsengine/-/littlejsengine-1.25.0.tgz", - "integrity": "sha512-qk/Vhua9IemOD+mSOGB1AbGCSoJV4AY7aC62d+7G9FDlxXVfokk39oXuVixfwxoV6n6Y4b24scMcgtTtmhQI1w==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/littlejsengine/-/littlejsengine-1.28.0.tgz", + "integrity": "sha512-HTea1Xzn/lwY8i/1BMCojYl7Pfd9GpvMi5hTpMhJtwkzAHI4bfR4o9+nTg+wbMbuAR14xtTEf/hbDVWHkCSkjA==", "license": "MIT", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=22.12.0" } }, "node_modules/magic-string": { diff --git a/package.json b/package.json index 1c18299..6832857 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "homepage": "https://pavelsafronov.gitlab.io/explodorama/", "license": "MIT", "dependencies": { - "littlejsengine": "1.25.0" + "littlejsengine": "1.28.0" }, "devDependencies": { "@types/node": "^26.6.5", diff --git a/src/main.ts b/src/main.ts index be06713..0f9bb72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { setCanvasPixelRatio, setDebugWatermark, setGLEnable, + glSetAntialias, setShowSplashScreen, setTilesPixelated, PostProcessPlugin, @@ -45,6 +46,8 @@ import { device, recentErrors, reportError } from './devlog' import { hashSeed } from './sim/rng' import { CONTROL_KINDS, DYING_KINDS, LIVING_KINDS, SCENES, type Gesture, type Scene, type SceneKind } from './sim/demo' import { spriteUrl } from './render/sprites' +import { countFrame, timed } from './ui/fps' +import { canVibrate, feedbackSettings, initFeedback, setFeedbackSettings, turnFeedback, vibrate } from './ui/feedback' 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' @@ -199,6 +202,7 @@ function act(action: Action, auto = false): void { const wasGoing = state.status !== 'gameOver' && state.status !== 'won' const before = state animateTurn(result, action) + turnFeedback(result.events, action, anim) record.actions.push(action) rememberTurn(before, action, result.events) // A run that has just ended goes on the high-score board (not from a test URL); its banner @@ -428,7 +432,8 @@ function showMenu(): void { body: `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` + (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') + - `<p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` + + feedbackChoices() + + `<p class="credit">Art and sounds by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` + `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and Board Game Icons (CC0). Thank you!</p>` + // The version is the rules version: it changes exactly when old saves stop resuming. `<p class="credit">Version ${SIM_VERSION}${commitLine()}. ` + @@ -442,6 +447,26 @@ function showMenu(): void { { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: newRunDialog }, ], }) + // The switches work at once, without a button (user request, 2026-10-24: sounds and vibration). + for (const input of document.querySelectorAll<HTMLInputElement>('#dialog-body .feedback input')) { + input.addEventListener('change', () => { + setFeedbackSettings({ ...feedbackSettings(), [input.name]: input.checked }) + // Turned on: a short buzz, so the player knows at once whether it works on their phone. + if (input.name === 'vibration' && input.checked) vibrate(200) + }) + } +} + +/** The menu's Sound and Vibration switches; Vibration only where the browser can vibrate. */ +function feedbackChoices(): string { + const f = feedbackSettings() + const checked = (on: boolean) => (on ? ' checked' : '') + return ( + `<fieldset class="choices feedback"><legend>Settings</legend>` + + `<label><input type="checkbox" name="sound"${checked(f.sound)}><span><b>Sound</b></span></label>` + + (canVibrate() ? `<label><input type="checkbox" name="vibration"${checked(f.vibration)}><span><b>Vibration</b></span></label>` : '') + + `</fieldset>` + ) } /** @@ -461,7 +486,7 @@ function newRunDialog(): void { body: warning + `<fieldset class="choices"><legend>Modes</legend>` + - `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> An extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet and every charge (the level’s own is still hidden). Achievements still count, but are marked with a red border.</span></label>` + + `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> A special gem lies next to you on every level.</span></label>` + `<label><input type="checkbox" name="immortal"${checked(o.immortal)}><span><b>Immortal</b> Nothing can kill you. You can play through to the end, but an Immortal run earns no achievements.</span></label>` + `</fieldset><fieldset class="choices"><legend>Seed</legend>` + `<label class="seed"><input type="text" name="seed" inputmode="numeric" autocomplete="off" maxlength="40" placeholder="random"><span>Leave it empty for a new dungeon, or type the seed of one to play it (shown in the menu and at the end of a run).</span></label>` + @@ -950,7 +975,7 @@ function startDemo(): void { youTag.hidden = false crtScreen.hidden = false setDialogInDemo(true) - postProcess?.setShaderCode(CRT_SHADER) + if (postProcess) postProcess.enabled = true nextScene() } @@ -1108,7 +1133,7 @@ function endDemo(): void { crtScreen.hidden = true crtScreen.classList.remove('interference') vacuumAt = Infinity - postProcess?.setShaderCode() + if (postProcess) postProcess.enabled = false 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') @@ -1169,11 +1194,16 @@ if (!testing || params.has('demo')) startDemo() // cover it, but the demo hides the bar (only the development build has it at all). setDebugWatermark(false) // Sharp on high-density screens, capped so phones don't pay for 3×. -setCanvasPixelRatio(Math.min(devicePixelRatio, 2)) +setCanvasPixelRatio(Math.min(devicePixelRatio, Number(params.get('ratio')) || 2)) // WebGL. It was Canvas 2D from 2026-10-19 to 2026-10-24, because screenshots of the WebGL canvas // showed a black strip that isn't in the drawing; that was Playwright's headless shell, and the // screenshots now use the full Chromium (decisions.md). -setGLEnable(true) +setGLEnable(params.get('gl') !== '0') +// No antialiasing: on an Android phone, Chrome ran the game at 12 fps with it and 60 without +// (user's measurements, 2026-10-24); the board is sprites on a square grid, which it hardly smooths. +// Speed experiments, with the frame-rate meter (`?fps`): `?aa` turns antialiasing back on, +// `?gl=0` draws with Canvas 2D (no demo CRT, no clips), `?ratio=1` at 1 pixel per CSS pixel. +glSetAntialias(params.has('aa')) // The sprites are smooth vector-style art drawn smaller than their 128 px, not pixel art. setTilesPixelated(false) @@ -1198,8 +1228,12 @@ 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 } + initFeedback() + // Post-processing: the CRT in the demo, and off in play. A pass-through in play cost a copy of + // the whole frame every frame, which Android Chrome ran at under 1 fps (user report, 2026-10-24). + const crt = new PostProcessPlugin(CRT_SHADER) + crt.values = { interference: 0, staticNoise: 0.03 } + crt.enabled = !!demo 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 @@ -1229,7 +1263,7 @@ await engineInit( // A long press shouldn't open the browser's context menu. mainCanvas.addEventListener('contextmenu', (e) => e.preventDefault()) }, - guarded('update', () => { + timed(guarded('update', () => { demoTick() // In the demo the board and the action bar under it fit above the caption panel, with room // between them and it. @@ -1247,16 +1281,17 @@ await engineInit( snapCamera = false pruneAnimation(anim, time) autoStep() - }), + })), () => {}, - guarded('draw', () => { + timed(guarded('draw', () => { // A dialog's clip first: it uses the canvas for a moment, then clears it (ui/clip.ts). drawClips() drawBoard({ state, danger, selected, hover, destination, path, armed, ...anim, pathBlocked: !!path?.[0] && !safeStep(path[0]), }) - }), + countFrame() + })), () => {}, TEXTURES, ) diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts index f9a4f35..c8fa0c1 100644 --- a/src/sim/enemies.ts +++ b/src/sim/enemies.ts @@ -163,11 +163,14 @@ export function moveEnemies(s: GameState, events: GameEvent[]): void { } } -/** Chance of a coin drop, and of an action-charge drop, per type (PLAN.md §10). To tune. */ +/** + * Chance of a coin drop, and of an action-charge drop, per type (PLAN.md §10). Coin chances raised + * 2026-10-24 for the shop (from 0.2, 0.5 and 0.2): a kill is worth about 1 coin on average. + */ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = { - blob: { coins: 0.2, charge: 0.15 }, - seeker: { coins: 0.5, charge: 0.25 }, - drifter: { coins: 0.2, charge: 0.5 }, + blob: { coins: 0.6, charge: 0.15 }, + seeker: { coins: 0.8, charge: 0.25 }, + drifter: { coins: 0.5, charge: 0.5 }, } /** diff --git a/src/sim/record.ts b/src/sim/record.ts index edb4f71..dd49645 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunO * Bumped whenever a rule change would make an old record replay differently. Records from * another version can't be trusted to replay (PLAN.md §11). */ -export const SIM_VERSION = 26 +export const SIM_VERSION = 27 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/src/sim/shop.ts b/src/sim/shop.ts index 13ef245..8d9491c 100644 --- a/src/sim/shop.ts +++ b/src/sim/shop.ts @@ -14,19 +14,23 @@ import { CHARGE_IDS, isCharge, type GameEvent, type GameState, type OfferKind, t export const SHOP_EVERY = 3 export const SHOP_SIZE = 4 -/** Prices by rarity: common things cheap, rare ones dear (user's choice; to tune by playtesting). */ +/** + * Prices by rarity: common things cheap, rare ones dear (user's choice). Lowered 2026-10-24 (user + * request, "leaning towards cheap"): three levels earn about 12 coins, or about 27 within par each + * time (PLAN.md §8), so the first shop buys two cheap things, or three or four with par bonuses. + */ export const PRICES: Record<OfferKind, number> = { - bombPlus: 6, firePlus: 6, fusePlus: 8, run: 10, kick: 10, throw: 10, diagonal: 10, slide: 12, rpg: 12, detonator: 12, - wallPass: 10, bombPass: 10, flamePass: 14, star: 10, heart: 18, coinBag: 0, teleport: 16, proximity: 16, - pierce: 7, power: 7, nuke: 9, shield: 8, freeze: 7, decoy: 6, icbm: 9, + bombPlus: 4, firePlus: 4, fusePlus: 5, run: 6, kick: 6, throw: 6, diagonal: 6, slide: 8, rpg: 8, detonator: 8, + wallPass: 6, bombPass: 6, flamePass: 9, star: 6, heart: 12, coinBag: 0, teleport: 10, proximity: 10, + pierce: 4, power: 4, nuke: 6, shield: 5, freeze: 4, decoy: 3, icbm: 6, } /** How often each charge comes up in the shop: as often as an uncommon power-up. */ const CHARGE_WEIGHT = 7 -/** The price of the next restock: 2 coins, one more each time in the same shop. */ +/** The price of the next restock: 1 coin, one more each time in the same shop. */ export function rerollPrice(shop: Shop): number { - return 2 + shop.rerolls + return 1 + shop.rerolls } /** Whether clearing `level` opens the shop. */ diff --git a/src/sim/state.ts b/src/sim/state.ts index f96f059..20bc73b 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -7,7 +7,9 @@ import { DEFAULT_OPTIONS, type Bomb, type Boosts, type Cell, type GameState, typ export const START_LIVES = 3 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 } -/** Coins awarded for clearing a level within its par. */ +/** Coins awarded for clearing any level (since 2026-10-24, for the shop). */ +export const CLEAR_BONUS = 2 +/** Coins awarded on top for clearing a level within its par. */ export const PAR_BONUS = 5 /** What the player takes from one attempt at a level into the next. */ diff --git a/src/sim/turn.ts b/src/sim/turn.ts index e8fe6b6..9b35197 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -2,7 +2,7 @@ import { ACTIONS, applyCharge, isAvailable, isValidTarget, slidePassed } from '. import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' import { enemyKinds, LAST_LEVEL } from './levelgen' import { detonate, proximityAlert, tickBombs } from './explosions' -import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state' +import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, CLEAR_BONUS, PAR_BONUS } from './state' import { stream } from './rng' import { buy, openShop, reroll, shopAfter } from './shop' import { CHARGE_IDS, DIRS, DIR_LIST, isCharge, type Action, type Death, type GameEvent, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from './types' @@ -81,10 +81,10 @@ export function step(prev: GameState, action: Action): StepResult { pickUp(s, s.player, events) if (exitOpen(s) && eq(s.player, s.exit)) { const parBonus = s.turn + 1 <= s.par ? PAR_BONUS : 0 - s.coins += parBonus + s.coins += CLEAR_BONUS + parBonus s.turn++ s.runTurns++ - events.push({ type: 'levelCleared', level: s.level, turns: s.turn, parBonus }) + events.push({ type: 'levelCleared', level: s.level, turns: s.turn, clearBonus: CLEAR_BONUS, parBonus }) if (s.level >= LAST_LEVEL) { s.status = 'won' events.push({ type: 'runWon', turns: s.runTurns, deaths: s.deaths }) diff --git a/src/sim/types.ts b/src/sim/types.ts index 5022ec7..5533ebb 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -315,7 +315,7 @@ export type GameEvent = | { type: 'pickedUp'; drop: Drop } | { type: 'exitRevealed'; pos: Vec } | { type: 'exitOpened'; pos: Vec } - | { type: 'levelCleared'; level: number; turns: number; parBonus: number } + | { type: 'levelCleared'; level: number; turns: number; clearBonus: number; parBonus: number } | { type: 'levelStarted'; level: number } | { type: 'bombKicked'; from: Vec; to: Vec } /** An RPG or ICBM bomb flew from the player to where it landed. */ diff --git a/src/style.css b/src/style.css index c9aa076..b632190 100644 --- a/src/style.css +++ b/src/style.css @@ -1475,3 +1475,18 @@ canvas.clip[hidden] { .shop-item[aria-pressed='true'] .buy { display: flex; } + +/* The frame-rate meter (ui/fps.ts): with ?fps. */ +#fps { + position: fixed; + left: 4px; + bottom: 4px; + z-index: 1000; + padding: 2px 6px; + border-radius: 4px; + background: rgb(0 0 0 / 0.65); + color: #fff; + font: 11px/1.3 ui-monospace, monospace; + white-space: pre; + pointer-events: none; +} diff --git a/src/ui/controls.ts b/src/ui/controls.ts index caa46c4..5b74fd8 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -1,5 +1,5 @@ import { canPick } from '../sim/picks' -import { PAR_BONUS, START_LIVES } from '../sim/state' +import { CLEAR_BONUS, PAR_BONUS, START_LIVES } from '../sim/state' import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Powers } from '../sim/types' import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' import { CHARGE_TEXT, chargeLeft, deathText, type HudItem } from './info' @@ -234,8 +234,8 @@ export function createControls(handlers: Handlers) { bannerButton2.textContent = 'New dungeon' bannerIcon.src = spriteUrl('hud_heart_empty') } else if (s.status === 'cleared') { - const bonus = s.turn <= s.par ? ` Under par: +${PAR_BONUS} coins!` : '' - bannerText.textContent = `Level ${s.level} cleared in ${s.turn} turns.${bonus}` + const bonus = s.turn <= s.par ? `+${CLEAR_BONUS + PAR_BONUS} coins, under par!` : `+${CLEAR_BONUS} coins.` + bannerText.textContent = `Level ${s.level} cleared in ${s.turn} turns. ${bonus}` bannerButton.textContent = 'Next level' bannerIcon.src = spriteUrl('flag_green_a') } else if (s.status === 'won') { diff --git a/src/ui/feedback.ts b/src/ui/feedback.ts new file mode 100644 index 0000000..847769b --- /dev/null +++ b/src/ui/feedback.ts @@ -0,0 +1,157 @@ +import { Sound, time } from 'littlejsengine' +import type { Animation } from '../render/animate' +import { WALK_SECONDS } from '../render/effects' +import type { Action, GameEvent } from '../sim/types' + +/** + * Sounds and vibration for the game's turns and buttons (PLAN.md §13; user request, 2026-10-24). + * Sounds from Kenney's New Platformer Pack and UI Pack (CC0), converted from the packs' .ogg to + * .mp3 so every browser plays them, under the packs' own names; ZzFX (LittleJS `Sound` with an + * array) for what the packs have no sound for: the blast, a bomb set down, the ICBM's launch. + * Vibration only where the browser has it (`navigator.vibrate`: Android, not iPhone). Both can + * be turned off in the menu, remembered in this browser. Only the game makes them: the demo and + * the clips play silent. + */ +const URLS = import.meta.glob<string>(['../../assets/kenney/sounds/*.mp3', '../../assets/kenney-ui/Sounds/*.mp3'], { + eager: true, + query: '?url', + import: 'default', +}) + +function file(name: string): string { + const url = URLS[`../../assets/kenney/sounds/${name}.mp3`] ?? URLS[`../../assets/kenney-ui/Sounds/${name}.mp3`] + if (!url) throw new Error(`Missing sound: ${name}.mp3`) + return url +} + +type SoundName = keyof typeof SOUND_SOURCES + +const SOUND_SOURCES = { + coin: () => file('sfx_coin'), + gem: () => file('sfx_gem'), + charge: () => file('sfx_select'), + enemyDied: () => file('sfx_disappear'), + hurt: () => file('sfx_hurt'), + bump: () => file('sfx_bump'), + jump: () => file('sfx_jump'), + cleared: () => file('sfx_jump-high'), + magic: () => file('sfx_magic'), + thrown: () => file('sfx_throw'), + tap: () => file('tap-a'), + switch: () => file('switch-a'), + // ZzFX: volume, randomness, frequency, attack, sustain, release, shape (4 noise), shape curve, slide… + boom: () => [1.2, 0.1, 60, 0.01, 0.12, 0.7, 4, 1.4, 0, 0, 0, 0, 0, 1, 0, 0.5, 0, 0.5, 0.1], + bombPlaced: () => [0.6, 0, 160, 0, 0.02, 0.09, 1, 2, -12], + launch: () => [0.7, 0.05, 220, 0.05, 0.25, 0.3, 4, 1, 12], +} satisfies Record<string, () => string | number[]> + +const SETTINGS_KEY = 'explodorama.feedback' + +export interface FeedbackSettings { + sound: boolean + vibration: boolean +} + +let settings: FeedbackSettings = loadSettings() +let sounds: Partial<Record<SoundName, Sound>> = {} + +function loadSettings(): FeedbackSettings { + try { + const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? '{}') as Partial<FeedbackSettings> + return { sound: saved.sound !== false, vibration: saved.vibration !== false } + } catch { + return { sound: true, vibration: true } + } +} + +export function feedbackSettings(): FeedbackSettings { + return { ...settings } +} + +export function setFeedbackSettings(next: FeedbackSettings): void { + settings = { ...next } + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)) + } catch { + // Not remembered (private browsing): it still holds until the page closes. + } +} + +/** Whether this browser can vibrate at all; the menu only offers the switch where it can. */ +export function canVibrate(): boolean { + return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function' +} + +/** Loads every sound; after LittleJS has started (it makes the audio context). */ +export function initFeedback(): void { + sounds = Object.fromEntries(Object.entries(SOUND_SOURCES).map(([name, source]) => [name, new Sound(source())])) as typeof sounds + // Every button of the UI clicks (the action bar, the dialogs, the banner). + document.addEventListener('click', (e) => { + if ((e.target as Element | null)?.closest?.('button')) play('tap', 0.5) + }) +} + +export function play(name: SoundName, volume = 1, pitch = 1, delay = 0): void { + if (!settings.sound) return + const sound = sounds[name] + if (!sound) return + if (delay > 0) window.setTimeout(() => settings.sound && sound.play(undefined, volume, pitch), delay * 1000) + else sound.play(undefined, volume, pitch) +} + +export function vibrate(pattern: number | number[], delay = 0): void { + if (!settings.vibration || !canVibrate()) return + const buzz = () => { + // False: the browser refused (no tap on the page yet, or vibration blocked); said in the + // console, which `npm run dev` prints in its terminal, since a phone shows nothing. + if (settings.vibration && !navigator.vibrate(pattern)) console.warn(`navigator.vibrate(${JSON.stringify(pattern)}) was refused`) + } + if (delay > 0) window.setTimeout(buzz, delay * 1000) + else buzz() +} + +/** + * The sounds and buzzes of a turn the player played, each timed with its animation: a blast, and + * what it kills, when it shows (an ICBM's when it lands: `fx.blastAt`); a pickup when the walk + * there ends. Several blasts in one turn (a chain) make one louder boom, not a pile of them. + */ +export function turnFeedback(events: readonly GameEvent[], action: Action, anim: Animation): void { + const boom = Math.max(0, anim.fx.blastAt - time) + const arrive = action.id === 'move' || action.id === 'diagonal' || action.id === 'kick' ? WALK_SECONDS * 0.8 : 0 + const blasts = events.filter((e) => e.type === 'exploded').length + if (blasts) { + play('boom', Math.min(1.6, 0.9 + 0.15 * blasts), 1, boom) + vibrate(Math.min(90, 30 + 15 * blasts), boom) + } + if (events.some((e) => e.type === 'enemyDied')) play('enemyDied', 0.7, 1, boom + 0.05) + let pickedCoins = false + let pickedCharge = false + for (const e of events) { + switch (e.type) { + case 'bombPlaced': play('bombPlaced'); break + case 'bombKicked': play('bump'); break + case 'bombFired': play(action.id === 'icbm' ? 'launch' : 'thrown'); break + case 'moved': + if (action.id === 'run' || action.id === 'slide') play('jump', 0.5) + else if (action.id === 'teleport') play('magic', 0.7, 1.25) + break + case 'pickedUp': + if (e.drop.kind === 'coins') pickedCoins = true + else pickedCharge = true + break + case 'powerUpTaken': play('gem', 1, 1, arrive); vibrate(15, arrive); break + case 'powerUpChosen': play('switch'); break + case 'bought': play('coin', 1, 0.8); break + case 'rerolled': play('switch'); break + case 'exitOpened': play('magic', 0.8, 0.8, boom); break + case 'shieldBroke': play('bump', 1, 0.7, boom); vibrate(40, boom); break + case 'enemiesSpawned': play('hurt', 0.8, 0.5, boom); break + case 'decoyPlaced': play('charge'); break + case 'playerDied': play('hurt', 1, 1, boom); vibrate([60, 40, 120], boom); break + case 'levelCleared': play('cleared', 1, 1, arrive); vibrate([20, 40, 20], arrive); break + case 'runWon': play('gem', 1, 1.2, arrive + 0.35); break + } + } + if (pickedCoins) { play('coin', 1, 1, arrive); vibrate(10, arrive) } + if (pickedCharge) { play('charge', 1, 1, arrive); vibrate(10, arrive) } +} diff --git a/src/ui/fps.ts b/src/ui/fps.ts new file mode 100644 index 0000000..a61844e --- /dev/null +++ b/src/ui/fps.ts @@ -0,0 +1,52 @@ +import { getCanvasPixelRatio, mainCanvas } from 'littlejsengine' + +/** + * A frame-rate meter in the bottom left corner (user request, 2026-10-24: the game crawled in + * Android Chrome). Shown with `?fps` in the address (in `npm run dev` too until the user turned it + * off there, same day). It says the frames a second, the slowest frame of the last half second, + * how long the game's own update and drawing took per frame (the rest of a slow frame is the + * browser: style, layout, painting, the GPU), and the canvas's size in pixels with the screen's + * pixel ratio. + */ +const SHOW = new URLSearchParams(location.search).has('fps') +const EVERY_MS = 500 + +let meter: HTMLElement | null = null +let frames = 0 +let since = performance.now() +let lastFrame = since +let slowest = 0 +let gameMs = 0 + +/** Wraps an engine callback, so the time spent in it counts as the game's own. */ +export function timed(callback: () => void): () => void { + if (!SHOW) return callback + return () => { + const start = performance.now() + callback() + gameMs += performance.now() - start + } +} + +/** Called once a frame, after drawing. */ +export function countFrame(): void { + if (!SHOW) return + if (!meter) { + meter = document.createElement('div') + meter.id = 'fps' + document.body.append(meter) + } + const now = performance.now() + slowest = Math.max(slowest, now - lastFrame) + lastFrame = now + frames++ + if (now - since < EVERY_MS) return + const seconds = (now - since) / 1000 + meter.textContent = + `${Math.round(frames / seconds)} fps · slowest ${Math.round(slowest)} ms · game ${(gameMs / frames).toFixed(1)} ms\n` + + `${mainCanvas.width}×${mainCanvas.height} px · ×${getCanvasPixelRatio().toFixed(2)}` + frames = 0 + slowest = 0 + gameMs = 0 + since = now +} diff --git a/src/ui/info.ts b/src/ui/info.ts index 2053839..28478f1 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -1,6 +1,6 @@ import { BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE, teleportRange } from '../sim/actions' import { LAST_LEVEL } from '../sim/levelgen' -import { bombAt, cellAt, eq, exitOpen, isBurning, PAR_BONUS } from '../sim/state' +import { bombAt, cellAt, eq, exitOpen, isBurning, CLEAR_BONUS, PAR_BONUS } from '../sim/state' import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn' import { SHOP_EVERY } from '../sim/shop' import type { ActionId, ChargeId, Death, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types' @@ -118,7 +118,7 @@ export function deathLine(level: number, d: Death | null): string { /** One line per mode the run has on (none in a plain run). */ export function modeLines(s: GameState): string[] { const lines: string[] = [] - if (s.options.dev) lines.push('Developer mode: an extra, rainbow gem lies right next to you at the start of every level, offering every power-up you don’t have yet and every charge. The level’s own gem is still hidden under a crate. Achievements still count, but are marked with a red border.') + if (s.options.dev) lines.push('Developer mode: a special gem lies next to you on every level.') if (s.options.immortal) lines.push('Immortal: nothing can kill you. You can play through to the end, but the run earns no achievements.') return lines } @@ -161,14 +161,14 @@ export function hudInfo(item: HudItem, s: GameState): Info { return { icon: 'hud_coin', title: `Coins: ${s.coins}`, - body: [`From enemies, coin bags, and finishing a level under par. Spend them in the shop, which opens after every ${SHOP_EVERY}rd level.`], + body: [`From enemies, coin bags, and finishing a level: ${CLEAR_BONUS} for each, ${PAR_BONUS} more under par. Spend them in the shop, which opens after every ${SHOP_EVERY}rd level.`], clip: 'coins', } case 'turn': return { icon: 'hud_character_0', title: `Turn ${s.turn}, par ${s.par}`, - body: [`Turns taken on this level. Finish it in ${s.par} turns or fewer for a bonus of ${PAR_BONUS} coins.`], + body: [`Turns taken on this level. Finish it in ${s.par} turns or fewer for ${PAR_BONUS} bonus coins (on top of the ${CLEAR_BONUS} for clearing it).`], } case 'bombs': return { diff --git a/test/enemies.test.ts b/test/enemies.test.ts index 622067f..919f04d 100644 --- a/test/enemies.test.ts +++ b/test/enemies.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { ACTIONS, FREEZE_TURNS, isAvailable } from '../src/sim/actions' import { LASTING_CHARGES, PLAN_AHEAD, planIntents, rollDrops, stepToward } from '../src/sim/enemies' import { enemyCount, enemyKinds, generateLevel, SAFE_DISTANCE, START } from '../src/sim/levelgen' -import { newRun, PAR_BONUS } from '../src/sim/state' +import { CLEAR_BONUS, newRun, PAR_BONUS } from '../src/sim/state' import { EXIT_SPAWN, step } from '../src/sim/turn' import type { GameState, Vec } from '../src/sim/types' import { board, v } from './helpers' @@ -375,8 +375,8 @@ describe('the exit', () => { s = r.state const cleared = step(s, { id: 'move', target: v(2, 1) }) expect(cleared.state.status).toBe('cleared') - expect(cleared.state.coins).toBe(PAR_BONUS) - expect(cleared.events.at(-1)).toEqual({ type: 'levelCleared', level: 1, turns: 7, parBonus: PAR_BONUS }) + expect(cleared.state.coins).toBe(CLEAR_BONUS + PAR_BONUS) + expect(cleared.events.at(-1)).toEqual({ type: 'levelCleared', level: 1, turns: 7, clearBonus: CLEAR_BONUS, parBonus: PAR_BONUS }) }) it('no par bonus over par', () => { @@ -386,7 +386,7 @@ describe('the exit', () => { ####`), turn: 100, par: 50 } const { state } = step(s, { id: 'move', target: v(2, 1) }) expect(state.status).toBe('cleared') - expect(state.coins).toBe(0) + expect(state.coins).toBe(CLEAR_BONUS) }) it('continue after clearing starts the next level, carrying lives, stats, coins and charges', () => { @@ -398,7 +398,7 @@ describe('the exit', () => { const cleared = step(s, { id: 'move', target: v(2, 1) }).state const { state, events } = step(cleared, { id: 'continue' }) expect(events).toEqual([{ type: 'levelStarted', level: 2 }]) - expect(state).toMatchObject({ level: 2, turn: 0, lives: 2, coins: 4 + PAR_BONUS, status: 'playing' }) + expect(state).toMatchObject({ level: 2, turn: 0, lives: 2, coins: 4 + CLEAR_BONUS + PAR_BONUS, status: 'playing' }) expect(state.stats.fire).toBe(3) expect(state.boosts.pierce).toBe(2) expect(state.enemies.length).toBe(enemyCount(2)) diff --git a/test/shop.test.ts b/test/shop.test.ts index 5256f94..26574a9 100644 --- a/test/shop.test.ts +++ b/test/shop.test.ts @@ -64,14 +64,14 @@ describe('the shop', () => { expect(() => step(s, { id: 'buy', kind: s.shop!.items[0]! })).toThrow() }) - it('restocks for 2 coins, then 3, 4…, with a new draw', () => { + it('restocks for 1 coin, then 2, 3…, with a new draw', () => { let s = step(cleared(3, 20), { id: 'continue' }).state - expect(rerollPrice(s.shop!)).toBe(2) + expect(rerollPrice(s.shop!)).toBe(1) s = step(s, { id: 'reroll' }).state - expect(s.coins).toBe(18) - expect(rerollPrice(s.shop!)).toBe(3) + expect(s.coins).toBe(19) + expect(rerollPrice(s.shop!)).toBe(2) s = step(s, { id: 'reroll' }).state - expect(s.coins).toBe(15) + expect(s.coins).toBe(17) expect(s.shop!.items).toEqual(drawShop(s, 2)) })