explodorama / commit
Re-style with Kenney's New Platformer Pack
PLAN.md | 159 +++++++++++---------------- README.md | 29 +++-- assets/kenney/License.txt | 28 +++++ assets/kenney/block_blue.png | Bin 0 -> 803 bytes assets/kenney/block_green.png | Bin 0 -> 803 bytes assets/kenney/block_planks.png | Bin 0 -> 2472 bytes assets/kenney/block_red.png | Bin 0 -> 815 bytes assets/kenney/block_yellow.png | Bin 0 -> 803 bytes assets/kenney/bomb.png | Bin 0 -> 1952 bytes assets/kenney/bomb_active.png | Bin 0 -> 1978 bytes assets/kenney/character_green_climb_a.png | Bin 0 -> 3193 bytes assets/kenney/character_green_climb_b.png | Bin 0 -> 3164 bytes assets/kenney/character_green_duck.png | Bin 0 -> 3136 bytes assets/kenney/character_green_front.png | Bin 0 -> 3349 bytes assets/kenney/character_green_hit.png | Bin 0 -> 3447 bytes assets/kenney/character_green_idle.png | Bin 0 -> 3295 bytes assets/kenney/character_green_jump.png | Bin 0 -> 3382 bytes assets/kenney/character_green_walk_a.png | Bin 0 -> 3329 bytes assets/kenney/character_green_walk_b.png | Bin 0 -> 3421 bytes assets/kenney/coin_bronze.png | Bin 0 -> 2133 bytes assets/kenney/coin_gold.png | Bin 0 -> 2164 bytes assets/kenney/coin_silver.png | Bin 0 -> 2008 bytes assets/kenney/door_closed.png | Bin 0 -> 659 bytes assets/kenney/door_closed_top.png | Bin 0 -> 2066 bytes assets/kenney/door_open.png | Bin 0 -> 141 bytes assets/kenney/door_open_top.png | Bin 0 -> 1692 bytes assets/kenney/fireball.png | Bin 0 -> 2485 bytes assets/kenney/flag_green_a.png | Bin 0 -> 1291 bytes assets/kenney/fly_a.png | Bin 0 -> 3376 bytes assets/kenney/fly_b.png | Bin 0 -> 3098 bytes assets/kenney/fly_rest.png | Bin 0 -> 3342 bytes assets/kenney/frog_idle.png | Bin 0 -> 3331 bytes assets/kenney/frog_jump.png | Bin 0 -> 2632 bytes assets/kenney/frog_rest.png | Bin 0 -> 2486 bytes assets/kenney/gem_blue.png | Bin 0 -> 1436 bytes assets/kenney/gem_green.png | Bin 0 -> 1421 bytes assets/kenney/gem_red.png | Bin 0 -> 1434 bytes assets/kenney/gem_yellow.png | Bin 0 -> 1394 bytes assets/kenney/heart.png | Bin 0 -> 1226 bytes assets/kenney/hud_character_0.png | Bin 0 -> 1149 bytes assets/kenney/hud_character_1.png | Bin 0 -> 821 bytes assets/kenney/hud_character_2.png | Bin 0 -> 1110 bytes assets/kenney/hud_character_3.png | Bin 0 -> 1174 bytes assets/kenney/hud_character_4.png | Bin 0 -> 775 bytes assets/kenney/hud_character_5.png | Bin 0 -> 1106 bytes assets/kenney/hud_character_6.png | Bin 0 -> 1159 bytes assets/kenney/hud_character_7.png | Bin 0 -> 1058 bytes assets/kenney/hud_character_8.png | Bin 0 -> 1164 bytes assets/kenney/hud_character_9.png | Bin 0 -> 1164 bytes assets/kenney/hud_coin.png | Bin 0 -> 2407 bytes assets/kenney/hud_heart.png | Bin 0 -> 1685 bytes assets/kenney/hud_heart_empty.png | Bin 0 -> 1683 bytes assets/kenney/lock_yellow.png | Bin 0 -> 1450 bytes assets/kenney/mouse_rest.png | Bin 0 -> 2033 bytes assets/kenney/mouse_walk_a.png | Bin 0 -> 2155 bytes assets/kenney/mouse_walk_b.png | Bin 0 -> 2177 bytes assets/kenney/saw_a.png | Bin 0 -> 3048 bytes assets/kenney/saw_b.png | Bin 0 -> 3324 bytes assets/kenney/saw_rest.png | Bin 0 -> 2933 bytes assets/kenney/slime_block_jump.png | Bin 0 -> 1350 bytes assets/kenney/slime_block_rest.png | Bin 0 -> 1218 bytes assets/kenney/slime_block_walk_a.png | Bin 0 -> 1340 bytes assets/kenney/slime_block_walk_b.png | Bin 0 -> 1333 bytes assets/kenney/slime_fire_flat.png | Bin 0 -> 2109 bytes assets/kenney/slime_fire_rest.png | Bin 0 -> 2719 bytes assets/kenney/slime_fire_walk_a.png | Bin 0 -> 2830 bytes assets/kenney/slime_fire_walk_b.png | Bin 0 -> 2769 bytes assets/kenney/slime_normal_flat.png | Bin 0 -> 1076 bytes assets/kenney/slime_normal_rest.png | Bin 0 -> 1565 bytes assets/kenney/slime_normal_walk_a.png | Bin 0 -> 1678 bytes assets/kenney/slime_normal_walk_b.png | Bin 0 -> 1645 bytes assets/kenney/star.png | Bin 0 -> 2369 bytes assets/kenney/terrain_stone_block_center.png | Bin 0 -> 920 bytes decisions.md | 22 +++- docs/screenshots/bomb-picked.png | Bin 35985 -> 173420 bytes docs/screenshots/countdown.png | Bin 38812 -> 178477 bytes docs/screenshots/death.png | Bin 38055 -> 165699 bytes docs/screenshots/desktop.png | Bin 58710 -> 569343 bytes docs/screenshots/scrolled.png | Bin 35250 -> 151868 bytes docs/screenshots/start.png | Bin 35171 -> 172573 bytes index.html | 5 +- src/main.ts | 11 +- src/render/board.ts | 72 +++++++----- src/render/sprites.ts | 57 ++++++++++ src/style.css | 133 +++++++++++++++++----- src/ui/controls.ts | 30 ++++- vite.config.ts | 4 +- 87 files changed, 382 insertions(+), 168 deletions(-) diff --git a/PLAN.md b/PLAN.md index afe9c7a..0c583eb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -26,7 +26,7 @@ turn), §15 (notes for the implementer) and §18 (milestones). Reasons for decis | 10 | Curses | **Exist, but only appear after the first shop** (level 2 onward). See §6. | | 11 | Reapers / turn limit | **Later** (post-MVP). The par-turn coin bonus stays. | | 12 | Run length | **50 levels with an ending**, like the NES. A win produces a **victory certificate with a QR code** for that seed. See §11. | -| 13 | Art | **No final art yet.** Use one consistent icon set: **Icongeek26, "Outline" style, from Flaticon** (black line on transparent PNG), with attribution — the same artist the user's other projects use. See §13. | +| 13 | Art | **Kenney's New Platformer Pack 1.1** (CC0): sprites for the player, enemies, board, pickups, HUD and buttons. *Changed 2026-10-20 at the user's request; it replaces the earlier plan of Icongeek26 Flaticon icons.* See §13. | Still open: see §14. @@ -350,85 +350,59 @@ itself, because the sim is deterministic: ## 13. Look and icons -**LittleJS ships no icon set** (its npm package contains only a small built-in font image), -so the game uses Flaticon icons: - -- **Artist and style:** Icongeek26, "Outline" style: - <https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard>. - Black line art on a transparent background, downloaded as PNG (512 px). -- **Licence:** Flaticon free licence, which **requires attribution**. Follow `nordic-skating`: - credit in the README (Attribution section) and in an in-game About/Credits dialog — - "Icons by [Icongeek26](https://www.flaticon.com/authors/icongeek26), from Flaticon." - The icons are not covered by the project's own licence; say so in the README. -- **Who downloads them:** the user, by hand (Flaticon needs a browser session). Until a file is - present, the game draws a labelled placeholder shape, and a check (`npm run icons:check` or a - test) lists missing icons by name. -- **Source files:** `assets/icons/<name>.png`, names from the table below. -- **Atlas:** a `tools/atlas.mjs` script packs them into one `static/atlas.png` (e.g. 128 px - cells) plus a generated `src/render/atlas.ts` (name → tile index), so LittleJS loads a single - image. Use Playwright for image work, as `nordic-skating/tools/icons.mjs` does — no extra - image library. -- **Colouring:** the icons are black. LittleJS's `drawTile(pos, size, tileInfo, color, angle, - mirror, additiveColor)` (confirmed in the type definitions of the pinned 1.25.0) can turn them any colour: - black ink + `additiveColor` = that colour, and transparency is kept. The DOM action bar uses the - same PNGs as a CSS `mask` over a coloured background (the `nordic-skating` technique). -- **Board look:** walls, pillars, soft blocks and floor are plain drawn shapes, not icons. - Icons are for things: player, enemies, bombs, pickups, drops, curses, door, and actions. - Colour shows meaning (player blue, enemies red, pickups green, coins gold, curses purple, - danger red overlay), but **shape alone must already tell them apart** — never colour only. -- **PWA icons and favicons:** generated from one chosen icon (bomb) by `npm run icons`, copied - from `nordic-skating/tools/icons.mjs`. - -Icon list (search terms are suggestions; any Icongeek26 Outline icon that reads clearly works): - -| Name (`assets/icons/<name>.png`) | Used for | Search for | -|---|---|---| -| player | the player | robot / astronaut / person | -| bomb | bombs, Place bomb, app icon | bomb | -| explosion | flame (animation) | explosion / fire | -| door | exit (open) | door | -| lock | exit (closed) | padlock | -| coin | coins | coin | -| coin-bag | Coin bag pickup | money bag | -| heart | Heart pickup, lives in HUD | heart | -| bomb-plus | Bomb +1 | bombs / plus | -| fire | Fire +1 | fire / flame | -| fuse | Fuse +1, Fuse ± action | timer / stopwatch | -| skates | Skates, Run | roller skate | -| wall-pass | Wall Pass | ghost / wall | -| bomb-pass | Bomb Pass | jump / step over | -| flame-pass | Flame Pass | fireproof / fire shield | -| detonator | Detonator, Detonate | remote control | -| star | Star | star | -| skull | curses | skull | -| move | Move | footprints | -| wait | Wait | hourglass | -| kick | Kick | boot / kick | -| throw | Throw | throw / hand | -| shield | Shield | shield | -| diagonal | Diagonal step | diagonal arrow | -| teleport | Teleport | portal / teleport | -| pierce | Pierce bomb | drill | -| power | Power bomb | lightning | -| line | Line bomb | line / row | -| freeze | Freeze | snowflake | -| decoy | Decoy | scarecrow / dummy | -| cleanse | Cleanse | potion / sparkle | -| magnet | Magnet | magnet | -| lucky | Lucky | clover | -| map | Map | treasure map | -| shop | shop | shopping cart / store | -| workshop | Workshop | wrench / toolbox | -| trophy | ending, certificate | trophy / medal | -| enemy-blob | Blob | balloon / slime | -| enemy-seeker | Seeker | eye | -| enemy-drifter | Drifter | spinning top | -| enemy-sprinter | Sprinter | lightning bug / rocket | -| enemy-ghost | Ghost | ghost | -| enemy-wisp | Wisp | flame spirit / jellyfish | -| enemy-hunter | Hunter | spider | -| enemy-reaper | Reaper (later) | grim reaper / scythe | -| settings, sound-on, sound-off, info | UI | gear, speaker, speaker off, info | +*Changed 2026-10-20 at the user's request.* The game uses **Kenney's New Platformer Pack 1.1** +(<https://kenney.nl/assets/new-platformer-pack>), which the user downloaded. It replaces the earlier +plan of black Icongeek26 outline icons from Flaticon over a plain drawn board. + +- **Licence:** CC0, so attribution isn't required, but **the user wants Kenney credited + properly**: in the README's Attribution section and in the game (a credit line with a link to + kenney.nl on the death / game-over banner; move it to the About dialog when there is one). The + pack's `License.txt` is kept in `assets/kenney/`. +- **Source files:** only the sprites the game uses (or is about to use, below) are copied, unchanged + and under the pack's own file names, into `assets/kenney/`, so each can be found again in the + pack. All are 128 × 128: tiles and enemies from the pack's `Double` folders, characters from + `Default` (which are already 128 px). +- **Loading:** no atlas. `src/render/sprites.ts` lists the board's sprites; each is its own LittleJS + texture (`engineInit`'s image list) and is drawn with `drawTile`. With the Canvas 2D renderer + separate images cost nothing measurable. `setTilesPixelated(false)`, since the art is smooth. + The HTML HUD and buttons use the same PNGs as `<img>`. Vite keeps every sprite a separate file + (`assetsInlineLimit: 0`), so art for things not in the game yet is never downloaded. +- **Board look:** sprites everywhere. Colour still shows meaning, but **shape alone must already + tell things apart** — never colour only. +- **HUD:** the pack's HUD digits for numbers, hearts (full/empty) for lives. +- **Sounds:** the pack has 10 `.ogg` effects (`sfx_bump`, `coin`, `disappear`, `gem`, `hurt`, + `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). + +Sprite assignments (✅ = drawn now; the rest are copied and waiting for their feature): + +| Thing | Sprite(s) in `assets/kenney/` | +|---|---| +| ✅ player | `character_green_idle`; `character_green_hit` when dead; mirrored when facing left. Other green frames (walk, jump, duck, climb, front) are there for animation. | +| ✅ outer wall | `terrain_stone_block_center` | +| ✅ pillars | `block_blue`, `block_green`, `block_red`, `block_yellow`, cycling by level | +| ✅ soft block | `block_planks` (wooden crate) | +| ✅ bomb | `bomb`; flashes to `bomb_active` on its last turn; timer drawn in `hud_character_0`–`9` | +| ✅ flame | `fireball` (spinning, fading) over an orange tile | +| ✅ HUD | `hud_heart` / `hud_heart_empty` (lives), `flag_green_a` (level), `bomb`, `fireball`, digits | +| ✅ action buttons | Move `character_green_walk_a`, Bomb `bomb`, Wait `character_green_duck` | +| ✅ banners | `character_green_hit` (lost a life), `hud_heart_empty` (game over); favicon `bomb` | +| exit (closed / open) | `door_closed` + `door_closed_top`, `door_open` + `door_open_top`; `lock_yellow` | +| coins | `coin_gold`, `coin_silver`, `coin_bronze`; `hud_coin` in the HUD | +| pickups (power-ups) | gems: `gem_red` Fire +1, `gem_blue` Bomb +1, `gem_yellow` Fuse +1, `gem_green` Skates (suggested; final mapping when M6 is built); `heart` Heart; `star` Star | +| Blob | `slime_normal_rest`, `_walk_a`, `_walk_b`, `_flat` (squashed when killed) | +| Seeker | `fly_rest`, `fly_a`, `fly_b` | +| Drifter | `saw_rest`, `saw_a`, `saw_b` | +| Sprinter | `mouse_rest`, `mouse_walk_a`, `mouse_walk_b` | +| Ghost (passes soft blocks) | `slime_block_rest`, `_walk_a`, `_walk_b`, `_jump` | +| Wisp | `slime_fire_rest`, `_walk_a`, `_walk_b`, `_flat` | +| Hunter | `frog_rest`, `frog_idle`, `frog_jump` | + +The pack has more enemies (bee, ladybug, snail, worm, barnacle, fish, spiked slime, falling +block) and more tiles (keys, locks, levers, spikes, torches, …) for later enemies, curses and +actions; copy them in when they are used. Icons the pack doesn't have (shop, workshop, trophy, +settings, sound, and most action charges) are still open. --- @@ -449,9 +423,8 @@ Icon list (search terms are suggestions; any Icongeek26 Outline icon that reads plays the same day? *Recommendation:* later milestone, cheap to add. 7. **Certificate strength:** replay-verifiable (§11, recommended; nearly free since the sim is deterministic) or a simple checksum? -8. **Board theme:** light "paper" board with the icons black as drawn (matches the icon style), - or dark board with icons recoloured light? *Recommendation:* light by default, dark as a - setting/OS preference — `additiveColor` makes both cheap. +8. **Board theme:** ~~light "paper" board or dark board?~~ Settled by the Kenney art + (2026-10-20): a pale sky-blue board under dark bars, coloured as the sprites are. --- @@ -512,18 +485,18 @@ src/ workshop.ts tracks, tiers, deposits record.ts run record, replay, hash, SIM_VERSION render/ LittleJS - board.ts shapes, icons, highlights, danger overlay, intents - atlas.ts generated by tools/atlas.mjs + 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 camera.ts ui/ DOM: action bar, HUD, shop, death choice, Workshop, certificate, About - audio.ts ZzFX sound effects (built into LittleJS — no audio files) + audio.ts Kenney pack sound effects (§13); ZzFX for any it lacks persist.ts localStorage save/load, export/import main.ts -assets/icons/ Icongeek26 PNGs (user-downloaded) +assets/kenney/ Kenney New Platformer Pack sprites + License.txt (§13) static/ Vite publicDir (NOT public/ — see §17) test/ Vitest -tools/ atlas.mjs, icons.mjs, screenshots.mjs (Playwright) +tools/ icons.mjs (PWA icons), screenshots.mjs (Playwright) ``` - `step()` **returns events** (`moved`, `bombPlaced`, `bombTicked`, `exploded`, `blockDestroyed`, @@ -538,7 +511,7 @@ tools/ atlas.mjs, icons.mjs, screenshots.mjs (Playwright) ## 17. Tooling and deploy (copy from `~/code/nordic-skating`) - `package.json` scripts: `dev` = `vite --host`, `build` = `tsc --noEmit && vite build`, - `test` = `vitest run`, `screenshots`, `icons`, `atlas`. + `test` = `vitest run`, `screenshots`, `icons`. - Dev deps as in nordic-skating: `typescript`, `vite`, `vitest`, `playwright`, `vite-plugin-qrcode` (scan to open on a phone), `@types/node`. `vite-plugin-pwa` arrives with the icons in M7. @@ -547,10 +520,10 @@ tools/ atlas.mjs, icons.mjs, screenshots.mjs (Playwright) - 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 - (display fullscreen, icons, offline cache incl. `atlas.png`). Read nordic-skating's file — its + (display fullscreen, icons, offline cache incl. the sprites). Read nordic-skating's file — its comments explain each choice. - `.gitlab-ci.yml`: copy nordic-skating's (test stage, then `pages` with `mv dist public` and the - "fail loudly" checks; add a check that `atlas.png` exists). Project at + "fail loudly" checks; add a check that the sprites were built). Project at `gitlab.com/PavelSafronov/explodorama`, served at `https://pavelsafronov.gitlab.io/explodorama/`. - Docs to create and keep current: `README.md` (how to play; every action, pickup, curse, enemy, Workshop track; Attribution), this `PLAN.md`, `decisions.md` (each decision + why — seed it @@ -566,13 +539,13 @@ tools/ atlas.mjs, icons.mjs, screenshots.mjs (Playwright) (enemy phases are marked placeholders), run record + replay, tests. 3. **M2 – Bombs:** ✅ adjacent placement, timers, blast, chains, soft blocks, death, lives. 4. **M3 – Mobile UI:** ✅ action bar, two-tap targeting, highlights, danger overlay, commit preview - (press-and-hold / hover), keyboard controls, placeholder shapes. ⏳ No animation of moves yet + (press-and-hold / hover), keyboard controls; Kenney sprites (2026-10-20). ⏳ No animation of moves yet (instant), only a flame flash. 5. **M4 – World:** ✅ levelgen (31 × 13), side-scrolling camera. ⏳ Exit door, par. 6. **M5 – Enemies:** Blob, Seeker, Drifter first; intents, drops, charge inventory, charge actions. 7. **M6 – Run loop:** NES pickups, coins, shop after each level, Keep/Fresh death choice, curses from level 2, 50-level progression and ending, mid-run save. -8. **M7 – Look & feel:** icon atlas + attribution, animations, ZzFX sounds, PWA icons, remaining +8. **M7 – Look & feel:** animations, 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 4fcfa59..ca216cb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Enemies, pickups, the shop and the 50-level run are planned — see [PLAN.md](PL ## How to play -You are the blue circle. Each turn you pick **one** action; then the world takes its turn. +You are the green astronaut. Each turn you pick **one** action; then the world takes its turn. | Action | Phone | Keyboard | What it does | |---|---|---|---| @@ -28,9 +28,9 @@ You are the blue circle. Each turn you pick **one** action; then the world takes ### Bombs - The number on a bomb is **how many more of your actions until it explodes**. A new bomb - shows **3**. A bomb showing **1** (red, pulsing) explodes at the end of your next action. + shows **3**. A bomb showing **1** (flashing white) explodes at the end of your next action. - The blast is a cross reaching **1 tile** in each direction. It stops at walls; it destroys - the first soft block (brown) in each direction and stops there. + the first soft block (wooden crate) in each direction and stops there. - A bomb caught in a blast **explodes at once**, whatever its number (a chain reaction). - Bombs block movement. @@ -46,8 +46,9 @@ layout. With none left, the run is over and the seed is shown, so the same board ### The board -31 × 13 tiles, like the original: dark walls and pillars (indestructible), brown soft blocks -(destructible), and the camera follows you sideways. +31 × 13 tiles, like the original: a stone outer wall and metal pillars (indestructible; the +pillars change colour from level to level), wooden crates as soft blocks (destructible), and the +camera follows you sideways. ## Running it @@ -71,7 +72,9 @@ Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch cannot disagree with what happens. - **`src/render/`** — draws the board with [LittleJS](https://github.com/KilledByAPixel/LittleJS) (pinned to an exact version, using its Canvas 2D renderer). -- **`src/ui/`** — the HUD, action bar and banners, as plain HTML. +- **`src/render/sprites.ts`** — the sprite list. The art lives in `assets/kenney/` under the + pack's own file names. +- **`src/ui/`** — the HUD, action bar and banners, as plain HTML, using the same sprites as icons. - Every run is recorded as its seed plus the list of actions, which replays to the identical state. This will become mid-run saves, replays and victory certificates. @@ -79,10 +82,16 @@ Design and decisions: [PLAN.md](PLAN.md), [decisions.md](decisions.md). ## Attribution -No third-party artwork is used yet. The planned icons are by -[Icongeek26](https://www.flaticon.com/authors/icongeek26), from Flaticon, under the Flaticon -free licence, which requires attribution; they will be credited here and in the game, and will -not be covered by this project's licence. +Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1 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 (on the banner shown +after each death). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>. + +The sprites used are copied, unchanged and under their original file names, into +[`assets/kenney/`](assets/kenney/) with the pack's [licence file](assets/kenney/License.txt). They +are not covered by this project's MIT licence. Sound effects, when the game gets them, will come +from the same pack. ## Licence diff --git a/assets/kenney/License.txt b/assets/kenney/License.txt new file mode 100644 index 0000000..2346704 --- /dev/null +++ b/assets/kenney/License.txt @@ -0,0 +1,28 @@ + + + New Platformer Pack (1.1) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 03-12-2025 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + You can use this content for personal, educational, and commercial purposes. + + Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement) + + ------------------------------ + + • Website : www.kenney.nl + • Donate : www.kenney.nl/donate + + • Patreon : patreon.com/kenney + + Follow on social media for updates: + + • Twitter: twitter.com/KenneyNL + • BlueSky: kenney.bsky.social + • Instagram: instagram.com/kenney_nl \ No newline at end of file diff --git a/assets/kenney/block_blue.png b/assets/kenney/block_blue.png new file mode 100644 index 0000000..2cd5345 Binary files /dev/null and b/assets/kenney/block_blue.png differ diff --git a/assets/kenney/block_green.png b/assets/kenney/block_green.png new file mode 100644 index 0000000..fe56e2f Binary files /dev/null and b/assets/kenney/block_green.png differ diff --git a/assets/kenney/block_planks.png b/assets/kenney/block_planks.png new file mode 100644 index 0000000..0af3e4a Binary files /dev/null and b/assets/kenney/block_planks.png differ diff --git a/assets/kenney/block_red.png b/assets/kenney/block_red.png new file mode 100644 index 0000000..e9f7fed Binary files /dev/null and b/assets/kenney/block_red.png differ diff --git a/assets/kenney/block_yellow.png b/assets/kenney/block_yellow.png new file mode 100644 index 0000000..3115c5c Binary files /dev/null and b/assets/kenney/block_yellow.png differ diff --git a/assets/kenney/bomb.png b/assets/kenney/bomb.png new file mode 100644 index 0000000..f902149 Binary files /dev/null and b/assets/kenney/bomb.png differ diff --git a/assets/kenney/bomb_active.png b/assets/kenney/bomb_active.png new file mode 100644 index 0000000..dacff54 Binary files /dev/null and b/assets/kenney/bomb_active.png differ diff --git a/assets/kenney/character_green_climb_a.png b/assets/kenney/character_green_climb_a.png new file mode 100644 index 0000000..d66a8bb Binary files /dev/null and b/assets/kenney/character_green_climb_a.png differ diff --git a/assets/kenney/character_green_climb_b.png b/assets/kenney/character_green_climb_b.png new file mode 100644 index 0000000..540e88b Binary files /dev/null and b/assets/kenney/character_green_climb_b.png differ diff --git a/assets/kenney/character_green_duck.png b/assets/kenney/character_green_duck.png new file mode 100644 index 0000000..db945f2 Binary files /dev/null and b/assets/kenney/character_green_duck.png differ diff --git a/assets/kenney/character_green_front.png b/assets/kenney/character_green_front.png new file mode 100644 index 0000000..996dfae Binary files /dev/null and b/assets/kenney/character_green_front.png differ diff --git a/assets/kenney/character_green_hit.png b/assets/kenney/character_green_hit.png new file mode 100644 index 0000000..eee3c53 Binary files /dev/null and b/assets/kenney/character_green_hit.png differ diff --git a/assets/kenney/character_green_idle.png b/assets/kenney/character_green_idle.png new file mode 100644 index 0000000..49562df Binary files /dev/null and b/assets/kenney/character_green_idle.png differ diff --git a/assets/kenney/character_green_jump.png b/assets/kenney/character_green_jump.png new file mode 100644 index 0000000..79f990c Binary files /dev/null and b/assets/kenney/character_green_jump.png differ diff --git a/assets/kenney/character_green_walk_a.png b/assets/kenney/character_green_walk_a.png new file mode 100644 index 0000000..547fd6d Binary files /dev/null and b/assets/kenney/character_green_walk_a.png differ diff --git a/assets/kenney/character_green_walk_b.png b/assets/kenney/character_green_walk_b.png new file mode 100644 index 0000000..c2a1d3f Binary files /dev/null and b/assets/kenney/character_green_walk_b.png differ diff --git a/assets/kenney/coin_bronze.png b/assets/kenney/coin_bronze.png new file mode 100644 index 0000000..3ebb2ed Binary files /dev/null and b/assets/kenney/coin_bronze.png differ diff --git a/assets/kenney/coin_gold.png b/assets/kenney/coin_gold.png new file mode 100644 index 0000000..8724da8 Binary files /dev/null and b/assets/kenney/coin_gold.png differ diff --git a/assets/kenney/coin_silver.png b/assets/kenney/coin_silver.png new file mode 100644 index 0000000..590e9e4 Binary files /dev/null and b/assets/kenney/coin_silver.png differ diff --git a/assets/kenney/door_closed.png b/assets/kenney/door_closed.png new file mode 100644 index 0000000..93a02c6 Binary files /dev/null and b/assets/kenney/door_closed.png differ diff --git a/assets/kenney/door_closed_top.png b/assets/kenney/door_closed_top.png new file mode 100644 index 0000000..50c804d Binary files /dev/null and b/assets/kenney/door_closed_top.png differ diff --git a/assets/kenney/door_open.png b/assets/kenney/door_open.png new file mode 100644 index 0000000..b9c6f56 Binary files /dev/null and b/assets/kenney/door_open.png differ diff --git a/assets/kenney/door_open_top.png b/assets/kenney/door_open_top.png new file mode 100644 index 0000000..f0d4e1d Binary files /dev/null and b/assets/kenney/door_open_top.png differ diff --git a/assets/kenney/fireball.png b/assets/kenney/fireball.png new file mode 100644 index 0000000..4133e6a Binary files /dev/null and b/assets/kenney/fireball.png differ diff --git a/assets/kenney/flag_green_a.png b/assets/kenney/flag_green_a.png new file mode 100644 index 0000000..2b08602 Binary files /dev/null and b/assets/kenney/flag_green_a.png differ diff --git a/assets/kenney/fly_a.png b/assets/kenney/fly_a.png new file mode 100644 index 0000000..7a1bb9b Binary files /dev/null and b/assets/kenney/fly_a.png differ diff --git a/assets/kenney/fly_b.png b/assets/kenney/fly_b.png new file mode 100644 index 0000000..5e250d4 Binary files /dev/null and b/assets/kenney/fly_b.png differ diff --git a/assets/kenney/fly_rest.png b/assets/kenney/fly_rest.png new file mode 100644 index 0000000..3c70fe2 Binary files /dev/null and b/assets/kenney/fly_rest.png differ diff --git a/assets/kenney/frog_idle.png b/assets/kenney/frog_idle.png new file mode 100644 index 0000000..de90bef Binary files /dev/null and b/assets/kenney/frog_idle.png differ diff --git a/assets/kenney/frog_jump.png b/assets/kenney/frog_jump.png new file mode 100644 index 0000000..bc63391 Binary files /dev/null and b/assets/kenney/frog_jump.png differ diff --git a/assets/kenney/frog_rest.png b/assets/kenney/frog_rest.png new file mode 100644 index 0000000..a74e18f Binary files /dev/null and b/assets/kenney/frog_rest.png differ diff --git a/assets/kenney/gem_blue.png b/assets/kenney/gem_blue.png new file mode 100644 index 0000000..7cd3ec8 Binary files /dev/null and b/assets/kenney/gem_blue.png differ diff --git a/assets/kenney/gem_green.png b/assets/kenney/gem_green.png new file mode 100644 index 0000000..7ccce2e Binary files /dev/null and b/assets/kenney/gem_green.png differ diff --git a/assets/kenney/gem_red.png b/assets/kenney/gem_red.png new file mode 100644 index 0000000..953761c Binary files /dev/null and b/assets/kenney/gem_red.png differ diff --git a/assets/kenney/gem_yellow.png b/assets/kenney/gem_yellow.png new file mode 100644 index 0000000..1e7acfd Binary files /dev/null and b/assets/kenney/gem_yellow.png differ diff --git a/assets/kenney/heart.png b/assets/kenney/heart.png new file mode 100644 index 0000000..baab2e4 Binary files /dev/null and b/assets/kenney/heart.png differ diff --git a/assets/kenney/hud_character_0.png b/assets/kenney/hud_character_0.png new file mode 100644 index 0000000..3042a55 Binary files /dev/null and b/assets/kenney/hud_character_0.png differ diff --git a/assets/kenney/hud_character_1.png b/assets/kenney/hud_character_1.png new file mode 100644 index 0000000..1ce87b4 Binary files /dev/null and b/assets/kenney/hud_character_1.png differ diff --git a/assets/kenney/hud_character_2.png b/assets/kenney/hud_character_2.png new file mode 100644 index 0000000..344e578 Binary files /dev/null and b/assets/kenney/hud_character_2.png differ diff --git a/assets/kenney/hud_character_3.png b/assets/kenney/hud_character_3.png new file mode 100644 index 0000000..ecd4b1e Binary files /dev/null and b/assets/kenney/hud_character_3.png differ diff --git a/assets/kenney/hud_character_4.png b/assets/kenney/hud_character_4.png new file mode 100644 index 0000000..488f053 Binary files /dev/null and b/assets/kenney/hud_character_4.png differ diff --git a/assets/kenney/hud_character_5.png b/assets/kenney/hud_character_5.png new file mode 100644 index 0000000..0a7687d Binary files /dev/null and b/assets/kenney/hud_character_5.png differ diff --git a/assets/kenney/hud_character_6.png b/assets/kenney/hud_character_6.png new file mode 100644 index 0000000..a3eeb0e Binary files /dev/null and b/assets/kenney/hud_character_6.png differ diff --git a/assets/kenney/hud_character_7.png b/assets/kenney/hud_character_7.png new file mode 100644 index 0000000..2faa35e Binary files /dev/null and b/assets/kenney/hud_character_7.png differ diff --git a/assets/kenney/hud_character_8.png b/assets/kenney/hud_character_8.png new file mode 100644 index 0000000..7a678fb Binary files /dev/null and b/assets/kenney/hud_character_8.png differ diff --git a/assets/kenney/hud_character_9.png b/assets/kenney/hud_character_9.png new file mode 100644 index 0000000..844a6a9 Binary files /dev/null and b/assets/kenney/hud_character_9.png differ diff --git a/assets/kenney/hud_coin.png b/assets/kenney/hud_coin.png new file mode 100644 index 0000000..89c4a53 Binary files /dev/null and b/assets/kenney/hud_coin.png differ diff --git a/assets/kenney/hud_heart.png b/assets/kenney/hud_heart.png new file mode 100644 index 0000000..edc3ca4 Binary files /dev/null and b/assets/kenney/hud_heart.png differ diff --git a/assets/kenney/hud_heart_empty.png b/assets/kenney/hud_heart_empty.png new file mode 100644 index 0000000..9b548d8 Binary files /dev/null and b/assets/kenney/hud_heart_empty.png differ diff --git a/assets/kenney/lock_yellow.png b/assets/kenney/lock_yellow.png new file mode 100644 index 0000000..b3905d7 Binary files /dev/null and b/assets/kenney/lock_yellow.png differ diff --git a/assets/kenney/mouse_rest.png b/assets/kenney/mouse_rest.png new file mode 100644 index 0000000..36bced1 Binary files /dev/null and b/assets/kenney/mouse_rest.png differ diff --git a/assets/kenney/mouse_walk_a.png b/assets/kenney/mouse_walk_a.png new file mode 100644 index 0000000..da39c6a Binary files /dev/null and b/assets/kenney/mouse_walk_a.png differ diff --git a/assets/kenney/mouse_walk_b.png b/assets/kenney/mouse_walk_b.png new file mode 100644 index 0000000..3542beb Binary files /dev/null and b/assets/kenney/mouse_walk_b.png differ diff --git a/assets/kenney/saw_a.png b/assets/kenney/saw_a.png new file mode 100644 index 0000000..98eb775 Binary files /dev/null and b/assets/kenney/saw_a.png differ diff --git a/assets/kenney/saw_b.png b/assets/kenney/saw_b.png new file mode 100644 index 0000000..e49321c Binary files /dev/null and b/assets/kenney/saw_b.png differ diff --git a/assets/kenney/saw_rest.png b/assets/kenney/saw_rest.png new file mode 100644 index 0000000..cd10501 Binary files /dev/null and b/assets/kenney/saw_rest.png differ diff --git a/assets/kenney/slime_block_jump.png b/assets/kenney/slime_block_jump.png new file mode 100644 index 0000000..c6134e2 Binary files /dev/null and b/assets/kenney/slime_block_jump.png differ diff --git a/assets/kenney/slime_block_rest.png b/assets/kenney/slime_block_rest.png new file mode 100644 index 0000000..620a1ab Binary files /dev/null and b/assets/kenney/slime_block_rest.png differ diff --git a/assets/kenney/slime_block_walk_a.png b/assets/kenney/slime_block_walk_a.png new file mode 100644 index 0000000..237dc56 Binary files /dev/null and b/assets/kenney/slime_block_walk_a.png differ diff --git a/assets/kenney/slime_block_walk_b.png b/assets/kenney/slime_block_walk_b.png new file mode 100644 index 0000000..e9dfde3 Binary files /dev/null and b/assets/kenney/slime_block_walk_b.png differ diff --git a/assets/kenney/slime_fire_flat.png b/assets/kenney/slime_fire_flat.png new file mode 100644 index 0000000..bbd3df5 Binary files /dev/null and b/assets/kenney/slime_fire_flat.png differ diff --git a/assets/kenney/slime_fire_rest.png b/assets/kenney/slime_fire_rest.png new file mode 100644 index 0000000..6332464 Binary files /dev/null and b/assets/kenney/slime_fire_rest.png differ diff --git a/assets/kenney/slime_fire_walk_a.png b/assets/kenney/slime_fire_walk_a.png new file mode 100644 index 0000000..b8d7a72 Binary files /dev/null and b/assets/kenney/slime_fire_walk_a.png differ diff --git a/assets/kenney/slime_fire_walk_b.png b/assets/kenney/slime_fire_walk_b.png new file mode 100644 index 0000000..918b8b2 Binary files /dev/null and b/assets/kenney/slime_fire_walk_b.png differ diff --git a/assets/kenney/slime_normal_flat.png b/assets/kenney/slime_normal_flat.png new file mode 100644 index 0000000..85da736 Binary files /dev/null and b/assets/kenney/slime_normal_flat.png differ diff --git a/assets/kenney/slime_normal_rest.png b/assets/kenney/slime_normal_rest.png new file mode 100644 index 0000000..10d8fb5 Binary files /dev/null and b/assets/kenney/slime_normal_rest.png differ diff --git a/assets/kenney/slime_normal_walk_a.png b/assets/kenney/slime_normal_walk_a.png new file mode 100644 index 0000000..c551587 Binary files /dev/null and b/assets/kenney/slime_normal_walk_a.png differ diff --git a/assets/kenney/slime_normal_walk_b.png b/assets/kenney/slime_normal_walk_b.png new file mode 100644 index 0000000..3f78558 Binary files /dev/null and b/assets/kenney/slime_normal_walk_b.png differ diff --git a/assets/kenney/star.png b/assets/kenney/star.png new file mode 100644 index 0000000..2f773f8 Binary files /dev/null and b/assets/kenney/star.png differ diff --git a/assets/kenney/terrain_stone_block_center.png b/assets/kenney/terrain_stone_block_center.png new file mode 100644 index 0000000..23ef36a Binary files /dev/null and b/assets/kenney/terrain_stone_block_center.png differ diff --git a/decisions.md b/decisions.md index dce4a51..f26bba2 100644 --- a/decisions.md +++ b/decisions.md @@ -21,7 +21,7 @@ in [PLAN.md](PLAN.md); this file records the reasoning so it isn't lost. 11. **Reapers and the turn limit come later.** 12. **50 levels and an ending**, with a victory certificate and QR code. 13. **Icons: Icongeek26 "Outline" from Flaticon**, with attribution, until there is final art. - LittleJS ships no icons. + LittleJS ships no icons. *Replaced 2026-10-20 — see "Art: Kenney's New Platformer Pack" below.* ## Technical @@ -54,3 +54,23 @@ so every flame that reaches a block that turn stops at it. `vite-plugin-pwa` needs icons, which come with the icon set in M7. The CI checks for a manifest and service worker will be added then. + +### 2026-10-20 — Art: Kenney's New Platformer Pack, replacing the Flaticon plan + +The user supplied Kenney's New Platformer Pack 1.1 and asked for the game to be re-styled with it: +`character_green` as the player, the pack's enemies for enemies, its blocks for the board, gems as +power-ups, and its coins, doors, bomb and HUD icons. This replaces design decision 13 (Icongeek26 +outline icons over a board of plain drawn shapes). The pack is CC0, so attribution is optional; +the README credits Kenney anyway. + +- **Separate textures, no atlas.** Every sprite is its own LittleJS texture, listed in + `src/render/sprites.ts`. The Canvas 2D renderer doesn't batch, so an atlas would add a build + step and save nothing. Revisit only if the WebGL renderer comes back. +- **Copied, not referenced.** Only the sprites in use or assigned to a planned feature are copied + into `assets/kenney/`, unchanged and under their pack names, so the build doesn't depend on a + folder outside the repo and every file can be traced back to the pack. +- **Not inlined.** `assetsInlineLimit: 0`: Vite would otherwise turn every sprite under 4 KB into + a data URL inside the script, including the art for enemies and pickups that aren't drawn yet. +- **Not pixelated.** `setTilesPixelated(false)`: the sprites are smooth art drawn at about + 100–110 device pixels from 128, which looks jagged without smoothing. +- **Pale floor, not grass.** The player is mint green; a green floor hid them. diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index 82b288a..d5507da 100644 Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png index 9014a94..073e704 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 e6e12b9..54ee735 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 2ba5115..947a818 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index 7b22db6..999adfd 100644 Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index 3145463..c7b6252 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/index.html b/index.html index 8002e41..a71dcf5 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,8 @@ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" /> <title>Explodorama</title> <meta name="description" content="A turn-based, mobile-first, roguelike bomb-laying game. Every bomb counts down with your moves." /> -<meta name="theme-color" content="#f4efe4" /> +<meta name="theme-color" content="#353541" /> +<link rel="icon" type="image/png" href="/assets/kenney/bomb.png" /> <meta name="mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" /> <link rel="stylesheet" href="/src/style.css" /> @@ -19,8 +20,10 @@ </header> <nav id="actions" aria-label="Actions"></nav> <div id="banner" hidden> + <img id="banner-icon" alt="" /> <p id="banner-text"></p> <button id="banner-button" type="button"></button> + <p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a> · New Platformer Pack (CC0)</p> </div> <script type="module" src="/src/main.ts"></script> </body> diff --git a/src/main.ts b/src/main.ts index ec5f51d..80fa620 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,11 +6,13 @@ import { setCanvasPixelRatio, setGLEnable, setShowSplashScreen, + setTilesPixelated, time, vec2, } from 'littlejsengine' import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame } from './render/board' import { updateCamera } from './render/camera' +import { TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' import { predictDanger, type Danger } from './sim/danger' import { newRecord, type RunRecord } from './sim/record' @@ -30,6 +32,7 @@ let danger: Danger let selected: ActionId | null = null let hover: Vec | null = null let flames: Flame[] = [] +let facingLeft = false let snapCamera = true function startRun(seed: number): void { @@ -49,6 +52,7 @@ function act(action: Action): void { for (const e of result.events) { if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time }) if (e.type === 'levelRestarted') snapCamera = true + if (e.type === 'moved' && e.to.x !== e.from.x) facingLeft = e.to.x < e.from.x } selected = null afterChange() @@ -125,10 +129,12 @@ startRun(Number(params.get('seed')) || randomSeed()) // Sharp on high-density screens, capped so phones don't pay for 3×. setCanvasPixelRatio(Math.min(devicePixelRatio, 2)) -// Canvas 2D, not WebGL: a board of a few hundred rectangles doesn't need it, and headless +// Canvas 2D, not WebGL: a board of a few hundred sprites doesn't need it, and headless // Chromium's screenshots of the WebGL canvas showed a black strip that isn't in the drawing, // which made the screenshots untrustworthy for review (decisions.md). setGLEnable(false) +// The sprites are smooth vector-style art drawn smaller than their 128 px, not pixel art. +setTilesPixelated(false) await engineInit( () => { @@ -153,6 +159,7 @@ await engineInit( flames = flames.filter((f) => time - f.born < FLAME_SECONDS) }, () => {}, - () => drawBoard({ state, danger, selected, hover, flames }), + () => drawBoard({ state, danger, selected, hover, flames, facingLeft }), () => {}, + TEXTURES, ) diff --git a/src/render/board.ts b/src/render/board.ts index 95d0db3..5277b15 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -1,9 +1,10 @@ -import { drawCircle, drawRect, drawText, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine' +import { drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine' import { ACTIONS } from '../sim/actions' import type { Danger } from '../sim/danger' import { blastTiles } from '../sim/explosions' import { eq, idx } from '../sim/state' import type { ActionId, GameState, Vec } from '../sim/types' +import { digitSprite, sprite, type BoardSprite } from './sprites' /** * The one place where sim tiles meet LittleJS world space. The sim's y grows downward @@ -30,46 +31,48 @@ export interface View { /** The tile under the pointer or finger, for the commit preview. */ hover: Vec | null flames: Flame[] + /** The way the player last stepped. The character sprite faces right; this mirrors it. */ + facingLeft: boolean } export const FLAME_SECONDS = 0.45 const C = { - grid: rgb(0.85, 0.82, 0.75), - floor: rgb(0.96, 0.94, 0.89), - wall: rgb(0.23, 0.21, 0.19), - block: rgb(0.64, 0.48, 0.3), - blockFace: rgb(0.72, 0.57, 0.37), + sky: rgb(0.765, 0.89, 1), + floor: rgb(0.95, 0.97, 0.99), + floorAlt: rgb(0.9, 0.94, 0.98), dangerNow: rgb(0.9, 0.15, 0.1, 0.45), dangerNext: rgb(1, 0.35, 0.2, 0.18), - target: rgb(0.18, 0.44, 0.84, 0.4), - targetHover: rgb(0.18, 0.44, 0.84, 0.7), + target: rgb(0.25, 0.62, 0.87, 0.4), + targetHover: rgb(0.25, 0.62, 0.87, 0.7), preview: rgb(1, 0.55, 0.1, 0.4), flame: rgb(1, 0.6, 0.1), - bomb: rgb(0.11, 0.1, 0.09), - bombHot: rgb(0.8, 0.1, 0.08), white: rgb(1, 1, 1), - player: rgb(0.18, 0.44, 0.84), - playerDead: rgb(0.55, 0.55, 0.55), + ghost: rgb(1, 1, 1, 0.45), } -export const BACKGROUND: Color = C.grid +export const BACKGROUND: Color = C.sky + +/** Pillars change colour from level to level; the outer wall is always stone. */ +const PILLARS: BoardSprite[] = ['block_blue', 'block_green', 'block_red', 'block_yellow'] export function drawBoard(view: View): void { const { state: s, danger, selected, hover } = 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) + const pillar = sprite(PILLARS[(s.level - 1) % PILLARS.length]!) for (let y = 0; y < s.height; y++) { for (let x = 0; x < s.width; x++) { const p = { x, y } const pos = toWorld(s, p) const cell = s.cells[idx(s, p)] - if (cell === 'wall') drawRect(pos, wallSize, C.wall) - else if (cell === 'block') { - drawRect(pos, vec2(0.94), C.block) - drawRect(pos, vec2(0.62), C.blockFace) - } else drawRect(pos, vec2(0.94), C.floor) + drawRect(pos, wallSize, (x + y) % 2 ? C.floorAlt : C.floor) + if (cell === 'wall') { + const edge = x === 0 || y === 0 || x === s.width - 1 || y === s.height - 1 + drawTile(pos, wallSize, edge ? sprite('terrain_stone_block_center') : pillar) + } else if (cell === 'block') drawTile(pos, tileSize, sprite('block_planks')) } } @@ -89,23 +92,32 @@ export function drawBoard(view: View): void { for (const f of view.flames) { const fade = 1 - (time - f.born) / FLAME_SECONDS if (fade <= 0) continue - for (const t of f.tiles) drawRect(toWorld(s, t), vec2(0.9), C.flame.scale(1, fade)) + for (const t of f.tiles) { + const pos = toWorld(s, t) + drawRect(pos, vec2(0.9), C.flame.scale(1, fade)) + drawTile(pos, vec2(0.6 + 0.5 * (1 - fade)), sprite('fireball'), C.white.scale(1, fade), time * 6) + } } for (const b of s.bombs) { - const pos = toWorld(s, b.pos) - const hot = b.timer <= 1 - const size = hot ? 0.74 + 0.06 * Math.sin(time * 10) : 0.74 - drawCircle(pos, size, hot ? C.bombHot : C.bomb) - drawText(String(b.timer), pos, 0.5, C.white) - } - if (hovered && selected === 'bomb') { - drawCircle(toWorld(s, hovered), 0.74, C.bomb.scale(1, 0.45)) - drawText(String(s.stats.fuse), toWorld(s, hovered), 0.5, C.white) + // 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) } + if (hovered && selected === 'bomb') drawBomb(toWorld(s, hovered), s.stats.fuse, 'bomb', C.ghost) + + if (hovered && selected === 'move') drawTile(toWorld(s, hovered), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft) + const alive = s.status === 'playing' + drawTile(toWorld(s, s.player), tileSize, sprite(alive ? 'character_green_idle' : 'character_green_hit'), C.white, 0, view.facingLeft) +} - if (hovered && selected === 'move') drawCircle(toWorld(s, hovered), 0.72, C.player.scale(1, 0.4)) - drawCircle(toWorld(s, s.player), 0.72, s.status === 'playing' ? C.player : C.playerDead, 0.06, C.white) +/** The bomb sprite with its timer on the body, which sits a little below and right of the tile's centre. */ +function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color): void { + drawTile(pos, vec2(0.9), 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) } function tileOf(s: GameState, i: number): Vec { diff --git a/src/render/sprites.ts b/src/render/sprites.ts new file mode 100644 index 0000000..26e0571 --- /dev/null +++ b/src/render/sprites.ts @@ -0,0 +1,57 @@ +import { tile, vec2, type TileInfo } from 'littlejsengine' + +/** + * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's + * own file names, so a sprite can always be found again in the pack. Every file is 128 × 128: + * tiles and enemies from the pack's `Double` folders, characters from `Default`. + */ +const URLS = import.meta.glob<string>('../../assets/kenney/*.png', { eager: true, query: '?url', import: 'default' }) + +export function spriteUrl(name: string): string { + const url = URLS[`../../assets/kenney/${name}.png`] + if (!url) throw new Error(`Missing sprite: assets/kenney/${name}.png`) + return url +} + +/** The sprites drawn on the canvas. Their order is their LittleJS texture index. */ +const BOARD = [ + 'terrain_stone_block_center', + 'block_blue', + 'block_green', + 'block_red', + 'block_yellow', + 'block_planks', + 'bomb', + 'bomb_active', + 'fireball', + 'character_green_idle', + 'character_green_hit', + 'hud_character_0', + 'hud_character_1', + 'hud_character_2', + 'hud_character_3', + 'hud_character_4', + 'hud_character_5', + 'hud_character_6', + 'hud_character_7', + 'hud_character_8', + 'hud_character_9', +] as const + +export type BoardSprite = (typeof BOARD)[number] + +/** For `engineInit`, which loads them all before the first frame. */ +export const TEXTURES = BOARD.map(spriteUrl) + +const tiles = new Map<BoardSprite, TileInfo>() + +/** Only after `engineInit` has loaded the textures. */ +export function sprite(name: BoardSprite): TileInfo { + let t = tiles.get(name) + if (!t) tiles.set(name, (t = tile(0, vec2(128), BOARD.indexOf(name)))) + return t +} + +export function digitSprite(d: number): TileInfo { + return sprite(`hud_character_${d}` as BoardSprite) +} diff --git a/src/style.css b/src/style.css index f04fa06..d74d693 100644 --- a/src/style.css +++ b/src/style.css @@ -1,10 +1,11 @@ +/* Colours taken from the Kenney sprites, so the bars and the board look like one set. */ :root { - --paper: #f4efe4; - --ink: #1d1b18; - --muted: #6b665c; - --accent: #2f6fd6; - --bar-bg: #fffaf0; - --line: #d9d1c0; + --sky: #c3e3ff; + --ink: #353541; + --muted: #6b6b7b; + --accent: #2cc987; + --accent-dark: #1f9e69; + --panel: #fff; } html, @@ -12,9 +13,9 @@ body { margin: 0; height: 100%; overflow: hidden; - background: var(--paper); + background: var(--sky); color: var(--ink); - font: 16px/1.3 system-ui, -apple-system, sans-serif; + font: 16px/1.3 ui-rounded, 'SF Pro Rounded', system-ui, -apple-system, sans-serif; /* A game surface: no pull-to-refresh, no double-tap zoom, no text selection. */ touch-action: none; user-select: none; @@ -23,6 +24,19 @@ body { overscroll-behavior: none; } +img { + pointer-events: none; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + #hud { position: fixed; top: 0; @@ -31,11 +45,45 @@ body { z-index: 2; display: flex; justify-content: space-between; + align-items: center; gap: 8px; - padding: calc(env(safe-area-inset-top) + 8px) 16px 8px; - background: var(--bar-bg); - border-bottom: 1px solid var(--line); - font-variant-numeric: tabular-nums; + padding: calc(env(safe-area-inset-top) + 6px) 16px 6px; + background: var(--ink); + color: #fff; +} + +#hud > span { + display: flex; + align-items: center; +} + +#hud .icon { + width: 28px; + height: 28px; + /* A light rim, so the dark bomb sprite stands out on the dark bar. */ + filter: drop-shadow(0 0 1.5px rgb(255 255 255 / 0.7)); +} + +#hud .digit { + width: 20px; + height: 20px; + margin-right: -4px; +} + +#hud .icon + .sr-only + .digit { + margin-left: 2px; +} + +#hud .digit + .icon { + margin-left: 10px; +} + +#hud small { + margin-right: 4px; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; } #actions { @@ -48,40 +96,57 @@ body { gap: 8px; overflow-x: auto; padding: 8px 16px calc(env(safe-area-inset-bottom) + 8px); - background: var(--bar-bg); - border-top: 1px solid var(--line); + background: var(--ink); } #actions button { flex: 1 0 auto; + display: grid; + grid-template-columns: auto auto; + grid-template-rows: auto auto; + align-items: center; + justify-content: center; + column-gap: 6px; min-width: 72px; min-height: 56px; - border: 2px solid var(--ink); + margin-bottom: 4px; + border: 3px solid var(--ink); border-radius: 12px; - background: #fff; + background: var(--panel); + /* Kenney-style chunky button: a solid shadow that it sinks into when pressed. */ + box-shadow: 0 4px 0 #1e1e27; color: var(--ink); font: inherit; - font-weight: 600; + font-weight: 800; +} + +#actions .icon { + grid-row: span 2; + width: 36px; + height: 36px; +} + +#actions button:active:not(:disabled) { + transform: translateY(4px); + box-shadow: none; } #actions button[aria-pressed='true'] { background: var(--accent); - border-color: var(--accent); color: #fff; } #actions button:disabled { - opacity: 0.35; + opacity: 0.45; } #actions kbd { - display: block; font: 11px ui-monospace, monospace; color: var(--muted); } #actions button[aria-pressed='true'] kbd { - color: #dce8ff; + color: #e2fff2; } #banner { @@ -90,29 +155,47 @@ body { z-index: 3; display: grid; place-content: center; + justify-items: center; gap: 16px; padding: 16px; text-align: center; - background: rgb(244 239 228 / 0.85); + background: rgb(53 53 65 / 0.75); + color: #fff; } #banner[hidden] { display: none; } +#banner img { + width: 96px; + height: 96px; +} + #banner p { margin: 0; font-size: 24px; - font-weight: 700; + font-weight: 800; +} + +#banner .credit { + font-size: 13px; + font-weight: 600; + opacity: 0.85; +} + +#banner .credit a { + color: inherit; } #banner button { min-height: 56px; padding: 0 32px; - border: 0; + border: 3px solid var(--ink); border-radius: 12px; background: var(--accent); + box-shadow: 0 4px 0 var(--accent-dark); color: #fff; font: inherit; - font-weight: 700; + font-weight: 800; } diff --git a/src/ui/controls.ts b/src/ui/controls.ts index 2cbd69e..c3be5e9 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -1,5 +1,7 @@ import { ACTIONS, ACTION_ORDER, isAvailable } from '../sim/actions' +import { START_LIVES } from '../sim/state' import type { ActionId, GameState } from '../sim/types' +import { spriteUrl } from '../render/sprites' /** The HTML around the board: HUD, action bar, and the banner shown on death. */ export interface Handlers { @@ -10,6 +12,16 @@ export interface Handlers { } const KEYS: Record<ActionId, string> = { move: 'WASD', bomb: 'B', wait: 'Space' } +const ICONS: Record<ActionId, string> = { move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck' } + +function icon(name: string, className = 'icon'): string { + return `<img class="${className}" src="${spriteUrl(name)}" alt="" draggable="false">` +} + +/** A number in the pack's HUD digits. The images are decoration; `label` is what screen readers get. */ +function number(n: number, label: string): string { + return `<span class="sr-only">${label}</span>` + [...String(n)].map((d) => icon(`hud_character_${d}`, 'digit')).join('') +} export function createControls(handlers: Handlers) { const bar = document.getElementById('actions')! @@ -18,7 +30,7 @@ export function createControls(handlers: Handlers) { const b = document.createElement('button') b.type = 'button' b.dataset.action = id - b.innerHTML = `${ACTIONS[id].label}<kbd>${KEYS[id]}</kbd>` + b.innerHTML = `${icon(ICONS[id])}<span>${ACTIONS[id].label}</span><kbd>${KEYS[id]}</kbd>` b.addEventListener('click', () => handlers.pick(id)) bar.append(b) buttons.set(id, b) @@ -26,6 +38,7 @@ export function createControls(handlers: Handlers) { const banner = document.getElementById('banner')! const bannerText = document.getElementById('banner-text')! + const bannerIcon = document.getElementById('banner-icon') as HTMLImageElement const bannerButton = document.getElementById('banner-button') as HTMLButtonElement bannerButton.addEventListener('click', () => handlers.banner()) @@ -42,18 +55,25 @@ export function createControls(handlers: Handlers) { b.disabled = !isAvailable(s, id) b.setAttribute('aria-pressed', String(selected === id)) } - hud.lives.textContent = `♥ ${s.lives}` - hud.level.textContent = `Level ${s.level}` - hud.turn.textContent = `Turn ${s.turn}` - hud.stats.textContent = `💣${s.stats.capacity} 🔥${s.stats.fire}` + // Lives lost from the starting three show as empty hearts; extra lives add full ones. + const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) => + icon(i < s.lives ? 'hud_heart' : 'hud_heart_empty'), + ) + hud.lives.innerHTML = `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}` + hud.level.innerHTML = icon('flag_green_a') + number(s.level, 'Level ') + hud.turn.innerHTML = `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + hud.stats.innerHTML = + icon('bomb') + number(s.stats.capacity, 'Bombs ') + icon('fireball') + number(s.stats.fire, ', fire ') banner.hidden = s.status === 'playing' if (s.status === 'dead') { bannerText.textContent = `Boom. ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` bannerButton.textContent = 'Try again' + bannerIcon.src = spriteUrl('character_green_hit') } else if (s.status === 'gameOver') { bannerText.textContent = `Game over on level ${s.level}. Seed ${s.seed}.` bannerButton.textContent = 'New run' + bannerIcon.src = spriteUrl('hud_heart_empty') } }, /** True if the banner is showing; Enter presses its button. */ diff --git a/vite.config.ts b/vite.config.ts index 74e70f8..63193ba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,5 +16,7 @@ export default defineConfig({ // Not `public/`: GitLab Pages requires its artifact to be a directory named `public`, and // `mv dist public` moves dist *inside* an existing `public/` instead of renaming it. publicDir: 'static', - build: { target: 'es2022' }, + // Sprites stay separate files rather than being inlined into the script: the art for enemies and + // pickups that aren't in the game yet then costs nothing until something draws it. + build: { target: 'es2022', assetsInlineLimit: 0 }, })