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:
parent
81aa6e00a8
commit
abb55d6d7b
|
|
@ -6,7 +6,7 @@
|
||||||
"length": 5888,
|
"length": 5888,
|
||||||
"laps": 2,
|
"laps": 2,
|
||||||
"mainLaps": 2,
|
"mainLaps": 2,
|
||||||
"qualifyMs": 66500,
|
"qualifyMs": 64400,
|
||||||
"hurdles": [
|
"hurdles": [
|
||||||
{
|
{
|
||||||
"t": "M",
|
"t": "M",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"length": 5393,
|
"length": 5393,
|
||||||
"laps": 2,
|
"laps": 2,
|
||||||
"mainLaps": 2,
|
"mainLaps": 2,
|
||||||
"qualifyMs": 66500,
|
"qualifyMs": 62100,
|
||||||
"hurdles": [
|
"hurdles": [
|
||||||
{
|
{
|
||||||
"t": "M",
|
"t": "M",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"length": 6416,
|
"length": 6416,
|
||||||
"laps": 2,
|
"laps": 2,
|
||||||
"mainLaps": 2,
|
"mainLaps": 2,
|
||||||
"qualifyMs": 76100,
|
"qualifyMs": 71400,
|
||||||
"hurdles": [
|
"hurdles": [
|
||||||
{
|
{
|
||||||
"t": "M",
|
"t": "M",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"length": 6528,
|
"length": 6528,
|
||||||
"laps": 2,
|
"laps": 2,
|
||||||
"mainLaps": 2,
|
"mainLaps": 2,
|
||||||
"qualifyMs": 84100,
|
"qualifyMs": 73600,
|
||||||
"hurdles": [
|
"hurdles": [
|
||||||
{
|
{
|
||||||
"t": "M",
|
"t": "M",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"length": 5752,
|
"length": 5752,
|
||||||
"laps": 2,
|
"laps": 2,
|
||||||
"mainLaps": 2,
|
"mainLaps": 2,
|
||||||
"qualifyMs": 79400,
|
"qualifyMs": 71000,
|
||||||
"hurdles": [
|
"hurdles": [
|
||||||
{
|
{
|
||||||
"t": "M",
|
"t": "M",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"length": 6600,
|
"length": 6600,
|
||||||
"laps": 2,
|
"laps": 2,
|
||||||
"mainLaps": 2,
|
"mainLaps": 2,
|
||||||
"qualifyMs": 74800,
|
"qualifyMs": 70900,
|
||||||
"hurdles": [
|
"hurdles": [
|
||||||
{
|
{
|
||||||
"t": "C",
|
"t": "C",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"length": 6900,
|
"length": 6900,
|
||||||
"laps": 2,
|
"laps": 2,
|
||||||
"mainLaps": 2,
|
"mainLaps": 2,
|
||||||
"qualifyMs": 85800,
|
"qualifyMs": 77300,
|
||||||
"hurdles": [
|
"hurdles": [
|
||||||
{
|
{
|
||||||
"t": "J",
|
"t": "J",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"length": 7200,
|
"length": 7200,
|
||||||
"laps": 2,
|
"laps": 2,
|
||||||
"mainLaps": 2,
|
"mainLaps": 2,
|
||||||
"qualifyMs": 83500,
|
"qualifyMs": 82500,
|
||||||
"hurdles": [
|
"hurdles": [
|
||||||
{
|
{
|
||||||
"t": "J",
|
"t": "J",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"length": 7400,
|
"length": 7400,
|
||||||
"laps": 2,
|
"laps": 2,
|
||||||
"mainLaps": 2,
|
"mainLaps": 2,
|
||||||
"qualifyMs": 93900,
|
"qualifyMs": 79500,
|
||||||
"hurdles": [
|
"hurdles": [
|
||||||
{
|
{
|
||||||
"t": "H",
|
"t": "H",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"length": 7800,
|
"length": 7800,
|
||||||
"laps": 2,
|
"laps": 2,
|
||||||
"mainLaps": 2,
|
"mainLaps": 2,
|
||||||
"qualifyMs": 87200,
|
"qualifyMs": 85000,
|
||||||
"hurdles": [
|
"hurdles": [
|
||||||
{
|
{
|
||||||
"t": "S",
|
"t": "S",
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@ update the checkboxes and the Status line as work lands.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Waves 0–4 done 2026-07-29.** Ten tracks in the bank — the five original NES courses transcribed
|
**Complete 2026-07-29, through Wave 5 and two rounds of playtest fixes.** Ten tracks — the five
|
||||||
from their track maps, plus five new ones. SELECTION A, SELECTION B and DESIGN all implemented.
|
original NES courses transcribed from their track maps, plus five new ones. SELECTION A, SELECTION B
|
||||||
`node tools/verifyExcitebike.js` → **579 checks green**. Wave 5 (art polish, icon frame 91) is
|
and DESIGN all implemented. `node tools/verifyExcitebike.js` → **620 checks green**.
|
||||||
**NEXT**.
|
|
||||||
|
|
||||||
**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
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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
|
**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`.
|
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)
|
Two reports, both of which turned out to be bugs rather than tuning.
|
||||||
- [ ] Parallax backdrops and per-track crowd colour; the themes exist but are plain
|
|
||||||
- [ ] Revisit cool-zone and mud transcription (see the Wave 2 gap)
|
**"Riding into mud or grass instantly crashes, and you crash again after getting up."** The "grass" is
|
||||||
- [ ] Playtest, then retune from what it actually feels like
|
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
|
## How difficulty is decided
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export const THEMES = {
|
||||||
bg: [
|
bg: [
|
||||||
subpalette(BACKDROP, 0x09, 0x18, 0x29), // crowd: dark green, olive, yellow-green
|
subpalette(BACKDROP, 0x09, 0x18, 0x29), // crowd: dark green, olive, yellow-green
|
||||||
subpalette(BACKDROP, 0x03, 0x33, 0x30), // wall: deep violet, lavender, white
|
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
|
subpalette(BACKDROP, 0x08, 0x17, 0x27), // dirt: dark brown, brown, tan
|
||||||
],
|
],
|
||||||
sprites: [
|
sprites: [
|
||||||
|
|
@ -70,7 +70,7 @@ export const THEMES = {
|
||||||
bg: [
|
bg: [
|
||||||
subpalette(BACKDROP, 0x06, 0x17, 0x28),
|
subpalette(BACKDROP, 0x06, 0x17, 0x28),
|
||||||
subpalette(BACKDROP, 0x04, 0x24, 0x34),
|
subpalette(BACKDROP, 0x04, 0x24, 0x34),
|
||||||
subpalette(BACKDROP, 0x0b, 0x19, 0x29),
|
subpalette(BACKDROP, 0x08, 0x19, 0x29),
|
||||||
subpalette(BACKDROP, 0x07, 0x16, 0x27),
|
subpalette(BACKDROP, 0x07, 0x16, 0x27),
|
||||||
],
|
],
|
||||||
sprites: [
|
sprites: [
|
||||||
|
|
@ -85,7 +85,7 @@ export const THEMES = {
|
||||||
bg: [
|
bg: [
|
||||||
subpalette(BACKDROP, 0x01, 0x11, 0x21),
|
subpalette(BACKDROP, 0x01, 0x11, 0x21),
|
||||||
subpalette(BACKDROP, 0x02, 0x12, 0x31),
|
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
|
// Floodlit earth against dark grass. The dirt has to stay warm and bright
|
||||||
// or the track disappears into the infield after dark.
|
// or the track disappears into the infield after dark.
|
||||||
subpalette(BACKDROP, 0x08, 0x18, 0x28),
|
subpalette(BACKDROP, 0x08, 0x18, 0x28),
|
||||||
|
|
@ -119,7 +119,7 @@ export const THEMES = {
|
||||||
subpalette(BACKDROP, 0x02, 0x12, 0x31),
|
subpalette(BACKDROP, 0x02, 0x12, 0x31),
|
||||||
// Snowfield, and churned earth cut through it. All-grey on all-white had
|
// Snowfield, and churned earth cut through it. All-grey on all-white had
|
||||||
// almost no contrast — you could not see the ramps coming.
|
// 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),
|
subpalette(BACKDROP, 0x07, 0x17, 0x27),
|
||||||
],
|
],
|
||||||
sprites: [
|
sprites: [
|
||||||
|
|
@ -418,6 +418,54 @@ export const TILES = {
|
||||||
/** Every tile in the bank, keyed by name — used by the verifier. */
|
/** Every tile in the bank, keyed by name — used by the verifier. */
|
||||||
export const TILE_NAMES = Object.keys(TILES);
|
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
|
// The bike
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,31 @@ export const TUNE = {
|
||||||
// Where a rider aims the nose on the way down: a little above the ground,
|
// Where a rider aims the nose on the way down: a little above the ground,
|
||||||
// because that is the forgiving side.
|
// because that is the forgiving side.
|
||||||
landBias: 0.14,
|
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.
|
// Surfaces.
|
||||||
mudSpeedCap: 74,
|
mudSpeedCap: 74,
|
||||||
roughSpeedDrain: 60,
|
roughSpeedDrain: 60,
|
||||||
roughBounce: 34,
|
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
|
// 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
|
// 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.
|
// 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;
|
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) {
|
function emit(state, ev) {
|
||||||
state.events.push(ev);
|
state.events.push(ev);
|
||||||
}
|
}
|
||||||
|
|
@ -461,13 +489,14 @@ function stepBike(state, b, input) {
|
||||||
|
|
||||||
// ---- rough ground and obstacles -----------------------------------------
|
// ---- rough ground and obstacles -----------------------------------------
|
||||||
if (b.state === STATE.RIDING) {
|
if (b.state === STATE.RIDING) {
|
||||||
if (surf === SURFACE.OBSTACLE) {
|
if (surf === SURFACE.OBSTACLE || surf === SURFACE.GAP) {
|
||||||
crash(state, b, 'obstacle');
|
if (b.vx > TUNE.surfaceCrashSpeed) {
|
||||||
return;
|
crash(state, b, surf === SURFACE.GAP ? 'gap' : 'obstacle');
|
||||||
}
|
return;
|
||||||
if (surf === SURFACE.GAP) {
|
}
|
||||||
crash(state, b, 'gap');
|
// Bogged down rather than thrown off. Capped below the crash speed, so a
|
||||||
return;
|
// 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) {
|
if (surf === SURFACE.ROUGH) {
|
||||||
// Whoops do not launch you, they rattle you: speed bleeds away and the
|
// 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) {
|
if (b.y <= ground) {
|
||||||
b.y = ground;
|
b.y = ground;
|
||||||
const impact = Math.abs(b.vy);
|
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 (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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Positive is nose-up relative to the ground, i.e. rear wheel first.
|
// 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;
|
let quality;
|
||||||
if (rel >= 0) {
|
if (rel >= 0) {
|
||||||
// Landing back-wheel-first is the right way to do it. Only a wheelie
|
// 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);
|
const slope = launchSlopeAt(model, lane, b.x);
|
||||||
b.state = STATE.AIRBORNE;
|
b.state = STATE.AIRBORNE;
|
||||||
b.vy = b.vx * slope;
|
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;
|
b.pitchVel = 0;
|
||||||
emit(state, { type: 'launch', bike: b.index, isPlayer: b.isPlayer, speed: b.vx });
|
emit(state, { type: 'launch', bike: b.index, isPlayer: b.isPlayer, speed: b.vx });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
INFIELD_Y, INFIELD_H, LANES_Y, LANE_H, LANE_COUNT, APRON_Y, APRON_H, TILE,
|
INFIELD_Y, INFIELD_H, LANES_Y, LANE_H, LANE_COUNT, APRON_Y, APRON_H, TILE,
|
||||||
} from './ExcitebikeNES.js';
|
} from './ExcitebikeNES.js';
|
||||||
import {
|
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,
|
SP_PLAYER, SP_RIVAL, SP_FLESH, SP_FX, S,
|
||||||
buildFontGrid, buildBikeFrames, buildTumbleFrame, buildRunFrame,
|
buildFontGrid, buildBikeFrames, buildTumbleFrame, buildRunFrame,
|
||||||
buildDownedBike, buildDustFrame, packStrip,
|
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;
|
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 = 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);
|
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.
|
// 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.
|
// climb. Matches the launch-edge drop the sim uses.
|
||||||
const SHEER_FACE = 3;
|
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_X = 96;
|
||||||
export const HEAT_BAR_Y = 20;
|
export const HEAT_BAR_Y = 20;
|
||||||
export const HEAT_BAR_W = 64;
|
export const HEAT_BAR_W = 64;
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
*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
|
## Still to do
|
||||||
|
|
||||||
- **iconFrame 91** in `assets/images/game-icons.png` (44×44, row 6 col 2) — the menu lists the game
|
- Cool zones and mud are not transcribed from the maps; see the markings section of
|
||||||
fine without it, but it has no icon
|
`docs/excitebike-build-plan.md` for what was tried and what to try next
|
||||||
- parallax backdrops and per-track crowd colour; the themes exist but the backdrop is plain
|
|
||||||
|
|
|
||||||
|
|
@ -167,24 +167,54 @@ function findGaps(img, t) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Textured patches: stretches where the shade colour floods a lane far above
|
* Surface markings on flat ground.
|
||||||
* its baseline. On these maps that is churned ground — mud and whoops.
|
*
|
||||||
|
* 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) {
|
function findMarkings(img, t, ramps) {
|
||||||
const shade = [...t.material].find((c) => c !== t.surface) ?? t.surface;
|
const covered = new Uint8Array(t.length);
|
||||||
const perLane = [];
|
for (const r of ramps) {
|
||||||
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
|
for (let i = Math.max(0, r.x - 2); i < Math.min(t.length, r.x + r.width + 2); i += 1) {
|
||||||
const y0 = LANES_Y + lane * LANE_H;
|
covered[i] = 1;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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' };
|
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) {
|
function transcribe(n) {
|
||||||
const img = decodePng(readFileSync(join(DIR, `track${n}.png`)));
|
const img = decodePng(readFileSync(join(DIR, `track${n}.png`)));
|
||||||
const t = readTrack(img);
|
const t = readTrack(img);
|
||||||
const ramps = findRamps(img, t);
|
const ramps = findRamps(img, t);
|
||||||
const gapLanes = findGaps(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.
|
// Merge per-lane gap runs that overlap into one hurdle spanning those lanes.
|
||||||
const gapEvents = [];
|
const gapEvents = [];
|
||||||
|
|
@ -251,24 +302,10 @@ function transcribe(n) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const texEvents = [];
|
const mud = mergeLaneRuns(marks.mud);
|
||||||
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
|
const cool = mergeLaneRuns(marks.cool);
|
||||||
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]) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { n, img, t, ramps, gaps, patches, theme: THEMES[n] };
|
return { n, img, t, ramps, gaps, mud, cool, theme: THEMES[n] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
@ -280,7 +317,8 @@ for (const r of results) {
|
||||||
console.log(` surface #${r.t.surface.toString(16).padStart(6, '0')}`
|
console.log(` surface #${r.t.surface.toString(16).padStart(6, '0')}`
|
||||||
+ ` infield #${r.t.infield.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(' ')}`);
|
+ ` 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();
|
const byHeight = new Map();
|
||||||
for (const ramp of r.ramps) {
|
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(' 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(' 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import {
|
||||||
gridSlots, fitsSubpalette,
|
gridSlots, fitsSubpalette,
|
||||||
} from '../src/games/excitebike/ExcitebikeNES.js';
|
} from '../src/games/excitebike/ExcitebikeNES.js';
|
||||||
import {
|
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,
|
buildBikeFrames, buildBikeFrame, buildTumbleFrame, buildRunFrame, buildDustFrame,
|
||||||
buildDownedBike, packStrip, pitchFrameIndex, pitchFrameAngle, bikeFrame,
|
buildDownedBike, packStrip, pitchFrameIndex, pitchFrameAngle, bikeFrame,
|
||||||
BIKE_FRAME, BIKE_FRAME_COUNT, PITCH_FRAMES, PITCH_MAX, TREAD_FRAMES,
|
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');
|
'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) {
|
for (const id of THEME_IDS) {
|
||||||
const theme = THEMES[id];
|
const theme = THEMES[id];
|
||||||
check(`theme ${id} has 4 background subpalettes`, theme.bg.length === 4);
|
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);
|
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.
|
// Overheating must stall you, and cool zones must be the answer.
|
||||||
{
|
{
|
||||||
const hot = buildTrackModel({
|
const hot = buildTrackModel({
|
||||||
|
|
@ -556,6 +625,45 @@ section('4. Physics invariants');
|
||||||
`${TUNE.speedAccel} -> ${TUNE.speedTurbo}`);
|
`${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.
|
// Contact: catching a leader from behind at speed puts YOU down.
|
||||||
{
|
{
|
||||||
const flat = buildTrackModel({
|
const flat = buildTrackModel({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue