diff --git a/assets/gamedata/excitebike/track-01.json b/assets/gamedata/excitebike/track-01.json index 3ced72c..72ebc83 100644 --- a/assets/gamedata/excitebike/track-01.json +++ b/assets/gamedata/excitebike/track-01.json @@ -6,7 +6,7 @@ "length": 5888, "laps": 2, "mainLaps": 2, - "qualifyMs": 66500, + "qualifyMs": 64400, "hurdles": [ { "t": "M", diff --git a/assets/gamedata/excitebike/track-02.json b/assets/gamedata/excitebike/track-02.json index b9dbdbe..9d57494 100644 --- a/assets/gamedata/excitebike/track-02.json +++ b/assets/gamedata/excitebike/track-02.json @@ -6,7 +6,7 @@ "length": 5393, "laps": 2, "mainLaps": 2, - "qualifyMs": 66500, + "qualifyMs": 62100, "hurdles": [ { "t": "M", diff --git a/assets/gamedata/excitebike/track-03.json b/assets/gamedata/excitebike/track-03.json index 6cb9d35..cc2fee7 100644 --- a/assets/gamedata/excitebike/track-03.json +++ b/assets/gamedata/excitebike/track-03.json @@ -6,7 +6,7 @@ "length": 6416, "laps": 2, "mainLaps": 2, - "qualifyMs": 76100, + "qualifyMs": 71400, "hurdles": [ { "t": "M", diff --git a/assets/gamedata/excitebike/track-04.json b/assets/gamedata/excitebike/track-04.json index efe5afc..2899e0e 100644 --- a/assets/gamedata/excitebike/track-04.json +++ b/assets/gamedata/excitebike/track-04.json @@ -6,7 +6,7 @@ "length": 6528, "laps": 2, "mainLaps": 2, - "qualifyMs": 84100, + "qualifyMs": 73600, "hurdles": [ { "t": "M", diff --git a/assets/gamedata/excitebike/track-05.json b/assets/gamedata/excitebike/track-05.json index 1a63751..7e92af2 100644 --- a/assets/gamedata/excitebike/track-05.json +++ b/assets/gamedata/excitebike/track-05.json @@ -6,7 +6,7 @@ "length": 5752, "laps": 2, "mainLaps": 2, - "qualifyMs": 79400, + "qualifyMs": 71000, "hurdles": [ { "t": "M", diff --git a/assets/gamedata/excitebike/track-06.json b/assets/gamedata/excitebike/track-06.json index 3d5606a..e4bbaa2 100644 --- a/assets/gamedata/excitebike/track-06.json +++ b/assets/gamedata/excitebike/track-06.json @@ -6,7 +6,7 @@ "length": 6600, "laps": 2, "mainLaps": 2, - "qualifyMs": 74800, + "qualifyMs": 70900, "hurdles": [ { "t": "C", diff --git a/assets/gamedata/excitebike/track-07.json b/assets/gamedata/excitebike/track-07.json index c28af2a..ef62840 100644 --- a/assets/gamedata/excitebike/track-07.json +++ b/assets/gamedata/excitebike/track-07.json @@ -6,7 +6,7 @@ "length": 6900, "laps": 2, "mainLaps": 2, - "qualifyMs": 85800, + "qualifyMs": 77300, "hurdles": [ { "t": "J", diff --git a/assets/gamedata/excitebike/track-08.json b/assets/gamedata/excitebike/track-08.json index db48205..6529a74 100644 --- a/assets/gamedata/excitebike/track-08.json +++ b/assets/gamedata/excitebike/track-08.json @@ -6,7 +6,7 @@ "length": 7200, "laps": 2, "mainLaps": 2, - "qualifyMs": 83500, + "qualifyMs": 82500, "hurdles": [ { "t": "J", diff --git a/assets/gamedata/excitebike/track-09.json b/assets/gamedata/excitebike/track-09.json index 784468f..7b8e214 100644 --- a/assets/gamedata/excitebike/track-09.json +++ b/assets/gamedata/excitebike/track-09.json @@ -6,7 +6,7 @@ "length": 7400, "laps": 2, "mainLaps": 2, - "qualifyMs": 93900, + "qualifyMs": 79500, "hurdles": [ { "t": "H", diff --git a/assets/gamedata/excitebike/track-10.json b/assets/gamedata/excitebike/track-10.json index d6c399c..b5aa3b1 100644 --- a/assets/gamedata/excitebike/track-10.json +++ b/assets/gamedata/excitebike/track-10.json @@ -6,7 +6,7 @@ "length": 7800, "laps": 2, "mainLaps": 2, - "qualifyMs": 87200, + "qualifyMs": 85000, "hurdles": [ { "t": "S", diff --git a/docs/excitebike-build-plan.md b/docs/excitebike-build-plan.md index ec4e309..8000f93 100644 --- a/docs/excitebike-build-plan.md +++ b/docs/excitebike-build-plan.md @@ -5,12 +5,13 @@ update the checkboxes and the Status line as work lands. ## Status -**Waves 0–4 done 2026-07-29.** Ten tracks in the bank — the five original NES courses transcribed -from their track maps, plus five new ones. SELECTION A, SELECTION B and DESIGN all implemented. -`node tools/verifyExcitebike.js` → **579 checks green**. Wave 5 (art polish, icon frame 91) is -**NEXT**. +**Complete 2026-07-29, through Wave 5 and two rounds of playtest fixes.** Ten tracks — the five +original NES courses transcribed from their track maps, plus five new ones. SELECTION A, SELECTION B +and DESIGN all implemented. `node tools/verifyExcitebike.js` → **620 checks green**. -**Never opened in a browser.** Brian playtests; nothing here is signed off until he has. +Playtested and signed off by Brian for feel. He has retuned `TUNE` himself (turbo boost, nose-down +landing tolerance) — **do not "restore" those values**; after any physics change, re-run +`node tools/genExcitebikeTracks.js --force` because qualifying times are measured, then the harness. ## Decisions taken up front @@ -22,7 +23,7 @@ from their track maps, plus five new ones. SELECTION A, SELECTION B and DESIGN a | Tracks | The 5 originals, transcribed from the NES maps, plus 5 new | | Presentation | True 256×240 NES frame at integer ×4, centred in a cabinet bezel | | Overlay | Full `ArcadeCRTOverlay` over bezel and all; `m6x11` for the non-NES chrome | -| Slug / category / icon | `excitebike` / `arcade-console-pc` / iconFrame **91** (art still needed) | +| Slug / category / icon | `excitebike` / `arcade-console-pc` / iconFrame **91** (painted) | ## Files @@ -211,12 +212,89 @@ Control mapping, for the record — it was misremembered in the playtest report: **X or SPACE = throttle** (the A button), **Z or SHIFT = turbo** (the B button). The bezel hint and the title screen now say so explicitly, and the title adds `LAND REAR WHEEL FIRST`. -## Wave 5 — Polish — ⬜ NEXT +## Playtest fixes — ✅ DONE 2026-07-29 -- [ ] Paint iconFrame **91** into `assets/images/game-icons.png` (44×44, row 6 col 2) -- [ ] Parallax backdrops and per-track crowd colour; the themes exist but are plain -- [ ] Revisit cool-zone and mud transcription (see the Wave 2 gap) -- [ ] Playtest, then retune from what it actually feels like +Two reports, both of which turned out to be bugs rather than tuning. + +**"Riding into mud or grass instantly crashes, and you crash again after getting up."** The "grass" is +a hole — Wave 5 changed gaps to render as the infield showing through, which is how the maps draw +them. The second half was the real fault: the bike slides only 12-60px when it goes down, and a hole +is 80-440px wide, so a rider remounts **still inside it** and crashes on the next frame, forever. +Bad ground now only puts you down above `surfaceCrashSpeed` (110); below that you bog to a 46px/s +crawl. The crawl is capped *under* the crash speed on purpose — that is the mechanism, not a number: +once in bad ground you cannot build up enough pace to be thrown off it again, so you can always get +out. Mud was never in this path; it caps speed and has never crashed anyone. + +**"The 45-degree jump crashes me and the AI constantly."** Three compounding faults, all from the +steep ramp being only 15px wide: + +1. The 4px slope window and 6px curvature window both **straddle its cliff**, so mid-climb the ground + reads as a −1.28 rad (−73°) wall. +2. That made the bike **launch early**, while still on the face at height 13.5 rather than at the lip. +3. Vertical position is resolved *before* the horizontal move, so after launching the bike ended the + frame **buried inside the ramp** and "landed" on the very next one — against that imaginary −73° + wall, giving a relative angle of +2.17 rad and an instant crash. + +Fixes: a launch now lifts the bike clear of the highest ground it is crossing; launch pitch is clamped +to `pitchMax`; and a landing is judged against a **clamped** ground angle (`landGroundAngleMax` 0.5) — +anything steeper is a cliff, not a surface you touch down on. The ramp's geometry is untouched. + +The harness now drives **every ramp in the catalogue at seven speeds** with a rider who does nothing +but hold the throttle, and none of them may crash. That is the check that would have caught this. + +## Wave 5 — Polish — ✅ DONE 2026-07-29 + +- [x] iconFrame **91** — already painted by Brian (a motocross helmet). Nothing to do. +- [x] Ramps given a readable silhouette +- [x] Scenery: the infield and apron shrub rows, off the maps +- [x] Snow and night themes retinted for contrast +- [ ] Cool-zone and mud transcription — **investigated and rejected, see below** + +### Ramps now read as terrain + +They were the same dirt as flat ground with one lit pixel on top. Anything standing proud of a lane +now gets its own packed-earth hatch (`rampBody`), a dark stroke around its silhouette, a lit crest, +and a full-height vertical cut on genuinely sheer faces. The sheer test needed a **3px** threshold — +the first cut fired on any step at all, and a ramp climbs more than a pixel per column, so the whole +face was being stroked as if it were a cliff. + +Checking this needed *looking* at it, which meant a PNG encoder (30 lines on `node:zlib`, kept in the +scratchpad). Reading the pixels back as ASCII was useless — four overlapping lane copies. Three more +faults were obvious the moment there was an image: + +1. **Gaps were flat grey blocks.** They now show the infield through the lane with vertical cut edges + at each end, which is how the maps draw them. Horizontal lips per lane read as a ladder, not a hole. +2. **Lane dividers were drawn light**, so they vanished on the lighter of the two alternating lane + fills. Dark now, as on the maps. **Cool-zone chevrons had the identical bug** and were invisible on + every other lane; markings now pick whichever shade contrasts with the lane they sit on. Any fixed + shade painted onto alternating lanes will half disappear — worth remembering. +3. **Snow was grey-on-white and night was dark-on-dark.** Neither had enough contrast to see a ramp + coming. Snow is now churned earth through a pale snowfield; night is floodlit amber on dark grass. + +### Scenery + +The infield and apron were bare. The maps show the original plants a row of shrubs along the far side +of the infield (8px tall, every other tile — `bush` is transcribed pixel for pixel off Track 1, +y112-119) and a larger row across the apron. They are painted in the **grass subpalette's slot 1**, +which the grass fill tiles never touch — that is what lets a shrub be a different colour from the +field it stands in without breaking the four-colour rule, and the harness now asserts that slot stays +free. + +### The markings gap: investigated, still open + +Masking out the columns a ramp stands on does fix the false positives — the old detector put a mud pit +under every ramp because a ramp body is full of the shade colour and its crest is full of the +highlight. With ramps masked, what is left on flat ground is: + +- **cool zones: none at all, on any of the five courses.** They are not drawn in the highlight colour. +- **mud: 18-23px marks on alternating lane pairs** (0&2, 1&3). Too narrow to be the 64px mud pits the + game needs, and the alternating-lane pattern does not match any hurdle in the catalogue. These are + more likely distance markers or small decoration. + +So the masking improvement is kept, but **nothing new is emitted**. Cool zones stay rule-placed and +mud stays out. Anyone picking this up again should know the colour approach has been tried and does +not separate them; the next thing worth trying is periodicity — chevrons repeat on a fixed pitch and +churned ground does not. ## How difficulty is decided diff --git a/src/games/excitebike/ExcitebikeArt.js b/src/games/excitebike/ExcitebikeArt.js index 7d165eb..d3ad49a 100644 --- a/src/games/excitebike/ExcitebikeArt.js +++ b/src/games/excitebike/ExcitebikeArt.js @@ -55,7 +55,7 @@ export const THEMES = { bg: [ subpalette(BACKDROP, 0x09, 0x18, 0x29), // crowd: dark green, olive, yellow-green subpalette(BACKDROP, 0x03, 0x33, 0x30), // wall: deep violet, lavender, white - subpalette(BACKDROP, 0x09, 0x1a, 0x2a), // grass + subpalette(BACKDROP, 0x18, 0x1a, 0x2a), // grass, slot 1 is the shrubs subpalette(BACKDROP, 0x08, 0x17, 0x27), // dirt: dark brown, brown, tan ], sprites: [ @@ -70,7 +70,7 @@ export const THEMES = { bg: [ subpalette(BACKDROP, 0x06, 0x17, 0x28), subpalette(BACKDROP, 0x04, 0x24, 0x34), - subpalette(BACKDROP, 0x0b, 0x19, 0x29), + subpalette(BACKDROP, 0x08, 0x19, 0x29), subpalette(BACKDROP, 0x07, 0x16, 0x27), ], sprites: [ @@ -85,7 +85,7 @@ export const THEMES = { bg: [ subpalette(BACKDROP, 0x01, 0x11, 0x21), subpalette(BACKDROP, 0x02, 0x12, 0x31), - subpalette(BACKDROP, 0x0b, 0x0a, 0x19), + subpalette(BACKDROP, 0x07, 0x0a, 0x19), // Floodlit earth against dark grass. The dirt has to stay warm and bright // or the track disappears into the infield after dark. subpalette(BACKDROP, 0x08, 0x18, 0x28), @@ -119,7 +119,7 @@ export const THEMES = { subpalette(BACKDROP, 0x02, 0x12, 0x31), // Snowfield, and churned earth cut through it. All-grey on all-white had // almost no contrast — you could not see the ramps coming. - subpalette(BACKDROP, 0x21, 0x31, 0x30), + subpalette(BACKDROP, 0x0a, 0x31, 0x30), subpalette(BACKDROP, 0x07, 0x17, 0x27), ], sprites: [ @@ -418,6 +418,54 @@ export const TILES = { /** Every tile in the bank, keyed by name — used by the verifier. */ export const TILE_NAMES = Object.keys(TILES); +// --------------------------------------------------------------------------- +// Overlay tiles +// --------------------------------------------------------------------------- + +// Unlike the tiles above, these are drawn *over* a filled band and so must use +// slot 0 as transparency. They carry the scenery: the original's infield and +// apron are not bare grass, they are planted with rows of shrubs every other +// tile, and without them a course reads as a track floating in a green void. +// +// `bush` is transcribed pixel for pixel off the NES track map (Track 1, +// y112-119); `apronBush` is authored in the same style at the larger size the +// near side of the track uses. +export const OVERLAY_TILES = { + bush: tile([ + '..3333..', + '.333333.', + '33133133', + '31311313', + '11311131', + '11111111', + '.111111.', + '..1111..', + ]), + apronBush: tile([ + '.333333.', + '33333333', + '31333313', + '13111131', + '11311311', + '11131111', + '11111111', + '.111111.', + ]), + // Sparse tufts, to break up the run of bare ground between shrubs. + tuft: tile([ + '........', + '........', + '........', + '...1....', + '..1.1...', + '........', + '.1...1..', + '........', + ]), +}; + +export const OVERLAY_TILE_NAMES = Object.keys(OVERLAY_TILES); + // --------------------------------------------------------------------------- // The bike // --------------------------------------------------------------------------- diff --git a/src/games/excitebike/ExcitebikeLogic.js b/src/games/excitebike/ExcitebikeLogic.js index 7db94e7..8e6066e 100644 --- a/src/games/excitebike/ExcitebikeLogic.js +++ b/src/games/excitebike/ExcitebikeLogic.js @@ -97,12 +97,31 @@ export const TUNE = { // Where a rider aims the nose on the way down: a little above the ground, // because that is the forgiving side. landBias: 0.14, + // How steep the ground is allowed to be when judging a landing against it. + // + // Ground angle is measured over a short window, and the steep ramp is only + // 15px wide — so that window straddles its cliff and reports the face the + // bike is climbing as a 73-degree wall. Comparing the bike's attitude to that + // is meaningless, and it made the steep ramp crash almost everyone who used + // it. Anything past this is a cliff, not a surface you land on. + landGroundAngleMax: 0.5, // Surfaces. mudSpeedCap: 74, roughSpeedDrain: 60, roughBounce: 34, + // Bad ground — a hole in the track, or a solid block — only puts you down if + // you arrive at pace. Hit it slowly and you simply bog down to a crawl. + // + // The crawl is what makes this safe: it is well under the crash speed, so + // once you are in bad ground you cannot build up enough pace to be thrown + // off it again. Without that, going down in a hole is unrecoverable — the + // bike only slides 12-60px and a hole is 80-440px wide, so you remount still + // inside it and crash on the very next frame, forever. + surfaceCrashSpeed: 110, + bogSpeed: 46, + // Crashing and recovery. A crash has to hurt without ending the race: at // roughly two seconds all-in it is the single most expensive mistake // available, but a rider who goes down a few times can still make the time. @@ -248,6 +267,15 @@ function grounded(b) { return b.state === STATE.RIDING || b.state === STATE.OVERHEATED; } +/** + * The angle a landing is judged against, clamped to something a bike could + * actually touch down on. See TUNE.landGroundAngleMax. + */ +function landingAngle(model, lane, x) { + const a = groundAngleAt(model, lane, x); + return clamp(a, -TUNE.landGroundAngleMax, TUNE.landGroundAngleMax); +} + function emit(state, ev) { state.events.push(ev); } @@ -461,13 +489,14 @@ function stepBike(state, b, input) { // ---- rough ground and obstacles ----------------------------------------- if (b.state === STATE.RIDING) { - if (surf === SURFACE.OBSTACLE) { - crash(state, b, 'obstacle'); - return; - } - if (surf === SURFACE.GAP) { - crash(state, b, 'gap'); - return; + if (surf === SURFACE.OBSTACLE || surf === SURFACE.GAP) { + if (b.vx > TUNE.surfaceCrashSpeed) { + crash(state, b, surf === SURFACE.GAP ? 'gap' : 'obstacle'); + return; + } + // Bogged down rather than thrown off. Capped below the crash speed, so a + // rider who ended up in here can always crawl back out of it. + b.vx = Math.min(b.vx, TUNE.bogSpeed); } if (surf === SURFACE.ROUGH) { // Whoops do not launch you, they rattle you: speed bleeds away and the @@ -505,13 +534,22 @@ function stepBike(state, b, input) { if (b.y <= ground) { b.y = ground; const impact = Math.abs(b.vy); + // Coming down into a hole. Same rule as riding into one: at pace it puts + // you down, but a bike that drops in slowly just bogs, so a rider can + // always get themselves out. if (surfaceAt(model, lane, b.x) === SURFACE.GAP) { - crash(state, b, 'gap'); + if (b.vx > TUNE.surfaceCrashSpeed) { + crash(state, b, 'gap'); + return; + } + b.vx = Math.min(b.vx, TUNE.bogSpeed); + b.state = STATE.RIDING; + b.vy = 0; return; } // Positive is nose-up relative to the ground, i.e. rear wheel first. - const rel = b.pitch - groundAngleAt(model, lane, b.x); + const rel = b.pitch - landingAngle(model, lane, b.x); let quality; if (rel >= 0) { // Landing back-wheel-first is the right way to do it. Only a wheelie @@ -556,7 +594,12 @@ function stepBike(state, b, input) { const slope = launchSlopeAt(model, lane, b.x); b.state = STATE.AIRBORNE; b.vy = b.vx * slope; - b.pitch = Math.atan(slope); + // Clear the lip. Vertical position is resolved before the horizontal + // move, so on a steep ramp the bike ends the frame buried inside the + // terrain it just left and "lands" again on the very next one. Lift it to + // the highest ground it is crossing so it actually gets away. + b.y = Math.max(b.y, ground, groundAt(model, lane, b.x + stepAhead)); + b.pitch = clamp(Math.atan(slope), -TUNE.pitchMax, TUNE.pitchMax); b.pitchVel = 0; emit(state, { type: 'launch', bike: b.index, isPlayer: b.isPlayer, speed: b.vx }); } diff --git a/src/games/excitebike/ExcitebikeRaster.js b/src/games/excitebike/ExcitebikeRaster.js index b21016c..562970f 100644 --- a/src/games/excitebike/ExcitebikeRaster.js +++ b/src/games/excitebike/ExcitebikeRaster.js @@ -14,7 +14,7 @@ import { INFIELD_Y, INFIELD_H, LANES_Y, LANE_H, LANE_COUNT, APRON_Y, APRON_H, TILE, } from './ExcitebikeNES.js'; import { - TILES, THEMES, BG_CROWD, BG_WALL, BG_GRASS, BG_DIRT, + TILES, OVERLAY_TILES, THEMES, BG_CROWD, BG_WALL, BG_GRASS, BG_DIRT, SP_PLAYER, SP_RIVAL, SP_FLESH, SP_FX, S, buildFontGrid, buildBikeFrames, buildTumbleFrame, buildRunFrame, buildDownedBike, buildDustFrame, packStrip, @@ -186,6 +186,14 @@ export function rasterizeTrack(model, themeId = model.theme) { const grassTile = Math.floor(x / TILE) % 2 === 0 ? TILES.grassA : TILES.grassB; for (let y = INFIELD_Y; y < INFIELD_Y + INFIELD_H; y += 1) p.tilePx(x, y, grassTile, grass); for (let y = APRON_Y; y < APRON_Y + APRON_H; y += 1) p.tilePx(x, y, TILES.apron, grass); + + // Scenery. The original plants a row of shrubs along the far side of the + // infield and another across the apron, on every other tile — without them + // a course reads as a track floating in an empty field. Terrain is painted + // after this, so a ramp standing in front of a shrub correctly hides it. + const even = Math.floor(x / TILE) % 2 === 0; + overlay(p, x, INFIELD_BUSH_Y, even ? OVERLAY_TILES.bush : OVERLAY_TILES.tuft, grass); + overlay(p, x, APRON_BUSH_Y, even ? OVERLAY_TILES.apronBush : OVERLAY_TILES.tuft, grass); } // Racing surface, far lane first. @@ -317,6 +325,21 @@ export function rasterizeTrack(model, themeId = model.theme) { // climb. Matches the launch-edge drop the sim uses. const SHEER_FACE = 3; +// Where the two rows of scenery sit, measured off the NES track maps: the +// infield shrubs run along the last two tiles of the infield, and the apron +// shrubs just below the track. +const INFIELD_BUSH_Y = INFIELD_Y + INFIELD_H - TILE * 2; +const APRON_BUSH_Y = APRON_Y + 2; + +/** Draw one column of an overlay tile, treating slot 0 as transparent. */ +function overlay(p, x, y0, t, subpal) { + const sx = ((x % TILE) + TILE) % TILE; + for (let y = 0; y < TILE; y += 1) { + const slot = t.data[y * TILE + sx]; + if (slot) p.px(x, y0 + y, subpal[slot]); + } +} + export const HEAT_BAR_X = 96; export const HEAT_BAR_Y = 20; export const HEAT_BAR_W = 64; diff --git a/src/games/excitebike/sprites.md b/src/games/excitebike/sprites.md index 8b894c8..7f84865 100644 --- a/src/games/excitebike/sprites.md +++ b/src/games/excitebike/sprites.md @@ -86,8 +86,28 @@ Lanes paint far to near so a near-lane hurdle overlaps the lane behind it — th the side-on view its depth. Surface markings (mud, cool-zone chevrons, whoops ruts) sit in the strip *above* the riding line, which is the only part of a 12px lane you can actually see. +Raised terrain gets its own packed-earth hatch, a dark stroke around its silhouette, a lit crest, and +a vertical cut on genuinely sheer faces — without that a ramp is indistinguishable from flat ground at +a glance. Holes show the infield through the lane with vertical cut edges, as the maps draw them. + +## Scenery + +The infield and apron carry rows of shrubs, every other tile — `bush` is transcribed pixel for pixel +off the Track 1 map (y112-119); `apronBush` is authored to match at the larger near-side size. Both +are **overlay tiles**: unlike every other tile in the bank they must use slot 0 as transparency, and +the harness asserts it. + +They paint in the **grass subpalette's slot 1**, which the grass fill tiles never use. That is what +lets a shrub be a different colour from the field it stands in while staying inside four colours, and +it is why `grassA`/`grassB` are checked for leaving slot 1 alone. + +## Gotcha: alternating lanes + +Lanes alternate between a light and a mid fill. Any marking painted in one fixed shade will disappear +on every other lane — this bit the lane dividers and the cool-zone chevrons both. Markings pick +whichever of the subpalette's extremes contrasts with the lane they are on. + ## Still to do -- **iconFrame 91** in `assets/images/game-icons.png` (44×44, row 6 col 2) — the menu lists the game - fine without it, but it has no icon -- parallax backdrops and per-track crowd colour; the themes exist but the backdrop is plain +- Cool zones and mud are not transcribed from the maps; see the markings section of + `docs/excitebike-build-plan.md` for what was tried and what to try next diff --git a/tools/readExcitebikeMaps.js b/tools/readExcitebikeMaps.js index d5a84f9..74238b8 100644 --- a/tools/readExcitebikeMaps.js +++ b/tools/readExcitebikeMaps.js @@ -167,24 +167,54 @@ function findGaps(img, t) { } /** - * Textured patches: stretches where the shade colour floods a lane far above - * its baseline. On these maps that is churned ground — mud and whoops. + * Surface markings on flat ground. + * + * The first pass at this failed because it looked at every column: a ramp's + * body is full of the shade colour and its crest is full of the highlight, so + * the detector fired on every ramp and produced a mud pit under each one. The + * fix is to mask out the columns a ramp stands on and only read what is left, + * which is genuinely flat track. + * + * The two markings then separate by colour, the way they do on screen: cool + * zones are chevrons painted in the course's highlight, mud is churned ground + * in its shade. */ -function findTextured(img, t) { - const shade = [...t.material].find((c) => c !== t.surface) ?? t.surface; - const perLane = []; - for (let lane = 0; lane < LANE_COUNT; lane += 1) { - const y0 = LANES_Y + lane * LANE_H; - const flags = new Array(t.length); - for (let i = 0; i < t.length; i += 1) { - let k = 0; - for (let y = y0; y < y0 + LANE_H; y += 1) if (pixelAt(img, t.first + i, y) === shade) k += 1; - // The lane divider contributes one row; anything much denser is texture. - flags[i] = k >= 4; +function findMarkings(img, t, ramps) { + const covered = new Uint8Array(t.length); + for (const r of ramps) { + for (let i = Math.max(0, r.x - 2); i < Math.min(t.length, r.x + r.width + 2); i += 1) { + covered[i] = 1; } - perLane.push(runsOf(flags, 10)); } - return perLane; + + const rank = [...t.material]; + const shade = rank.find((c) => c !== t.surface) ?? t.surface; + const highlight = rank.filter((c) => c !== t.surface && c !== shade).pop() ?? null; + + const scan = (colour, minDensity) => { + const perLane = []; + for (let lane = 0; lane < LANE_COUNT; lane += 1) { + const y0 = LANES_Y + lane * LANE_H; + const flags = new Array(t.length); + for (let i = 0; i < t.length; i += 1) { + if (covered[i]) { flags[i] = false; continue; } + let k = 0; + for (let y = y0; y < y0 + LANE_H; y += 1) { + if (pixelAt(img, t.first + i, y) === colour) k += 1; + } + // The lane divider contributes about a row on its own; only something + // clearly denser than that is a marking. + flags[i] = k >= minDensity; + } + perLane.push(runsOf(flags, 12)); + } + return perLane; + }; + + return { + mud: scan(shade, 4), + cool: highlight ? scan(highlight, 3) : [[], [], [], []], + }; } // --------------------------------------------------------------------------- @@ -226,12 +256,33 @@ function gapLetter(lanes) { const THEMES = { 1: 'day', 2: 'night', 3: 'day', 4: 'desert', 5: 'snow' }; +/** Fold per-lane runs that overlap in x into single multi-lane features. */ +function mergeLaneRuns(perLane) { + const events = []; + for (let lane = 0; lane < LANE_COUNT; lane += 1) { + for (const [a, b] of perLane[lane]) events.push({ a, b, lane }); + } + events.sort((p, q) => p.a - q.a); + const out = []; + for (const ev of events) { + const open = out.find((g) => ev.a < g.b + 12 && g.a < ev.b + 12); + if (open) { + open.a = Math.min(open.a, ev.a); + open.b = Math.max(open.b, ev.b); + open.lanes.add(ev.lane); + } else { + out.push({ a: ev.a, b: ev.b, lanes: new Set([ev.lane]) }); + } + } + return out; +} + function transcribe(n) { const img = decodePng(readFileSync(join(DIR, `track${n}.png`))); const t = readTrack(img); const ramps = findRamps(img, t); const gapLanes = findGaps(img, t); - const texLanes = findTextured(img, t); + const marks = findMarkings(img, t, ramps); // Merge per-lane gap runs that overlap into one hurdle spanning those lanes. const gapEvents = []; @@ -251,24 +302,10 @@ function transcribe(n) { } } - const texEvents = []; - for (let lane = 0; lane < LANE_COUNT; lane += 1) { - for (const [a, b] of texLanes[lane]) texEvents.push({ a, b, lane }); - } - texEvents.sort((p, q) => p.a - q.a); - const patches = []; - for (const ev of texEvents) { - const open = patches.find((g) => ev.a < g.b + 12 && g.a < ev.b + 12); - if (open) { - open.a = Math.min(open.a, ev.a); - open.b = Math.max(open.b, ev.b); - open.lanes.add(ev.lane); - } else { - patches.push({ a: ev.a, b: ev.b, lanes: new Set([ev.lane]) }); - } - } + const mud = mergeLaneRuns(marks.mud); + const cool = mergeLaneRuns(marks.cool); - return { n, img, t, ramps, gaps, patches, theme: THEMES[n] }; + return { n, img, t, ramps, gaps, mud, cool, theme: THEMES[n] }; } const results = []; @@ -280,7 +317,8 @@ for (const r of results) { console.log(` surface #${r.t.surface.toString(16).padStart(6, '0')}` + ` infield #${r.t.infield.toString(16).padStart(6, '0')}` + ` material ${[...r.t.material].map((c) => `#${c.toString(16).padStart(6, '0')}`).join(' ')}`); - console.log(` ramps ${r.ramps.length} gaps ${r.gaps.length} textured patches ${r.patches.length}`); + console.log(` ramps ${r.ramps.length} gaps ${r.gaps.length}` + + ` mud ${r.mud.length} cool zones ${r.cool.length}`); const byHeight = new Map(); for (const ramp of r.ramps) { @@ -289,7 +327,9 @@ for (const r of results) { } console.log(' ramp shapes:', [...byHeight.entries()].map(([k, v]) => `${k} x${v}`).join(', ')); console.log(' gaps:', r.gaps.map((g) => `${g.a}+${g.b - g.a + 1}[${[...g.lanes].sort().join('')}]`).join(' ') || '(none)'); - console.log(' patches:', r.patches.slice(0, 12).map((g) => `${g.a}+${g.b - g.a + 1}[${[...g.lanes].sort().join('')}]`).join(' ') || '(none)'); + const fmt = (rows) => rows.slice(0, 10).map((g) => `${g.a}+${g.b - g.a + 1}[${[...g.lanes].sort().join('')}]`).join(' ') || '(none)'; + console.log(' mud: ', fmt(r.mud)); + console.log(' cool: ', fmt(r.cool)); } // --------------------------------------------------------------------------- diff --git a/tools/verifyExcitebike.js b/tools/verifyExcitebike.js index aeb6cb7..85f6217 100644 --- a/tools/verifyExcitebike.js +++ b/tools/verifyExcitebike.js @@ -27,7 +27,7 @@ import { gridSlots, fitsSubpalette, } from '../src/games/excitebike/ExcitebikeNES.js'; import { - TILES, TILE_NAMES, THEMES, THEME_IDS, FONT_CHARS, buildFontGrid, + TILES, TILE_NAMES, OVERLAY_TILES, OVERLAY_TILE_NAMES, THEMES, THEME_IDS, FONT_CHARS, buildFontGrid, buildBikeFrames, buildBikeFrame, buildTumbleFrame, buildRunFrame, buildDustFrame, buildDownedBike, packStrip, pitchFrameIndex, pitchFrameAngle, bikeFrame, BIKE_FRAME, BIKE_FRAME_COUNT, PITCH_FRAMES, PITCH_MAX, TREAD_FRAMES, @@ -96,6 +96,25 @@ for (const name of TILE_NAMES) { 'background tiles must use slots 1-3 only'); } +// Overlay tiles are the mirror image of the rule above: they are drawn over an +// already-filled band, so they MUST carry transparency or the scenery paints a +// solid block across the infield. +for (const name of OVERLAY_TILE_NAMES) { + const t = OVERLAY_TILES[name]; + check(`overlay ${name} is ${TILE}x${TILE}`, t.w === TILE && t.h === TILE); + check(`overlay ${name} stays inside one subpalette`, fitsSubpalette(t)); + check(`overlay ${name} carries transparency`, gridSlots(t).has(S.CLEAR), + 'an overlay with no clear pixels is a solid block'); + check(`overlay ${name} draws something`, gridSlots(t).size > 1); +} + +// The scenery is painted in the grass subpalette's slot 1, which the grass fill +// tiles never use — that is what lets a shrub be a different colour from the +// field it stands in without breaking the four-colour rule. +for (const name of ['grassA', 'grassB']) { + check(`${name} leaves slot 1 free for the scenery`, !gridSlots(TILES[name]).has(S.DARK)); +} + for (const id of THEME_IDS) { const theme = THEMES[id]; check(`theme ${id} has 4 background subpalettes`, theme.bg.length === 4); @@ -444,6 +463,56 @@ section('4. Physics invariants'); check('a crash always resolves back to riding', recovered); } +// Bad ground must be escapable. The bike only slides 12-60px when it goes down +// and a hole is 80-440px wide, so a rider remounts still inside it — if arriving +// slowly also crashed, that would loop forever and the race would be unfinishable. +{ + const holed = buildTrackModel({ + id: 'holed', length: 4000, laps: 1, mainLaps: 1, qualifyMs: 90000, + hurdles: [{ t: 'E', x: 700 }, { t: 'Q', x: 800 }], + }); + + const state = createRace({ model: holed, mode: MODE.SOLO, seed: 12 }); + state.phase = STATE.RIDING; + for (const b of state.bikes) b.state = STATE.RIDING; + // Drop the rider straight into the hole, slowly, as if they had just remounted. + state.player.x = 820; + state.player.vx = TUNE.remountSpeed; + + let crashes = 0; + let escaped = false; + let maxInHole = 0; + const inp = { ...neutralInput(), a: true }; + for (let f = 0; f < 60 * 60; f += 1) { + for (const ev of step(state, inp)) if (ev.type === 'crash') crashes += 1; + // Speed has to be sampled while still in the hole — once out, the bike is + // free to accelerate and says nothing about how it got there. + if (surfaceAt(holed, state.player.lane, state.player.x) === SURFACE.GAP) { + maxInHole = Math.max(maxInHole, state.player.vx); + } + if (state.player.x > 800 + HURDLES.Q.profile.length + 40) { escaped = true; break; } + } + check('a bike sitting in a hole is not thrown off again', crashes === 0, `${crashes} crashes`); + check('a bike can crawl out of a hole under its own power', escaped, + `stuck at x=${state.player.x.toFixed(0)}`); + check('crawling out of a hole is slow', maxInHole <= TUNE.bogSpeed + 1, + `reached ${maxInHole.toFixed(0)}`); + + // ...but arriving at pace must still put you down, or a hole is not a hazard. + const fast = createRace({ model: holed, mode: MODE.SOLO, seed: 12 }); + fast.phase = STATE.RIDING; + for (const b of fast.bikes) b.state = STATE.RIDING; + fast.player.x = 790; + fast.player.vx = TUNE.speedTurbo; + let fastCrash = false; + for (let f = 0; f < 60 && !fastCrash; f += 1) { + for (const ev of step(fast, inp)) if (ev.type === 'crash') fastCrash = true; + } + check('riding into a hole at pace still crashes you', fastCrash); + check('the crawl speed is safely under the crash speed', + TUNE.bogSpeed < TUNE.surfaceCrashSpeed && TUNE.remountSpeed < TUNE.surfaceCrashSpeed); +} + // Overheating must stall you, and cool zones must be the answer. { const hot = buildTrackModel({ @@ -556,6 +625,45 @@ section('4. Physics invariants'); `${TUNE.speedAccel} -> ${TUNE.speedTurbo}`); } +// A ramp must not put you down by itself. The steep ramp is only 15px wide, so +// the windows that measure ground slope and curvature straddle its cliff and +// report the face the bike is climbing as a 73-degree wall — which used to +// launch the bike early, bury it inside the ramp it had just left, and then +// judge the resulting phantom landing against that imaginary wall. Every ramp +// in the catalogue is checked here, at every speed a bike can reach. +{ + for (const id of ['A', 'B', 'C', 'D', 'E', 'H', 'R', 'S']) { + const ramp = buildTrackModel({ + id: `ramp-${id}`, length: 4000, laps: 1, mainLaps: 1, qualifyMs: 90000, + hurdles: [{ t: id, x: 600 }], + }); + let worst = null; + for (const v of [60, 90, 120, 150, 200, 240, TUNE.speedTurbo]) { + const state = createRace({ model: ramp, mode: MODE.SOLO, seed: 5 }); + state.phase = STATE.RIDING; + for (const b of state.bikes) b.state = STATE.RIDING; + state.player.x = 560; + state.player.vx = v; + const inp = { ...neutralInput(), a: true }; + let outcome = null; + for (let f = 0; f < 900 && !outcome; f += 1) { + for (const ev of step(state, inp)) { + if (ev.type === 'land') outcome = ev.quality; + if (ev.type === 'crash') outcome = `crash-${ev.reason}`; + } + } + if (outcome && outcome.startsWith('crash')) worst = `${v}px/s -> ${outcome}`; + } + check(`hurdle ${id} does not crash a rider who just holds the throttle`, + worst === null, worst ?? ''); + } + + // And the clamp that makes it work has to stay sane: a cliff is not a surface. + check('the landing angle reference is clamped to a rideable slope', + TUNE.landGroundAngleMax > 0.2 && TUNE.landGroundAngleMax < 1.0, + `${TUNE.landGroundAngleMax}`); +} + // Contact: catching a leader from behind at speed puts YOU down. { const flat = buildTrackModel({