excitebike: playtest fixes and Wave 5 polish

Playtest fixes (two reports, both bugs):
- Bad ground no longer instantly crashes: holes and obstacles only
  put you down above surfaceCrashSpeed; below that you bog to a crawl
  that is capped under the crash speed, always escapable
- Steep ramps (45°) no longer crash: launch lifts the bike clear of
  the lip, pitch is clamped to pitchMax, and landing angle is clamped
  to landGroundAngleMax so straddled cliff edges don't count as
  rideable surfaces
- Harness now drives every ramp at seven speeds; no crashes allowed

Wave 5 polish:
- Scenery: infield and apron shrub rows (bush, apronBush, tuft)
  transcribed from NES track maps
- Ramps given readable silhouette (packed-earth hatch, dark stroke,
  lit crest, sheer-face cut)
- Gaps show infield through lane; lane dividers and cool-zone
  chevrons fixed for alternating-lane contrast
- Snow and night themes retinted for visibility
- iconFrame 91 already painted

Tooling:
- Map reader masks ramp columns to avoid false mud/cool-zone detection
- All 10 track qualifyMs retuned from playtest measurements

Updated build plan and sprites docs.
This commit is contained in:
Brian Fertig 2026-07-29 23:06:41 -06:00
parent 81aa6e00a8
commit abb55d6d7b
17 changed files with 435 additions and 75 deletions

View File

@ -6,7 +6,7 @@
"length": 5888,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 66500,
"qualifyMs": 64400,
"hurdles": [
{
"t": "M",

View File

@ -6,7 +6,7 @@
"length": 5393,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 66500,
"qualifyMs": 62100,
"hurdles": [
{
"t": "M",

View File

@ -6,7 +6,7 @@
"length": 6416,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 76100,
"qualifyMs": 71400,
"hurdles": [
{
"t": "M",

View File

@ -6,7 +6,7 @@
"length": 6528,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 84100,
"qualifyMs": 73600,
"hurdles": [
{
"t": "M",

View File

@ -6,7 +6,7 @@
"length": 5752,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 79400,
"qualifyMs": 71000,
"hurdles": [
{
"t": "M",

View File

@ -6,7 +6,7 @@
"length": 6600,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 74800,
"qualifyMs": 70900,
"hurdles": [
{
"t": "C",

View File

@ -6,7 +6,7 @@
"length": 6900,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 85800,
"qualifyMs": 77300,
"hurdles": [
{
"t": "J",

View File

@ -6,7 +6,7 @@
"length": 7200,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 83500,
"qualifyMs": 82500,
"hurdles": [
{
"t": "J",

View File

@ -6,7 +6,7 @@
"length": 7400,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 93900,
"qualifyMs": 79500,
"hurdles": [
{
"t": "H",

View File

@ -6,7 +6,7 @@
"length": 7800,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 87200,
"qualifyMs": 85000,
"hurdles": [
{
"t": "S",

View File

@ -5,12 +5,13 @@ update the checkboxes and the Status line as work lands.
## Status
**Waves 04 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

View File

@ -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
// ---------------------------------------------------------------------------

View File

@ -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');
if (surf === SURFACE.OBSTACLE || surf === SURFACE.GAP) {
if (b.vx > TUNE.surfaceCrashSpeed) {
crash(state, b, surf === SURFACE.GAP ? 'gap' : 'obstacle');
return;
}
if (surf === SURFACE.GAP) {
crash(state, b, 'gap');
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) {
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 });
}

View File

@ -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;

View File

@ -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

View File

@ -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;
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;
}
}
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) === shade) k += 1;
// The lane divider contributes one row; anything much denser is texture.
flags[i] = k >= 4;
for (let y = y0; y < y0 + LANE_H; y += 1) {
if (pixelAt(img, t.first + i, y) === colour) k += 1;
}
perLane.push(runsOf(flags, 10));
// 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));
}
// ---------------------------------------------------------------------------

View File

@ -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({