Excitebike

This commit is contained in:
Brian Fertig 2026-07-29 22:05:21 -06:00
parent a6573726ff
commit 4e818ad104
34 changed files with 7695 additions and 13 deletions

View File

@ -0,0 +1,137 @@
{
"version": 1,
"id": "track-01",
"name": "TRACK 1",
"theme": "day",
"length": 5888,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 79100,
"hurdles": [
{
"t": "M",
"x": 577
},
{
"t": "H",
"x": 1057
},
{
"t": "B",
"x": 1385
},
{
"t": "N",
"x": 1588
},
{
"t": "B",
"x": 1817
},
{
"t": "B",
"x": 1873
},
{
"t": "M",
"x": 2116
},
{
"t": "B",
"x": 2385
},
{
"t": "B",
"x": 2441
},
{
"t": "B",
"x": 2497
},
{
"t": "N",
"x": 2708
},
{
"t": "C",
"x": 2945
},
{
"t": "C",
"x": 3036
},
{
"t": "M",
"x": 3220
},
{
"t": "E",
"x": 3425
},
{
"t": "N",
"x": 3545
},
{
"t": "E",
"x": 3713
},
{
"t": "M",
"x": 3849
},
{
"t": "E",
"x": 4033
},
{
"t": "A",
"x": 4065
},
{
"t": "A",
"x": 4105
},
{
"t": "A",
"x": 4145
},
{
"t": "A",
"x": 4185
},
{
"t": "C",
"x": 4369
},
{
"t": "Q",
"x": 4440
},
{
"t": "D",
"x": 4690
},
{
"t": "D",
"x": 4778
},
{
"t": "D",
"x": 4898
},
{
"t": "D",
"x": 4986
},
{
"t": "N",
"x": 5324
},
{
"t": "C",
"x": 5657
}
],
"rough": []
}

View File

@ -0,0 +1,109 @@
{
"version": 1,
"id": "track-02",
"name": "TRACK 2",
"theme": "night",
"length": 5393,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 81200,
"hurdles": [
{
"t": "M",
"x": 521
},
{
"t": "R",
"x": 945
},
{
"t": "B",
"x": 1233
},
{
"t": "R",
"x": 1497
},
{
"t": "N",
"x": 1816
},
{
"t": "B",
"x": 1993
},
{
"t": "B",
"x": 2121
},
{
"t": "M",
"x": 2280
},
{
"t": "A",
"x": 2465
},
{
"t": "H",
"x": 2504
},
{
"t": "N",
"x": 2680
},
{
"t": "E",
"x": 2849
},
{
"t": "A",
"x": 2881
},
{
"t": "Q",
"x": 2928
},
{
"t": "E",
"x": 3033
},
{
"t": "Q",
"x": 3072
},
{
"t": "M",
"x": 3273
},
{
"t": "D",
"x": 3458
},
{
"t": "D",
"x": 3546
},
{
"t": "D",
"x": 3634
},
{
"t": "D",
"x": 3722
},
{
"t": "P",
"x": 3984
},
{
"t": "N",
"x": 4612
},
{
"t": "C",
"x": 5153
}
],
"rough": []
}

View File

@ -0,0 +1,109 @@
{
"version": 1,
"id": "track-03",
"name": "TRACK 3",
"theme": "day",
"length": 6416,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 103300,
"hurdles": [
{
"t": "M",
"x": 845
},
{
"t": "B",
"x": 1593
},
{
"t": "B",
"x": 1665
},
{
"t": "C",
"x": 1857
},
{
"t": "C",
"x": 1980
},
{
"t": "C",
"x": 2185
},
{
"t": "H",
"x": 2248
},
{
"t": "C",
"x": 2308
},
{
"t": "P",
"x": 2552
},
{
"t": "N",
"x": 2956
},
{
"t": "E",
"x": 3273
},
{
"t": "B",
"x": 3361
},
{
"t": "Q",
"x": 3424
},
{
"t": "E",
"x": 3729
},
{
"t": "H",
"x": 3833
},
{
"t": "H",
"x": 4137
},
{
"t": "C",
"x": 4260
},
{
"t": "C",
"x": 4353
},
{
"t": "H",
"x": 4441
},
{
"t": "P",
"x": 4744
},
{
"t": "M",
"x": 5068
},
{
"t": "O",
"x": 5304
},
{
"t": "N",
"x": 5784
},
{
"t": "C",
"x": 6177
}
],
"rough": []
}

View File

@ -0,0 +1,141 @@
{
"version": 1,
"id": "track-04",
"name": "TRACK 4",
"theme": "desert",
"length": 6528,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 103200,
"hurdles": [
{
"t": "M",
"x": 640
},
{
"t": "H",
"x": 1184
},
{
"t": "P",
"x": 1241
},
{
"t": "N",
"x": 1516
},
{
"t": "H",
"x": 1704
},
{
"t": "P",
"x": 1761
},
{
"t": "B",
"x": 1953
},
{
"t": "M",
"x": 2208
},
{
"t": "B",
"x": 2489
},
{
"t": "Q",
"x": 2552
},
{
"t": "E",
"x": 2841
},
{
"t": "E",
"x": 2961
},
{
"t": "H",
"x": 3008
},
{
"t": "A",
"x": 3081
},
{
"t": "A",
"x": 3121
},
{
"t": "N",
"x": 3300
},
{
"t": "E",
"x": 3521
},
{
"t": "H",
"x": 3568
},
{
"t": "H",
"x": 3624
},
{
"t": "H",
"x": 3680
},
{
"t": "M",
"x": 3939
},
{
"t": "P",
"x": 4192
},
{
"t": "O",
"x": 4440
},
{
"t": "H",
"x": 4729
},
{
"t": "H",
"x": 4817
},
{
"t": "H",
"x": 4905
},
{
"t": "H",
"x": 4993
},
{
"t": "H",
"x": 5081
},
{
"t": "Q",
"x": 5176
},
{
"t": "N",
"x": 5713
},
{
"t": "E",
"x": 6233
},
{
"t": "C",
"x": 6289
}
],
"rough": []
}

View File

@ -0,0 +1,141 @@
{
"version": 1,
"id": "track-05",
"name": "TRACK 5",
"theme": "snow",
"length": 5752,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 112600,
"hurdles": [
{
"t": "M",
"x": 633
},
{
"t": "E",
"x": 1169
},
{
"t": "Q",
"x": 1208
},
{
"t": "C",
"x": 1348
},
{
"t": "R",
"x": 1441
},
{
"t": "A",
"x": 1665
},
{
"t": "A",
"x": 1713
},
{
"t": "A",
"x": 1761
},
{
"t": "N",
"x": 2124
},
{
"t": "E",
"x": 2529
},
{
"t": "H",
"x": 2697
},
{
"t": "H",
"x": 2801
},
{
"t": "H",
"x": 2913
},
{
"t": "C",
"x": 3017
},
{
"t": "Q",
"x": 3088
},
{
"t": "H",
"x": 3264
},
{
"t": "P",
"x": 3321
},
{
"t": "B",
"x": 3529
},
{
"t": "E",
"x": 3625
},
{
"t": "E",
"x": 3849
},
{
"t": "Q",
"x": 3888
},
{
"t": "H",
"x": 3952
},
{
"t": "D",
"x": 4042
},
{
"t": "D",
"x": 4130
},
{
"t": "A",
"x": 4217
},
{
"t": "D",
"x": 4258
},
{
"t": "D",
"x": 4346
},
{
"t": "D",
"x": 4434
},
{
"t": "D",
"x": 4522
},
{
"t": "H",
"x": 4609
},
{
"t": "M",
"x": 5068
},
{
"t": "C",
"x": 5521
}
],
"rough": []
}

View File

@ -0,0 +1,152 @@
{
"version": 1,
"id": "track-06",
"name": "TRACK 6",
"theme": "dusk",
"length": 6600,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 120600,
"hurdles": [
{
"t": "C",
"x": 256
},
{
"t": "E",
"x": 483
},
{
"t": "J",
"x": 603
},
{
"t": "I",
"x": 752
},
{
"t": "D",
"x": 924
},
{
"t": "B",
"x": 1153
},
{
"t": "M",
"x": 1326
},
{
"t": "D",
"x": 1520
},
{
"t": "C",
"x": 1706
},
{
"t": "E",
"x": 1887
},
{
"t": "P",
"x": 2022
},
{
"t": "M",
"x": 2362
},
{
"t": "A",
"x": 2560
},
{
"t": "E",
"x": 2694
},
{
"t": "J",
"x": 2891
},
{
"t": "K",
"x": 3039
},
{
"t": "O",
"x": 3240
},
{
"t": "N",
"x": 3541
},
{
"t": "B",
"x": 3727
},
{
"t": "L",
"x": 3890
},
{
"t": "O",
"x": 4074
},
{
"t": "J",
"x": 4413
},
{
"t": "N",
"x": 4587
},
{
"t": "B",
"x": 4769
},
{
"t": "O",
"x": 4914
},
{
"t": "D",
"x": 5209
},
{
"t": "L",
"x": 5382
},
{
"t": "M",
"x": 5590
},
{
"t": "C",
"x": 5806
},
{
"t": "B",
"x": 6030
},
{
"t": "C",
"x": 6178
}
],
"rough": [
{
"x": 2303,
"lanes": [
2,
3
]
},
{
"x": 6386,
"lanes": [
2,
3
]
}
]
}

View File

@ -0,0 +1,168 @@
{
"version": 1,
"id": "track-07",
"name": "TRACK 7",
"theme": "desert",
"length": 6900,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 103900,
"hurdles": [
{
"t": "J",
"x": 256
},
{
"t": "I",
"x": 404
},
{
"t": "P",
"x": 580
},
{
"t": "E",
"x": 863
},
{
"t": "B",
"x": 994
},
{
"t": "L",
"x": 1128
},
{
"t": "M",
"x": 1348
},
{
"t": "L",
"x": 1508
},
{
"t": "J",
"x": 1698
},
{
"t": "P",
"x": 1845
},
{
"t": "C",
"x": 2117
},
{
"t": "E",
"x": 2262
},
{
"t": "M",
"x": 2404
},
{
"t": "E",
"x": 2595
},
{
"t": "P",
"x": 2753
},
{
"t": "B",
"x": 3006
},
{
"t": "O",
"x": 3211
},
{
"t": "N",
"x": 3472
},
{
"t": "C",
"x": 3678
},
{
"t": "E",
"x": 3850
},
{
"t": "I",
"x": 3972
},
{
"t": "K",
"x": 4127
},
{
"t": "C",
"x": 4313
},
{
"t": "M",
"x": 4488
},
{
"t": "J",
"x": 4684
},
{
"t": "H",
"x": 4862
},
{
"t": "J",
"x": 5081
},
{
"t": "G",
"x": 5200
},
{
"t": "F",
"x": 5340
},
{
"t": "M",
"x": 5523
},
{
"t": "C",
"x": 5690
},
{
"t": "F",
"x": 5853
},
{
"t": "G",
"x": 6051
},
{
"t": "K",
"x": 6221
},
{
"t": "P",
"x": 6391
}
],
"rough": [
{
"x": 1937,
"lanes": [
2,
3
]
},
{
"x": 5111,
"lanes": [
2,
3
]
}
]
}

View File

@ -0,0 +1,207 @@
{
"version": 1,
"id": "track-08",
"name": "TRACK 8",
"theme": "night",
"length": 7200,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 105800,
"hurdles": [
{
"t": "J",
"x": 256
},
{
"t": "G",
"x": 396
},
{
"t": "K",
"x": 575
},
{
"t": "J",
"x": 760
},
{
"t": "J",
"x": 883
},
{
"t": "C",
"x": 1021
},
{
"t": "J",
"x": 1182
},
{
"t": "N",
"x": 1300
},
{
"t": "C",
"x": 1467
},
{
"t": "F",
"x": 1617
},
{
"t": "J",
"x": 1762
},
{
"t": "E",
"x": 1865
},
{
"t": "I",
"x": 1973
},
{
"t": "C",
"x": 2110
},
{
"t": "M",
"x": 2257
},
{
"t": "F",
"x": 2454
},
{
"t": "G",
"x": 2651
},
{
"t": "F",
"x": 2806
},
{
"t": "C",
"x": 2979
},
{
"t": "I",
"x": 3132
},
{
"t": "M",
"x": 3269
},
{
"t": "C",
"x": 3425
},
{
"t": "S",
"x": 3555
},
{
"t": "J",
"x": 3782
},
{
"t": "H",
"x": 3889
},
{
"t": "C",
"x": 4066
},
{
"t": "Q",
"x": 4109
},
{
"t": "M",
"x": 4332
},
{
"t": "H",
"x": 4494
},
{
"t": "E",
"x": 4676
},
{
"t": "J",
"x": 4795
},
{
"t": "I",
"x": 4936
},
{
"t": "I",
"x": 5098
},
{
"t": "H",
"x": 5201
},
{
"t": "N",
"x": 5389
},
{
"t": "I",
"x": 5604
},
{
"t": "I",
"x": 5762
},
{
"t": "S",
"x": 5907
},
{
"t": "H",
"x": 6129
},
{
"t": "J",
"x": 6323
},
{
"t": "N",
"x": 6495
},
{
"t": "C",
"x": 6688
},
{
"t": "K",
"x": 6843
}
],
"rough": [
{
"x": 1683,
"lanes": [
2,
3
]
},
{
"x": 4346,
"lanes": [
2,
3
]
},
{
"x": 7017,
"lanes": [
2,
3
]
}
]
}

View File

@ -0,0 +1,199 @@
{
"version": 1,
"id": "track-09",
"name": "TRACK 9",
"theme": "snow",
"length": 7400,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 119700,
"hurdles": [
{
"t": "H",
"x": 256
},
{
"t": "H",
"x": 409
},
{
"t": "J",
"x": 596
},
{
"t": "O",
"x": 726
},
{
"t": "K",
"x": 998
},
{
"t": "S",
"x": 1189
},
{
"t": "N",
"x": 1368
},
{
"t": "O",
"x": 1573
},
{
"t": "I",
"x": 1866
},
{
"t": "L",
"x": 2015
},
{
"t": "S",
"x": 2171
},
{
"t": "N",
"x": 2383
},
{
"t": "G",
"x": 2534
},
{
"t": "C",
"x": 2706
},
{
"t": "K",
"x": 2863
},
{
"t": "H",
"x": 3046
},
{
"t": "C",
"x": 3234
},
{
"t": "M",
"x": 3415
},
{
"t": "I",
"x": 3611
},
{
"t": "H",
"x": 3718
},
{
"t": "C",
"x": 3909
},
{
"t": "J",
"x": 4073
},
{
"t": "J",
"x": 4193
},
{
"t": "J",
"x": 4336
},
{
"t": "N",
"x": 4462
},
{
"t": "S",
"x": 4658
},
{
"t": "F",
"x": 4885
},
{
"t": "F",
"x": 5042
},
{
"t": "J",
"x": 5206
},
{
"t": "J",
"x": 5333
},
{
"t": "N",
"x": 5440
},
{
"t": "E",
"x": 5628
},
{
"t": "G",
"x": 5742
},
{
"t": "O",
"x": 5898
},
{
"t": "J",
"x": 6175
},
{
"t": "J",
"x": 6277
},
{
"t": "N",
"x": 6410
},
{
"t": "E",
"x": 6556
},
{
"t": "C",
"x": 6710
},
{
"t": "I",
"x": 6890
},
{
"t": "H",
"x": 6988
}
],
"rough": [
{
"x": 1724,
"lanes": [
0,
1
]
},
{
"x": 4392,
"lanes": [
2,
3
]
},
{
"x": 7188,
"lanes": [
0,
1
]
}
]
}

View File

@ -0,0 +1,207 @@
{
"version": 1,
"id": "track-10",
"name": "TRACK 10",
"theme": "night",
"length": 7800,
"laps": 2,
"mainLaps": 2,
"qualifyMs": 120400,
"hurdles": [
{
"t": "S",
"x": 256
},
{
"t": "L",
"x": 488
},
{
"t": "K",
"x": 677
},
{
"t": "S",
"x": 869
},
{
"t": "J",
"x": 1084
},
{
"t": "M",
"x": 1237
},
{
"t": "K",
"x": 1417
},
{
"t": "L",
"x": 1592
},
{
"t": "R",
"x": 1788
},
{
"t": "L",
"x": 2116
},
{
"t": "N",
"x": 2291
},
{
"t": "I",
"x": 2480
},
{
"t": "H",
"x": 2589
},
{
"t": "F",
"x": 2789
},
{
"t": "C",
"x": 2911
},
{
"t": "S",
"x": 3039
},
{
"t": "M",
"x": 3250
},
{
"t": "H",
"x": 3394
},
{
"t": "E",
"x": 3566
},
{
"t": "H",
"x": 3662
},
{
"t": "J",
"x": 3816
},
{
"t": "K",
"x": 3954
},
{
"t": "G",
"x": 4155
},
{
"t": "N",
"x": 4294
},
{
"t": "C",
"x": 4459
},
{
"t": "L",
"x": 4610
},
{
"t": "E",
"x": 4791
},
{
"t": "H",
"x": 4902
},
{
"t": "C",
"x": 5060
},
{
"t": "H",
"x": 5190
},
{
"t": "M",
"x": 5344
},
{
"t": "R",
"x": 5497
},
{
"t": "P",
"x": 5836
},
{
"t": "J",
"x": 6063
},
{
"t": "H",
"x": 6169
},
{
"t": "N",
"x": 6327
},
{
"t": "K",
"x": 6495
},
{
"t": "J",
"x": 6697
},
{
"t": "I",
"x": 6788
},
{
"t": "G",
"x": 6926
},
{
"t": "J",
"x": 7104
},
{
"t": "I",
"x": 7247
},
{
"t": "M",
"x": 7383
}
],
"rough": [
{
"x": 1516,
"lanes": [
2,
3
]
},
{
"x": 3900,
"lanes": [
2,
3
]
},
{
"x": 6360,
"lanes": [
2,
3
]
}
]
}

View File

@ -0,0 +1,75 @@
{
"version": 1,
"tracks": [
{
"n": 1,
"id": "track-01",
"name": "TRACK 1",
"theme": "day",
"file": "track-01.json"
},
{
"n": 2,
"id": "track-02",
"name": "TRACK 2",
"theme": "night",
"file": "track-02.json"
},
{
"n": 3,
"id": "track-03",
"name": "TRACK 3",
"theme": "day",
"file": "track-03.json"
},
{
"n": 4,
"id": "track-04",
"name": "TRACK 4",
"theme": "desert",
"file": "track-04.json"
},
{
"n": 5,
"id": "track-05",
"name": "TRACK 5",
"theme": "snow",
"file": "track-05.json"
},
{
"n": 6,
"id": "track-06",
"name": "TRACK 6",
"theme": "dusk",
"file": "track-06.json"
},
{
"n": 7,
"id": "track-07",
"name": "TRACK 7",
"theme": "desert",
"file": "track-07.json"
},
{
"n": 8,
"id": "track-08",
"name": "TRACK 8",
"theme": "night",
"file": "track-08.json"
},
{
"n": 9,
"id": "track-09",
"name": "TRACK 9",
"theme": "snow",
"file": "track-09.json"
},
{
"n": 10,
"id": "track-10",
"name": "TRACK 10",
"theme": "night",
"file": "track-10.json"
}
]
}

View File

@ -0,0 +1,204 @@
# Excitebike — build plan
Living plan doc. Survives context clears: **read this file first**, pick the next unchecked item,
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**.
**Never opened in a browser.** Brian playtests; nothing here is signed off until he has.
## Decisions taken up front
| Question | Answer |
|---|---|
| Scope | Single player, faithful to the 1984 NES original |
| Modes | SELECTION A (solo vs the clock), SELECTION B (vs rivals), DESIGN — the original's three |
| Rivals | Anonymous NES bikes in the alternate palette. No names, no opponent roster |
| 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) |
## Files
| File | Role | State |
|---|---|---|
| `src/games/excitebike/ExcitebikeNES.js` | NES palette, index grids, 8×8 tiles, screen geometry | done |
| `src/games/excitebike/ExcitebikeArt.js` | Tile bank, themes, 5×7 font, parametric bike/rider/dust | done |
| `src/games/excitebike/ExcitebikeTrack.js` | Hurdles AS, terrain compiler, sampling, validation | done |
| `src/games/excitebike/ExcitebikeLogic.js` | 60Hz sim: throttle, heat, jumps, crashes, rivals | done |
| `src/games/excitebike/ExcitebikeAuto.js` | Reference riders + `probeTrack` | done |
| `src/games/excitebike/ExcitebikeRaster.js` | Index grids and terrain → canvases | done |
| `src/games/excitebike/ExcitebikeGame.js` | Phaser scene: viewport, screens, HUD, audio | done |
| `src/games/excitebike/ExcitebikeDesign.js` | DESIGN mode screen | done |
| `src/games/excitebike/ExcitebikeDesignData.js` | Node-safe half of DESIGN, so the harness can pin it | done |
| `src/games/excitebike/sprites.md` | Drop-in art spec | done |
| `assets/gamedata/excitebike/` | `tracks.json` + `track-01..10.json` | done |
| `tools/genExcitebikeTracks.js` | Builds the bank, measures the qualifying times | done |
| `tools/readExcitebikeMaps.js` | Transcribes the five originals from their map PNGs | done |
| `tools/lib/png.js`, `tools/lib/canvasStub.js` | Zero-dependency PNG reader; headless canvas | done |
| `tools/verifyExcitebike.js` | The harness | done |
Wiring is in place across the six touchpoints: `gamesRegistry.js`, `main.js` (import + scene array),
`GameRoomScene.js` `slugDispatch`, `assetManifest.js` (music only — every pixel is generated),
`soundtrack.js` (`excitebike: 'nintendo'`). `PreloadScene` needed no change: there is no artwork JSON
because there is no artwork to fetch.
---
## Wave 0 — NES foundation — ✅ DONE 2026-07-29
Palette, tile bank, raster stage, the masked ×4 viewport, CRT overlay, all wiring.
- [x] 64-entry NES master palette; everything drawn samples only from it
- [x] Art as **index grids** of subpalette slots, not pixels, so Node can assert on it
- [x] 8×8 tile bank as string art; 5×7 font in 8×8 cells via Phaser `RetroFont`
- [x] One container at NES coordinates, `setScale(4)`, geometry-masked to a 1024×960 window
- [x] Cabinet bezel drawn inside the scene so the CRT curve wraps it too
### Wave 0 findings
1. **The art has to be data to be testable.** Grids of slot indices cost nothing and let the harness
prove every tile stays inside four colours and every sprite inside its subpalette — with no canvas
and no dependency. `ExcitebikeRaster.js` is the only module that touches the DOM, and
`tools/lib/canvasStub.js` stubs enough of it (`createImageData`/`putImageData`/`getImageData`) to
run that module under Node as well. Both halves are checked.
2. **Draw the bike parametrically, not by hand.** 17 pitch angles × 4 wheel phases is 68 frames of
the same machine; hand-authoring them gets you 68 subtly different machines. Drawing into an index
grid keeps the NES constraints while giving smooth animation.
3. **Containers ignore child depth.** Draw order inside the viewport container is insertion order.
Bikes are sorted per frame with `bikeLayer.sort('y')` so nearer lanes paint last.
4. **Integer scale only.** 1080/240 is 4.5, and half the rows would be five pixels tall while the
rest were four. On a scrolling track that shimmer is very visible. ×4 in a bezel is correct.
5. **`RetroFont` is not used anywhere else in this repo** — the config shape was verified against the
Phaser 3.90 bundle before committing to it (`ParseRetroFont`: `image`, `width`, `height`, `chars`,
`charsPerRow`, `offset`, `spacing`; `xAdvance` is `width`, so text centres on `len * 8`).
## Wave 1 — Ride it — ✅ DONE 2026-07-29
Full bike physics, heat, jumps, crashes, SELECTION A.
- [x] Fixed 60Hz accumulator, seeded RNG, deterministic from a seed + input trace
- [x] A accelerates and releasing brakes; B is turbo and heats the engine; cool zones dump heat
- [x] Landing judged on `|pitch groundAngle|`: clean, hard, or down
- [x] Crash → tumble → run on foot mashing A → remount
- [x] `M:SS:hh` clock, `3RD` target, heat meter, wall BEST — the original's HUD
### Wave 1 findings
1. **Feathering turbo was free speed.** With a slow bleed off the top end, tapping B held you at turbo
pace while the meter hovered. `turboBleedRate` makes speed above the normal top end fall away fast,
so sustained turbo has to be paid for in heat.
2. **Pitch control needs weight.** The first cut integrated pitch velocity eight times too fast and
the nose could be swung end to end in a fifth of a second, which made every landing trivial and
made the skill dial do nothing. At ~0.3s level-to-full-up, jumps have to be planned on the way up.
3. **Landing tolerance must narrow with impact speed.** A fixed window meant a passive rider never
crashed: gravity alone left them inside it. `landSpeedSqueeze` makes the big jumps the ones that
punish a lazy attitude, which is what the ramps are for.
4. **A crash cost three seconds and there were six a race.** Recovery dominated everything. Shorter
tumble, a shorter slide, and a shove on the remount instead of a dead stop brought it to about two.
## Wave 2 — The five NES courses — ✅ DONE 2026-07-29
All 19 hurdles, and the originals transcribed rather than guessed.
- [x] `tools/lib/png.js` — PNG decode on `node:zlib`, no dependency
- [x] `tools/readExcitebikeMaps.js` — palette, extent, ramps, holes, straight off the images
- [x] Hurdle catalogue reshaped to the heights and footprints the originals actually use
- [x] Playfield geometry corrected to the original's own bands
### Wave 2 findings
1. **The maps carry a legend panel.** The first 256512px of each image is a black/navy panel with
the track's stats on it, not track. Reading the image width as the course length is wrong by up to
512px. The real lengths are **5888 / 5393 / 6416 / 6528 / 5752**.
2. **Lanes are 12 pixels.** The playfield splits crowd 039, sky and wall 4063, infield 64127, four
12px lanes 128175, apron 176191. The first build guessed 24px lanes in a 56152 band and was
wrong about every proportion. A lane is a tile and a half deep, so lane dividers land mid-tile —
that is the real geometry, not a rounding slip.
3. **Almost nothing in the original is a ramp with a cliff on the end.** They are *hills*. Of Track 1's
22 raised features, 19 rise and come straight back down. Only the short steep ones (h16 w15) end
high. The catalogue was rebuilt around that: A/B/C/D/H/R are mounds, E and S are the launchers.
4. **So hills have to launch you by curvature.** You leave a crest when `v² · curvature > g`. That one
line is what turns the original's rolling terrain into jumps at speed and leaves it as bumps at a
crawl. Without it every course in the bank is a flat road with decoration on it.
5. **A launch edge is two pixels wide and the bike moves four.** Testing the crest at exactly the
bike's position stepped straight over every ramp. The sim now tests the stretch it is about to
cross, scaled by speed.
6. **Each course has a rare highlight colour** used only on lit ramp faces. A material threshold tuned
on the common colours misses it, which loses small ramps — and a missed ramp in front of a hole
turns a jump into an unclearable wall. That was the one validation failure in the transcription.
### Known gap, deliberate
Cool zones and mud are **not** transcribed. Both are drawn as texture inside the lane band, and on
these maps that texture is not separable from the shading on a ramp body — the detector fires on
every ramp. Rather than emit hurdles the images do not support, cool zones are placed into the long
clear stretches on a rule and mud is left out. This is the one part of the five courses that is not
the original's. Worth revisiting if the maps can be read better.
## Wave 3 — Racing — ✅ DONE 2026-07-29
- [x] Rival bikes, gap avoidance, lane preference, traffic awareness
- [x] The manual's contact rule: catch a leader from behind and *you* go down
- [x] Qualify → main race → next track, best times and progress in localStorage
### Wave 3 findings
1. **Contact needs a closing speed.** Crashing whoever was behind on any overlap wiped the pack out on
every straight. Below `contactKnockdownSpeed` the trailing bike is simply held up.
2. **The reference rider has to avoid traffic too**, or SELECTION B measures a bot flaw rather than the
race: it rear-ended the pack all afternoon and never won.
3. **Rivals must be as fallible as a player.** Give them exact landing angles and they ride perfectly,
the race has no attrition, and nobody holding a controller can win. They now misjudge and react
late on the same terms the `human` probe does.
## Wave 4 — DESIGN mode — ✅ DONE 2026-07-29
- [x] `ABCDEFGHIJKLMNOPQRS CL END LP` strip, bike-as-cursor, 50-hurdle cap, laps 19
- [x] `PLAY MODE A / PLAY MODE B / DESIGN / SAVE / LOAD / RESET`, one save slot
- [x] A designed track is priced by the same reference rider the shipped ones are
### Editor contract, pinned by the verifier
`ExcitebikeDesign.js` imports Phaser and cannot run under Node, so the parts the editor rests on live
in `ExcitebikeDesignData.js` and §9 of the harness asserts them: the palette strip is the 19 hurdles
plus `CL`/`END`/`LP`, a blank design is a legal track, a designed track survives a save/load
round-trip and can be ridden to the finish, and the 50-hurdle and 9-lap caps hold.
## Wave 5 — Polish — ⬜ NEXT
- [ ] 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
## How difficulty is decided
One place: `tools/genExcitebikeTracks.js`. Qualifying times are **measured, never guessed** — the
`human` probe in `ExcitebikeAuto.js` drives each course over five seeds and the target is the median
plus 6%. The expert is deliberately *not* the yardstick: with perfect information and no reaction time
it is barely slowed by hazards at all, and a target priced off it would be unreachable.
The harness asserts both ends and the gradient across ability:
| rider | wins | podium |
|---|---|---|
| expert | 50100% | 85100% |
| human | 035% | 2070% |
| steady | 020% | 035% |
| naive | 02% | 010% |
A change that makes the pack trivial or impossible breaks one of those bands.
## Sources for original-game behaviour
- Official Nintendo manual (NES Classic edition), `CLV-P-NAAHE_en.pdf` — controls, HUD, the three
modes, cool zones, the rival contact rule, DESIGN mode's menu and caps
- The NES instruction manual's hurdle list — the 19 letters AS and their names
- nesmaps.com full-course track maps for Tracks 15 — the source for every length and hurdle position

View File

@ -64,6 +64,11 @@ export const MANIFEST = {
gootower: [
image('gootower-bg', 'assets/images/gootower/background-1.png'),
],
// Excitebike draws every pixel itself (see ExcitebikeRaster.js), so there is
// no art to fetch — only its soundtrack.
excitebike: [
(scene) => musicFrom(scene, 'nintendo-music'),
],
forbiddenisland: [
// Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded
// frame 2i+1 (see IslandData.TILE_FRAME_ROW).

View File

@ -118,3 +118,4 @@ registerGame({ slug: 'tetrisattack', name: 'Tetris Attack', category: 'arcade-co
registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 88 });
registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 89 });
registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 });
registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 });

View File

@ -0,0 +1,662 @@
// Excitebike art — every pixel in the game, generated as NES index grids.
//
// Headless: no Phaser, no canvas. Each generator returns grids of subpalette
// slot indices (see ExcitebikeNES.js); ExcitebikeRaster.js pairs them with a
// subpalette and paints real pixels. That keeps the art assertable in Node —
// tools/verifyExcitebike.js checks that no tile escapes its 4 colours.
//
// Two kinds of art live here:
// - background tiles, hand-authored 8x8 as string art, because they repeat
// on a tile lattice exactly the way a nametable does
// - sprites, drawn parametrically into index grids, because the bike needs
// 17 pitch angles x 4 wheel phases and hand-authoring 68 frames of the
// same machine is how you get 68 subtly different machines
import {
makeGrid, tile, setPx, fillRect, hLine, vLine, line, fillCircle, strokeCircle,
fillPoly, blit, subpalette, TILE,
} from './ExcitebikeNES.js';
// ---------------------------------------------------------------------------
// Slots
// ---------------------------------------------------------------------------
// Slot 0 is transparent for sprites and the shared backdrop for background
// tiles. Background tiles never use slot 0 as a drawable colour, so the
// backdrop only ever shows through where we mean it to.
export const S = { CLEAR: 0, DARK: 1, MID: 2, LIGHT: 3 };
// ---------------------------------------------------------------------------
// Palettes
// ---------------------------------------------------------------------------
// The PPU holds four background subpalettes and four sprite subpalettes at a
// time. Excitebike spends them like this, and a theme just swaps the numbers.
export const BG_CROWD = 0;
export const BG_WALL = 1;
export const BG_GRASS = 2;
export const BG_DIRT = 3;
export const SP_PLAYER = 0;
export const SP_RIVAL = 1;
export const SP_FLESH = 2;
export const SP_FX = 3;
export const BACKDROP = 0x0f; // black, shared by the playfield edges and the HUD
/**
* Track themes. Each is a full PPU palette state: 4 background subpalettes and
* 4 sprite subpalettes, all master-palette indices. `day` reproduces the
* original's first course; the rest reskin the same tiles for later tracks.
*/
export const THEMES = {
day: {
name: 'DAY',
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, 0x08, 0x17, 0x27), // dirt: dark brown, brown, tan
],
sprites: [
subpalette(BACKDROP, 0x0f, 0x30, 0x16), // player: black, white, red
subpalette(BACKDROP, 0x0f, 0x30, 0x11), // rival: black, white, blue
subpalette(BACKDROP, 0x0f, 0x27, 0x30), // flesh/dust: black, tan, white
subpalette(BACKDROP, 0x0f, 0x00, 0x30), // fx: black, grey, white
],
},
dusk: {
name: 'DUSK',
bg: [
subpalette(BACKDROP, 0x06, 0x17, 0x28),
subpalette(BACKDROP, 0x04, 0x24, 0x34),
subpalette(BACKDROP, 0x0b, 0x19, 0x29),
subpalette(BACKDROP, 0x07, 0x16, 0x27),
],
sprites: [
subpalette(BACKDROP, 0x0f, 0x30, 0x16),
subpalette(BACKDROP, 0x0f, 0x30, 0x2a),
subpalette(BACKDROP, 0x0f, 0x27, 0x30),
subpalette(BACKDROP, 0x0f, 0x00, 0x30),
],
},
night: {
name: 'NIGHT',
bg: [
subpalette(BACKDROP, 0x01, 0x11, 0x21),
subpalette(BACKDROP, 0x02, 0x12, 0x31),
subpalette(BACKDROP, 0x0b, 0x0a, 0x19),
subpalette(BACKDROP, 0x0d, 0x00, 0x10),
],
sprites: [
subpalette(BACKDROP, 0x0f, 0x30, 0x16),
subpalette(BACKDROP, 0x0f, 0x30, 0x2c),
subpalette(BACKDROP, 0x0f, 0x27, 0x30),
subpalette(BACKDROP, 0x0f, 0x00, 0x30),
],
},
desert: {
name: 'DESERT',
bg: [
subpalette(BACKDROP, 0x08, 0x28, 0x38),
subpalette(BACKDROP, 0x21, 0x31, 0x30),
subpalette(BACKDROP, 0x18, 0x28, 0x38),
subpalette(BACKDROP, 0x17, 0x27, 0x36),
],
sprites: [
subpalette(BACKDROP, 0x0f, 0x30, 0x16),
subpalette(BACKDROP, 0x0f, 0x30, 0x1c),
subpalette(BACKDROP, 0x0f, 0x27, 0x30),
subpalette(BACKDROP, 0x0f, 0x00, 0x30),
],
},
snow: {
name: 'SNOW',
bg: [
subpalette(BACKDROP, 0x00, 0x10, 0x30),
subpalette(BACKDROP, 0x02, 0x12, 0x31),
subpalette(BACKDROP, 0x10, 0x20, 0x30),
subpalette(BACKDROP, 0x00, 0x10, 0x20),
],
sprites: [
subpalette(BACKDROP, 0x0f, 0x30, 0x16),
subpalette(BACKDROP, 0x0f, 0x30, 0x05),
subpalette(BACKDROP, 0x0f, 0x27, 0x30),
subpalette(BACKDROP, 0x0f, 0x00, 0x30),
],
},
};
export const THEME_IDS = Object.keys(THEMES);
// ---------------------------------------------------------------------------
// Font
// ---------------------------------------------------------------------------
// A 5x7 letterform in an 8x8 cell — the shape of every first-party NES status
// bar. Rendered white so BitmapText tinting can recolour it per HUD field.
export const FONT_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ .:-/'!?*>=+()%";
const GLYPHS = {
'0': ['.###.', '#...#', '#..##', '#.#.#', '##..#', '#...#', '.###.'],
'1': ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'],
'2': ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'],
'3': ['#####', '...#.', '..#..', '...#.', '....#', '#...#', '.###.'],
'4': ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'],
'5': ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],
'6': ['..##.', '.#...', '#....', '####.', '#...#', '#...#', '.###.'],
'7': ['#####', '....#', '...#.', '..#..', '.#...', '.#...', '.#...'],
'8': ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
'9': ['.###.', '#...#', '#...#', '.####', '....#', '...#.', '.##..'],
A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
B: ['####.', '#...#', '#...#', '####.', '#...#', '#...#', '####.'],
C: ['.###.', '#...#', '#....', '#....', '#....', '#...#', '.###.'],
D: ['###..', '#..#.', '#...#', '#...#', '#...#', '#..#.', '###..'],
E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
F: ['#####', '#....', '#....', '####.', '#....', '#....', '#....'],
G: ['.###.', '#...#', '#....', '#.###', '#...#', '#...#', '.###.'],
H: ['#...#', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
I: ['.###.', '..#..', '..#..', '..#..', '..#..', '..#..', '.###.'],
J: ['..###', '...#.', '...#.', '...#.', '...#.', '#..#.', '.##..'],
K: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'],
L: ['#....', '#....', '#....', '#....', '#....', '#....', '#####'],
M: ['#...#', '##.##', '#.#.#', '#.#.#', '#...#', '#...#', '#...#'],
N: ['#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#', '#...#'],
O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
Q: ['.###.', '#...#', '#...#', '#...#', '#.#.#', '#..#.', '.##.#'],
R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
V: ['#...#', '#...#', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '##.##', '#...#'],
X: ['#...#', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '#...#'],
Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'],
Z: ['#####', '....#', '...#.', '..#..', '.#...', '#....', '#####'],
' ': ['.....', '.....', '.....', '.....', '.....', '.....', '.....'],
'.': ['.....', '.....', '.....', '.....', '.....', '.##..', '.##..'],
':': ['.....', '.##..', '.##..', '.....', '.##..', '.##..', '.....'],
'-': ['.....', '.....', '.....', '#####', '.....', '.....', '.....'],
'/': ['....#', '....#', '...#.', '..#..', '.#...', '#....', '#....'],
"'": ['..#..', '..#..', '.....', '.....', '.....', '.....', '.....'],
'!': ['..#..', '..#..', '..#..', '..#..', '..#..', '.....', '..#..'],
'?': ['.###.', '#...#', '....#', '...#.', '..#..', '.....', '..#..'],
'*': ['.....', '#.#.#', '.###.', '#####', '.###.', '#.#.#', '.....'],
'>': ['.#...', '..#..', '...#.', '....#', '...#.', '..#..', '.#...'],
'=': ['.....', '.....', '#####', '.....', '#####', '.....', '.....'],
'+': ['.....', '..#..', '..#..', '#####', '..#..', '..#..', '.....'],
'(': ['...#.', '..#..', '.#...', '.#...', '.#...', '..#..', '...#.'],
')': ['.#...', '..#..', '...#.', '...#.', '...#.', '..#..', '.#...'],
'%': ['#...#', '#..#.', '...#.', '..#..', '.#...', '#..#.', '#...#'],
};
export const FONT_CELL = 8;
export const FONT_COLS = 16;
/**
* One 8x8 cell per character of FONT_CHARS, laid out FONT_COLS to a row
* the layout Phaser's RetroFont.Parse expects.
*/
export function buildFontGrid() {
const rows = Math.ceil(FONT_CHARS.length / FONT_COLS);
const g = makeGrid(FONT_COLS * FONT_CELL, rows * FONT_CELL);
for (let i = 0; i < FONT_CHARS.length; i += 1) {
const glyph = GLYPHS[FONT_CHARS[i]];
if (!glyph) throw new Error(`no glyph authored for "${FONT_CHARS[i]}"`);
const ox = (i % FONT_COLS) * FONT_CELL + 1;
const oy = Math.floor(i / FONT_COLS) * FONT_CELL;
for (let y = 0; y < glyph.length; y += 1) {
for (let x = 0; x < glyph[y].length; x += 1) {
if (glyph[y][x] === '#') setPx(g, ox + x, oy + y, S.LIGHT);
}
}
}
return { grid: g, chars: FONT_CHARS, cell: FONT_CELL, cols: FONT_COLS, rows };
}
// ---------------------------------------------------------------------------
// Background tiles
// ---------------------------------------------------------------------------
// Authored with slots 1..3 only — slot 0 would punch through to the backdrop.
export const TILES = {
// Packed spectators: alternating head rows over a dark stand.
crowdA: tile([
'11111111',
'13113113',
'12212212',
'11111111',
'31131131',
'21221221',
'11111111',
'12121212',
]),
crowdB: tile([
'11111111',
'31131131',
'21221221',
'11111111',
'13113113',
'12212212',
'11111111',
'21212121',
]),
// Stadium wall the BEST time is painted across.
wall: tile([
'22222222',
'23333332',
'22222222',
'22222222',
'22222222',
'23333332',
'22222222',
'11111111',
]),
wallPlain: tile([
'22222222',
'22222222',
'22222222',
'22222222',
'22222222',
'22222222',
'22222222',
'11111111',
]),
// Infield turf, two phases so long runs do not visibly repeat.
grassA: tile([
'22222222',
'23222322',
'22222222',
'22322232',
'22222222',
'32223222',
'22222222',
'22232223',
]),
grassB: tile([
'22222222',
'22322232',
'22222222',
'32223222',
'22222222',
'23222322',
'22222222',
'22322232',
]),
// Graded dirt. `dirtLight` and `dirtDark` alternate lane to lane the way the
// original's four lanes do.
dirtLight: tile([
'33333333',
'33323333',
'33333333',
'32333333',
'33333333',
'33333323',
'33333333',
'33333332',
]),
dirtDark: tile([
'22222222',
'22232222',
'22222222',
'23222222',
'22222222',
'22222232',
'22222222',
'22222223',
]),
// Dashed white lane divider, drawn over whichever dirt tile it sits on.
laneDashLight: tile([
'33333333',
'11111111',
'33333333',
'33333333',
'33333333',
'33333333',
'33333333',
'33333333',
]),
laneDashDark: tile([
'22222222',
'11111111',
'22222222',
'22222222',
'22222222',
'22222222',
'22222222',
'22222222',
]),
// Rutted ground either side of the racing surface.
apron: tile([
'11111111',
'12111211',
'11111111',
'11211121',
'11111111',
'21112111',
'11111111',
'11121112',
]),
// Churned mud: slows you to a crawl.
mud: tile([
'11111111',
'12111121',
'11211111',
'11112111',
'21111112',
'11121111',
'11111211',
'12111121',
]),
// Cool zone chevrons. Two tiles make one full '»' pointing down-track.
coolLeft: tile([
'22222222',
'22333222',
'22233322',
'22223332',
'22223332',
'22233322',
'22333222',
'22222222',
]),
coolRight: tile([
'22222222',
'33322222',
'23332222',
'22333222',
'22333222',
'23332222',
'33322222',
'22222222',
]),
// Start/finish check, and the timing-line post.
checkerA: tile([
'33331111',
'33331111',
'33331111',
'33331111',
'11113333',
'11113333',
'11113333',
'11113333',
]),
checkerB: tile([
'11113333',
'11113333',
'11113333',
'11113333',
'33331111',
'33331111',
'33331111',
'33331111',
]),
// Solid fills, handy for gaps and shadow.
solidDark: tile([
'11111111', '11111111', '11111111', '11111111',
'11111111', '11111111', '11111111', '11111111',
]),
};
/** Every tile in the bank, keyed by name — used by the verifier. */
export const TILE_NAMES = Object.keys(TILES);
// ---------------------------------------------------------------------------
// The bike
// ---------------------------------------------------------------------------
// Frame geometry, in NES pixels, measured from the rear axle with +x forward
// and +y up. Small enough to sit in a 24px lane, which is what forces the
// stubby proportions the original's bike also has.
const WHEEL_R = 4;
const REAR_AXLE = [0, 0];
const FRONT_AXLE = [16, 0];
const SEAT = [3, 7];
const STEER_HEAD = [12, 7];
const BAR = [14, 10];
const HIP = [4, 9];
const SHOULDER = [8, 15];
const HELMET = [10.5, 18];
const HELMET_R = 3.5;
const KNEE = [8, 5];
const FOOT = [6, 2];
// Frames are square so a single pivot serves every angle. The pivot is the
// rear contact patch: the sim positions the bike by it, and rotation about it
// is what makes a wheelie read correctly.
export const BIKE_FRAME = 40;
export const BIKE_PIVOT_X = 13;
export const BIKE_PIVOT_Y = 25;
export const PITCH_FRAMES = 17;
export const PITCH_MAX = 0.7; // rad, +-40 degrees
export const TREAD_FRAMES = 4;
/** Sim pitch (radians, + = nose up) -> pitch frame index. */
export function pitchFrameIndex(pitch) {
const t = (Math.max(-PITCH_MAX, Math.min(PITCH_MAX, pitch)) + PITCH_MAX) / (2 * PITCH_MAX);
return Math.round(t * (PITCH_FRAMES - 1));
}
/** Pitch frame index -> the angle it depicts. */
export function pitchFrameAngle(index) {
return -PITCH_MAX + (index / (PITCH_FRAMES - 1)) * 2 * PITCH_MAX;
}
/** Sheet frame number for a pitch/tread pair. */
export function bikeFrame(pitchIdx, treadIdx) {
return pitchIdx * TREAD_FRAMES + (treadIdx % TREAD_FRAMES);
}
export const BIKE_FRAME_COUNT = PITCH_FRAMES * TREAD_FRAMES;
function rot(pt, sin, cos) {
// Design space has +y up; grids have +y down, so the sign flips on the way out.
const [x, y] = pt;
return [x * cos - y * sin, x * sin + y * cos];
}
function drawWheel(g, px, py, treadPhase, sin, cos) {
strokeCircle(g, px, py, WHEEL_R, S.DARK, 2);
setPx(g, Math.round(px), Math.round(py), S.MID);
// Two spokes, phase-shifted per tread frame, so the wheel visibly turns.
const base = (treadPhase / TREAD_FRAMES) * Math.PI;
for (let k = 0; k < 2; k += 1) {
const a = base + k * (Math.PI / 2);
// The spoke rides with the frame's rotation as well as its own.
const sx = Math.cos(a) * cos - Math.sin(a) * sin;
const sy = Math.cos(a) * sin + Math.sin(a) * cos;
line(g, px, py, px + sx * (WHEEL_R - 1), py - sy * (WHEEL_R - 1), S.MID);
}
}
/**
* One bike-and-rider frame at the given pitch and wheel phase.
* `bodySlot` picks which of the two drawable colours the machine is painted in,
* which is all that separates the player's bike from a rival's.
*/
export function buildBikeFrame(pitch, treadPhase, { bodySlot = S.LIGHT } = {}) {
const g = makeGrid(BIKE_FRAME, BIKE_FRAME);
const sin = Math.sin(pitch);
const cos = Math.cos(pitch);
const P = (pt) => {
const [x, y] = rot(pt, sin, cos);
return [BIKE_PIVOT_X + x, BIKE_PIVOT_Y - y];
};
const rear = P(REAR_AXLE);
const front = P(FRONT_AXLE);
const seat = P(SEAT);
const head = P(STEER_HEAD);
const bar = P(BAR);
const hip = P(HIP);
const shoulder = P(SHOULDER);
const helmet = P(HELMET);
const knee = P(KNEE);
const foot = P(FOOT);
// Engine and bodywork first, so the frame tubes and rider overlay them.
fillPoly(g, [seat, head, bar, [bar[0] - 3, bar[1] + 3], seat], bodySlot);
fillPoly(g, [
[rear[0] + 3 * cos, rear[1] - 3 * sin],
[seat[0] + 1, seat[1] + 1],
[head[0] - 1, head[1] + 2],
[head[0] - 2 * cos, head[1] + 2 * sin],
], S.MID);
// Frame: swingarm, downtube, forks.
line(g, rear[0], rear[1], seat[0], seat[1], S.DARK);
line(g, seat[0], seat[1], head[0], head[1], S.DARK);
line(g, head[0], head[1], front[0], front[1], S.DARK);
line(g, head[0], head[1], bar[0], bar[1], S.DARK);
line(g, rear[0], rear[1], head[0], head[1], S.DARK);
drawWheel(g, rear[0], rear[1], treadPhase, sin, cos);
drawWheel(g, front[0], front[1], treadPhase, sin, cos);
// Rider: legs, torso, arms, helmet. Drawn last so he sits on the machine.
line(g, hip[0], hip[1], knee[0], knee[1], S.DARK);
line(g, knee[0], knee[1], foot[0], foot[1], S.DARK);
fillPoly(g, [
[hip[0] - 1, hip[1] - 1], [hip[0] + 2, hip[1] + 1],
[shoulder[0] + 2, shoulder[1] + 1], [shoulder[0] - 1, shoulder[1] - 1],
], bodySlot);
line(g, shoulder[0], shoulder[1], bar[0], bar[1], S.DARK);
fillCircle(g, helmet[0], helmet[1], HELMET_R, S.DARK);
fillCircle(g, helmet[0], helmet[1], HELMET_R - 1.5, bodySlot);
// Visor, pointing the way he is going.
line(g, helmet[0] + 1, helmet[1], helmet[0] + HELMET_R, helmet[1] - 1, S.MID);
return g;
}
/** All BIKE_FRAME_COUNT frames, in sheet order. */
export function buildBikeFrames(opts = {}) {
const frames = [];
for (let p = 0; p < PITCH_FRAMES; p += 1) {
for (let t = 0; t < TREAD_FRAMES; t += 1) {
frames.push(buildBikeFrame(pitchFrameAngle(p), t, opts));
}
}
return frames;
}
// ---------------------------------------------------------------------------
// The rider, off the bike
// ---------------------------------------------------------------------------
export const RIDER_FRAME = 24;
export const TUMBLE_FRAMES = 6;
export const RUN_FRAMES = 4;
/** Rider mid-crash: a ragdoll rolled through a full turn over six frames. */
export function buildTumbleFrame(index) {
const g = makeGrid(RIDER_FRAME, RIDER_FRAME);
const a = (index / TUMBLE_FRAMES) * Math.PI * 2;
const cx = RIDER_FRAME / 2;
const cy = RIDER_FRAME / 2;
const sin = Math.sin(a);
const cos = Math.cos(a);
const at = (x, y) => [cx + x * cos - y * sin, cy + x * sin + y * cos];
const head = at(0, -5);
const hips = at(0, 3);
const hand = at(-6, -1);
const foot = at(6, 4);
line(g, hips[0], hips[1], foot[0], foot[1], S.DARK);
line(g, hips[0], hips[1], hand[0], hand[1], S.DARK);
fillPoly(g, [
[head[0] - 2, head[1]], [head[0] + 2, head[1]],
[hips[0] + 2, hips[1]], [hips[0] - 2, hips[1]],
], S.LIGHT);
fillCircle(g, head[0], head[1], 3.5, S.DARK);
fillCircle(g, head[0], head[1], 2, S.LIGHT);
return g;
}
/** Rider on foot, chasing the bike. Four-frame run cycle. */
export function buildRunFrame(index) {
const g = makeGrid(RIDER_FRAME, RIDER_FRAME);
const cx = RIDER_FRAME / 2;
const groundY = RIDER_FRAME - 3;
const swing = Math.sin((index / RUN_FRAMES) * Math.PI * 2);
const lift = index % 2 === 0 ? 0 : -1;
const hipY = groundY - 9 + lift;
const shoulderY = groundY - 15 + lift;
// Legs, out of phase.
line(g, cx, hipY, cx + swing * 4, groundY, S.DARK);
line(g, cx, hipY, cx - swing * 4, groundY - 1, S.DARK);
// Torso.
fillRect(g, cx - 2, shoulderY, 5, hipY - shoulderY + 1, S.LIGHT);
// Arms, opposing the legs.
line(g, cx, shoulderY + 1, cx - swing * 4, shoulderY + 5, S.DARK);
line(g, cx, shoulderY + 1, cx + swing * 3, shoulderY + 4, S.DARK);
// Helmet.
fillCircle(g, cx, shoulderY - 3 + lift, 3.5, S.DARK);
fillCircle(g, cx, shoulderY - 3 + lift, 2, S.LIGHT);
return g;
}
/** The abandoned bike, lying on its side until the rider catches up. */
export function buildDownedBike() {
const g = makeGrid(BIKE_FRAME, BIKE_FRAME);
const y = BIKE_PIVOT_Y;
strokeCircle(g, BIKE_PIVOT_X, y, WHEEL_R, S.DARK, 2);
strokeCircle(g, BIKE_PIVOT_X + 16, y, WHEEL_R, S.DARK, 2);
line(g, BIKE_PIVOT_X, y, BIKE_PIVOT_X + 16, y, S.DARK);
fillRect(g, BIKE_PIVOT_X + 4, y - 4, 8, 4, S.MID);
return g;
}
// ---------------------------------------------------------------------------
// Effects
// ---------------------------------------------------------------------------
export const DUST_FRAME = 16;
export const DUST_FRAMES = 4;
/** Roost kicked up by the rear wheel; also the puff on a hard landing. */
export function buildDustFrame(index) {
const g = makeGrid(DUST_FRAME, DUST_FRAME);
const r = 2 + index * 1.6;
const cx = DUST_FRAME / 2;
const cy = DUST_FRAME - 4;
const slot = index < 2 ? S.LIGHT : S.MID;
fillCircle(g, cx, cy, r, slot);
fillCircle(g, cx - r * 0.8, cy + 1, r * 0.6, slot);
fillCircle(g, cx + r * 0.7, cy - 1, r * 0.5, slot);
// Punch a couple of holes so it reads as a cloud, not a blob.
fillCircle(g, cx + 1, cy - 1, Math.max(0, r - 3), S.CLEAR);
return g;
}
// ---------------------------------------------------------------------------
// Composite helpers used by the raster stage
// ---------------------------------------------------------------------------
/** Lay `frames` out left to right into one grid — a sprite sheet strip. */
export function packStrip(frames) {
if (!frames.length) throw new Error('packStrip needs at least one frame');
const fw = frames[0].w;
const fh = frames[0].h;
const sheet = makeGrid(fw * frames.length, fh);
frames.forEach((f, i) => {
if (f.w !== fw || f.h !== fh) throw new Error('packStrip needs uniform frame sizes');
blit(sheet, f, i * fw, 0);
});
return { grid: sheet, frameWidth: fw, frameHeight: fh, count: frames.length };
}
/** Draw an 8x8 tile into an arbitrary grid at tile coordinates. */
export function putTile(dst, t, tx, ty) {
for (let y = 0; y < TILE; y += 1) {
for (let x = 0; x < TILE; x += 1) {
setPx(dst, tx * TILE + x, ty * TILE + y, t.data[y * TILE + x]);
}
}
}
export { hLine, vLine, fillRect, line, fillCircle, fillPoly, blit, makeGrid, setPx };

View File

@ -0,0 +1,179 @@
// A reference rider, good enough to be the yardstick for every track.
//
// The generator uses it to set qualifying times, and the verifier uses it to
// prove those times are beatable. Both must judge a track the same way, which
// is why this lives in src/ and not inside either tool — the same rider, and
// the same seed, from both directions.
import {
SURFACE, surfaceAt, groundAngleAt, isLaunchEdge, curvatureAt, LANE_COUNT,
} from './ExcitebikeTrack.js';
import {
createRace, step, neutralInput, finalizeRace, mulberry32, STATE, STEP_MS, MODE,
} from './ExcitebikeLogic.js';
/**
* The reference riders, from the ceiling down to the floor.
*
* expert perfect information, no lag. What the track can theoretically give.
* human the yardstick the qualifying times are actually set from: it looks
* ahead less far, reacts on a delay, and cannot place the nose
* exactly. A track is fair when this rider can qualify on it.
* steady a cautious human, low on the throttle.
* naive holds the throttle down and steers at nothing. The difficulty
* floor: a track this rider can qualify on is asking nothing.
*/
export const AUTO_SKILL = {
expert: { heatCeiling: 0.74, lookahead: 190, pitchDeadzone: 0.04, reactionFrames: 1, jitter: 0 },
human: { heatCeiling: 0.66, lookahead: 140, pitchDeadzone: 0.11, reactionFrames: 7, jitter: 0.09 },
steady: { heatCeiling: 0.48, lookahead: 120, pitchDeadzone: 0.16, reactionFrames: 11, jitter: 0.14 },
naive: null,
};
function laneScore(model, lane, x, lookahead) {
let cost = 0;
for (let d = 0; d < lookahead; d += 8) {
const s = surfaceAt(model, lane, x + d);
const w = 1 - d / (lookahead * 1.35);
if (s === SURFACE.GAP) cost += 900 * w;
else if (s === SURFACE.OBSTACLE) cost += 900 * w;
else if (s === SURFACE.MUD) cost += 130 * w;
else if (s === SURFACE.ROUGH) cost += 85 * w;
else if (s === SURFACE.COOL) cost -= 28 * w;
}
return cost;
}
/**
* Cost of running up behind a slower bike in a lane.
*
* Without this the reference rider rams the back of the pack all race: the
* contact rule puts the bike that closes from behind on the floor, so a rider
* who ignores traffic spends SELECTION B crashing. Weighted by how fast it is
* closing, because a rival pulling away is not in the way.
*/
function trafficAhead(state, self, lane) {
if (state.bikes.length < 2) return 0;
let cost = 0;
for (const other of state.bikes) {
if (other === self || other.state === STATE.FINISHED) continue;
if (Math.abs(other.lane - lane) > 0.7) continue;
const gap = other.x - self.x;
if (gap < -24 || gap > 150) continue;
const closing = Math.max(0, self.vx - other.vx);
cost += (90 + closing * 3) * (1 - gap / 150);
}
return cost;
}
function memo(state) {
if (!state._auto) state._auto = { frame: 0, lane: null, rng: mulberry32(state.seed ^ 0x9e3779b9) };
return state._auto;
}
/** One frame of input from the reference rider. */
export function autoInput(state, skill = AUTO_SKILL.expert) {
const inp = neutralInput();
const b = state.player;
const m = state.model;
inp.a = true;
if (!skill) return inp; // the naive rider: throttle only
const mem = memo(state);
mem.frame += 1;
const lane = Math.max(0, Math.min(LANE_COUNT - 1, Math.round(b.lane)));
if (b.state === STATE.AIRBORNE) {
// Point the bike at the ground it is about to meet — the landing-angle
// rule is the whole game, so this is where the time is won or lost. A less
// able rider misjudges that target, and is slower to act on it.
const want = groundAngleAt(m, lane, b.x + b.vx * 0.25)
+ (skill.jitter ? (mem.rng() - 0.5) * skill.jitter : 0);
const err = want - b.pitch;
// Reaction lag: hold the previous decision between glances at the ground.
if (mem.frame % skill.reactionFrames === 0 || mem.air == null) {
mem.air = err > skill.pitchDeadzone ? 'up' : (err < -skill.pitchDeadzone ? 'down' : 'hold');
}
if (mem.air === 'up') inp.left = true;
else if (mem.air === 'down') inp.right = true;
return inp;
}
mem.air = null;
if (b.state === STATE.RUNNING) {
inp.a = Math.floor(state.elapsedMs / STEP_MS) % 2 === 0;
return inp;
}
const here = surfaceAt(m, lane, b.x);
if (b.temp < skill.heatCeiling || here === SURFACE.COOL) inp.b = true;
if (mem.frame % skill.reactionFrames === 0 || mem.lane == null) {
let best = lane;
let bestCost = Infinity;
for (let l = 0; l < LANE_COUNT; l += 1) {
const cost = laneScore(m, l, b.x, skill.lookahead)
+ trafficAhead(state, b, l)
+ Math.abs(l - b.lane) * 9;
if (cost < bestCost) { bestCost = cost; best = l; }
}
mem.lane = best;
}
if (mem.lane < Math.round(b.lane)) inp.up = true;
else if (mem.lane > Math.round(b.lane)) inp.down = true;
// Loft the front wheel just before anything that will throw the bike up, so
// it leaves the ground level rather than nose-down.
if (isLaunchEdge(m, lane, b.x + 10) || curvatureAt(m, lane, b.x + 10) < -0.02) inp.left = true;
return inp;
}
/**
* Drive a whole race and report the outcome. `maxSeconds` is a safety net: a
* track that cannot be finished must fail loudly rather than hang the tool.
*/
export function runAuto(model, {
mode = MODE.SOLO, skill = AUTO_SKILL.expert, seed = 1, rivalCount = 5, maxSeconds = 600,
} = {}) {
const state = createRace({ model, mode, rivalCount, seed });
const limit = Math.ceil((maxSeconds * 1000) / STEP_MS);
const counts = {};
let frames = 0;
while (state.phase !== STATE.FINISHED && frames < limit) {
for (const ev of step(state, autoInput(state, skill))) {
counts[ev.type] = (counts[ev.type] ?? 0) + 1;
}
frames += 1;
}
const result = finalizeRace(state);
return { ...result, events: counts, frames, timedOut: frames >= limit, state };
}
const PROBE_SEEDS = [3, 9, 17, 23, 31];
/**
* Measure a track the way both the generator and the verifier need it measured.
*
* The `human` rider is jittered, so a single run says very little; this takes
* the median finish time across several seeds and the mean crash rate. Those
* two numbers are what a qualifying time is set from and what the difficulty
* curve is asserted against keeping them in one place is what stops the tool
* that writes the tracks and the tool that checks them from disagreeing.
*/
export function probeTrack(model, { skill = AUTO_SKILL.human, seeds = PROBE_SEEDS } = {}) {
const runs = seeds.map((seed) => runAuto(model, { skill, seed }));
const finished = runs.filter((r) => r.finished && !r.timedOut);
const times = finished.map((r) => r.ms).sort((a, b) => a - b);
const crashes = runs.reduce((s, r) => s + (r.events.crash ?? 0), 0) / runs.length;
return {
runs,
finishedCount: finished.length,
medianMs: times.length ? times[Math.floor(times.length / 2)] : null,
worstMs: times.length ? times[times.length - 1] : null,
bestMs: times.length ? times[0] : null,
crashesPerKpx: (crashes / model.length) * 1000,
};
}
export { PROBE_SEEDS };

View File

@ -0,0 +1,388 @@
// DESIGN mode — the original's third title-screen option, rebuilt.
//
// The manual's flow, kept intact: a menu of PLAY MODE A / PLAY MODE B / DESIGN
// / SAVE / LOAD / RESET; an editor where the palette strip reads
// ABCDEFGHIJKLMNOPQRS CL END LP, the bike itself is the cursor, and B drops the
// selected hurdle where the bike is standing; a fifty-hurdle ceiling; and a lap
// count from one to nine chosen at the end.
//
// One save slot, as on the Famicom's tape — saving over your track loses the
// old one. That is the original's behaviour and it is deliberate.
import * as Phaser from 'phaser';
import { playSound, SFX } from '../../ui/Sounds.js';
import { SCREEN_W, HUD_Y, LANES_Y, LANE_H, LANE_COUNT, nesColor } from './ExcitebikeNES.js';
import { rasterizeTrack } from './ExcitebikeRaster.js';
import { BIKE_FRAME, BIKE_PIVOT_X, BIKE_PIVOT_Y, bikeFrame, pitchFrameIndex } from './ExcitebikeArt.js';
import {
HURDLES, MAX_HURDLES, MAX_LAPS, START_PAD, FINISH_PAD,
buildTrackModel, validateTrack,
} from './ExcitebikeTrack.js';
import { runAuto, AUTO_SKILL } from './ExcitebikeAuto.js';
import { MODE } from './ExcitebikeLogic.js';
import {
TOOLS, TOOL_CLEAR, TOOL_END, TOOL_LAPS, DESIGN_LENGTH,
blankTrack, readSlot, saveSlot,
} from './ExcitebikeDesignData.js';
const CURSOR_SPEED = 340; // px/s while A is held
const CURSOR_FAST = 1100; // px/s with the turbo button too
const TEXT = { white: 0xf8f8f8, red: 0xf83800, amber: 0xf8b800, green: 0x58d854, grey: 0x787878 };
// ---------------------------------------------------------------------------
// Entry
// ---------------------------------------------------------------------------
export function openDesignMode(scene) {
scene.teardownRace();
scene.clearView();
scene.screen = 'design';
scene.design = {
phase: 'menu',
track: scene.designTrack ?? blankTrack(),
cursorX: START_PAD + 40,
tool: 0,
dirty: true,
repeat: 0,
};
scene.designTrack = scene.design.track;
scene.stepDesign = (delta) => stepDesign(scene, delta);
scene.closeDesign = () => {
scene.stepDesign = null;
scene.closeDesign = null;
scene.design = null;
scene.showTitle();
};
showDesignMenu(scene);
}
// ---------------------------------------------------------------------------
// Menu
// ---------------------------------------------------------------------------
function clearWorld(scene) {
for (const layer of [scene.trackLayer, scene.bikeLayer, scene.fxLayer, scene.hudLayer]) {
layer?.removeAll(true);
}
}
function showDesignMenu(scene) {
scene.clearView();
clearWorld(scene);
scene.design.phase = 'menu';
scene.nesRect(0, 0, SCREEN_W, SCREEN_H, nesColor(0x02));
scene.nesTextCentered(24, 'DESIGN', TEXT.amber);
const count = scene.design.track.hurdles.length;
scene.nesTextCentered(44, `${count} / ${MAX_HURDLES} HURDLES ${scene.design.track.laps} LAPS`, TEXT.grey);
const saved = readSlot();
scene.makeMenu(
['PLAY MODE A', 'PLAY MODE B', 'DESIGN', 'SAVE', 'LOAD', 'RESET'],
{
x: 76, y: 80, spacing: 16,
onPick: (i) => {
switch (i) {
case 0: playDesign(scene, MODE.SOLO); break;
case 1: playDesign(scene, MODE.RACE); break;
case 2: showEditor(scene); break;
case 3: saveSlot(scene.design.track); showDesignMenu(scene); flash(scene, 'SAVED', TEXT.green); break;
case 4: {
const loaded = readSlot();
if (loaded) {
scene.design.track = loaded;
scene.designTrack = loaded;
scene.design.dirty = true;
showDesignMenu(scene);
flash(scene, 'LOADED', TEXT.green);
} else {
flash(scene, 'NO SAVED TRACK', TEXT.red);
}
break;
}
default:
scene.design.track = blankTrack();
scene.designTrack = scene.design.track;
scene.design.dirty = true;
scene.closeDesign();
break;
}
},
},
);
scene.nesTextCentered(190, saved ? 'ONE SAVE SLOT - SAVING OVERWRITES IT' : 'NO TRACK SAVED YET', TEXT.grey);
scene.nesTextCentered(206, 'AN EMPTY TRACK RUNS FOREVER', TEXT.grey);
scene.nesTextCentered(220, 'ESC TITLE', TEXT.grey);
}
function flash(scene, msg, tint) {
const t = scene.nesTextCentered(160, msg, tint);
scene.tweens.add({ targets: t, alpha: 0, duration: 1100, onComplete: () => t.setVisible(false) });
}
// ---------------------------------------------------------------------------
// Editor
// ---------------------------------------------------------------------------
function showEditor(scene) {
scene.clearView();
clearWorld(scene);
const d = scene.design;
d.phase = 'edit';
d.dirty = true;
scene.buildThemeTextures(d.track.theme);
d.trackImage = scene.add.image(0, 0, '__DEFAULT').setOrigin(0, 0);
scene.trackLayer.add(d.trackImage);
// The bike is the cursor, exactly as in the original. Same pivot as in a
// race, so where it stands is where the hurdle lands.
d.cursorBike = scene.add.image(0, 0, 'excitebike-bike-player', bikeFrame(pitchFrameIndex(0), 0))
.setOrigin(BIKE_PIVOT_X / BIKE_FRAME, BIKE_PIVOT_Y / BIKE_FRAME);
scene.bikeLayer.add(d.cursorBike);
const hud = scene.add.image(0, HUD_Y, 'excitebike-hud').setOrigin(0, 0);
scene.hudLayer.add(hud);
const txt = (x, y, str, tint) => {
const t = scene.add.bitmapText(x, y, 'excitebike-font', str).setTint(tint);
scene.hudLayer.add(t);
return t;
};
// The palette strip. One glyph per tool, so it fits the NES frame exactly as
// the original's does.
d.toolText = txt(8, HUD_Y + 6, TOOLS.map((t) => (t.length === 1 ? t : ` ${t}`)).join(''), TEXT.grey);
d.toolCursor = txt(8, HUD_Y + 15, '^', TEXT.red);
d.nameText = txt(8, HUD_Y + 26, '', TEXT.white);
d.statText = txt(8, HUD_Y + 36, '', TEXT.amber);
syncEditorHud(scene);
}
/** Byte offset of a tool's glyph in the palette strip, for the caret. */
function toolCaretX(index) {
let x = 8;
for (let i = 0; i < index; i += 1) x += (TOOLS[i].length === 1 ? 1 : TOOLS[i].length + 1) * 8;
return x + (TOOLS[index].length === 1 ? 0 : 8);
}
function syncEditorHud(scene) {
const d = scene.design;
const tool = TOOLS[d.tool];
d.toolCursor.x = toolCaretX(d.tool);
const name = HURDLES[tool]?.name
?? (tool === TOOL_CLEAR ? 'CLEAR UNDER BIKE'
: tool === TOOL_END ? 'FINISH EDITING'
: 'SET LAP COUNT');
d.nameText.setText(`${tool} ${name}`);
d.statText.setText(
`X=${String(Math.round(d.cursorX)).padStart(4, ' ')} `
+ `${d.track.hurdles.length}/${MAX_HURDLES} LAPS ${d.track.laps}`,
);
}
function rebuildEditorTrack(scene) {
const d = scene.design;
const model = buildTrackModel(d.track);
if (scene.textures.exists('excitebike-design-track')) scene.textures.remove('excitebike-design-track');
scene.textures.addCanvas('excitebike-design-track', rasterizeTrack(model, d.track.theme));
scene.textures.get('excitebike-design-track').setFilter(Phaser.Textures.FilterMode.NEAREST);
d.trackImage.setTexture('excitebike-design-track');
d.model = model;
d.dirty = false;
}
const PLACE_MIN = START_PAD + 8;
function placeMax(track, toolId) {
const footprint = HURDLES[toolId]?.profile.length ?? 0;
return track.length - FINISH_PAD - footprint - 8;
}
function placeTool(scene) {
const d = scene.design;
const tool = TOOLS[d.tool];
if (tool === TOOL_END) { finishEditing(scene); return; }
if (tool === TOOL_LAPS) { showLapPicker(scene); return; }
if (tool === TOOL_CLEAR) {
// Remove whatever the bike is standing on.
const hit = d.track.hurdles.findIndex((h) => {
const len = HURDLES[h.t].profile.length;
return d.cursorX >= h.x - 4 && d.cursorX <= h.x + len + 4;
});
if (hit >= 0) {
d.track.hurdles.splice(hit, 1);
d.dirty = true;
playSound(scene, SFX.EIGHTBIT_ACTION);
} else {
playSound(scene, SFX.EIGHTBIT_MOVE);
}
syncEditorHud(scene);
return;
}
if (d.track.hurdles.length >= MAX_HURDLES) {
playSound(scene, SFX.EIGHTBIT_ACTION);
return;
}
const x = Math.round(Math.min(Math.max(d.cursorX, PLACE_MIN), placeMax(d.track, tool)));
const footprint = HURDLES[tool].profile.length;
// Refuse to stack hurdles on top of each other — overlapping footprints make
// terrain that cannot be read at speed.
const clash = d.track.hurdles.some((h) => {
const len = HURDLES[h.t].profile.length;
return x < h.x + len && h.x < x + footprint;
});
if (clash) { playSound(scene, SFX.EIGHTBIT_ACTION); return; }
d.track.hurdles.push({ t: tool, x });
d.track.hurdles.sort((a, b) => a.x - b.x);
d.dirty = true;
playSound(scene, SFX.EIGHTBIT_SELECT);
syncEditorHud(scene);
}
// ---------------------------------------------------------------------------
// Laps
// ---------------------------------------------------------------------------
function showLapPicker(scene) {
const d = scene.design;
d.phase = 'laps';
scene.clearView();
scene.nesRect(48, 72, 160, 76, nesColor(0x0f));
scene.nesTextCentered(84, 'NUMBER OF LAPS', TEXT.amber);
d.lapText = scene.nesTextCentered(108, `< ${d.track.laps} >`, TEXT.white);
scene.nesTextCentered(128, 'B CONFIRM', TEXT.grey);
}
function stepLapPicker(scene) {
const d = scene.design;
if (scene.justDown('left') || scene.justDown('a')) {
d.track.laps = d.track.laps <= 1 ? MAX_LAPS : d.track.laps - 1;
} else if (scene.justDown('right') || scene.justDown('d')) {
d.track.laps = d.track.laps >= MAX_LAPS ? 1 : d.track.laps + 1;
} else if (scene.justDown('turbo') || scene.justDown('turbo2') || scene.justDown('start')) {
d.track.mainLaps = d.track.laps;
playSound(scene, SFX.EIGHTBIT_SELECT);
showEditor(scene);
return;
} else {
return;
}
d.lapText.setText(`< ${d.track.laps} >`);
d.lapText.x = Math.round((SCREEN_W - d.lapText.text.length * 8) / 2);
playSound(scene, SFX.EIGHTBIT_MOVE);
}
// ---------------------------------------------------------------------------
// Finishing and playing
// ---------------------------------------------------------------------------
/**
* Close the editor and price the track. The original invents a third-place
* time for a designed course; here that time comes from the same reference
* rider the shipped tracks are timed against, so a designed track is judged on
* exactly the same terms.
*/
function finishEditing(scene) {
const d = scene.design;
const model = buildTrackModel(d.track);
const errors = validateTrack(model, d.track);
if (errors.length) {
flash(scene, errors[0].slice(0, 30).toUpperCase(), TEXT.red);
playSound(scene, SFX.EIGHTBIT_ACTION);
return;
}
const run = runAuto(model, { skill: AUTO_SKILL.expert, seed: 5, maxSeconds: 400 });
d.track.qualifyMs = run.finished
? Math.round((run.ms * 1.14) / 100) * 100
: 60000 * d.track.laps;
scene.designTrack = d.track;
showDesignMenu(scene);
flash(scene, `3RD ${(d.track.qualifyMs / 1000).toFixed(1)}S`, TEXT.green);
}
function playDesign(scene, mode) {
const d = scene.design;
const track = { ...d.track };
if (!track.hurdles.length) {
// The manual is explicit: an empty design runs forever. Make that literal
// rather than a bug by handing the rider a very long, very empty course.
track.laps = 9;
track.mainLaps = 9;
}
const errors = validateTrack(buildTrackModel(track), track);
if (errors.length) { flash(scene, 'TRACK IS NOT RIDEABLE', TEXT.red); return; }
scene.stepDesign = null;
scene.closeDesign = null;
const back = scene.design;
scene.design = null;
scene.chosen.trackN = 0;
scene.startRace(track, mode, { fromDesign: true });
scene.designTrack = back.track;
}
// ---------------------------------------------------------------------------
// Frame
// ---------------------------------------------------------------------------
function stepDesign(scene, delta) {
const d = scene.design;
if (!d) return;
if (d.phase === 'menu') { scene.stepMenu(); return; }
if (d.phase === 'laps') { stepLapPicker(scene); return; }
// Palette selection.
if (scene.justDown('left') || scene.justDown('a')) {
d.tool = (d.tool - 1 + TOOLS.length) % TOOLS.length;
syncEditorHud(scene);
playSound(scene, SFX.EIGHTBIT_MOVE);
} else if (scene.justDown('right') || scene.justDown('d')) {
d.tool = (d.tool + 1) % TOOLS.length;
syncEditorHud(scene);
playSound(scene, SFX.EIGHTBIT_MOVE);
}
// Drive the cursor bike. A goes forward; hold turbo as well to travel fast,
// and up/down to walk it back.
const k = scene.keys;
const fast = k.turbo.isDown || k.turbo2.isDown;
const back = k.up.isDown || k.w.isDown;
if (k.accel.isDown || k.accel2.isDown) {
const speed = (fast ? CURSOR_FAST : CURSOR_SPEED) * (delta / 1000);
d.cursorX += back ? -speed : speed;
d.cursorX = Math.min(Math.max(d.cursorX, PLACE_MIN), d.track.length - FINISH_PAD - 8);
syncEditorHud(scene);
}
// B places. Held-down repeat is deliberately disabled: one press, one hurdle.
if (!fast && (scene.justDown('turbo') || scene.justDown('turbo2'))) placeTool(scene);
if (scene.justDown('start')) finishEditing(scene);
if (d.dirty) rebuildEditorTrack(scene);
const scroll = Math.max(0, Math.min(d.cursorX - SCREEN_W / 2, d.track.length - SCREEN_W));
d.trackImage.x = Math.round(-scroll);
d.cursorBike.x = Math.round(d.cursorX - scroll);
// Park the cursor bike on the nearest lane, where the player's bike rides.
d.cursorBike.y = LANES_Y + LANE_COUNT * LANE_H - 1;
}
export { TOOLS, DESIGN_LENGTH, blankTrack, readSlot, saveSlot };

View File

@ -0,0 +1,49 @@
// The parts of DESIGN mode that are data rather than presentation.
//
// ExcitebikeDesign.js is a Phaser screen and cannot be loaded under Node, so
// the palette strip, the blank-track shape and the save slot live here where
// tools/verifyExcitebike.js can reach them. The editor's contract is then
// pinned by the harness rather than left untested — the same arrangement
// docs/gootower-build-plan.md settled on for its editor.
import { HURDLE_IDS } from './ExcitebikeTrack.js';
export const SLOT_KEY = 'excitebike-design';
export const DESIGN_LENGTH = 4800;
// The strip the editor prints along the bottom of the screen, exactly as the
// original's reads: ABCDEFGHIJKLMNOPQRS CL END LP.
export const TOOL_CLEAR = 'CL';
export const TOOL_END = 'END';
export const TOOL_LAPS = 'LP';
export const TOOLS = [...HURDLE_IDS, TOOL_CLEAR, TOOL_END, TOOL_LAPS];
/** An untouched design: flat, two laps, no hurdles. */
export function blankTrack() {
return {
version: 1,
id: 'design',
name: 'YOUR TRACK',
theme: 'day',
length: DESIGN_LENGTH,
laps: 2,
mainLaps: 2,
qualifyMs: 60000,
hurdles: [],
rough: [],
};
}
/** One save slot, as on the Famicom's tape. Saving overwrites what was there. */
export function readSlot() {
try {
const raw = globalThis.localStorage?.getItem(SLOT_KEY);
if (!raw) return null;
const json = JSON.parse(raw);
return json && Array.isArray(json.hurdles) ? json : null;
} catch (_) { return null; }
}
export function saveSlot(track) {
try { globalThis.localStorage?.setItem(SLOT_KEY, JSON.stringify(track)); } catch (_) { /* blocked or full */ }
}

View File

@ -0,0 +1,932 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { api } from '../../services/api.js';
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { SFX, playSound } from '../../ui/Sounds.js';
import {
SCREEN_W, SCREEN_H, HUD_Y, WALL_Y, LANES_Y, LANE_H, nesColor,
} from './ExcitebikeNES.js';
import {
FONT_CHARS, FONT_COLS,
BIKE_FRAME, BIKE_PIVOT_X, BIKE_PIVOT_Y, RIDER_FRAME, DUST_FRAME,
TREAD_FRAMES, TUMBLE_FRAMES, RUN_FRAMES, DUST_FRAMES,
pitchFrameIndex, bikeFrame,
} from './ExcitebikeArt.js';
import {
buildFontCanvas, buildBikeSheetCanvas, buildTumbleSheetCanvas, buildRunSheetCanvas,
buildDustSheetCanvas, buildDownedBikeCanvas, buildHudCanvas, rasterizeTrack,
heatColor, HEAT_BAR_X, HEAT_BAR_Y, HEAT_BAR_W, HEAT_BAR_H,
SP_PLAYER, SP_RIVAL,
} from './ExcitebikeRaster.js';
import { buildTrackModel, bestWallMs } from './ExcitebikeTrack.js';
import {
createRace, step, finalizeRace, playerPlace, formatTime,
neutralInput, STATE, MODE, STEP_MS, TUNE,
} from './ExcitebikeLogic.js';
import { openDesignMode } from './ExcitebikeDesign.js';
// The NES frame, pixel-doubled twice, centred in a cabinet bezel. Integer scale
// only — a 4.5x fit to the full 1080 would give some pixels five rows and
// others four, and the shimmer that causes is very visible on a scrolling track.
export const VIEW_SCALE = 4;
export const VIEW_X = Math.round((GAME_WIDTH - SCREEN_W * VIEW_SCALE) / 2);
export const VIEW_Y = Math.round((GAME_HEIGHT - SCREEN_H * VIEW_SCALE) / 2);
const FONT_KEY = 'excitebike-font';
const GAMEDATA = 'assets/gamedata/excitebike';
const BEST_KEY = 'excitebike-times';
const PROGRESS_KEY = 'excitebike-progress';
// Where the bike sits on screen. The original keeps it left of centre so you
// can read the track coming at you.
const CAMERA_LEAD = 72;
const TEXT = {
white: 0xf8f8f8,
red: 0xf83800,
amber: 0xf8b800,
green: 0x58d854,
blue: 0x3cbcfc,
grey: 0x787878,
};
export default class ExcitebikeGame extends Phaser.Scene {
constructor() {
super('ExcitebikeGame');
}
init(data) {
this.gameDef = data?.game ?? { slug: 'excitebike', name: 'Excitebike' };
this.screen = 'boot';
this.view = [];
this.hitZones = [];
this.menu = null;
this.race = null;
this.accum = 0;
this.trackIndex = null;
this.trackCache = new Map();
this.model = null;
this.chosen = { mode: MODE.SOLO, trackN: 1 };
this.designTrack = null;
this.raceOver = false;
}
// -------------------------------------------------------------------------
// Boot
// -------------------------------------------------------------------------
create() {
this.cameras.main.setBackgroundColor('#05060a');
this.buildTextures();
this.buildBezel();
this.buildViewport();
this.bindInput();
const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
this.crt = applyArcadeCRTOverlay(this, {
accentTint: 0xf83800,
scanlineTint: 0x9ce0ff,
curveAmount: 0.32,
});
this.events.once('shutdown', () => {
this.crt?.destroy();
this.music?.destroy?.();
this.stopEngine();
});
this.loadTrackIndex();
}
/**
* Every pixel in this game is generated at boot. Nothing is fetched, so the
* whole art pipeline is one synchronous pass through ExcitebikeRaster.
*/
buildTextures() {
const nearest = (key) => {
const tex = this.textures.get(key);
if (tex) tex.setFilter(Phaser.Textures.FilterMode.NEAREST);
};
if (!this.cache.bitmapFont.has(FONT_KEY)) {
const font = buildFontCanvas();
if (!this.textures.exists(FONT_KEY)) this.textures.addCanvas(FONT_KEY, font.canvas);
nearest(FONT_KEY);
this.cache.bitmapFont.add(FONT_KEY, Phaser.GameObjects.RetroFont.Parse(this, {
image: FONT_KEY,
offset: { x: 0, y: 0 },
width: font.cell,
height: font.cell,
chars: FONT_CHARS,
charsPerRow: FONT_COLS,
spacing: { x: 0, y: 0 },
}));
}
if (!this.textures.exists('excitebike-hud')) {
this.textures.addCanvas('excitebike-hud', buildHudCanvas());
}
nearest('excitebike-hud');
}
/** Sprite sheets are themed, so they are rebuilt when the track theme changes. */
buildThemeTextures(themeId) {
if (this.themeBuilt === themeId) return;
this.themeBuilt = themeId;
const sheets = [
['excitebike-bike-player', () => buildBikeSheetCanvas(themeId, SP_PLAYER), BIKE_FRAME, BIKE_FRAME],
['excitebike-bike-rival', () => buildBikeSheetCanvas(themeId, SP_RIVAL), BIKE_FRAME, BIKE_FRAME],
['excitebike-tumble', () => buildTumbleSheetCanvas(themeId), RIDER_FRAME, RIDER_FRAME],
['excitebike-run', () => buildRunSheetCanvas(themeId), RIDER_FRAME, RIDER_FRAME],
['excitebike-dust', () => buildDustSheetCanvas(themeId), DUST_FRAME, DUST_FRAME],
];
for (const [key, make, fw, fh] of sheets) {
if (this.textures.exists(key)) this.textures.remove(key);
const built = make();
const tex = this.textures.addCanvas(key, built.canvas);
const cols = Math.floor(built.canvas.width / fw);
for (let i = 0; i < cols; i += 1) tex.add(i, 0, i * fw, 0, fw, fh);
tex.setFilter(Phaser.Textures.FilterMode.NEAREST);
}
for (const [key, slot] of [['excitebike-downed-player', SP_PLAYER], ['excitebike-downed-rival', SP_RIVAL]]) {
if (this.textures.exists(key)) this.textures.remove(key);
this.textures.addCanvas(key, buildDownedBikeCanvas(themeId, slot));
this.textures.get(key).setFilter(Phaser.Textures.FilterMode.NEAREST);
}
}
/** The cabinet the NES frame sits in. The CRT overlay wraps this too. */
buildBezel() {
const g = this.add.graphics().setDepth(-10);
g.fillStyle(0x0a0c12, 1).fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
const pad = 18;
g.fillStyle(0x161a24, 1).fillRoundedRect(
VIEW_X - pad, VIEW_Y - pad,
SCREEN_W * VIEW_SCALE + pad * 2, SCREEN_H * VIEW_SCALE + pad * 2, 14,
);
g.lineStyle(3, 0x2b3242, 1).strokeRoundedRect(
VIEW_X - pad, VIEW_Y - pad,
SCREEN_W * VIEW_SCALE + pad * 2, SCREEN_H * VIEW_SCALE + pad * 2, 14,
);
g.fillStyle(0x000000, 1).fillRect(VIEW_X - 4, VIEW_Y - 4, SCREEN_W * VIEW_SCALE + 8, SCREEN_H * VIEW_SCALE + 8);
const label = (x, y, str, size, color, origin = 0.5) => this.add.text(x, y, str, {
fontFamily: 'm6x11, "Julius Sans One"',
fontSize: `${size}px`,
color,
}).setOrigin(origin, 0.5).setDepth(-9);
label(GAME_WIDTH / 2, VIEW_Y - 36, 'E X C I T E B I K E', 34, '#f83800');
this.controlHint = label(
GAME_WIDTH / 2, VIEW_Y + SCREEN_H * VIEW_SCALE + 32,
'ARROWS STEER X ACCELERATE Z TURBO ENTER START ESC MENU',
22, COLORS.mutedHex,
);
}
/**
* One container at NES coordinates, scaled by an integer, masked to the
* viewport. Everything in the game is a child of this, so the whole game is
* authored in 256x240 space and nothing else has to think about scale.
*
* Note: Phaser containers draw children in insertion order and ignore child
* depth, so the sub-layers below are added in the order they must paint.
*/
buildViewport() {
this.nes = this.add.container(VIEW_X, VIEW_Y).setScale(VIEW_SCALE).setDepth(0);
const shape = this.make.graphics({ add: false });
shape.fillStyle(0xffffff);
shape.fillRect(VIEW_X, VIEW_Y, SCREEN_W * VIEW_SCALE, SCREEN_H * VIEW_SCALE);
this.nes.setMask(shape.createGeometryMask());
this.events.once('shutdown', () => shape.destroy());
this.trackLayer = this.add.container(0, 0);
this.bikeLayer = this.add.container(0, 0);
this.fxLayer = this.add.container(0, 0);
this.hudLayer = this.add.container(0, 0);
this.uiLayer = this.add.container(0, 0);
this.nes.add([this.trackLayer, this.bikeLayer, this.fxLayer, this.hudLayer, this.uiLayer]);
}
// -------------------------------------------------------------------------
// Input
// -------------------------------------------------------------------------
bindInput() {
const K = Phaser.Input.Keyboard.KeyCodes;
this.keys = this.input.keyboard.addKeys({
up: K.UP, down: K.DOWN, left: K.LEFT, right: K.RIGHT,
w: K.W, a: K.A, s: K.S, d: K.D,
accel: K.X, turbo: K.Z,
accel2: K.SPACE, turbo2: K.SHIFT,
start: K.ENTER, back: K.ESC, select: K.TAB,
});
this.input.keyboard.on('keydown-TAB', (e) => e.preventDefault());
}
/** The controller, as the sim wants it. */
readInput() {
const k = this.keys;
return {
a: k.accel.isDown || k.accel2.isDown,
b: k.turbo.isDown || k.turbo2.isDown,
up: k.up.isDown || k.w.isDown,
down: k.down.isDown || k.s.isDown,
left: k.left.isDown || k.a.isDown,
right: k.right.isDown || k.d.isDown,
};
}
justDown(key) {
return Phaser.Input.Keyboard.JustDown(this.keys[key]);
}
// -------------------------------------------------------------------------
// NES-space drawing helpers
// -------------------------------------------------------------------------
/** A line of NES text. Added to `uiLayer` and torn down with the screen. */
nesText(x, y, str, tint = TEXT.white, layer = this.uiLayer) {
const t = this.add.bitmapText(x, y, FONT_KEY, str);
t.setTint(tint);
layer.add(t);
if (layer === this.uiLayer) this.view.push(t);
return t;
}
nesTextCentered(y, str, tint = TEXT.white, layer = this.uiLayer) {
const t = this.nesText(0, y, str, tint, layer);
t.x = Math.round((SCREEN_W - str.length * 8) / 2);
return t;
}
nesRect(x, y, w, h, color, alpha = 1, layer = this.uiLayer) {
const r = this.add.rectangle(x, y, w, h, color, alpha).setOrigin(0, 0);
layer.add(r);
if (layer === this.uiLayer) this.view.push(r);
return r;
}
/**
* A click target for a menu row. Containers force their own origin, so these
* live outside the viewport container at screen coordinates instead.
*/
hitZone(nesX, nesY, nesW, nesH, onClick) {
const zone = this.add.rectangle(
VIEW_X + (nesX + nesW / 2) * VIEW_SCALE,
VIEW_Y + (nesY + nesH / 2) * VIEW_SCALE,
nesW * VIEW_SCALE, nesH * VIEW_SCALE, 0xffffff, 0.001,
).setDepth(40).setInteractive({ useHandCursor: true });
zone.on('pointerdown', onClick);
this.hitZones.push(zone);
return zone;
}
clearView() {
for (const o of this.view) o.destroy();
for (const z of this.hitZones) z.destroy();
this.view = [];
this.hitZones = [];
this.menu = null;
}
/**
* A keyboard-first menu with a blinking selector, as the original's title
* screen has. Mouse works too, but SELECT/START is the intended way in.
*/
makeMenu(items, { x = 84, y = 96, spacing = 16, onPick }) {
const rows = items.map((label, i) => this.nesText(x + 16, y + i * spacing, label));
const cursor = this.nesText(x, y, '>', TEXT.red);
items.forEach((label, i) => {
this.hitZone(x, y + i * spacing - 2, 160, spacing, () => {
this.menu.index = i;
this.syncMenu();
onPick(i);
});
});
this.menu = { items, rows, cursor, index: 0, x, y, spacing, onPick, blink: 0 };
this.syncMenu();
return this.menu;
}
syncMenu() {
const m = this.menu;
if (!m) return;
m.cursor.y = m.y + m.index * m.spacing;
m.rows.forEach((r, i) => r.setTint(i === m.index ? TEXT.white : TEXT.grey));
}
stepMenu() {
const m = this.menu;
if (!m) return;
if (this.justDown('up') || this.justDown('w')) {
m.index = (m.index - 1 + m.items.length) % m.items.length;
this.syncMenu();
playSound(this, SFX.EIGHTBIT_MOVE);
} else if (this.justDown('down') || this.justDown('s')) {
m.index = (m.index + 1) % m.items.length;
this.syncMenu();
playSound(this, SFX.EIGHTBIT_MOVE);
} else if (this.justDown('start') || this.justDown('accel') || this.justDown('accel2')) {
playSound(this, SFX.EIGHTBIT_SELECT);
m.onPick(m.index);
return;
}
m.blink += 1;
m.cursor.setVisible(Math.floor(m.blink / 20) % 2 === 0);
}
// -------------------------------------------------------------------------
// Data
// -------------------------------------------------------------------------
async loadTrackIndex() {
try {
const res = await fetch(`${GAMEDATA}/tracks.json`);
this.trackIndex = await res.json();
} catch (_) {
this.trackIndex = { tracks: [] };
}
this.showTitle();
}
async loadTrack(n) {
const entry = this.trackIndex.tracks.find((t) => t.n === n);
if (!entry) return null;
if (this.trackCache.has(entry.file)) return this.trackCache.get(entry.file);
const res = await fetch(`${GAMEDATA}/${entry.file}`);
const json = await res.json();
this.trackCache.set(entry.file, json);
return json;
}
readJson(key, fallback) {
try {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch (_) { return fallback; }
}
writeJson(key, value) {
try { window.localStorage.setItem(key, JSON.stringify(value)); } catch (_) { /* full or blocked */ }
}
bestTime(trackId, mode) {
const all = this.readJson(BEST_KEY, {});
return all[`${trackId}:${mode}`] ?? null;
}
recordTime(trackId, mode, ms) {
const all = this.readJson(BEST_KEY, {});
const key = `${trackId}:${mode}`;
if (all[key] == null || ms < all[key]) {
all[key] = ms;
this.writeJson(BEST_KEY, all);
return true;
}
return false;
}
/** Highest track number unlocked. Track 1 is always open. */
furthestTrack() {
return Math.max(1, this.readJson(PROGRESS_KEY, { furthest: 1 }).furthest ?? 1);
}
unlockTrack(n) {
if (n <= this.furthestTrack()) return;
this.writeJson(PROGRESS_KEY, { furthest: n });
}
// -------------------------------------------------------------------------
// Screens
// -------------------------------------------------------------------------
showTitle() {
this.teardownRace();
this.clearView();
this.screen = 'title';
this.nesRect(0, 0, SCREEN_W, SCREEN_H, nesColor(0x02));
// Title plate, drawn as a checkered band the way the original's is.
this.nesRect(40, 34, 176, 28, nesColor(0x0f));
for (let i = 0; i < 22; i += 1) {
this.nesRect(40 + i * 8, 34, 8, 4, i % 2 ? nesColor(0x30) : nesColor(0x0f));
this.nesRect(40 + i * 8, 58, 8, 4, i % 2 ? nesColor(0x0f) : nesColor(0x30));
}
this.nesTextCentered(42, 'EXCITEBIKE', TEXT.blue);
this.makeMenu(['SELECTION A', 'SELECTION B', 'DESIGN'], {
x: 84, y: 100, spacing: 18,
onPick: (i) => {
if (i === 0) { this.chosen.mode = MODE.SOLO; this.showTrackSelect(); }
else if (i === 1) { this.chosen.mode = MODE.RACE; this.showTrackSelect(); }
else this.openDesign();
},
});
this.nesText(52, 168, 'A SOLO VS THE CLOCK', TEXT.grey);
this.nesText(52, 180, 'B RACE THE PACK', TEXT.grey);
this.nesTextCentered(206, '(C) 1984 NINTENDO', TEXT.grey);
this.nesTextCentered(220, 'ESC LEAVE', TEXT.grey);
}
showTrackSelect() {
this.clearView();
this.screen = 'trackselect';
this.nesRect(0, 0, SCREEN_W, SCREEN_H, nesColor(0x02));
this.nesTextCentered(16, this.chosen.mode === MODE.SOLO ? 'SELECTION A' : 'SELECTION B', TEXT.amber);
this.nesTextCentered(28, 'SELECT TRACK', TEXT.grey);
const furthest = this.furthestTrack();
const tracks = this.trackIndex.tracks ?? [];
const labels = [];
const playable = [];
for (const t of tracks) {
const locked = t.n > furthest;
const best = this.bestTime(t.id, this.chosen.mode);
const bestStr = best ? formatTime(best) : '--:--:--';
labels.push(`${locked ? 'LOCKED ' : `TRACK ${String(t.n).padStart(2, ' ')} `}${locked ? '' : bestStr}`);
playable.push(locked ? null : t.n);
}
labels.push('BACK');
// Two columns, five rows each, so all ten tracks fit the NES frame.
const rows = Math.ceil(labels.length / 2);
const cursorRows = [];
labels.forEach((label, i) => {
const col = Math.floor(i / rows);
const row = i % rows;
const x = 20 + col * 120;
const y = 48 + row * 18;
const isBack = i === labels.length - 1;
const locked = !isBack && playable[i] == null;
const t = this.nesText(x + 10, y, label, locked ? TEXT.grey : TEXT.white);
cursorRows.push({ t, x, y, locked, isBack, n: playable[i] });
this.hitZone(x, y - 2, 116, 16, () => {
this.trackMenuIndex = i;
this.syncTrackMenu();
this.pickTrack(cursorRows[i]);
});
});
this.trackRows = cursorRows;
this.trackMenuIndex = Math.min(furthest - 1, cursorRows.length - 1);
this.trackCursor = this.nesText(0, 0, '>', TEXT.red);
this.syncTrackMenu();
this.nesTextCentered(196, 'ENTER START', TEXT.grey);
this.nesTextCentered(210, 'BEST TIMES SHOWN BESIDE EACH TRACK', TEXT.grey);
}
syncTrackMenu() {
const row = this.trackRows[this.trackMenuIndex];
this.trackCursor.x = row.x;
this.trackCursor.y = row.y;
this.trackRows.forEach((r, i) => {
r.t.setTint(r.locked ? TEXT.grey : (i === this.trackMenuIndex ? TEXT.white : 0xbcbcbc));
});
}
stepTrackMenu() {
const rows = this.trackRows;
const perCol = Math.ceil(rows.length / 2);
let moved = false;
if (this.justDown('up') || this.justDown('w')) { this.trackMenuIndex -= 1; moved = true; }
else if (this.justDown('down') || this.justDown('s')) { this.trackMenuIndex += 1; moved = true; }
else if (this.justDown('left') || this.justDown('a')) { this.trackMenuIndex -= perCol; moved = true; }
else if (this.justDown('right') || this.justDown('d')) { this.trackMenuIndex += perCol; moved = true; }
if (moved) {
this.trackMenuIndex = (this.trackMenuIndex + rows.length) % rows.length;
this.syncTrackMenu();
playSound(this, SFX.EIGHTBIT_MOVE);
return;
}
if (this.justDown('start') || this.justDown('accel') || this.justDown('accel2')) {
this.pickTrack(rows[this.trackMenuIndex]);
}
}
pickTrack(row) {
if (row.isBack) { playSound(this, SFX.EIGHTBIT_SELECT); this.showTitle(); return; }
if (row.locked) { playSound(this, SFX.EIGHTBIT_ACTION); return; }
playSound(this, SFX.EIGHTBIT_SELECT);
this.chosen.trackN = row.n;
this.startRaceOnTrack(row.n);
}
async startRaceOnTrack(n) {
const json = await this.loadTrack(n);
if (!json) { this.showTitle(); return; }
this.startRace(json, this.chosen.mode);
}
openDesign() {
openDesignMode(this);
}
// -------------------------------------------------------------------------
// Race
// -------------------------------------------------------------------------
startRace(trackJson, mode, { fromDesign = false } = {}) {
this.clearView();
this.teardownRace();
this.screen = 'race';
this.raceOver = false;
this.fromDesign = fromDesign;
this.trackJson = trackJson;
this.model = buildTrackModel(trackJson);
this.buildThemeTextures(this.model.theme);
this.race = createRace({
model: this.model,
mode,
rivalCount: 5,
seed: (Date.now() ^ (this.model.length * 2654435761)) >>> 0,
});
this.buildTrackView();
this.buildBikeViews();
this.buildRaceHud();
this.accum = 0;
this.startEngine();
}
buildTrackView() {
if (this.textures.exists('excitebike-track')) this.textures.remove('excitebike-track');
this.textures.addCanvas('excitebike-track', rasterizeTrack(this.model, this.model.theme));
this.textures.get('excitebike-track').setFilter(Phaser.Textures.FilterMode.NEAREST);
// A pair of images sharing one texture: the second covers the seam so a
// second lap scrolls straight out of the first with no join.
this.trackImages = [0, 1].map(() => {
const img = this.add.image(0, 0, 'excitebike-track').setOrigin(0, 0);
this.trackLayer.add(img);
return img;
});
}
buildBikeViews() {
this.bikeViews = this.race.bikes.map((b) => {
const bike = this.add.image(0, 0, b.isPlayer ? 'excitebike-bike-player' : 'excitebike-bike-rival', 0);
bike.setOrigin(BIKE_PIVOT_X / BIKE_FRAME, BIKE_PIVOT_Y / BIKE_FRAME);
const downed = this.add.image(0, 0, b.isPlayer ? 'excitebike-downed-player' : 'excitebike-downed-rival');
downed.setOrigin(BIKE_PIVOT_X / BIKE_FRAME, BIKE_PIVOT_Y / BIKE_FRAME).setVisible(false);
const rider = this.add.image(0, 0, 'excitebike-tumble', 0).setOrigin(0.5, 1).setVisible(false);
this.bikeLayer.add([downed, bike, rider]);
return { bike, downed, rider, model: b };
});
this.dustPool = [];
for (let i = 0; i < 10; i += 1) {
const d = this.add.image(0, 0, 'excitebike-dust', 0).setOrigin(0.5, 1).setVisible(false);
this.fxLayer.add(d);
this.dustPool.push(d);
}
}
buildRaceHud() {
const hud = this.add.image(0, HUD_Y, 'excitebike-hud').setOrigin(0, 0);
this.hudLayer.add(hud);
const txt = (x, y, str, tint) => {
const t = this.add.bitmapText(x, y, FONT_KEY, str).setTint(tint);
this.hudLayer.add(t);
return t;
};
// The 1st-place time is painted on the stadium wall, as on the original.
this.wallBest = txt(88, WALL_Y + 4, `BEST ${formatTime(bestWallMs(this.model))}`, TEXT.white);
txt(8, HUD_Y + 4, '3RD', TEXT.red);
txt(112, HUD_Y + 4, 'HEAT', TEXT.red);
txt(212, HUD_Y + 4, 'TIME', TEXT.red);
this.hudQualify = txt(8, HUD_Y + 13, formatTime(this.model.qualifyMs), TEXT.white);
this.hudTimer = txt(196, HUD_Y + 13, '0:00:00', TEXT.white);
this.hudLap = txt(8, HUD_Y + 34, 'LAP 1/2', TEXT.amber);
this.hudTrack = txt(214, HUD_Y + 34, `T=${this.fromDesign ? 'D' : this.chosen.trackN}`, TEXT.amber);
this.hudPlace = txt(104, HUD_Y + 34, '', TEXT.green);
this.heatBar = this.add.graphics();
this.hudLayer.add(this.heatBar);
this.centerMsg = txt(0, 96, '', TEXT.white);
this.setCenterMsg('');
}
setCenterMsg(str, tint = TEXT.white) {
this.centerMsg.setText(str);
this.centerMsg.setTint(tint);
this.centerMsg.x = Math.round((SCREEN_W - str.length * 8) / 2);
this.centerMsg.setVisible(str.length > 0);
}
teardownRace() {
this.stopEngine();
for (const layer of [this.trackLayer, this.bikeLayer, this.fxLayer, this.hudLayer]) {
layer?.removeAll(true);
}
this.race = null;
this.bikeViews = null;
this.trackImages = null;
this.dustPool = null;
}
// -------------------------------------------------------------------------
// Audio
// -------------------------------------------------------------------------
startEngine() {
this.stopEngine();
try {
this.engine = this.sound.add(SFX.ENGINE_MEDIUM, { loop: true, volume: 0.28, rate: 1 });
this.engine.play();
} catch (_) { this.engine = null; }
}
stopEngine() {
try { this.engine?.stop(); this.engine?.destroy(); } catch (_) { /* already gone */ }
this.engine = null;
}
updateEngine() {
if (!this.engine) return;
const b = this.race.player;
const idle = b.state === STATE.CRASHED || b.state === STATE.RUNNING;
const t = Math.min(1, b.vx / TUNE.speedTurbo);
this.engine.setRate(idle ? 0.7 : 0.85 + t * 1.25);
this.engine.setVolume(idle ? 0.08 : 0.2 + t * 0.16);
}
handleEvent(ev) {
switch (ev.type) {
case 'count': playSound(this, SFX.COUNTDOWN_TICK); break;
case 'go': playSound(this, SFX.COUNTDOWN_GO); this.setCenterMsg(''); break;
case 'launch':
if (ev.isPlayer) playSound(this, SFX.EIGHTBIT_JUMP);
break;
case 'land':
this.puff(ev.bike);
if (ev.isPlayer && ev.quality === 'hard') this.crt.pulse(0.35, 140);
break;
case 'crash':
this.puff(ev.bike);
if (ev.isPlayer) {
playSound(this, SFX.EIGHTBIT_EXPLODE);
this.crt.pulse(0.9, 360);
this.setCenterMsg('MASH X', TEXT.red);
}
break;
case 'remount':
if (ev.isPlayer) this.setCenterMsg('');
break;
case 'overheat':
if (ev.isPlayer) {
playSound(this, SFX.EIGHTBIT_EXPLODE_2);
this.setCenterMsg('OVERHEAT', TEXT.red);
this.crt.pulse(0.6, 240);
}
break;
case 'lap':
if (ev.isPlayer) playSound(this, SFX.EIGHTBIT_ACTIVATE);
break;
case 'finish':
if (ev.isPlayer) this.onPlayerFinish();
break;
default: break;
}
}
puff(bikeIndex) {
const view = this.bikeViews?.[bikeIndex];
if (!view) return;
const d = this.dustPool.find((s) => !s.visible);
if (!d) return;
d.setPosition(view.bike.x - 8, view.bike.y + 2).setFrame(0).setVisible(true).setAlpha(1);
this.tweens.addCounter({
from: 0, to: DUST_FRAMES - 1, duration: 220,
onUpdate: (tw) => d.setFrame(Math.round(tw.getValue())),
onComplete: () => d.setVisible(false),
});
}
// -------------------------------------------------------------------------
// Loop
// -------------------------------------------------------------------------
update(time, delta) {
if (this.screen === 'title' || this.screen === 'results') this.stepMenu();
else if (this.screen === 'trackselect') this.stepTrackMenu();
else if (this.screen === 'design') this.stepDesign?.(delta);
if (this.justDown('back')) this.onBack();
if (this.screen !== 'race' || !this.race) return;
this.accum += Math.min(delta, 100);
let steps = 0;
while (this.accum >= STEP_MS && steps < 6) {
this.accum -= STEP_MS;
steps += 1;
const input = this.raceOver ? neutralInput() : this.readInput();
for (const ev of step(this.race, input)) this.handleEvent(ev);
}
this.renderRace();
this.updateEngine();
}
onBack() {
if (this.screen === 'race') { this.teardownRace(); this.showTitle(); return; }
if (this.screen === 'trackselect' || this.screen === 'results') { this.showTitle(); return; }
if (this.screen === 'design') { this.closeDesign?.(); return; }
this.scene.start('GameMenu');
}
renderRace() {
const r = this.race;
const L = this.model.length;
const scrollX = r.player.x - CAMERA_LEAD;
// Track: two images, one lap apart.
const base = -(((scrollX % L) + L) % L);
this.trackImages[0].x = Math.round(base);
this.trackImages[1].x = Math.round(base + L);
// Bikes.
for (const view of this.bikeViews) {
const b = view.model;
let sx = b.x - scrollX;
// Wrap a lapped rival into the visible loop rather than losing it offscreen.
while (sx < -L / 2) sx += L;
while (sx > L / 2) sx -= L;
const surfaceY = LANES_Y + b.lane * LANE_H + LANE_H - 1 - b.y;
const riding = b.state !== STATE.CRASHED && b.state !== STATE.RUNNING;
view.bike.setVisible(riding);
view.downed.setVisible(!riding);
view.rider.setVisible(!riding);
if (riding) {
const tread = Math.floor(Math.abs(b.x / 6) % TREAD_FRAMES);
view.bike.setFrame(bikeFrame(pitchFrameIndex(b.pitch), tread));
view.bike.setPosition(Math.round(sx), Math.round(surfaceY));
} else {
const bikeSx = sx + (b.bikeX - b.x);
view.downed.setPosition(Math.round(bikeSx), Math.round(surfaceY));
if (b.state === STATE.CRASHED) {
const tumbleSx = sx + (b.tumbleX - b.x);
const f = Math.floor(b.stateMs / 90) % TUMBLE_FRAMES;
view.rider.setTexture('excitebike-tumble', f);
view.rider.setPosition(Math.round(tumbleSx), Math.round(surfaceY + 2));
} else {
const f = Math.floor(b.stateMs / 110) % RUN_FRAMES;
view.rider.setTexture('excitebike-run', f);
view.rider.setPosition(Math.round(sx), Math.round(surfaceY + 2));
}
// Keep the hidden sprite's y honest so the sort below stays stable.
view.bike.y = Math.round(surfaceY);
}
}
// Containers ignore child depth, so nearer lanes have to be sorted last.
this.bikeLayer.sort('y');
this.renderHud();
}
renderHud() {
const r = this.race;
const b = r.player;
this.hudTimer.setText(formatTime(r.elapsedMs));
this.hudLap.setText(`LAP ${Math.min(b.lap + 1, r.laps)}/${r.laps}`);
if (r.mode === MODE.RACE) {
this.hudPlace.setText(`${placeWord(playerPlace(r))}/${r.bikes.length}`);
} else {
this.hudPlace.setText('');
}
// Heat meter.
this.heatBar.clear();
const w = Math.round(HEAT_BAR_W * b.temp);
if (w > 0) {
this.heatBar.fillStyle(heatColor(b.temp), 1);
this.heatBar.fillRect(HEAT_BAR_X, HUD_Y + HEAT_BAR_Y, w, HEAT_BAR_H);
}
// A blinking full bar is the last warning before the stall.
if (b.temp > 0.9 && Math.floor(r.elapsedMs / 90) % 2 === 0) {
this.heatBar.fillStyle(nesColor(0x30), 1);
this.heatBar.fillRect(HEAT_BAR_X, HUD_Y + HEAT_BAR_Y, HEAT_BAR_W, HEAT_BAR_H);
}
if (r.phase === STATE.COUNTDOWN) {
const n = Math.ceil(r.countdownMs / 1000);
this.setCenterMsg(n > 0 ? String(n) : 'GO!', n > 0 ? TEXT.amber : TEXT.green);
}
}
// -------------------------------------------------------------------------
// Results
// -------------------------------------------------------------------------
onPlayerFinish() {
if (this.raceOver) return;
this.raceOver = true;
const result = finalizeRace(this.race);
playSound(this, result.qualified ? SFX.VICTORY_SHORT : SFX.EIGHTBIT_EXPLODE);
this.time.delayedCall(900, () => this.showResults(result));
}
showResults(result) {
const trackId = this.model.id;
const improved = result.ms != null && this.recordTime(trackId, result.mode, result.ms);
if (result.qualified && !this.fromDesign) {
if (result.mode === MODE.SOLO) {
// Qualifying earns you the main event on the same track.
this.pendingMain = true;
} else {
this.unlockTrack(Math.min(10, this.chosen.trackN + 1));
this.pendingMain = false;
}
} else {
this.pendingMain = false;
}
api.post('/history/single-player', {
slug: 'excitebike',
score: Math.max(0, Math.round(100000 - (result.ms ?? 100000))),
opponentScores: [],
result: result.qualified ? 'win' : 'loss',
}).catch(() => { /* best effort */ });
this.stopEngine();
this.clearView();
this.screen = 'results';
this.nesRect(0, 0, SCREEN_W, SCREEN_H, nesColor(0x02));
this.nesTextCentered(20, result.mode === MODE.SOLO ? 'QUALIFYING' : 'MAIN RACE', TEXT.amber);
this.nesTextCentered(34, this.model.name, TEXT.grey);
this.nesTextCentered(60, `TIME ${formatTime(result.ms)}`, TEXT.white);
if (result.mode === MODE.SOLO) {
this.nesTextCentered(74, `TARGET ${formatTime(this.model.qualifyMs)}`, TEXT.grey);
} else {
this.nesTextCentered(74, `PLACE ${placeWord(result.place)}`, TEXT.white);
}
if (improved) this.nesTextCentered(88, 'NEW BEST TIME', TEXT.green);
this.nesTextCentered(110, result.qualified ? 'QUALIFIED!' : 'FAILED TO QUALIFY',
result.qualified ? TEXT.green : TEXT.red);
const items = [];
const actions = [];
if (this.pendingMain) {
items.push('ENTER MAIN RACE');
actions.push(() => { this.chosen.mode = MODE.RACE; this.startRaceOnTrack(this.chosen.trackN); });
}
const restartMode = result.mode;
const restartJson = this.trackJson;
const wasDesign = this.fromDesign;
items.push('TRY AGAIN');
actions.push(() => (wasDesign
? this.startRace(restartJson, restartMode, { fromDesign: true })
: this.startRaceOnTrack(this.chosen.trackN)));
if (wasDesign) {
items.push('BACK TO DESIGN');
actions.push(() => this.openDesign());
} else {
items.push('SELECT TRACK');
actions.push(() => this.showTrackSelect());
}
items.push('TITLE');
actions.push(() => this.showTitle());
this.makeMenu(items, { x: 60, y: 140, spacing: 16, onPick: (i) => actions[i]() });
}
}
/** 1 -> '1ST', 2 -> '2ND', and so on — the field is small enough to spell out. */
function placeWord(n) {
const suffix = ['TH', 'ST', 'ND', 'RD'][n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 0];
return `${n}${suffix}`;
}

View File

@ -0,0 +1,693 @@
// Excitebike simulation. Headless, deterministic, no Phaser.
//
// Fixed 60Hz steps, seeded RNG, and inputs as plain booleans, so the same seed
// and the same input trace always produce the same finish time. That is what
// lets tools/verifyExcitebike.js drive a bot round every track and assert the
// qualifying times are actually beatable.
//
// The mechanics follow the NES manual:
// A accelerates and releasing it brakes; B is turbo, which heats the engine
// and stalls the bike when the meter fills. Up/down turn the handlebar
// between the four lanes. Left lifts the front wheel (higher, shorter jump),
// right lowers it (lower, longer). Land square and you keep your speed; land
// crooked and you go down, then run after the bike mashing A.
import {
LANE_COUNT, SURFACE, HURDLES,
groundAt, surfaceAt, groundAngleAt, isLaunchEdge, launchSlopeAt, curvatureAt,
} from './ExcitebikeTrack.js';
export const STEP_MS = 1000 / 60;
const DT = 1 / 60;
export const STATE = {
COUNTDOWN: 'countdown',
RIDING: 'riding',
AIRBORNE: 'airborne',
OVERHEATED: 'overheated',
CRASHED: 'crashed',
RUNNING: 'running',
FINISHED: 'finished',
};
export const MODE = { SOLO: 'A', RACE: 'B' };
// ---------------------------------------------------------------------------
// Tuning
// ---------------------------------------------------------------------------
export const TUNE = {
// Speed, in NES pixels per second. A lap of the original's first course is
// ~6400px and its qualifying time is 1:24 over two laps, which puts a good
// average right around 150.
speedAccel: 150,
speedTurbo: 250,
accelRate: 190,
brakeRate: 150,
dragRate: 26,
// Speed above the normal top end bleeds off fast once turbo is released,
// so feathering B cannot hold you at turbo pace for free.
turboBleedRate: 210,
// Heat. Roughly 3.2s of unbroken turbo takes a cold engine to a stall.
heatRate: 1 / 3.2,
coolRate: 1 / 6,
coolZoneRate: 1 / 1.2,
overheatMs: 4000,
// Lanes.
laneSpeed: 4.2, // lanes per second
// Air.
gravity: 620,
// Pitch is the skill in this game, so it has to have weight. These numbers
// give roughly a third of a second to swing the nose from level to fully up,
// and a little over a second for gravity alone to drop it from level to fully
// down — long enough that a big jump has to be planned on the way up.
pitchControl: 14, // rad/s^2 from the d-pad
pitchGravity: 3.0, // rad/s^2 nose-down torque in flight
pitchDamp: 0.92,
pitchMax: 0.7,
// Nose-up trades distance for height; nose-down does the reverse.
pitchDragScale: 0.35,
// Landing tolerance, radians of error between bike pitch and ground angle.
// The window narrows the harder you come down, so the big jumps are the ones
// that punish a lazy attitude — which is the whole point of the ramps.
landClean: 0.20,
landHard: 0.38,
landSpeedSqueeze: 0.35,
landSpeedFull: 200,
hardLandingKeep: 0.55,
// Surfaces.
mudSpeedCap: 74,
roughSpeedDrain: 60,
roughBounce: 34,
// 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.
tumbleMs: 700,
bikeSlideDrag: 150,
slideMin: 12,
slideMax: 60,
runSpeed: 46,
runMashBonus: 34,
// You get going again with a shove, not from a dead stop.
remountSpeed: 40,
// Contact.
contactLane: 0.62,
contactLength: 18,
contactKnockdownSpeed: 26,
};
export const BIKE_LENGTH = 20;
// ---------------------------------------------------------------------------
// Seeded RNG — the same mulberry32 every game in this repo carries
// ---------------------------------------------------------------------------
export function mulberry32(a) {
return function rng() {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
export function neutralInput() {
return { a: false, b: false, up: false, down: false, left: false, right: false };
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
function makeBike(index, { isPlayer, lane, laps }) {
return {
index,
isPlayer,
state: STATE.COUNTDOWN,
x: 0,
lane,
laneTarget: lane,
vx: 0,
y: 0, // height above the lane floor, same units as terrain height
vy: 0,
pitch: 0,
pitchVel: 0,
temp: 0,
lap: 0,
laps,
stateMs: 0,
tumbleX: 0,
bikeX: 0, // where the machine ends up while the rider is on foot
finishMs: null,
place: null,
// Rival personality, filled in for AI bikes.
ai: null,
prevA: false,
dashCharge: 0,
};
}
export const COUNTDOWN_MS = 3000;
/**
* Build a race. `mode` is MODE.SOLO for a qualifying run against the clock, or
* MODE.RACE for the main event with rival bikes on the track.
*/
export function createRace({ model, mode = MODE.SOLO, rivalCount = 5, seed = 1 }) {
const rng = mulberry32(seed >>> 0);
const laps = mode === MODE.RACE ? model.mainLaps : model.laps;
const bikes = [makeBike(0, { isPlayer: true, lane: 2, laps })];
if (mode === MODE.RACE) {
for (let i = 0; i < rivalCount; i += 1) {
const b = makeBike(i + 1, { isPlayer: false, lane: i % LANE_COUNT, laps });
// Rivals are staggered back from the line, as the original's pack is.
b.x = -18 - i * 14;
b.ai = {
// The field has to bracket a good rider: beatable, but not by default.
// The product of pace and heatCeiling is the rival's turbo threshold,
// and it spans from well under a strong player's to a little over it.
pace: 0.74 + rng() * 0.26,
heatCeiling: 0.46 + rng() * 0.22,
lanePref: i % LANE_COUNT,
lookahead: 120 + rng() * 60,
reaction: 0,
// Rivals are fallible in the air on the same terms a player is: they
// misjudge the landing angle and react to it late. Without the misjudging
// the pack rides perfectly and the race becomes unwinnable by anyone
// holding a controller.
airDeadzone: 0.07 + rng() * 0.09,
airJitter: 0.05 + rng() * 0.09,
airHold: 0,
airCmd: null,
airFrames: 4 + Math.floor(rng() * 5),
};
bikes.push(b);
}
}
return {
model,
mode,
laps,
seed: seed >>> 0,
rng,
bikes,
player: bikes[0],
phase: STATE.COUNTDOWN,
countdownMs: COUNTDOWN_MS,
elapsedMs: 0,
events: [],
finishedCount: 0,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const clamp = (v, lo, hi) => (v < lo ? lo : (v > hi ? hi : v));
function laneOf(b) {
return clamp(Math.round(b.lane), 0, LANE_COUNT - 1);
}
/** Whether this bike is on the ground and steerable. */
function grounded(b) {
return b.state === STATE.RIDING || b.state === STATE.OVERHEATED;
}
function emit(state, ev) {
state.events.push(ev);
}
function crash(state, b, reason) {
if (b.state === STATE.CRASHED || b.state === STATE.RUNNING || b.state === STATE.FINISHED) return;
b.state = STATE.CRASHED;
b.stateMs = 0;
b.tumbleX = b.x;
// The machine keeps going and comes to rest ahead of its rider.
b.bikeX = b.x + clamp(b.vx * 0.55, TUNE.slideMin, TUNE.slideMax);
b.vx = Math.abs(b.vx) * 0.5;
b.y = 0;
b.vy = 0;
b.pitch = 0;
b.pitchVel = 0;
emit(state, { type: 'crash', bike: b.index, isPlayer: b.isPlayer, reason, x: b.x });
}
// ---------------------------------------------------------------------------
// Rival AI
// ---------------------------------------------------------------------------
/** Cost of committing to a lane over the next stretch of track. */
function laneCost(model, lane, x, lookahead) {
let cost = 0;
for (let d = 0; d < lookahead; d += 8) {
const surf = surfaceAt(model, lane, x + d);
const weight = 1 - d / (lookahead * 1.4);
if (surf === SURFACE.GAP) cost += 800 * weight;
else if (surf === SURFACE.OBSTACLE) cost += 900 * weight;
else if (surf === SURFACE.ROUGH) cost += 90 * weight;
else if (surf === SURFACE.MUD) cost += 120 * weight;
else if (surf === SURFACE.COOL) cost -= 20 * weight;
}
return cost;
}
/** Traffic in a lane: a slower bike just ahead is a reason to move over. */
function trafficCost(state, self, lane, lookahead) {
let cost = 0;
for (const other of state.bikes) {
if (other === self || other.state === STATE.FINISHED) continue;
if (Math.abs(other.lane - lane) > 0.7) continue;
const gap = other.x - self.x;
if (gap < -TUNE.contactLength || gap > lookahead) continue;
cost += 140 * (1 - gap / lookahead);
}
return cost;
}
function rivalInput(state, b) {
const { model } = state;
const input = neutralInput();
const ai = b.ai;
input.a = true;
if (b.state === STATE.AIRBORNE) {
// Aim the bike at the ground it is about to meet, on a reaction delay.
ai.airHold -= 1;
if (ai.airHold <= 0 || ai.airCmd == null) {
ai.airHold = ai.airFrames;
const want = groundAngleAt(model, laneOf(b), b.x + b.vx * 0.25)
+ (state.rng() - 0.5) * ai.airJitter;
const err = want - b.pitch;
ai.airCmd = err > ai.airDeadzone ? 'up' : (err < -ai.airDeadzone ? 'down' : 'hold');
}
if (ai.airCmd === 'up') input.left = true;
else if (ai.airCmd === 'down') input.right = true;
return input;
}
ai.airCmd = null;
if (b.state === STATE.RUNNING) {
// Mash, on alternate frames.
input.a = Math.floor(state.elapsedMs / STEP_MS) % 2 === 0;
return input;
}
// Turbo up to this rival's own heat ceiling.
const surf = surfaceAt(model, laneOf(b), b.x);
if (b.temp < ai.heatCeiling * ai.pace || surf === SURFACE.COOL) input.b = true;
// Pick a lane, with hysteresis so they do not oscillate on the line.
ai.reaction -= STEP_MS;
if (ai.reaction <= 0) {
ai.reaction = 90;
let best = laneOf(b);
let bestCost = laneCost(model, best, b.x, ai.lookahead)
+ trafficCost(state, b, best, ai.lookahead) - 30;
for (let l = 0; l < LANE_COUNT; l += 1) {
const c = laneCost(model, l, b.x, ai.lookahead)
+ trafficCost(state, b, l, ai.lookahead)
+ Math.abs(l - ai.lanePref) * 22;
if (c < bestCost) { bestCost = c; best = l; }
}
b.laneTarget = best;
}
if (b.lane > b.laneTarget + 0.05) input.up = true;
else if (b.lane < b.laneTarget - 0.05) input.down = true;
// Pop the front wheel before a launch edge so they clear jumps cleanly.
if (isLaunchEdge(model, laneOf(b), b.x + 8)) input.left = true;
return input;
}
// ---------------------------------------------------------------------------
// Per-bike step
// ---------------------------------------------------------------------------
function stepBike(state, b, input) {
const { model } = state;
const lane = laneOf(b);
switch (b.state) {
case STATE.CRASHED: {
b.stateMs += STEP_MS;
// Rider tumbles, machine slides.
b.tumbleX += b.vx * DT;
b.vx = Math.max(0, b.vx - TUNE.bikeSlideDrag * DT);
if (b.stateMs >= TUNE.tumbleMs) {
b.state = STATE.RUNNING;
b.stateMs = 0;
b.x = b.tumbleX;
b.vx = 0;
}
return;
}
case STATE.RUNNING: {
b.stateMs += STEP_MS;
// Mashing A — a fresh press, not a held button — makes him run harder.
const mashed = input.a && !b.prevA;
if (mashed) b.dashCharge = Math.min(1, b.dashCharge + 0.16);
b.dashCharge = Math.max(0, b.dashCharge - 0.9 * DT);
const speed = TUNE.runSpeed + TUNE.runMashBonus * b.dashCharge;
b.x += speed * DT;
if (b.x >= b.bikeX) {
b.x = b.bikeX;
b.state = STATE.RIDING;
b.stateMs = 0;
b.vx = TUNE.remountSpeed;
b.dashCharge = 0;
emit(state, { type: 'remount', bike: b.index, isPlayer: b.isPlayer });
}
return;
}
case STATE.OVERHEATED: {
b.stateMs += STEP_MS;
b.vx = Math.max(0, b.vx - TUNE.brakeRate * 1.6 * DT);
b.x += b.vx * DT;
b.temp = Math.max(0, b.temp - (1 / (TUNE.overheatMs / 1000)) * DT);
if (b.stateMs >= TUNE.overheatMs) {
b.state = STATE.RIDING;
b.stateMs = 0;
b.temp = 0;
}
return;
}
default: break;
}
// ---- steering -----------------------------------------------------------
if (grounded(b)) {
if (input.up) b.laneTarget = clamp(Math.floor(b.lane - 0.01), 0, LANE_COUNT - 1);
else if (input.down) b.laneTarget = clamp(Math.ceil(b.lane + 0.01), 0, LANE_COUNT - 1);
const d = b.laneTarget - b.lane;
const stepLane = TUNE.laneSpeed * DT;
b.lane += clamp(d, -stepLane, stepLane);
if (Math.abs(b.laneTarget - b.lane) < 0.01) b.lane = b.laneTarget;
}
// ---- throttle and heat --------------------------------------------------
const surf = surfaceAt(model, lane, b.x);
const turbo = input.b;
let target = 0;
if (input.b) target = TUNE.speedTurbo;
else if (input.a) target = TUNE.speedAccel;
if (surf === SURFACE.MUD && grounded(b)) target = Math.min(target, TUNE.mudSpeedCap);
if (b.vx < target) {
b.vx = Math.min(target, b.vx + TUNE.accelRate * DT);
} else {
let bleed = input.a || input.b ? TUNE.dragRate : TUNE.brakeRate;
if (!input.b && b.vx > TUNE.speedAccel) bleed = TUNE.turboBleedRate;
b.vx = Math.max(target, b.vx - bleed * DT);
}
if (turbo) b.temp += TUNE.heatRate * DT;
else b.temp -= TUNE.coolRate * DT;
if (surf === SURFACE.COOL) b.temp -= TUNE.coolZoneRate * DT;
b.temp = clamp(b.temp, 0, 1);
if (b.temp >= 1 && grounded(b)) {
b.state = STATE.OVERHEATED;
b.stateMs = 0;
emit(state, { type: 'overheat', bike: b.index, isPlayer: b.isPlayer });
return;
}
// ---- 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.ROUGH) {
// Whoops do not launch you, they rattle you: speed bleeds away and the
// bike shakes, which is what makes them worth steering around.
b.vx = Math.max(0, b.vx - TUNE.roughSpeedDrain * DT);
b.pitch += Math.sin(b.x * 0.4) * 0.05;
}
}
// ---- pitch --------------------------------------------------------------
if (b.state === STATE.AIRBORNE) {
if (input.left) b.pitchVel += TUNE.pitchControl * DT;
if (input.right) b.pitchVel -= TUNE.pitchControl * DT;
b.pitchVel -= TUNE.pitchGravity * DT;
b.pitchVel *= TUNE.pitchDamp;
b.pitch = clamp(b.pitch + b.pitchVel * DT, -TUNE.pitchMax, TUNE.pitchMax);
} else {
// On the ground the bike follows the terrain, with a small wheelie allowed.
const target2 = groundAngleAt(model, lane, b.x);
let wheelie = 0;
if (input.left && b.vx > 40) wheelie = 0.28;
b.pitch += (target2 + wheelie - b.pitch) * 0.25;
b.pitchVel = 0;
}
// ---- vertical motion ----------------------------------------------------
const ground = groundAt(model, lane, b.x);
if (b.state === STATE.AIRBORNE) {
b.vy -= TUNE.gravity * DT;
b.y += b.vy * DT;
// Nose attitude trades height for distance.
b.vx -= b.pitch * TUNE.pitchDragScale * TUNE.gravity * DT * 0.12;
if (b.y <= ground) {
b.y = ground;
const err = Math.abs(b.pitch - groundAngleAt(model, lane, b.x));
const impact = Math.abs(b.vy);
if (surfaceAt(model, lane, b.x) === SURFACE.GAP) {
crash(state, b, 'gap');
return;
}
const squeeze = 1 - TUNE.landSpeedSqueeze * clamp(impact / TUNE.landSpeedFull, 0, 1);
if (err <= TUNE.landClean * squeeze) {
emit(state, { type: 'land', bike: b.index, isPlayer: b.isPlayer, quality: 'clean', impact });
} else if (err <= TUNE.landHard * squeeze) {
b.vx *= TUNE.hardLandingKeep;
emit(state, { type: 'land', bike: b.index, isPlayer: b.isPlayer, quality: 'hard', impact });
} else {
crash(state, b, 'landing');
return;
}
b.state = STATE.RIDING;
b.vy = 0;
}
} else {
b.y = ground;
// Two ways to leave the ground, and the courses use both.
//
// A launch edge is a cliff: the ground simply stops. Test the stretch the
// bike is about to cross this frame, not a fixed window, because at turbo
// pace a crest is only a frame or two wide.
//
// A crest is a hill: you fly when the ground curves away from under you
// faster than gravity pulls you into it, which is v^2 * curvature > g. That
// is what turns the original's rolling mounds into jumps at speed and
// leaves them as bumps when you are crawling.
const stepAhead = Math.max(2, Math.ceil(b.vx * DT) + 2);
const curve = curvatureAt(model, lane, b.x);
const overCrest = curve < 0 && b.vx * b.vx * -curve > TUNE.gravity;
if (b.vx > 45 && (overCrest || isLaunchEdge(model, lane, b.x, stepAhead))) {
const slope = launchSlopeAt(model, lane, b.x);
b.state = STATE.AIRBORNE;
b.vy = b.vx * slope;
b.pitch = Math.atan(slope);
b.pitchVel = 0;
emit(state, { type: 'launch', bike: b.index, isPlayer: b.isPlayer, speed: b.vx });
}
}
// ---- forward motion and laps -------------------------------------------
const before = b.x;
b.x += b.vx * DT;
const lapBefore = Math.floor(before / model.length);
const lapAfter = Math.floor(b.x / model.length);
// Rivals start behind the line at negative x, so only count crossings that
// actually complete a lap.
if (lapAfter > lapBefore && lapAfter >= 1) {
b.lap = lapAfter;
if (b.lap >= b.laps) {
b.state = STATE.FINISHED;
b.finishMs = state.elapsedMs;
state.finishedCount += 1;
b.place = state.finishedCount;
emit(state, { type: 'finish', bike: b.index, isPlayer: b.isPlayer, ms: b.finishMs, place: b.place });
} else {
emit(state, { type: 'lap', bike: b.index, isPlayer: b.isPlayer, lap: b.lap });
}
}
}
// ---------------------------------------------------------------------------
// Contact
// ---------------------------------------------------------------------------
// The manual's own rule: nudge a rival's front wheel with your back wheel and
// they go down. Catch one from behind instead and you are the one on the floor.
//
// Only a genuine closing speed puts anyone down, though. Riding nose-to-tail at
// the same pace just blocks you — otherwise a pack that bunches up on a straight
// wipes itself out, which is not what the original does.
function resolveContact(state) {
const bikes = state.bikes;
for (let i = 0; i < bikes.length; i += 1) {
const a = bikes[i];
if (!grounded(a)) continue;
for (let j = i + 1; j < bikes.length; j += 1) {
const c = bikes[j];
if (!grounded(c)) continue;
if (Math.abs(a.lane - c.lane) > TUNE.contactLane) continue;
const dx = a.x - c.x;
if (Math.abs(dx) > TUNE.contactLength) continue;
const ahead = dx > 0 ? a : c;
const behind = dx > 0 ? c : a;
const closing = behind.vx - ahead.vx;
if (closing < TUNE.contactKnockdownSpeed) {
// Held up behind the leader, and shuffled clear of overlapping.
behind.vx = Math.min(behind.vx, ahead.vx);
behind.x = ahead.x - TUNE.contactLength;
continue;
}
crash(state, behind, 'contact');
emit(state, {
type: 'knockdown', bike: behind.index, by: ahead.index, isPlayer: behind.isPlayer,
});
}
}
}
// ---------------------------------------------------------------------------
// Step
// ---------------------------------------------------------------------------
/**
* Advance one 60Hz frame. Returns the events produced by this step; the caller
* turns them into sound and particles.
*/
export function step(state, playerInput = neutralInput()) {
state.events = [];
if (state.phase === STATE.COUNTDOWN) {
state.countdownMs -= STEP_MS;
// Holding turbo on the line builds a dash start.
if (playerInput.b) {
state.player.dashCharge = Math.min(1, state.player.dashCharge + DT / 1.2);
state.player.temp = Math.min(0.85, state.player.temp + TUNE.heatRate * DT * 0.6);
}
if (state.countdownMs <= 0) {
state.phase = STATE.RIDING;
for (const b of state.bikes) {
b.state = STATE.RIDING;
if (b.isPlayer) {
b.vx = TUNE.speedAccel * 0.35 * b.dashCharge;
b.dashCharge = 0;
}
}
emit(state, { type: 'go' });
} else {
const t = Math.ceil(state.countdownMs / 1000);
if (state.countdownMs % 1000 > 1000 - STEP_MS) emit(state, { type: 'count', n: t });
}
state.player.prevA = playerInput.a;
return state.events;
}
if (state.phase === STATE.FINISHED) return state.events;
state.elapsedMs += STEP_MS;
for (const b of state.bikes) {
if (b.state === STATE.FINISHED) continue;
const input = b.isPlayer ? playerInput : rivalInput(state, b);
stepBike(state, b, input);
b.prevA = input.a;
}
if (state.bikes.length > 1) resolveContact(state);
if (state.player.state === STATE.FINISHED) {
state.phase = STATE.FINISHED;
}
return state.events;
}
// ---------------------------------------------------------------------------
// Results
// ---------------------------------------------------------------------------
/** Live standings: finishers by time, then everyone else by distance covered. */
export function standings(state) {
return state.bikes.slice().sort((a, b) => {
if (a.finishMs != null && b.finishMs != null) return a.finishMs - b.finishMs;
if (a.finishMs != null) return -1;
if (b.finishMs != null) return 1;
return b.x - a.x;
});
}
/** The player's 1-based position in the field right now. */
export function playerPlace(state) {
return standings(state).findIndex((b) => b.isPlayer) + 1;
}
/**
* Close out a race. Qualifying (SELECTION A) is judged purely on the clock;
* the main event (SELECTION B) is judged on placing, third or better.
*/
export function finalizeRace(state) {
const order = standings(state);
order.forEach((b, i) => { b.place = i + 1; });
const place = order.findIndex((b) => b.isPlayer) + 1;
const ms = state.player.finishMs;
const finished = state.player.state === STATE.FINISHED;
const qualified = state.mode === MODE.SOLO
? finished && ms != null && ms <= state.model.qualifyMs
: finished && place <= 3;
return { finished, ms, place, qualified, order, mode: state.mode };
}
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
/** NES status-bar clock: M:SS:hh, exactly as the original prints it. */
export function formatTime(ms) {
if (ms == null || !Number.isFinite(ms)) return '-:--:--';
const total = Math.max(0, Math.floor(ms));
const m = Math.floor(total / 60000);
const s = Math.floor((total % 60000) / 1000);
const h = Math.floor((total % 1000) / 10);
return `${m}:${String(s).padStart(2, '0')}:${String(h).padStart(2, '0')}`;
}
export { LANE_COUNT, SURFACE, HURDLES };

View File

@ -0,0 +1,298 @@
// NES hardware model for Excitebike — palette, pixel grids, 8x8 tile bank.
//
// Nothing in this file imports Phaser or touches a canvas, so `node` can load
// it directly and tools/verifyExcitebike.js can assert on the art itself. That
// is the whole point of the split: art here is *data* (grids of palette slot
// indices), and ExcitebikeRaster.js is the only module that turns those grids
// into real pixels.
//
// The NES constraints we actually honour:
// - every colour comes from the 64-entry master palette below, nothing else
// - a sprite or background tile sees only 4 colours at a time (a subpalette):
// slot 0 is transparent (sprites) / the backdrop colour (background), and
// slots 1..3 are the three drawable colours
// - everything is integer pixels on an 8x8 tile lattice
//
// Deliberate deviation: no 8-sprites-per-scanline flicker. On real hardware
// Excitebike's rival pack shimmers; reproduced here it would read as a bug.
// ---------------------------------------------------------------------------
// Master palette
// ---------------------------------------------------------------------------
// The 64 colours the PPU can produce, in hardware index order ($00-$3F).
// Entries $0D/$0E/$0F and $1E/$1F etc. are the blanking blacks — kept in place
// so an index here means the same thing it means on the console.
export const NES_PALETTE = [
0x7c7c7c, 0x0000fc, 0x0000bc, 0x4428bc, 0x940084, 0xa80020, 0xa81000, 0x881400,
0x503000, 0x007800, 0x006800, 0x005800, 0x004058, 0x000000, 0x000000, 0x000000,
0xbcbcbc, 0x0078f8, 0x0058f8, 0x6844fc, 0xd800cc, 0xe40058, 0xf83800, 0xe45c10,
0xac7c00, 0x00b800, 0x00a800, 0x00a844, 0x008888, 0x000000, 0x000000, 0x000000,
0xf8f8f8, 0x3cbcfc, 0x6888fc, 0x9878f8, 0xf878f8, 0xf85898, 0xf87858, 0xfca044,
0xf8b800, 0xb8f818, 0x58d854, 0x58f898, 0x00e8d8, 0x787878, 0x000000, 0x000000,
0xfcfcfc, 0xa4e4fc, 0xb8b8f8, 0xd8b8f8, 0xf8b8f8, 0xf8a4c0, 0xf0d0b0, 0xfce0a8,
0xf8d878, 0xd8f878, 0xb8f8b8, 0xb8f8d8, 0x00fcfc, 0xf8d8f8, 0x000000, 0x000000,
];
export const PALETTE_SIZE = NES_PALETTE.length;
/** Master-palette index -> 0xRRGGBB. Throws on an out-of-range index. */
export function nesColor(index) {
if (!Number.isInteger(index) || index < 0 || index >= PALETTE_SIZE) {
throw new RangeError(`NES palette index out of range: ${index}`);
}
return NES_PALETTE[index];
}
/** Master-palette index -> '#rrggbb', for canvas fillStyle. */
export function nesCssColor(index) {
return `#${nesColor(index).toString(16).padStart(6, '0')}`;
}
// ---------------------------------------------------------------------------
// Subpalettes
// ---------------------------------------------------------------------------
// A subpalette is exactly 4 master-palette indices. Slot 0 is transparent for
// sprites and the shared backdrop for background tiles; the PPU only ever
// renders slots 1..3 as distinct colours within one tile.
export const SUBPALETTE_SLOTS = 4;
/** Assert a subpalette is 4 valid master-palette indices; returns it frozen. */
export function subpalette(...indices) {
if (indices.length !== SUBPALETTE_SLOTS) {
throw new RangeError(`a subpalette needs ${SUBPALETTE_SLOTS} entries, got ${indices.length}`);
}
for (const i of indices) nesColor(i);
return Object.freeze(indices.slice());
}
export const TRANSPARENT = 0;
// ---------------------------------------------------------------------------
// Index grids
// ---------------------------------------------------------------------------
// A grid is a rectangle of subpalette slot indices (0..3). Every art generator
// in ExcitebikeArt.js returns one of these; the raster stage picks the actual
// colours by pairing a grid with a subpalette.
export function makeGrid(w, h, fill = TRANSPARENT) {
const data = new Uint8Array(w * h);
if (fill) data.fill(fill);
return { w, h, data };
}
export function cloneGrid(g) {
return { w: g.w, h: g.h, data: Uint8Array.from(g.data) };
}
export function getPx(g, x, y) {
if (x < 0 || y < 0 || x >= g.w || y >= g.h) return TRANSPARENT;
return g.data[y * g.w + x];
}
export function setPx(g, x, y, slot) {
if (x < 0 || y < 0 || x >= g.w || y >= g.h) return;
g.data[(y | 0) * g.w + (x | 0)] = slot;
}
export function fillRect(g, x, y, w, h, slot) {
const x0 = Math.max(0, Math.round(x));
const y0 = Math.max(0, Math.round(y));
const x1 = Math.min(g.w, Math.round(x + w));
const y1 = Math.min(g.h, Math.round(y + h));
for (let py = y0; py < y1; py += 1) {
g.data.fill(slot, py * g.w + x0, py * g.w + x1);
}
}
export function hLine(g, x0, x1, y, slot) {
const a = Math.min(x0, x1);
const b = Math.max(x0, x1);
for (let x = a; x <= b; x += 1) setPx(g, x, y, slot);
}
export function vLine(g, x, y0, y1, slot) {
const a = Math.min(y0, y1);
const b = Math.max(y0, y1);
for (let y = a; y <= b; y += 1) setPx(g, x, y, slot);
}
/** Bresenham, so diagonals land on the same pixels the PPU's artists would. */
export function line(g, x0, y0, x1, y1, slot) {
let x = Math.round(x0);
let y = Math.round(y0);
const ex = Math.round(x1);
const ey = Math.round(y1);
const dx = Math.abs(ex - x);
const dy = -Math.abs(ey - y);
const sx = x < ex ? 1 : -1;
const sy = y < ey ? 1 : -1;
let err = dx + dy;
for (;;) {
setPx(g, x, y, slot);
if (x === ex && y === ey) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; }
if (e2 <= dx) { err += dx; y += sy; }
}
}
export function fillCircle(g, cx, cy, r, slot) {
const r2 = r * r;
for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y += 1) {
for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x += 1) {
const dx = x - cx;
const dy = y - cy;
if (dx * dx + dy * dy <= r2) setPx(g, x, y, slot);
}
}
}
export function strokeCircle(g, cx, cy, r, slot, thickness = 1) {
const outer = r * r;
const innerR = Math.max(0, r - thickness);
const inner = innerR * innerR;
for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y += 1) {
for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x += 1) {
const dx = x - cx;
const dy = y - cy;
const d2 = dx * dx + dy * dy;
if (d2 <= outer && d2 >= inner) setPx(g, x, y, slot);
}
}
}
/** Filled convex polygon via scanline. Points are [[x,y], ...]. */
export function fillPoly(g, points, slot) {
let minY = Infinity;
let maxY = -Infinity;
for (const [, y] of points) {
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
for (let y = Math.floor(minY); y <= Math.ceil(maxY); y += 1) {
const xs = [];
for (let i = 0; i < points.length; i += 1) {
const [ax, ay] = points[i];
const [bx, by] = points[(i + 1) % points.length];
if ((ay <= y && by > y) || (by <= y && ay > y)) {
xs.push(ax + ((y - ay) / (by - ay)) * (bx - ax));
}
}
xs.sort((a, b) => a - b);
for (let i = 0; i + 1 < xs.length; i += 2) {
hLine(g, Math.round(xs[i]), Math.round(xs[i + 1]), y, slot);
}
}
}
/** Copy `src` onto `dst` at (x, y). Slot 0 in src is treated as transparent. */
export function blit(dst, src, x, y, { flipX = false, flipY = false } = {}) {
for (let sy = 0; sy < src.h; sy += 1) {
for (let sx = 0; sx < src.w; sx += 1) {
const slot = src.data[sy * src.w + sx];
if (slot === TRANSPARENT) continue;
const tx = x + (flipX ? src.w - 1 - sx : sx);
const ty = y + (flipY ? src.h - 1 - sy : sy);
setPx(dst, tx, ty, slot);
}
}
}
/** Every slot index the grid actually uses, transparent included. */
export function gridSlots(g) {
const seen = new Set();
for (let i = 0; i < g.data.length; i += 1) seen.add(g.data[i]);
return seen;
}
/** True when the grid stays inside a single 4-colour subpalette. */
export function fitsSubpalette(g) {
for (const slot of gridSlots(g)) {
if (slot >= SUBPALETTE_SLOTS) return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Tiles
// ---------------------------------------------------------------------------
export const TILE = 8;
/**
* Author an 8x8 tile as 8 strings of 8 characters:
* '.' or '0' = slot 0, '1'/'2'/'3' = the three drawable slots.
* Readable in source and trivially checkable in Node.
*/
export function tile(rows) {
if (rows.length !== TILE) throw new RangeError(`a tile needs ${TILE} rows, got ${rows.length}`);
const g = makeGrid(TILE, TILE);
for (let y = 0; y < TILE; y += 1) {
const row = rows[y];
if (row.length !== TILE) throw new RangeError(`tile row ${y} must be ${TILE} chars, got "${row}"`);
for (let x = 0; x < TILE; x += 1) {
const ch = row[x];
const slot = ch === '.' ? 0 : Number(ch);
if (!Number.isInteger(slot) || slot < 0 || slot >= SUBPALETTE_SLOTS) {
throw new RangeError(`bad tile pixel "${ch}" at ${x},${y}`);
}
g.data[y * TILE + x] = slot;
}
}
return g;
}
/** Tile the whole of `dst` with `src`, offset by (ox, oy). Used for terrain fills. */
export function tileFill(dst, src, ox = 0, oy = 0, x0 = 0, y0 = 0, w = dst.w, h = dst.h) {
for (let y = y0; y < y0 + h; y += 1) {
const sy = (((y - oy) % src.h) + src.h) % src.h;
for (let x = x0; x < x0 + w; x += 1) {
const sx = (((x - ox) % src.w) + src.w) % src.w;
setPx(dst, x, y, src.data[sy * src.w + sx]);
}
}
}
// ---------------------------------------------------------------------------
// Screen geometry
// ---------------------------------------------------------------------------
// The NES frame, and Excitebike's split of it. The 192px playfield matches the
// height of the original's track maps; the remaining 48px is the status bar.
export const SCREEN_W = 256;
export const SCREEN_H = 240;
export const PLAYFIELD_H = 192;
export const HUD_Y = PLAYFIELD_H;
export const HUD_H = SCREEN_H - PLAYFIELD_H;
// Playfield bands, top to bottom. These are the original's own divisions,
// measured off the NES track maps by tools/readExcitebikeMaps.js: crowd to y40,
// sky and wall to y64, a deep band of infield down to y128, then four twelve-
// pixel lanes, then the apron. The lanes really are only 12px tall — the bike
// stands well over a lane's height, and that overlap is what the original
// looks like.
export const CROWD_Y = 0;
export const CROWD_H = 40;
export const WALL_Y = 40; // the stadium wall the BEST time is painted on
export const WALL_H = 24;
export const INFIELD_Y = 64;
export const INFIELD_H = 64;
export const LANES_Y = 128;
export const LANE_H = 12;
export const LANE_COUNT = 4;
export const LANES_H = LANE_H * LANE_COUNT; // 48
export const APRON_Y = LANES_Y + LANES_H; // 176
export const APRON_H = PLAYFIELD_H - APRON_Y; // 16
/** Screen y of the riding surface for a (possibly fractional) lane. */
export function laneY(lane) {
return LANES_Y + (lane + 0.5) * LANE_H;
}
/** Nearest whole lane, clamped to the track. */
export function clampLane(lane) {
return Math.min(LANE_COUNT - 1, Math.max(0, lane));
}

View File

@ -0,0 +1,329 @@
// Excitebike raster stage — index grids and compiled terrain into real pixels.
//
// This is the only module in the game that touches a canvas, and it imports no
// Phaser, so the scene stays a thin consumer: it asks for canvases here and
// hands them to textures.addCanvas.
//
// Deliberate deviation from the hardware: colour-attribute boundaries are
// per-pixel here, where the PPU constrains them to 16x16 blocks. Everything
// else stays true — colours come only from the master palette, sprites see
// four at a time, and every coordinate is an integer pixel.
import {
nesColor, SCREEN_W, PLAYFIELD_H, CROWD_Y, CROWD_H, WALL_Y, WALL_H,
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,
SP_PLAYER, SP_RIVAL, SP_FLESH, SP_FX, S,
buildFontGrid, buildBikeFrames, buildTumbleFrame, buildRunFrame,
buildDownedBike, buildDustFrame, packStrip,
TUMBLE_FRAMES, RUN_FRAMES, DUST_FRAMES,
} from './ExcitebikeArt.js';
import { SURFACE } from './ExcitebikeTrack.js';
export function makeCanvas(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
// ---------------------------------------------------------------------------
// Grid -> canvas
// ---------------------------------------------------------------------------
/**
* Paint an index grid with a subpalette. Slot 0 is transparent unless
* `opaque` is set, in which case it takes the subpalette's backdrop entry.
*/
export function gridToCanvas(grid, subpal, { opaque = false } = {}) {
const canvas = makeCanvas(grid.w, grid.h);
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(grid.w, grid.h);
const buf = img.data;
const rgb = subpal.map((i) => nesColor(i));
for (let i = 0; i < grid.data.length; i += 1) {
const slot = grid.data[i];
const o = i * 4;
if (slot === 0 && !opaque) { buf[o + 3] = 0; continue; }
const c = rgb[slot];
buf[o] = (c >> 16) & 0xff;
buf[o + 1] = (c >> 8) & 0xff;
buf[o + 2] = c & 0xff;
buf[o + 3] = 255;
}
ctx.putImageData(img, 0, 0);
return canvas;
}
// ---------------------------------------------------------------------------
// Sprite sheets
// ---------------------------------------------------------------------------
/** White-on-transparent glyph atlas for Phaser's RetroFont; tint per HUD field. */
export function buildFontCanvas() {
const font = buildFontGrid();
const canvas = gridToCanvas(font.grid, [0x0f, 0x0f, 0x0f, 0x30]);
return { canvas, ...font };
}
/**
* One bike sheet per rider colour: PITCH_FRAMES x TREAD_FRAMES frames in a
* single strip. `spriteSlot` picks SP_PLAYER or SP_RIVAL out of the theme.
*/
export function buildBikeSheetCanvas(themeId, spriteSlot = SP_PLAYER) {
const theme = THEMES[themeId] ?? THEMES.day;
const strip = packStrip(buildBikeFrames());
return {
canvas: gridToCanvas(strip.grid, theme.sprites[spriteSlot]),
frameWidth: strip.frameWidth,
frameHeight: strip.frameHeight,
count: strip.count,
};
}
export function buildTumbleSheetCanvas(themeId) {
const theme = THEMES[themeId] ?? THEMES.day;
const frames = [];
for (let i = 0; i < TUMBLE_FRAMES; i += 1) frames.push(buildTumbleFrame(i));
const strip = packStrip(frames);
return {
canvas: gridToCanvas(strip.grid, theme.sprites[SP_FLESH]),
frameWidth: strip.frameWidth,
frameHeight: strip.frameHeight,
count: strip.count,
};
}
export function buildRunSheetCanvas(themeId) {
const theme = THEMES[themeId] ?? THEMES.day;
const frames = [];
for (let i = 0; i < RUN_FRAMES; i += 1) frames.push(buildRunFrame(i));
const strip = packStrip(frames);
return {
canvas: gridToCanvas(strip.grid, theme.sprites[SP_FLESH]),
frameWidth: strip.frameWidth,
frameHeight: strip.frameHeight,
count: strip.count,
};
}
export function buildDustSheetCanvas(themeId) {
const theme = THEMES[themeId] ?? THEMES.day;
const frames = [];
for (let i = 0; i < DUST_FRAMES; i += 1) frames.push(buildDustFrame(i));
const strip = packStrip(frames);
return {
canvas: gridToCanvas(strip.grid, theme.sprites[SP_FX]),
frameWidth: strip.frameWidth,
frameHeight: strip.frameHeight,
count: strip.count,
};
}
export function buildDownedBikeCanvas(themeId, spriteSlot = SP_PLAYER) {
const theme = THEMES[themeId] ?? THEMES.day;
return gridToCanvas(buildDownedBike(), theme.sprites[spriteSlot]);
}
// ---------------------------------------------------------------------------
// Track
// ---------------------------------------------------------------------------
// A tiny RGBA writer. Painting the track is a few million pixel writes, so it
// goes straight into an ImageData buffer rather than through canvas calls.
function makePainter(w, h) {
const canvas = makeCanvas(w, h);
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(w, h);
const buf = img.data;
return {
canvas,
commit() { ctx.putImageData(img, 0, 0); return canvas; },
px(x, y, rgb) {
if (x < 0 || y < 0 || x >= w || y >= h) return;
const o = (y * w + x) * 4;
buf[o] = (rgb >> 16) & 0xff;
buf[o + 1] = (rgb >> 8) & 0xff;
buf[o + 2] = rgb & 0xff;
buf[o + 3] = 255;
},
// Sample a tile at world (x, y) and write it with the given subpalette.
tilePx(x, y, t, rgb, phaseX = 0, phaseY = 0) {
const sx = (((x + phaseX) % TILE) + TILE) % TILE;
const sy = (((y + phaseY) % TILE) + TILE) % TILE;
this.px(x, y, rgb[t.data[sy * TILE + sx]]);
},
};
}
/**
* Paint an entire track to one canvas, `length` x 192. The scene shows it with
* a pair of Images so a second lap scrolls seamlessly out of the first.
*
* Lanes are painted far to near, so a ramp in a near lane correctly overlaps
* the lane behind it that overlap is what gives the original its depth.
*/
export function rasterizeTrack(model, themeId = model.theme) {
const theme = THEMES[themeId] ?? THEMES.day;
const len = model.length;
const p = makePainter(len, PLAYFIELD_H);
const crowd = theme.bg[BG_CROWD].map(nesColor);
const wall = theme.bg[BG_WALL].map(nesColor);
const grass = theme.bg[BG_GRASS].map(nesColor);
const dirt = theme.bg[BG_DIRT].map(nesColor);
// Bands behind the racing surface.
for (let x = 0; x < len; x += 1) {
const crowdTile = Math.floor(x / TILE) % 2 === 0 ? TILES.crowdA : TILES.crowdB;
for (let y = CROWD_Y; y < CROWD_Y + CROWD_H; y += 1) p.tilePx(x, y, crowdTile, crowd);
const wallTile = Math.floor(x / TILE) % 8 === 0 ? TILES.wallPlain : TILES.wall;
for (let y = WALL_Y; y < WALL_Y + WALL_H; y += 1) p.tilePx(x, y, wallTile, wall);
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);
}
// Racing surface, far lane first.
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
const top = LANES_Y + lane * LANE_H;
const bottom = top + LANE_H;
const base = lane % 2 === 0 ? TILES.dirtLight : TILES.dirtDark;
const dash = lane % 2 === 0 ? TILES.laneDashLight : TILES.laneDashDark;
const heights = model.height[lane];
const surfaces = model.surface[lane];
for (let x = 0; x < len; x += 1) {
const surf = surfaces[x];
if (surf === SURFACE.GAP) {
// A hole: the void below the track, with a lip so the edge reads.
for (let y = top; y < bottom; y += 1) p.px(x, y, dirt[1]);
p.px(x, top, dirt[2]);
continue;
}
const h = heights[x];
const surfaceY = Math.round(bottom - 1 - h);
// The lane is a solid strip of dirt with the riding line along its near
// edge; a ramp does not carve into the strip, it stands proud of it. So
// fill the whole band and then extend the body up to the riding line,
// which overlaps the lane behind whenever the hurdle is tall.
for (let y = Math.min(top, surfaceY); y < bottom; y += 1) p.tilePx(x, y, base, dirt);
// Surface markings sit in the strip *above* the riding line — that band
// is the ground receding away from the viewer, and it is the only part of
// a lane you can actually see at 12 pixels tall.
const markTop = surfaceY - (LANE_H - 3);
if (surf === SURFACE.MUD) {
for (let y = markTop; y <= surfaceY; y += 1) p.tilePx(x, y, TILES.mud, dirt);
}
if (surf === SURFACE.COOL) {
const chev = Math.floor(x / TILE) % 2 === 0 ? TILES.coolLeft : TILES.coolRight;
for (let y = markTop; y <= surfaceY; y += 1) {
const sx = ((x % TILE) + TILE) % TILE;
const sy = (((y - markTop) % TILE) + TILE) % TILE;
if (chev.data[sy * TILE + sx] === S.LIGHT) p.px(x, y, dirt[3]);
}
}
if (surf === SURFACE.ROUGH) {
// Washboard: a scatter of dark ruts across the strip.
for (let y = markTop; y <= surfaceY; y += 1) {
if ((x * 3 + y * 5) % 7 < 2) p.px(x, y, dirt[1]);
}
}
// A ramp face needs a lit top edge to be legible at all; flat ground does
// not, because there its "edge" is just the lane divider.
if (h > 0.5) {
p.px(x, surfaceY, dirt[3]);
p.px(x, surfaceY + 1, dirt[2]);
}
// A solid block, sat proud of the surface so you can see it coming.
if (surf === SURFACE.OBSTACLE) {
for (let y = surfaceY - 10; y <= surfaceY; y += 1) {
p.px(x, y, (x + y) % 4 < 2 ? dirt[1] : dirt[3]);
}
}
// Dashed divider along the lane's near edge.
if (h < 1 && x % 16 < 8) {
p.tilePx(x, bottom - 1, dash, dirt);
p.px(x, bottom - 1, dirt[3]);
}
}
}
// Start/finish check, drawn last so it sits on top of every lane.
for (let x = 0; x < 8; x += 1) {
for (let y = LANES_Y; y < LANES_Y + LANE_H * LANE_COUNT; y += 1) {
const t = Math.floor(y / TILE) % 2 === 0 ? TILES.checkerA : TILES.checkerB;
p.tilePx(x, y, t, [dirt[0], nesColor(0x0f), dirt[2], nesColor(0x30)]);
}
}
return p.commit();
}
// ---------------------------------------------------------------------------
// HUD chrome
// ---------------------------------------------------------------------------
export const HEAT_BAR_X = 96;
export const HEAT_BAR_Y = 20;
export const HEAT_BAR_W = 64;
export const HEAT_BAR_H = 8;
/**
* The static furniture of the status bar: black ground, the heat meter's
* housing and its end caps. Live values (times, heat fill) are drawn over it.
*/
export function buildHudCanvas() {
const p = makePainter(SCREEN_W, 48);
const black = nesColor(0x0f);
const grey = nesColor(0x00);
const violet = nesColor(0x14);
for (let y = 0; y < 48; y += 1) for (let x = 0; x < SCREEN_W; x += 1) p.px(x, y, black);
// A hairline across the top, separating the bar from the track.
for (let x = 0; x < SCREEN_W; x += 1) p.px(x, 0, grey);
// Heat meter housing.
const bx = HEAT_BAR_X;
const by = HEAT_BAR_Y;
for (let x = bx - 2; x < bx + HEAT_BAR_W + 2; x += 1) {
p.px(x, by - 2, violet);
p.px(x, by + HEAT_BAR_H + 1, violet);
}
for (let y = by - 2; y <= by + HEAT_BAR_H + 1; y += 1) {
p.px(bx - 2, y, violet);
p.px(bx + HEAT_BAR_W + 1, y, violet);
}
// The little intake arrows either side, as on the original's meter.
for (let i = 0; i < 4; i += 1) {
p.px(bx - 6 + i, by + 2 + i, violet);
p.px(bx - 6 + i, by + 5 - i, violet);
p.px(bx + HEAT_BAR_W + 5 - i, by + 2 + i, violet);
p.px(bx + HEAT_BAR_W + 5 - i, by + 5 - i, violet);
}
return p.commit();
}
/** Colour of the heat fill at a given 0..1 temperature — green, amber, red. */
export function heatColor(t) {
if (t < 0.55) return nesColor(0x2a);
if (t < 0.82) return nesColor(0x28);
return nesColor(0x16);
}
export { SP_PLAYER, SP_RIVAL, SP_FLESH, SP_FX };

View File

@ -0,0 +1,360 @@
// Excitebike track model — the 19 hurdles A-S, and the compiler that turns a
// hurdle list into per-lane terrain.
//
// Headless: Node imports this directly, and so does the renderer. That shared
// use is deliberate. The raster stage paints each lane straight off the
// compiled height and surface arrays, so the ramp you see is provably the ramp
// the sim launches you from — there is no second, drifting copy of the
// geometry.
//
// Hurdle names are the manual's own (A small ramp ... S ramp and platform
// jump). "Left" and "right" in the manual mean the far and near halves of the
// four-lane track, so they map to lanes 0-1 and 2-3 here.
export const LANE_COUNT = 4;
export const SURFACE = {
DIRT: 0,
MUD: 1, // caps your speed
COOL: 2, // dumps engine heat
GAP: 3, // no track at all
ROUGH: 4, // washboard, bleeds speed and rattles you
OBSTACLE: 5, // a solid block: hit it with any pace and you go down
};
export const LANES_ALL = [0, 1, 2, 3];
export const LANES_FAR = [0, 1];
export const LANES_NEAR = [2, 3];
// ---------------------------------------------------------------------------
// Height profile primitives
// ---------------------------------------------------------------------------
// Each returns height in NES pixels at offset `u` (0..length) along the hurdle.
/** Slope up to `peak`, then a sheer drop — the shape that launches you. */
function rampUp(peak, rise, flat = 0) {
const length = rise + flat;
return { length, at: (u) => (u < rise ? (u / rise) * peak : peak) };
}
/** Symmetric mound: up and back down, no launch edge. */
function mound(peak, length) {
return { length, at: (u) => peak * Math.sin((u / length) * Math.PI) };
}
/** Ramp up, a level platform, then a sheer drop at the far end. */
function platform(peak, rise, deck) {
const length = rise + deck;
return { length, at: (u) => (u < rise ? (u / rise) * peak : peak) };
}
/** Repeating whoops. */
function washboard(peak, length, count) {
return { length, at: (u) => peak * 0.5 * (1 - Math.cos((u / length) * Math.PI * 2 * count)) };
}
const flat = (length) => ({ length, at: () => 0 });
// ---------------------------------------------------------------------------
// The catalogue
// ---------------------------------------------------------------------------
/**
* Every hurdle the original's DESIGN mode offers, in its letter order. The
* DESIGN palette strip renders straight off this, so the order is load-bearing.
*
* profile height contribution, or null for a purely surface-level feature
* surface SURFACE value painted over the hurdle's footprint
* lanes which lanes it occupies
* solid true if riding into its face is a crash rather than a climb
*/
// Heights and footprints are the originals', measured off the NES track maps.
// The five courses only ever use six ramp profiles — peaks of 9, 16/17, 24/25,
// 33 and 48 pixels — and those are exactly the shapes below.
export const HURDLES = {
A: { id: 'A', name: 'SMALL RAMP', lanes: LANES_ALL, profile: mound(9, 22) },
B: { id: 'B', name: 'MEDIUM RAMP', lanes: LANES_ALL, profile: mound(17, 38) },
C: { id: 'C', name: 'LARGE RAMP', lanes: LANES_ALL, profile: mound(25, 43) },
D: { id: 'D', name: 'TRIANGLE RAMP', lanes: LANES_ALL, profile: mound(16, 68) },
E: { id: 'E', name: 'STEEP RAMP', lanes: LANES_ALL, profile: rampUp(16, 13, 2) },
F: { id: 'F', name: 'LARGE RAMP FAR', lanes: LANES_FAR, profile: mound(25, 43) },
G: { id: 'G', name: 'LARGE RAMP NEAR', lanes: LANES_NEAR, profile: mound(25, 43) },
H: { id: 'H', name: 'JUMPING RAMP', lanes: LANES_ALL, profile: mound(33, 70) },
I: { id: 'I', name: 'OBSTACLE FAR', lanes: LANES_FAR, profile: flat(16), surface: SURFACE.OBSTACLE },
J: { id: 'J', name: 'OBSTACLE NEAR', lanes: LANES_NEAR, profile: flat(16), surface: SURFACE.OBSTACLE },
K: { id: 'K', name: 'MUD FAR', lanes: LANES_FAR, profile: flat(64), surface: SURFACE.MUD },
L: { id: 'L', name: 'MUD NEAR', lanes: LANES_NEAR, profile: flat(64), surface: SURFACE.MUD },
M: { id: 'M', name: 'COOL ZONE FAR', lanes: LANES_FAR, profile: flat(64), surface: SURFACE.COOL },
N: { id: 'N', name: 'COOL ZONE NEAR', lanes: LANES_NEAR, profile: flat(64), surface: SURFACE.COOL },
O: { id: 'O', name: 'GAP NEAR', lanes: LANES_NEAR, profile: flat(151), surface: SURFACE.GAP },
P: { id: 'P', name: 'GAP FAR', lanes: LANES_FAR, profile: flat(151), surface: SURFACE.GAP },
Q: { id: 'Q', name: 'FULL GAP', lanes: LANES_ALL, profile: flat(80), surface: SURFACE.GAP },
R: { id: 'R', name: 'MOUNTAIN', lanes: LANES_ALL, profile: mound(48, 206) },
S: { id: 'S', name: 'RAMP + PLATFORM', lanes: LANES_ALL, profile: platform(24, 40, 54) },
};
/** Letter order, exactly as the DESIGN mode strip prints it. */
export const HURDLE_IDS = Object.keys(HURDLES);
/** Washboard sections are terrain, not a DESIGN hurdle — tracks place them directly. */
export const ROUGH_PATCH = { name: 'WHOOPS', profile: washboard(5, 96, 4), surface: SURFACE.ROUGH };
export const MAX_HURDLES = 50; // the original's DESIGN cap
export const MAX_LAPS = 9;
// ---------------------------------------------------------------------------
// Compiler
// ---------------------------------------------------------------------------
const START_PAD = 96; // clear run-up before the first hurdle can be placed
const FINISH_PAD = 64; // clear run-out so the finish line is always rideable
const LAUNCH_RUNUP = 120; // how far back a full-width gap may look for its launcher
// Terrain rises out of the lane band into the infield. The infield is 64px
// deep (ExcitebikeNES.js), and above it is the stadium wall.
export const MAX_TERRAIN_HEIGHT = 64;
/**
* hurdle list -> per-lane terrain arrays.
*
* Heights add, so overlapping hurdles build on each other exactly as they do
* when you stack them in DESIGN mode. Surfaces are last-writer-wins, with GAP
* winning outright because you cannot pave over a hole.
*/
export function buildTrackModel(json) {
const length = json.length | 0;
const height = [];
const surface = [];
for (let l = 0; l < LANE_COUNT; l += 1) {
height.push(new Float32Array(length));
surface.push(new Uint8Array(length));
}
const hurdles = (json.hurdles ?? []).map((h) => ({ t: h.t, x: h.x | 0 }));
hurdles.sort((a, b) => a.x - b.x);
for (const placed of hurdles) {
const def = HURDLES[placed.t];
if (!def) continue;
const { profile } = def;
for (let u = 0; u < profile.length; u += 1) {
const x = placed.x + u;
if (x < 0 || x >= length) continue;
const dh = profile.at(u);
for (const lane of def.lanes) {
if (dh) height[lane][x] += dh;
if (def.surface != null && surface[lane][x] !== SURFACE.GAP) {
surface[lane][x] = def.surface;
}
}
}
}
// Whoops sections, placed by track data rather than by DESIGN mode.
for (const patch of json.rough ?? []) {
const lanes = patch.lanes ?? LANES_ALL;
for (let u = 0; u < ROUGH_PATCH.profile.length; u += 1) {
const x = patch.x + u;
if (x < 0 || x >= length) continue;
for (const lane of lanes) {
height[lane][x] += ROUGH_PATCH.profile.at(u);
if (surface[lane][x] !== SURFACE.GAP) surface[lane][x] = SURFACE.ROUGH;
}
}
}
return {
id: json.id,
name: json.name ?? json.id,
theme: json.theme ?? 'day',
length,
laps: json.laps ?? 1,
mainLaps: json.mainLaps ?? 2,
qualifyMs: json.qualifyMs ?? 90000,
height,
surface,
hurdles,
rough: json.rough ?? [],
};
}
/** The 1st-place time painted on the stadium wall: always 8s inside the qualifier. */
export function bestWallMs(model) {
return Math.max(1000, model.qualifyMs - 8000);
}
// ---------------------------------------------------------------------------
// Sampling
// ---------------------------------------------------------------------------
/** Terrain height for a lane at world x, wrapping across laps. */
export function groundAt(model, lane, x) {
const l = Math.min(LANE_COUNT - 1, Math.max(0, Math.round(lane)));
const xi = ((Math.round(x) % model.length) + model.length) % model.length;
return model.height[l][xi];
}
/** Surface class for a lane at world x. */
export function surfaceAt(model, lane, x) {
const l = Math.min(LANE_COUNT - 1, Math.max(0, Math.round(lane)));
const xi = ((Math.round(x) % model.length) + model.length) % model.length;
return model.surface[l][xi];
}
const SLOPE_WINDOW = 4;
/**
* Ground slope (rise over run) ahead of x, measured over a short window so a
* launch edge reads as a steep positive slope rather than a division by zero.
*/
export function slopeAt(model, lane, x) {
const a = groundAt(model, lane, x);
const b = groundAt(model, lane, x + SLOPE_WINDOW);
return (b - a) / SLOPE_WINDOW;
}
/** Ground angle in radians — what a landing pitch is compared against. */
export function groundAngleAt(model, lane, x) {
return Math.atan(slopeAt(model, lane, x));
}
export const LAUNCH_DROP = 4;
/**
* True when the ground falls away sharply within `span` pixels ahead the
* thing that turns a ramp into a jump instead of a slide back down.
*
* `span` matters: a ramp's far edge is only a pixel or two wide, and at turbo
* speed the bike covers four pixels a frame, so a test that only looked at the
* exact crest would be stepped straight over. Callers doing lookahead want a
* generous span; the sim passes the distance it is about to travel.
*/
export function isLaunchEdge(model, lane, x, span = 8) {
const here = groundAt(model, lane, x);
for (let d = 1; d <= span; d += 1) {
if (here - groundAt(model, lane, x + d) >= LAUNCH_DROP) return true;
}
return false;
}
/**
* The steepest ground the bike climbed on its way to `x`. This, not the slope
* at the crest itself, is what should throw it into the air a ramp with a
* flat top still launches you, and rolling off a level platform still does not.
*/
export function launchSlopeAt(model, lane, x, back = 8) {
let best = 0;
for (let d = 0; d <= back; d += 2) {
const s = slopeAt(model, lane, x - d);
if (s > best) best = s;
}
return best;
}
const CURVE_WINDOW = 6;
/**
* Second derivative of the ground: negative over a crest, positive in a dip.
*
* This is what makes the original's rolling mounds work. Almost none of the
* five courses' hurdles are ramps with a cliff at the end they are hills you
* ride up and over and you get airborne over one exactly when you are going
* fast enough that the ground curves away from under you quicker than gravity
* can pull you down. Without this, a hill is just a bump.
*/
export function curvatureAt(model, lane, x, d = CURVE_WINDOW) {
const a = groundAt(model, lane, x - d);
const b = groundAt(model, lane, x);
const c = groundAt(model, lane, x + d);
return (c - 2 * b + a) / (d * d);
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
/** Structural problems with a track, as a list of human-readable strings. */
export function validateTrack(model, json = null) {
const errors = [];
if (!model.id) errors.push('track has no id');
if (!(model.length > 512)) errors.push(`track length ${model.length} is too short`);
if (!(model.laps >= 1 && model.laps <= MAX_LAPS)) errors.push(`laps ${model.laps} out of range`);
if (!(model.mainLaps >= 1 && model.mainLaps <= MAX_LAPS)) {
errors.push(`mainLaps ${model.mainLaps} out of range`);
}
if (!(model.qualifyMs > 5000)) errors.push(`qualifyMs ${model.qualifyMs} is implausible`);
if (!Object.prototype.hasOwnProperty.call(HURDLES.A, 'profile')) errors.push('catalogue is broken');
for (const h of model.hurdles) {
if (!HURDLES[h.t]) errors.push(`unknown hurdle "${h.t}" at x=${h.x}`);
if (h.x < START_PAD) errors.push(`hurdle ${h.t} at x=${h.x} intrudes on the start run-up`);
if (h.x + (HURDLES[h.t]?.profile.length ?? 0) > model.length - FINISH_PAD) {
errors.push(`hurdle ${h.t} at x=${h.x} runs past the finish run-out`);
}
}
if (model.hurdles.length > MAX_HURDLES) {
errors.push(`${model.hurdles.length} hurdles exceeds the ${MAX_HURDLES} cap`);
}
// Hurdle heights add where footprints overlap, so a track can stack its way
// into the stadium wall. Terrain that tall would be drawn over the BEST time
// and would launch the bike off the top of the screen.
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
for (let x = 0; x < model.length; x += 1) {
if (model.height[lane][x] > MAX_TERRAIN_HEIGHT) {
errors.push(`lane ${lane} terrain reaches ${model.height[lane][x].toFixed(0)}px at x=${x},`
+ ` above the ${MAX_TERRAIN_HEIGHT}px ceiling`);
x = model.length;
}
}
}
// The start and finish stretches must be clean in every lane, or the timing
// line is not rideable and a lap can never be completed fairly.
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
for (let x = 0; x < START_PAD; x += 1) {
if (model.height[lane][x] !== 0 || model.surface[lane][x] !== SURFACE.DIRT) {
errors.push(`lane ${lane} is not clear at the start (x=${x})`);
break;
}
}
for (let x = model.length - FINISH_PAD; x < model.length; x += 1) {
if (model.height[lane][x] !== 0 || model.surface[lane][x] !== SURFACE.DIRT) {
errors.push(`lane ${lane} is not clear at the finish (x=${x})`);
break;
}
}
}
// At least one lane must be rideable at every point — unless the hole spans
// the whole track, in which case it has to be jumpable: something must throw
// you into the air within a bike's length of the near edge.
for (let x = 0; x < model.length; x += 1) {
let passable = false;
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
if (model.surface[lane][x] !== SURFACE.GAP) { passable = true; break; }
}
if (passable) continue;
if (model.surface[0][x - 1] === SURFACE.GAP) continue; // only test the near edge
let jumpable = false;
for (let lane = 0; lane < LANE_COUNT && !jumpable; lane += 1) {
for (let d = 1; d <= LAUNCH_RUNUP; d += 1) {
// Either a cliff or a crest will do — both throw the bike into the air.
if (isLaunchEdge(model, lane, x - d) || curvatureAt(model, lane, x - d) < -0.01) {
jumpable = true;
break;
}
}
}
if (!jumpable) {
errors.push(`the full-width gap at x=${x} has no ramp to clear it from`);
break;
}
}
if (json && json.length !== model.length) errors.push('json length disagrees with the model');
return errors;
}
export { START_PAD, FINISH_PAD };

View File

@ -0,0 +1,93 @@
# Excitebike art
Every pixel in this game is generated at runtime. There are no image files, and nothing is fetched —
the game's `assetManifest.js` entry loads its soundtrack and nothing else.
This note is the spec for what gets drawn and the rules it is drawn under, so the art can be judged,
changed, or replaced by hand later.
## The rules
Art is generated as **index grids**: rectangles of subpalette slot indices, not pixels
(`ExcitebikeNES.js`). `ExcitebikeRaster.js` pairs a grid with a subpalette and paints real colours.
Keeping art as data is what lets `tools/verifyExcitebike.js` assert on it in Node with no dependency
and no browser.
The NES constraints that are honoured:
- every colour comes from the **64-entry master palette**, nothing else
- a sprite or background tile sees **four colours at a time** — slot 0 transparent (sprites) or the
shared backdrop (background), slots 13 drawable
- everything is **integer pixels on an 8×8 tile lattice**
Two deliberate deviations, both to avoid reproducing a limitation as a bug:
- **No 8-sprites-per-scanline flicker.** On real hardware Excitebike's rival pack shimmers.
Reproduced here it reads as a rendering fault.
- **Colour-attribute boundaries are per-pixel**, where the PPU constrains them to 16×16 blocks. A ramp
crossing a block boundary would otherwise drag its neighbour's colours with it.
## Screen
256×240, integer ×4 into a 1024×960 window, centred in a cabinet bezel. The bands are the original's
own, measured off the NES track maps:
| band | y | notes |
|---|---|---|
| crowd | 039 | two spectator tiles, alternating |
| sky and wall | 4063 | the BEST time is painted here |
| infield | 64127 | terrain rises into this; nothing may reach the wall |
| lanes | 128175 | **four lanes of 12px** |
| apron | 176191 | rutted ground |
| status bar | 192239 | `3RD` target, HEAT meter, timer, lap, track |
A lane is a tile and a half deep, so lane dividers land mid-tile. That is the real geometry.
## Palettes
Five themes in `ExcitebikeArt.js`, each a full PPU palette state — four background subpalettes
(crowd, wall, grass, dirt) and four sprite subpalettes (player, rival, flesh, fx). A track names its
theme; adding one is a matter of adding an entry, and the harness checks every subpalette is four
valid master-palette indices with three distinguishable drawable colours.
`day` reproduces the original's first course. The others reskin the same tiles.
## Sprites
| sheet | frames | size | notes |
|---|---|---|---|
| bike | 68 | 40×40 | 17 pitch angles × 4 wheel phases, one sheet per rider colour |
| tumble | 6 | 24×24 | the rider rolled through a full turn |
| run | 4 | 24×24 | on foot, chasing the bike |
| dust | 4 | 16×16 | roost and landing puffs |
| downed bike | 1 | 40×40 | lying on its side |
The bike is **drawn parametrically** rather than authored frame by frame: geometry is a set of named
points in a design space with the origin at the rear axle, rotated by pitch and rasterised with
Bresenham lines and filled circles. Sixty-eight hand-drawn frames of the same machine would be
sixty-eight subtly different machines.
The pivot is the rear contact patch at (13, 25) in the 40×40 frame — the sim positions the bike by it,
and rotation about it is what makes a wheelie read correctly. Anything replacing this sheet must keep
that pivot and the frame order (`bikeFrame(pitchIndex, treadIndex)`).
## Font
5×7 letterforms in 8×8 cells, 51 glyphs, laid out 16 to a row and parsed by Phaser's `RetroFont`.
White on transparent so `BitmapText` tinting can recolour it per HUD field. Advance is 8px, so text
centres on `length * 8`.
## Track
Rasterised per race straight off the **compiled terrain arrays** in `ExcitebikeTrack.js`, not from a
second copy of the geometry. The ramp you see is provably the ramp the sim launches you from.
Lanes paint far to near so a near-lane hurdle overlaps the lane behind it — that overlap is what gives
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.
## 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

View File

@ -521,40 +521,83 @@ export default class ShiftGame extends Phaser.Scene {
.setDepth(D.overlay).setInteractive();
this.layer.add(dim);
const panelW = 680, panelH = 460;
const panelX = cx;
const panelY = cy;
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(COLORS.panel, 0.97);
panel.fillRoundedRect(cx - 340, cy - 230, 680, 460, 22);
panel.fillRoundedRect(panelX - panelW / 2, panelY - panelH / 2, panelW, panelH, 22);
panel.lineStyle(3, 0x45d17a, 1);
panel.strokeRoundedRect(cx - 340, cy - 230, 680, 460, 22);
panel.strokeRoundedRect(panelX - panelW / 2, panelY - panelH / 2, panelW, panelH, 22);
this.layer.add(panel);
const winTitle = this.add.text(cx, cy - 148, 'Puzzle Solved!', {
const winTitle = this.add.text(panelX, panelY - 148, 'Puzzle Solved!', {
fontFamily: 'Righteous', fontSize: '72px', color: '#45d17a',
}).setOrigin(0.5).setDepth(D.overlayUI);
}).setOrigin(0.5, 0.5).setDepth(D.overlayUI);
const movesLbl = this.add.text(cx, cy - 42, `${this.moves} moves`, {
const movesLbl = this.add.text(panelX, panelY - 42, `${this.moves} moves`, {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
}).setOrigin(0.5, 0.5).setDepth(D.overlayUI);
const bestMsg = newBest && !isNaN(prevB)
? `★ New Best! (was ${prevB})`
: `Best: ${displayBest} moves`;
const bestLbl = this.add.text(cx, cy + 30, bestMsg, {
const bestLbl = this.add.text(panelX, panelY + 30, bestMsg, {
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
}).setOrigin(0.5, 0.5).setDepth(D.overlayUI);
this.layer.add([winTitle, movesLbl, bestLbl]);
const again = new Button(this, cx - 200, cy + 140,
const btnW = 320, btnH = 66, btnGap = 20;
const btnY = panelY + 140;
const again = new Button(this, panelX - btnW / 2 - btnGap / 2, btnY,
'Play Again', () => this.startGame(this.difficulty, this.artworkId),
{ width: 320, height: 66, fontSize: 26 }).setDepth(D.overlayUI);
{ width: btnW, height: btnH, fontSize: 26 }).setDepth(D.overlayUI);
const goBack = new Button(this, cx + 200, cy + 140,
const goBack = new Button(this, panelX + btnW / 2 + btnGap / 2, btnY,
this.artworkList.length > 1 ? 'Choose Image' : 'Difficulty',
() => {
if (this.artworkList.length > 1) this.showArtworkSelect(this.difficulty);
else this.showDifficultySelect();
},
{ variant: 'ghost', width: 320, height: 66, fontSize: 26 }).setDepth(D.overlayUI);
{ variant: 'ghost', width: btnW, height: btnH, fontSize: 26 }).setDepth(D.overlayUI);
this.layer.add([again, goBack]);
// Animate: fade out the panel background, move text/buttons to upper-left
const offsetX = 230;
const offsetY = 230;
const delay = 1500; // Show centered for 1.5 seconds
this.time.delayedCall(delay, () => {
// Fade out the panel background
this.tweens.add({
targets: panel,
alpha: 0,
duration: 800,
ease: 'Cubic.easeInOut',
});
// Move text elements to upper-left, preserving their relative vertical spacing
this.tweens.add({
targets: [winTitle, movesLbl, bestLbl, again, goBack],
x: offsetX,
y: (obj) => {
const origPanelY = cy;
const dy = obj.y - origPanelY;
return offsetY + dy;
},
duration: 800,
ease: 'Cubic.easeInOut',
onComplete: () => {
// Animate the goBack button down another 50px
this.tweens.add({
targets: goBack,
y: '+=150',
duration: 800,
ease: 'Cubic.easeInOut',
});
},
});
});
}
}

View File

@ -102,6 +102,7 @@ import TotalAnnihilationGame from './games/totalannihilation/TotalAnnihilationGa
import BloxorzGame from './games/bloxorz/BloxorzGame.js';
import GooTowerGame from './games/gootower/GooTowerGame.js';
import GooTowerEditor from './games/gootower/GooTowerEditor.js';
import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js';
const config = {
type: Phaser.AUTO,
@ -216,6 +217,7 @@ const config = {
TotalAnnihilationGame,
BloxorzGame,
GooTowerGame,
ExcitebikeGame,
GooTowerEditor,
],
};

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {

View File

@ -20,6 +20,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
superkart: 'nintendo',
advancewars: 'nintendo',
tetrisattack: 'nintendo',
excitebike: 'nintendo',
totalannihilation: 'hacker',
coloradodefense: 'arcadedark',
tempest: 'arcadedark',

View File

@ -0,0 +1,87 @@
// The five original NES courses, transcribed from the track maps by
// tools/readExcitebikeMaps.js. Do not hand-edit: re-run the tool.
//
// MEASURED off the images: course length, and the position, width and
// height of every ramp and every hole in the track. Hurdle letters are the
// nearest match in the catalogue, chosen by peak height and footprint, so
// the shapes are the original's intent expressed in the vocabulary DESIGN
// mode offers rather than a pixel copy of terrain the game cannot hold.
//
// NOT measured: cool zones and mud. Both are drawn as texture inside the
// lane band, and on these maps that texture is not separable from the
// shading on a ramp body — the detector fires on every ramp. Rather than
// emit hurdles the images do not actually support, cool zones are placed
// into the long clear stretches on a rule (see the tool), and mud is left
// out. This is the one part of the transcription that is not the
// original's, and it is worth revisiting if the maps can be read better.
export const TRACKS = {
1: {
n: 1, length: 5888, theme: 'day', tier: 0,
hurdles: [
{ t: 'M', x: 577 }, { t: 'H', x: 1057 }, { t: 'B', x: 1385 }, { t: 'N', x: 1588 },
{ t: 'B', x: 1817 }, { t: 'B', x: 1873 }, { t: 'M', x: 2116 }, { t: 'B', x: 2385 },
{ t: 'B', x: 2441 }, { t: 'B', x: 2497 }, { t: 'N', x: 2708 }, { t: 'C', x: 2945 },
{ t: 'C', x: 3036 }, { t: 'M', x: 3220 }, { t: 'E', x: 3425 }, { t: 'N', x: 3545 },
{ t: 'E', x: 3713 }, { t: 'M', x: 3849 }, { t: 'E', x: 4033 }, { t: 'A', x: 4065 },
{ t: 'A', x: 4105 }, { t: 'A', x: 4145 }, { t: 'A', x: 4185 }, { t: 'C', x: 4369 },
{ t: 'Q', x: 4440 }, { t: 'D', x: 4690 }, { t: 'D', x: 4778 }, { t: 'D', x: 4898 },
{ t: 'D', x: 4986 }, { t: 'N', x: 5324 }, { t: 'C', x: 5657 },
],
rough: [],
},
2: {
n: 2, length: 5393, theme: 'night', tier: 1,
hurdles: [
{ t: 'M', x: 521 }, { t: 'R', x: 945 }, { t: 'B', x: 1233 }, { t: 'R', x: 1497 },
{ t: 'N', x: 1816 }, { t: 'B', x: 1993 }, { t: 'B', x: 2121 }, { t: 'M', x: 2280 },
{ t: 'A', x: 2465 }, { t: 'H', x: 2504 }, { t: 'N', x: 2680 }, { t: 'E', x: 2849 },
{ t: 'A', x: 2881 }, { t: 'Q', x: 2928 }, { t: 'E', x: 3033 }, { t: 'Q', x: 3072 },
{ t: 'M', x: 3273 }, { t: 'D', x: 3458 }, { t: 'D', x: 3546 }, { t: 'D', x: 3634 },
{ t: 'D', x: 3722 }, { t: 'P', x: 3984 }, { t: 'N', x: 4612 }, { t: 'C', x: 5153 },
],
rough: [],
},
3: {
n: 3, length: 6416, theme: 'day', tier: 2,
hurdles: [
{ t: 'M', x: 845 }, { t: 'B', x: 1593 }, { t: 'B', x: 1665 }, { t: 'C', x: 1857 },
{ t: 'C', x: 1980 }, { t: 'C', x: 2185 }, { t: 'H', x: 2248 }, { t: 'C', x: 2308 },
{ t: 'P', x: 2552 }, { t: 'N', x: 2956 }, { t: 'E', x: 3273 }, { t: 'B', x: 3361 },
{ t: 'Q', x: 3424 }, { t: 'E', x: 3729 }, { t: 'H', x: 3833 }, { t: 'H', x: 4137 },
{ t: 'C', x: 4260 }, { t: 'C', x: 4353 }, { t: 'H', x: 4441 }, { t: 'P', x: 4744 },
{ t: 'M', x: 5068 }, { t: 'O', x: 5304 }, { t: 'N', x: 5784 }, { t: 'C', x: 6177 },
],
rough: [],
},
4: {
n: 4, length: 6528, theme: 'desert', tier: 3,
hurdles: [
{ t: 'M', x: 640 }, { t: 'H', x: 1184 }, { t: 'P', x: 1241 }, { t: 'N', x: 1516 },
{ t: 'H', x: 1704 }, { t: 'P', x: 1761 }, { t: 'B', x: 1953 }, { t: 'M', x: 2208 },
{ t: 'B', x: 2489 }, { t: 'Q', x: 2552 }, { t: 'E', x: 2841 }, { t: 'E', x: 2961 },
{ t: 'H', x: 3008 }, { t: 'A', x: 3081 }, { t: 'A', x: 3121 }, { t: 'N', x: 3300 },
{ t: 'E', x: 3521 }, { t: 'H', x: 3568 }, { t: 'H', x: 3624 }, { t: 'H', x: 3680 },
{ t: 'M', x: 3939 }, { t: 'P', x: 4192 }, { t: 'O', x: 4440 }, { t: 'H', x: 4729 },
{ t: 'H', x: 4817 }, { t: 'H', x: 4905 }, { t: 'H', x: 4993 }, { t: 'H', x: 5081 },
{ t: 'Q', x: 5176 }, { t: 'N', x: 5713 }, { t: 'E', x: 6233 }, { t: 'C', x: 6289 },
],
rough: [],
},
5: {
n: 5, length: 5752, theme: 'snow', tier: 4,
hurdles: [
{ t: 'M', x: 633 }, { t: 'E', x: 1169 }, { t: 'Q', x: 1208 }, { t: 'C', x: 1348 },
{ t: 'R', x: 1441 }, { t: 'A', x: 1665 }, { t: 'A', x: 1713 }, { t: 'A', x: 1761 },
{ t: 'N', x: 2124 }, { t: 'E', x: 2529 }, { t: 'H', x: 2697 }, { t: 'H', x: 2801 },
{ t: 'H', x: 2913 }, { t: 'C', x: 3017 }, { t: 'Q', x: 3088 }, { t: 'H', x: 3264 },
{ t: 'P', x: 3321 }, { t: 'B', x: 3529 }, { t: 'E', x: 3625 }, { t: 'E', x: 3849 },
{ t: 'Q', x: 3888 }, { t: 'H', x: 3952 }, { t: 'D', x: 4042 }, { t: 'D', x: 4130 },
{ t: 'A', x: 4217 }, { t: 'D', x: 4258 }, { t: 'D', x: 4346 }, { t: 'D', x: 4434 },
{ t: 'D', x: 4522 }, { t: 'H', x: 4609 }, { t: 'M', x: 5068 }, { t: 'C', x: 5521 },
],
rough: [],
},
};
export default TRACKS;

View File

@ -0,0 +1,238 @@
// Builds the Excitebike track bank into assets/gamedata/excitebike/.
//
// node tools/genExcitebikeTracks.js # only writes missing files
// node tools/genExcitebikeTracks.js --force # rewrites everything
// node tools/genExcitebikeTracks.js --only=6,7 # rewrite specific tracks
//
// Tracks 1-5 are the original NES courses: their lengths and hurdle sequences
// are transcribed from the NES track maps by tools/readExcitebikeMaps.js, not
// invented. Tracks 6-10 are new, built from the same vocabulary and tuned to
// pick up where Track 5 leaves off.
//
// Qualifying times are MEASURED, never guessed: the reference rider in
// src/games/excitebike/ExcitebikeAuto.js drives each track, and the target is
// set from what it actually does. Hand-tuned files are never clobbered without
// --force, matching tools/genSuperKartTracks.js.
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { buildTrackModel, validateTrack, HURDLES, START_PAD, FINISH_PAD } from '../src/games/excitebike/ExcitebikeTrack.js';
import { runAuto, probeTrack, AUTO_SKILL } from '../src/games/excitebike/ExcitebikeAuto.js';
import { mulberry32, MODE } from '../src/games/excitebike/ExcitebikeLogic.js';
import { TRACKS as AUTHORED } from './data/excitebikeCourses.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'assets', 'gamedata', 'excitebike');
const args = process.argv.slice(2);
const FORCE = args.includes('--force');
const ONLY = (args.find((a) => a.startsWith('--only=')) ?? '').slice(7)
.split(',').filter(Boolean).map(Number);
// ---------------------------------------------------------------------------
// Generated courses (tracks 6-10)
// ---------------------------------------------------------------------------
// Weighted hurdle pools per difficulty tier. Ramps stay common throughout —
// they are the game — while hazards thicken as the tier rises.
const POOLS = [
{ ramps: 'AABBDD', hazards: 'KLMN', gaps: '' },
{ ramps: 'ABBCDE', hazards: 'IJKL', gaps: 'OP' },
{ ramps: 'BCCEFGH', hazards: 'IJKL', gaps: 'OPOP' },
{ ramps: 'CCEFGHHS', hazards: 'IIJJKL', gaps: 'OPOPQ' },
{ ramps: 'CEFGHHHSR', hazards: 'IIJJKKLL', gaps: 'OPQQ' },
];
// Spacing tightens track by track. It is the main difficulty dial: hurdles
// close together leave no room to pick a line, dry the heat meter out, or set
// up for the next landing.
// Tracks 6-10 pick up where the originals leave off, so they start around
// where Track 5 sits and climb from there.
const NEW_TRACKS = [
{ n: 6, length: 6600, theme: 'dusk', tier: 1, spacing: 145 },
{ n: 7, length: 6900, theme: 'desert', tier: 2, spacing: 130 },
{ n: 8, length: 7200, theme: 'night', tier: 3, spacing: 120 },
{ n: 9, length: 7400, theme: 'snow', tier: 3, spacing: 110 },
{ n: 10, length: 7800, theme: 'night', tier: 4, spacing: 105 },
];
function pick(rng, str) {
return str[Math.floor(rng() * str.length)];
}
/**
* Lay hurdles down the track at a rising cadence. Two rules keep a course
* rideable: nothing overlaps its neighbour's footprint, and a cool zone is
* never more than `coolEvery` away, so heat is always manageable if you look
* for it.
*/
function generateHurdles(spec, seed) {
const rng = mulberry32(seed);
const pool = POOLS[spec.tier];
const hurdles = [];
const first = START_PAD + 160;
const last = spec.length - FINISH_PAD - 220;
const coolEvery = 950;
let x = first;
let sinceCool = 0;
while (x < last) {
let t;
if (sinceCool > coolEvery) {
t = rng() < 0.5 ? 'M' : 'N';
sinceCool = 0;
} else {
const roll = rng();
if (roll < 0.5) t = pick(rng, pool.ramps);
else if (roll < 0.82) t = pick(rng, pool.hazards);
else if (pool.gaps) t = pick(rng, pool.gaps);
else t = pick(rng, pool.ramps);
if (t === 'M' || t === 'N') sinceCool = 0;
}
let footprint = HURDLES[t].profile.length;
if (t === 'Q') {
// A hole across all four lanes is only a hurdle if you can get airborne
// over it. Always build the launcher first, edge flush with the near lip.
const ramp = HURDLES.C.profile.length;
hurdles.push({ t: 'C', x: Math.round(x) });
hurdles.push({ t: 'Q', x: Math.round(x) + ramp });
footprint += ramp;
} else {
hurdles.push({ t, x: Math.round(x) });
}
const gap = spec.spacing * (0.72 + rng() * 0.6);
x += footprint + gap;
sinceCool += footprint + gap;
}
// Whoops sections, a couple per course, always on one half so there is a
// clean line for a rider who spots them.
const rough = [];
const roughCount = 1 + spec.tier / 2;
for (let i = 0; i < roughCount; i += 1) {
const rx = first + ((i + 0.5) / roughCount) * (last - first) + rng() * 120;
rough.push({ x: Math.round(rx), lanes: rng() < 0.5 ? [0, 1] : [2, 3] });
}
return { hurdles, rough };
}
// ---------------------------------------------------------------------------
// Timing
// ---------------------------------------------------------------------------
/**
* Set the qualifying target from what a fallible rider actually manages.
*
* Not the expert: the expert has perfect information and no reaction time, and
* timing a course off it would set a target nobody holding a controller could
* reach. The `human` probe misjudges landings and reacts on a delay, which is
* the behaviour a target should be priced against. The margin on top is small
* because that rider is already making mistakes.
*
* The other end is asserted by the verifier: a rider who just holds the
* throttle down has to miss the target from track 4 on.
*/
const QUALIFY_MARGIN = 1.06;
function measure(json) {
const model = buildTrackModel(json);
const probe = probeTrack(model);
const expert = runAuto(model, { skill: AUTO_SKILL.expert, seed: 9 });
const naive = runAuto(model, { skill: AUTO_SKILL.naive, seed: 9 });
return { model, probe, expert, naive };
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
function buildTrack(spec) {
const authored = AUTHORED[spec.n];
const base = {
version: 1,
id: `track-${String(spec.n).padStart(2, '0')}`,
name: `TRACK ${spec.n}`,
theme: spec.theme,
length: spec.length,
laps: 2,
mainLaps: 2,
qualifyMs: 90000,
};
const body = authored
? { hurdles: authored.hurdles, rough: authored.rough ?? [] }
: generateHurdles(spec, 0x5b1ce7 + spec.n * 7919);
const json = { ...base, ...body };
const { model, probe, expert, naive } = measure(json);
if (!expert.finished) throw new Error(`${json.id}: the reference rider could not finish it`);
if (probe.medianMs == null) throw new Error(`${json.id}: the human probe could not finish it`);
json.qualifyMs = Math.round((probe.medianMs * QUALIFY_MARGIN) / 100) * 100;
const errors = validateTrack(buildTrackModel(json), json);
return { json, model, probe, expert, naive, errors };
}
function main() {
mkdirSync(OUT, { recursive: true });
const specs = [
...Object.values(AUTHORED).map((a) => ({
n: a.n, length: a.length, theme: a.theme, tier: a.tier, spacing: 0,
})),
...NEW_TRACKS,
].sort((a, b) => a.n - b.n);
const index = { version: 1, tracks: [] };
let wrote = 0;
for (const spec of specs) {
if (ONLY.length && !ONLY.includes(spec.n)) {
const file = join(OUT, `track-${String(spec.n).padStart(2, '0')}.json`);
if (existsSync(file)) {
const existing = JSON.parse(readFileSync(file, 'utf8'));
index.tracks.push({ n: spec.n, id: existing.id, name: existing.name, theme: existing.theme, file: `${existing.id}.json` });
continue;
}
}
const { json, probe, expert, naive, errors } = buildTrack(spec);
const file = join(OUT, `${json.id}.json`);
if (errors.length) {
console.error(`FAIL ${json.id}:`);
for (const e of errors) console.error(` ${e}`);
process.exitCode = 1;
}
if (existsSync(file) && !FORCE && !ONLY.includes(spec.n)) {
const existing = JSON.parse(readFileSync(file, 'utf8'));
index.tracks.push({ n: spec.n, id: existing.id, name: existing.name, theme: existing.theme, file: `${existing.id}.json` });
console.log(`skip ${json.id} (exists; --force to rewrite)`);
continue;
}
writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`);
wrote += 1;
index.tracks.push({ n: spec.n, id: json.id, name: json.name, theme: json.theme, file: `${json.id}.json` });
const s = (ms) => (ms == null ? ' DNF' : `${(ms / 1000).toFixed(1)}`.padStart(6));
console.log(
`${json.id} len ${String(json.length).padStart(5)} hurdles ${String(json.hurdles.length).padStart(2)}`
+ ` expert ${s(expert.ms)} human ${s(probe.medianMs)} target ${s(json.qualifyMs)}`
+ ` naive ${s(naive.finished ? naive.ms : null)}`
+ ` crash/kpx ${probe.crashesPerKpx.toFixed(2)}`
+ `${naive.finished && naive.ms <= json.qualifyMs ? ' <-- TOO EASY' : ''}`,
);
}
writeFileSync(join(OUT, 'tracks.json'), `${JSON.stringify(index, null, 2)}\n`);
console.log(`\n${wrote} track file(s) written, index has ${index.tracks.length}`);
}
main();

102
tools/lib/canvasStub.js Normal file
View File

@ -0,0 +1,102 @@
// A canvas just real enough for ExcitebikeRaster.js to run under Node.
//
// The raster stage is the one Excitebike module that touches the DOM, and
// without this it could only ever be checked in a browser. It uses a very small
// slice of the API — createImageData, putImageData, getImageData — so stubbing
// it lets tools/verifyExcitebike.js assert on the actual pixels the game paints:
// that every colour on the track comes out of the NES master palette, and that
// a rasterised track is the size the model says it is.
//
// Importing this module installs the stub as a side effect. Import it before
// anything that reaches for `document`.
class StubImageData {
constructor(width, height) {
this.width = width;
this.height = height;
this.data = new Uint8ClampedArray(width * height * 4);
}
}
class StubContext2D {
constructor(canvas) {
this.canvas = canvas;
this.pixels = new Uint8ClampedArray(canvas.width * canvas.height * 4);
}
createImageData(w, h) {
return new StubImageData(w, h);
}
putImageData(img, dx = 0, dy = 0) {
const { width: cw, height: ch } = this.canvas;
for (let y = 0; y < img.height; y += 1) {
const ty = dy + y;
if (ty < 0 || ty >= ch) continue;
for (let x = 0; x < img.width; x += 1) {
const tx = dx + x;
if (tx < 0 || tx >= cw) continue;
const s = (y * img.width + x) * 4;
const d = (ty * cw + tx) * 4;
this.pixels[d] = img.data[s];
this.pixels[d + 1] = img.data[s + 1];
this.pixels[d + 2] = img.data[s + 2];
this.pixels[d + 3] = img.data[s + 3];
}
}
}
getImageData(sx = 0, sy = 0, sw = this.canvas.width, sh = this.canvas.height) {
const out = new StubImageData(sw, sh);
const { width: cw } = this.canvas;
for (let y = 0; y < sh; y += 1) {
for (let x = 0; x < sw; x += 1) {
const s = ((sy + y) * cw + (sx + x)) * 4;
const d = (y * sw + x) * 4;
out.data[d] = this.pixels[s];
out.data[d + 1] = this.pixels[s + 1];
out.data[d + 2] = this.pixels[s + 2];
out.data[d + 3] = this.pixels[s + 3];
}
}
return out;
}
}
class StubCanvas {
constructor() {
this._w = 0;
this._h = 0;
this._ctx = null;
}
get width() { return this._w; }
set width(v) { this._w = v | 0; this._ctx = null; }
get height() { return this._h; }
set height(v) { this._h = v | 0; this._ctx = null; }
getContext(kind) {
if (kind !== '2d') return null;
if (!this._ctx) this._ctx = new StubContext2D(this);
return this._ctx;
}
}
if (typeof globalThis.document === 'undefined') {
globalThis.document = {
createElement(tag) {
if (tag !== 'canvas') throw new Error(`canvasStub only makes canvases, not <${tag}>`);
return new StubCanvas();
},
};
}
/** Read a canvas back as flat RGBA — the verifier's window onto the pixels. */
export function canvasPixels(canvas) {
return canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
}
export { StubCanvas, StubContext2D, StubImageData };

151
tools/lib/png.js Normal file
View File

@ -0,0 +1,151 @@
// A minimal PNG reader, built on node:zlib.
//
// This repository has no dependencies and is not about to grow one for a
// one-off job, so decoding is done here: signature, chunks, inflate, unfilter.
// Enough of the format to read the NES track maps that tools/readExcitebikeMaps.js
// transcribes — greyscale, truecolour, palette and alpha variants at bit
// depths 1 through 16, non-interlaced.
import { inflateSync } from 'node:zlib';
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
function paeth(a, b, c) {
const p = a + b - c;
const pa = Math.abs(p - a);
const pb = Math.abs(p - b);
const pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
/** Undo the per-scanline filters PNG applies before compression. */
function unfilter(raw, width, height, bpp, bytesPerRow) {
const out = Buffer.alloc(height * bytesPerRow);
let pos = 0;
for (let y = 0; y < height; y += 1) {
const filter = raw[pos];
pos += 1;
const row = y * bytesPerRow;
const prev = row - bytesPerRow;
for (let i = 0; i < bytesPerRow; i += 1) {
const x = raw[pos + i];
const a = i >= bpp ? out[row + i - bpp] : 0;
const b = y > 0 ? out[prev + i] : 0;
const c = i >= bpp && y > 0 ? out[prev + i - bpp] : 0;
let value;
switch (filter) {
case 0: value = x; break;
case 1: value = x + a; break;
case 2: value = x + b; break;
case 3: value = x + ((a + b) >> 1); break;
case 4: value = x + paeth(a, b, c); break;
default: throw new Error(`unknown PNG filter ${filter} on row ${y}`);
}
out[row + i] = value & 0xff;
}
pos += bytesPerRow;
}
return out;
}
/** Pull `bitDepth`-wide samples out of a packed scanline. */
function sampleAt(row, index, bitDepth) {
if (bitDepth === 8) return row[index];
if (bitDepth === 16) return row[index * 2];
const perByte = 8 / bitDepth;
const byte = row[Math.floor(index / perByte)];
const shift = 8 - bitDepth * ((index % perByte) + 1);
return (byte >> shift) & ((1 << bitDepth) - 1);
}
/**
* Decode a PNG buffer to `{ width, height, data }`, where `data` is flat RGBA.
* Interlaced files are rejected rather than silently mis-read.
*/
export function decodePng(buffer) {
if (!buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error('not a PNG');
let width = 0;
let height = 0;
let bitDepth = 8;
let colorType = 6;
let palette = null;
let transparency = null;
const idat = [];
let offset = 8;
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.toString('ascii', offset + 4, offset + 8);
const body = buffer.subarray(offset + 8, offset + 8 + length);
offset += 12 + length;
if (type === 'IHDR') {
width = body.readUInt32BE(0);
height = body.readUInt32BE(4);
bitDepth = body[8];
colorType = body[9];
if (body[12] !== 0) throw new Error('interlaced PNGs are not supported');
} else if (type === 'PLTE') {
palette = body;
} else if (type === 'tRNS') {
transparency = body;
} else if (type === 'IDAT') {
idat.push(body);
} else if (type === 'IEND') {
break;
}
}
const channels = CHANNELS[colorType];
if (!channels) throw new Error(`unsupported PNG colour type ${colorType}`);
const bitsPerPixel = channels * bitDepth;
const bytesPerRow = Math.ceil((width * bitsPerPixel) / 8);
const bpp = Math.max(1, Math.ceil(bitsPerPixel / 8));
const raw = inflateSync(Buffer.concat(idat));
const rows = unfilter(raw, width, height, bpp, bytesPerRow);
const data = new Uint8ClampedArray(width * height * 4);
const max = (1 << bitDepth) - 1;
for (let y = 0; y < height; y += 1) {
const row = rows.subarray(y * bytesPerRow, (y + 1) * bytesPerRow);
for (let x = 0; x < width; x += 1) {
const o = (y * width + x) * 4;
let r; let g; let b; let a = 255;
if (colorType === 3) {
const idx = sampleAt(row, x, bitDepth);
r = palette[idx * 3];
g = palette[idx * 3 + 1];
b = palette[idx * 3 + 2];
if (transparency && idx < transparency.length) a = transparency[idx];
} else if (colorType === 0 || colorType === 4) {
const v = sampleAt(row, x * channels, bitDepth);
r = bitDepth === 8 || bitDepth === 16 ? v : Math.round((v / max) * 255);
g = r; b = r;
if (colorType === 4) a = sampleAt(row, x * channels + 1, bitDepth);
} else {
r = sampleAt(row, x * channels, bitDepth);
g = sampleAt(row, x * channels + 1, bitDepth);
b = sampleAt(row, x * channels + 2, bitDepth);
if (colorType === 6) a = sampleAt(row, x * channels + 3, bitDepth);
}
data[o] = r; data[o + 1] = g; data[o + 2] = b; data[o + 3] = a;
}
}
return { width, height, data };
}
/** 0xRRGGBB at (x, y), ignoring alpha. */
export function pixelAt(img, x, y) {
const o = (y * img.width + x) * 4;
return (img.data[o] << 16) | (img.data[o + 1] << 8) | img.data[o + 2];
}

368
tools/readExcitebikeMaps.js Normal file
View File

@ -0,0 +1,368 @@
// Transcribe the five original NES Excitebike courses from their track maps.
//
// node tools/readExcitebikeMaps.js <dir> # report what it finds
// node tools/readExcitebikeMaps.js <dir> --write # rewrite tools/data/excitebikeCourses.js
//
// <dir> holds track1.png .. track5.png, the full-course rips from nesmaps.com.
// They are third-party images and are not committed; this tool exists so the
// transcription is reproducible and reviewable rather than a wall of numbers
// somebody has to take on trust.
//
// How the maps read, established by inspection:
// - the left of each image is a legend panel, not track; the course starts at
// the first column whose lane band is actually made of track material
// - the playfield is 192px tall: crowd 0-39, sky 40-63, infield 64-127,
// four 12px lanes 128-175, apron 176-191
// - each course has its own palette. The surface colour is whatever the lane
// band is mostly made of; the infield colour showing through a lane band
// means the track is missing there
// - ramps are solid masses of track material rising out of the lane band, so
// they are found by walking up from the lane floor rather than by colour
//
// Heights and widths come out of the image; the catalogue letter is then the
// closest hurdle in src/games/excitebike/ExcitebikeTrack.js. That mapping is
// approximate by construction — the game's hurdles are a fixed vocabulary and
// the original's ramps vary continuously — so it is printed for review.
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { decodePng, pixelAt } from './lib/png.js';
import { HURDLES } from '../src/games/excitebike/ExcitebikeTrack.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const LANES_Y = 128;
const LANE_H = 12;
const LANE_COUNT = 4;
const LANES_BOTTOM = LANES_Y + LANE_H * LANE_COUNT; // 176
const INFIELD_Y = 100;
const args = process.argv.slice(2);
const DIR = args.find((a) => !a.startsWith('--'));
const WRITE = args.includes('--write');
if (!DIR) {
console.error('usage: node tools/readExcitebikeMaps.js <dir-with-track1..5.png> [--write]');
process.exit(1);
}
// ---------------------------------------------------------------------------
// Palette and extent
// ---------------------------------------------------------------------------
function commonest(counts) {
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
}
function readTrack(img) {
// Surface: whatever the lane band is mostly made of, sampled clear of the
// legend panel on the left.
const laneHist = new Map();
for (let y = LANES_Y; y < LANES_BOTTOM; y += 1) {
for (let x = Math.floor(img.width * 0.3); x < img.width; x += 1) {
const c = pixelAt(img, x, y);
laneHist.set(c, (laneHist.get(c) ?? 0) + 1);
}
}
const laneRank = commonest(laneHist);
const surface = laneRank[0][0];
const infieldHist = new Map();
for (let x = Math.floor(img.width * 0.3); x < img.width; x += 1) {
const c = pixelAt(img, x, INFIELD_Y);
infieldHist.set(c, (infieldHist.get(c) ?? 0) + 1);
}
const infield = commonest(infieldHist)[0][0];
// Track material: the surface plus every other lane-band colour that is not
// the infield showing through a hole and not chrome from the legend panel.
//
// The threshold has to be generous. Each course has a highlight colour used
// only on the lit faces of ramps, so it is rare — but leaving it out makes
// small ramps invisible to the reader, and a missed ramp in front of a hole
// turns a jump into an unclearable wall.
const material = new Set([surface]);
for (const [c, n] of laneRank.slice(1, 6)) {
if (c === infield) continue;
if (n < img.width * 0.4) continue;
material.add(c);
}
const isMaterial = (c) => material.has(c);
const bandMaterial = (x) => {
let k = 0;
for (let y = LANES_Y; y < LANES_BOTTOM; y += 1) if (isMaterial(pixelAt(img, x, y))) k += 1;
return k;
};
let first = 0;
let last = img.width - 1;
while (first < img.width && bandMaterial(first) <= 30) first += 1;
while (last > first && bandMaterial(last) <= 30) last -= 1;
return { surface, infield, material, isMaterial, first, last, length: last - first + 1 };
}
// ---------------------------------------------------------------------------
// Features
// ---------------------------------------------------------------------------
/** Top of the solid mass of track material standing in this column. */
function surfaceTop(img, t, x) {
let best = LANES_BOTTOM;
let miss = 0;
for (let y = LANES_BOTTOM - 1; y >= 56; y -= 1) {
if (t.isMaterial(pixelAt(img, x, y))) { best = y; miss = 0; } else {
miss += 1;
if (miss > 3) break;
}
}
return best;
}
function runsOf(flags, minWidth) {
const out = [];
let s = -1;
for (let i = 0; i < flags.length; i += 1) {
if (flags[i] && s < 0) s = i;
if (!flags[i] && s >= 0) {
if (i - s >= minWidth) out.push([s, i - 1]);
s = -1;
}
}
if (s >= 0 && flags.length - s >= minWidth) out.push([s, flags.length - 1]);
return out;
}
/** Ramps and mounds: connected terrain standing proud of the lane band. */
function findRamps(img, t) {
const top = new Int16Array(t.length);
for (let i = 0; i < t.length; i += 1) top[i] = surfaceTop(img, t, t.first + i);
const raised = Array.from(top, (y) => y < LANES_Y - 1);
return runsOf(raised, 8).map(([a, b]) => {
let peak = 0;
for (let i = a; i <= b; i += 1) peak = Math.max(peak, LANES_Y - top[i]);
const entry = LANES_Y - top[a];
const exit = LANES_Y - top[b];
return { x: a, width: b - a + 1, height: peak, entry, exit };
});
}
/** Holes: the infield showing through where track should be. */
function findGaps(img, t) {
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) === t.infield) k += 1;
flags[i] = k >= LANE_H - 2;
}
perLane.push(runsOf(flags, 8));
}
return perLane;
}
/**
* Textured patches: stretches where the shade colour floods a lane far above
* its baseline. On these maps that is churned ground mud and whoops.
*/
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;
}
perLane.push(runsOf(flags, 10));
}
return perLane;
}
// ---------------------------------------------------------------------------
// Mapping onto the hurdle catalogue
// ---------------------------------------------------------------------------
const RAMP_LETTERS = ['A', 'B', 'C', 'D', 'E', 'H', 'R', 'S'];
/** Closest catalogue hurdle to a measured ramp, by peak height then footprint. */
function classifyRamp(ramp) {
// A mound comes back down to the ground; a ramp ends on a launch edge. The
// test has to be relative, because a sine mound's last sample is close to
// zero rather than exactly zero.
const isMound = ramp.exit <= Math.max(3, ramp.height * 0.2);
let best = null;
let bestCost = Infinity;
for (const id of RAMP_LETTERS) {
const h = HURDLES[id];
let peak = 0;
for (let u = 0; u < h.profile.length; u += 1) peak = Math.max(peak, h.profile.at(u));
const endsFlat = h.profile.at(h.profile.length - 1) < peak * 0.2;
if (endsFlat !== isMound) continue;
const cost = Math.abs(peak - ramp.height) * 3 + Math.abs(h.profile.length - ramp.width) * 0.4;
if (cost < bestCost) { bestCost = cost; best = id; }
}
return best ?? 'B';
}
function gapLetter(lanes) {
if (lanes.length >= 4) return 'Q';
if (lanes.every((l) => l >= 2)) return 'O';
if (lanes.every((l) => l <= 1)) return 'P';
return 'Q';
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const THEMES = { 1: 'day', 2: 'night', 3: 'day', 4: 'desert', 5: 'snow' };
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);
// Merge per-lane gap runs that overlap into one hurdle spanning those lanes.
const gapEvents = [];
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
for (const [a, b] of gapLanes[lane]) gapEvents.push({ a, b, lane });
}
gapEvents.sort((p, q) => p.a - q.a);
const gaps = [];
for (const ev of gapEvents) {
const open = gaps.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 {
gaps.push({ a: ev.a, b: ev.b, lanes: new Set([ev.lane]) });
}
}
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]) });
}
}
return { n, img, t, ramps, gaps, patches, theme: THEMES[n] };
}
const results = [];
for (let n = 1; n <= 5; n += 1) results.push(transcribe(n));
for (const r of results) {
console.log(`\n=== TRACK ${r.n} =============================================`);
console.log(` image ${r.img.width}x${r.img.height} course starts at x=${r.t.first} length ${r.t.length}`);
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}`);
const byHeight = new Map();
for (const ramp of r.ramps) {
const key = `h${ramp.height} w${ramp.width}`;
byHeight.set(key, (byHeight.get(key) ?? 0) + 1);
}
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)');
}
// ---------------------------------------------------------------------------
// Emit
// ---------------------------------------------------------------------------
if (WRITE) {
const lines = [];
lines.push('// The five original NES courses, transcribed from the track maps by');
lines.push('// tools/readExcitebikeMaps.js. Do not hand-edit: re-run the tool.');
lines.push('//');
lines.push('// MEASURED off the images: course length, and the position, width and');
lines.push('// height of every ramp and every hole in the track. Hurdle letters are the');
lines.push('// nearest match in the catalogue, chosen by peak height and footprint, so');
lines.push('// the shapes are the original\'s intent expressed in the vocabulary DESIGN');
lines.push('// mode offers rather than a pixel copy of terrain the game cannot hold.');
lines.push('//');
lines.push('// NOT measured: cool zones and mud. Both are drawn as texture inside the');
lines.push('// lane band, and on these maps that texture is not separable from the');
lines.push('// shading on a ramp body — the detector fires on every ramp. Rather than');
lines.push('// emit hurdles the images do not actually support, cool zones are placed');
lines.push('// into the long clear stretches on a rule (see the tool), and mud is left');
lines.push('// out. This is the one part of the transcription that is not the');
lines.push('// original\'s, and it is worth revisiting if the maps can be read better.');
lines.push('');
lines.push('export const TRACKS = {');
for (const r of results) {
const hurdles = [];
for (const ramp of r.ramps) {
hurdles.push({ t: classifyRamp(ramp), x: ramp.x, w: ramp.width });
}
for (const g of r.gaps) {
hurdles.push({ t: gapLetter([...g.lanes]), x: g.a, w: g.b - g.a + 1 });
}
hurdles.sort((a, b) => a.x - b.x);
// Cool zones are not emitted from the maps — see the note in the header.
// They are placed into the long clear stretches between transcribed
// features, alternating halves, so heat is always manageable if you look
// for it. Everything else here is measured.
const withCool = [];
let lastEnd = 160;
let coolSide = 0;
for (const h of hurdles) {
const clear = h.x - lastEnd;
if (clear > 260) {
const at = Math.round(lastEnd + (clear - 64) / 2);
withCool.push({ t: coolSide % 2 === 0 ? 'M' : 'N', x: at });
coolSide += 1;
}
withCool.push({ t: h.t, x: h.x });
lastEnd = h.x + (HURDLES[h.t]?.profile.length ?? h.w ?? 0);
}
hurdles.length = 0;
hurdles.push(...withCool);
lines.push(` ${r.n}: {`);
lines.push(` n: ${r.n}, length: ${r.t.length}, theme: '${r.theme}', tier: ${r.n - 1},`);
lines.push(' hurdles: [');
for (let i = 0; i < hurdles.length; i += 4) {
lines.push(` ${hurdles.slice(i, i + 4).map((h) => `{ t: '${h.t}', x: ${h.x} }`).join(', ')},`);
}
lines.push(' ],');
lines.push(' rough: [],');
lines.push(' },');
}
lines.push('};');
lines.push('');
lines.push('export default TRACKS;');
const out = join(ROOT, 'tools', 'data', 'excitebikeCourses.js');
writeFileSync(out, `${lines.join('\n')}\n`);
console.log(`\nwrote ${out}`);
}

852
tools/verifyExcitebike.js Normal file
View File

@ -0,0 +1,852 @@
// Excitebike verification harness.
//
// node tools/verifyExcitebike.js
// node tools/verifyExcitebike.js --seeds=40 # deeper rival soak
//
// Sections, cheapest first:
// 1 NES hardware invariants — palette, tiles, sprites, font
// 2 The hurdle catalogue A-S
// 3 Terrain compilation and the shipped track bank
// 4 Physics invariants over a long run
// 5 Determinism
// 6 The difficulty gate: the reference rider qualifies, a naive one does not
// 7 Rival soak
// 8 Raster output, through the headless canvas stub
// 9 DESIGN mode round-trip
//
// Exits non-zero on any failure.
import './lib/canvasStub.js';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
NES_PALETTE, PALETTE_SIZE, nesColor, subpalette, SUBPALETTE_SLOTS, TILE,
SCREEN_W, SCREEN_H, PLAYFIELD_H, LANE_H, LANES_Y, INFIELD_Y, LANE_COUNT as NES_LANES,
gridSlots, fitsSubpalette,
} from '../src/games/excitebike/ExcitebikeNES.js';
import {
TILES, 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,
TUMBLE_FRAMES, RUN_FRAMES, DUST_FRAMES, S,
} from '../src/games/excitebike/ExcitebikeArt.js';
import {
HURDLES, HURDLE_IDS, SURFACE, LANE_COUNT, MAX_HURDLES, MAX_LAPS,
START_PAD, FINISH_PAD, buildTrackModel, validateTrack, bestWallMs,
groundAt, surfaceAt, slopeAt, isLaunchEdge, curvatureAt,
} from '../src/games/excitebike/ExcitebikeTrack.js';
import {
createRace, step, neutralInput, finalizeRace, formatTime, standings,
STATE, MODE, STEP_MS, TUNE, mulberry32,
} from '../src/games/excitebike/ExcitebikeLogic.js';
import { runAuto, probeTrack, AUTO_SKILL } from '../src/games/excitebike/ExcitebikeAuto.js';
import { rasterizeTrack, gridToCanvas, buildHudCanvas, buildFontCanvas } from '../src/games/excitebike/ExcitebikeRaster.js';
import { canvasPixels } from './lib/canvasStub.js';
import { blankTrack, TOOLS, DESIGN_LENGTH } from '../src/games/excitebike/ExcitebikeDesignData.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const GAMEDATA = join(ROOT, 'assets', 'gamedata', 'excitebike');
const args = process.argv.slice(2);
const SEEDS = Number((args.find((a) => a.startsWith('--seeds=')) ?? '--seeds=12').slice(8));
let checks = 0;
let failures = 0;
function check(name, cond, detail = '') {
checks += 1;
if (cond) return;
failures += 1;
console.error(`FAIL ${name}${detail ? `${detail}` : ''}`);
}
function section(title) {
console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 62 - title.length))}`);
}
// ---------------------------------------------------------------------------
// 1. NES hardware invariants
// ---------------------------------------------------------------------------
section('1. NES hardware');
check('master palette is 64 entries', PALETTE_SIZE === 64, `got ${PALETTE_SIZE}`);
check('every palette entry is a 24-bit colour',
NES_PALETTE.every((c) => Number.isInteger(c) && c >= 0 && c <= 0xffffff));
check('nesColor rejects an out-of-range index', (() => {
try { nesColor(64); return false; } catch (_) { return true; }
})());
const PALETTE_SET = new Set(NES_PALETTE);
check('subpalette() demands exactly four entries', (() => {
try { subpalette(0, 1, 2); return false; } catch (_) { return true; }
})());
// Background tiles must never draw with slot 0 — that slot is the shared
// backdrop, and a tile that relies on it punches a hole in the track.
for (const name of TILE_NAMES) {
const t = TILES[name];
check(`tile ${name} is ${TILE}x${TILE}`, t.w === TILE && t.h === TILE);
check(`tile ${name} stays inside one subpalette`, fitsSubpalette(t));
check(`tile ${name} does not draw with the backdrop slot`, !gridSlots(t).has(S.CLEAR),
'background tiles must use slots 1-3 only');
}
for (const id of THEME_IDS) {
const theme = THEMES[id];
check(`theme ${id} has 4 background subpalettes`, theme.bg.length === 4);
check(`theme ${id} has 4 sprite subpalettes`, theme.sprites.length === 4);
for (const sp of [...theme.bg, ...theme.sprites]) {
check(`theme ${id} subpalette is ${SUBPALETTE_SLOTS} entries`, sp.length === SUBPALETTE_SLOTS);
check(`theme ${id} subpalette indices are valid`, sp.every((i) => i >= 0 && i < PALETTE_SIZE));
}
// Three drawable colours that are actually distinguishable; two identical
// entries would silently waste a third of every sprite's palette.
for (const sp of theme.sprites) {
check(`theme ${id} sprite colours are distinct`,
new Set(sp.slice(1)).size === 3, `${sp}`);
}
}
const font = buildFontGrid();
check('font has a glyph for every declared character',
font.grid.w === font.cols * font.cell, `${font.grid.w}`);
check('font characters are unique', new Set(FONT_CHARS).size === FONT_CHARS.length);
check('font covers digits and A-Z',
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').every((c) => FONT_CHARS.includes(c)));
check('font stays inside one subpalette', fitsSubpalette(font.grid));
const bikeFrames = buildBikeFrames();
check('bike sheet has pitch x tread frames', bikeFrames.length === BIKE_FRAME_COUNT);
check('bike frames are square and tile-aligned',
bikeFrames.every((f) => f.w === BIKE_FRAME && f.h === BIKE_FRAME && f.w % TILE === 0));
check('every bike frame stays inside one subpalette', bikeFrames.every(fitsSubpalette));
check('every bike frame draws something', bikeFrames.every((f) => gridSlots(f).size > 1));
// A pitch frame must actually depict its angle: the extremes have to differ.
const level = buildBikeFrame(0, 0);
const noseUp = buildBikeFrame(PITCH_MAX, 0);
const noseDown = buildBikeFrame(-PITCH_MAX, 0);
const differs = (a, b) => a.data.some((v, i) => v !== b.data[i]);
check('nose-up is drawn differently from level', differs(level, noseUp));
check('nose-down is drawn differently from level', differs(level, noseDown));
check('nose-up and nose-down are drawn differently', differs(noseUp, noseDown));
// And the wheels must visibly turn, or the bike looks like it is sliding.
check('tread phases differ', differs(buildBikeFrame(0, 0), buildBikeFrame(0, 1)));
check('pitchFrameIndex clamps below', pitchFrameIndex(-99) === 0);
check('pitchFrameIndex clamps above', pitchFrameIndex(99) === PITCH_FRAMES - 1);
check('pitchFrameIndex is centred on level', pitchFrameIndex(0) === (PITCH_FRAMES - 1) / 2);
check('pitchFrameAngle round-trips', Math.abs(pitchFrameAngle(pitchFrameIndex(0.3)) - 0.3) < 0.05);
check('bikeFrame indexes inside the sheet',
bikeFrame(PITCH_FRAMES - 1, TREAD_FRAMES - 1) === BIKE_FRAME_COUNT - 1);
for (let i = 0; i < TUMBLE_FRAMES; i += 1) {
check(`tumble frame ${i} stays inside one subpalette`, fitsSubpalette(buildTumbleFrame(i)));
}
for (let i = 0; i < RUN_FRAMES; i += 1) {
check(`run frame ${i} stays inside one subpalette`, fitsSubpalette(buildRunFrame(i)));
}
for (let i = 0; i < DUST_FRAMES; i += 1) {
check(`dust frame ${i} stays inside one subpalette`, fitsSubpalette(buildDustFrame(i)));
}
check('run cycle animates', differs(buildRunFrame(0), buildRunFrame(1)));
check('tumble cycle animates', differs(buildTumbleFrame(0), buildTumbleFrame(2)));
check('downed bike draws something', gridSlots(buildDownedBike()).size > 1);
const strip = packStrip([buildDustFrame(0), buildDustFrame(1)]);
check('packStrip lays frames out in a row', strip.grid.w === strip.frameWidth * 2);
check('packStrip rejects mismatched frames', (() => {
try { packStrip([buildDustFrame(0), buildRunFrame(0)]); return false; } catch (_) { return true; }
})());
// Screen geometry has to add up, or the HUD and the track overlap.
check('playfield plus HUD is one NES frame', PLAYFIELD_H < SCREEN_H && SCREEN_W === 256);
check('four lanes fit the playfield', LANES_Y + LANE_H * NES_LANES <= PLAYFIELD_H);
// The playfield's bands sit on the tile lattice. Individual lanes deliberately
// do not: the original's lanes are 12px, a tile and a half, so a lane divider
// lands halfway down a tile. That is the real geometry, not a rounding slip.
check('playfield bands are tile-aligned',
LANES_Y % TILE === 0 && (LANE_H * NES_LANES) % TILE === 0 && PLAYFIELD_H % TILE === 0);
check('a lane is a tile and a half deep, as on the original', LANE_H === 12);
// ---------------------------------------------------------------------------
// 2. Hurdle catalogue
// ---------------------------------------------------------------------------
section('2. Hurdle catalogue');
const EXPECTED_IDS = 'ABCDEFGHIJKLMNOPQRS'.split('');
check('all 19 hurdles A-S are present', HURDLE_IDS.length === 19, `${HURDLE_IDS.length}`);
check('hurdle ids are the manual\'s letters, in order',
HURDLE_IDS.join('') === EXPECTED_IDS.join(''), HURDLE_IDS.join(''));
for (const id of HURDLE_IDS) {
const h = HURDLES[id];
check(`hurdle ${id} has a name`, typeof h.name === 'string' && h.name.length > 0);
check(`hurdle ${id} has a positive footprint`, h.profile.length > 0);
check(`hurdle ${id} occupies real lanes`,
h.lanes.length > 0 && h.lanes.every((l) => l >= 0 && l < LANE_COUNT));
// Every profile must start and end at a height the rest of the track can meet.
check(`hurdle ${id} starts at ground level`, Math.abs(h.profile.at(0)) < 0.001);
let maxH = 0;
for (let u = 0; u < h.profile.length; u += 1) maxH = Math.max(maxH, h.profile.at(u));
// A hurdle stands up out of the lane band into the infield. It must not reach
// the stadium wall, or it would be drawn over the BEST time.
check(`hurdle ${id} stays below the stadium wall`, maxH <= LANES_Y - INFIELD_Y,
`peak ${maxH.toFixed(1)}`);
}
// The manual's far/near pairs must genuinely mirror each other.
for (const [far, near] of [['F', 'G'], ['I', 'J'], ['K', 'L'], ['M', 'N'], ['P', 'O']]) {
check(`${far}/${near} are a far/near pair`,
HURDLES[far].lanes.join() !== HURDLES[near].lanes.join()
&& HURDLES[far].profile.length === HURDLES[near].profile.length);
}
check('Q spans every lane', HURDLES.Q.lanes.length === LANE_COUNT);
check('Q is a gap', HURDLES.Q.surface === SURFACE.GAP);
check('M and N are cool zones',
HURDLES.M.surface === SURFACE.COOL && HURDLES.N.surface === SURFACE.COOL);
check('K and L are mud', HURDLES.K.surface === SURFACE.MUD && HURDLES.L.surface === SURFACE.MUD);
check('I and J are solid obstacles',
HURDLES.I.surface === SURFACE.OBSTACLE && HURDLES.J.surface === SURFACE.OBSTACLE);
// The original's courses are built almost entirely from hills you ride up and
// over, not ramps with a cliff at the end — only E and S finish high. So the
// catalogue has to be shaped that way, and a hill has to launch you by its
// crest rather than by an edge.
const peakOf = (id) => {
let p = 0;
const h = HURDLES[id];
for (let u = 0; u < h.profile.length; u += 1) p = Math.max(p, h.profile.at(u));
return p;
};
const endsHigh = (id) => HURDLES[id].profile.at(HURDLES[id].profile.length - 1) > peakOf(id) * 0.5;
for (const id of ['E', 'S']) {
check(`${id} ends high, so its far edge is a cliff`, endsHigh(id));
}
for (const id of ['A', 'B', 'C', 'D', 'F', 'G', 'H', 'R']) {
check(`${id} is a hill that comes back down`, !endsHigh(id),
`ends at ${HURDLES[id].profile.at(HURDLES[id].profile.length - 1).toFixed(1)}`);
}
check('the hills rise in size A < B < C < H < R',
peakOf('A') < peakOf('B') && peakOf('B') < peakOf('C')
&& peakOf('C') < peakOf('H') && peakOf('H') < peakOf('R'),
[peakOf('A'), peakOf('B'), peakOf('C'), peakOf('H'), peakOf('R')].join('<'));
// The crest rule, pinned directly: a hill throws you off it at speed and does
// not at a crawl. Lose this and every course in the bank becomes a flat road.
{
const hill = buildTrackModel({
id: 'hill', length: 2000, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'H', x: 400 }],
});
const crest = 400 + Math.round(HURDLES.H.profile.length / 2);
const curve = curvatureAt(hill, 0, crest);
check('a hill is convex at its crest', curve < 0, `${curve}`);
check('a hill launches at racing speed', TUNE.speedTurbo ** 2 * -curve > TUNE.gravity);
check('a hill does not launch at walking pace', 30 ** 2 * -curve < TUNE.gravity);
check('flat ground is never convex', Math.abs(curvatureAt(hill, 0, 200)) < 1e-6);
}
// ---------------------------------------------------------------------------
// 3. Terrain compiler and the shipped bank
// ---------------------------------------------------------------------------
section('3. Terrain and the track bank');
const indexPath = join(GAMEDATA, 'tracks.json');
if (!existsSync(indexPath)) {
console.error('FAIL the track bank is missing');
console.error(' run: node tools/genExcitebikeTracks.js');
process.exit(1);
}
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
check('the bank holds ten tracks', index.tracks.length === 10, `${index.tracks.length}`);
// Course lengths as measured off the NES track maps by tools/readExcitebikeMaps.js.
// These are NOT the map images' widths — each image carries a legend panel on
// the left that is not track, so the course is shorter than the file.
const ORIGINAL_LENGTHS = { 1: 5888, 2: 5393, 3: 6416, 4: 6528, 5: 5752 };
const bank = [];
for (const entry of index.tracks) {
const file = join(GAMEDATA, entry.file);
check(`${entry.id} exists on disk`, existsSync(file));
if (!existsSync(file)) continue;
const json = JSON.parse(readFileSync(file, 'utf8'));
const model = buildTrackModel(json);
const errors = validateTrack(model, json);
check(`${entry.id} validates`, errors.length === 0, errors[0]);
check(`${entry.id} names a real theme`, THEME_IDS.includes(json.theme), json.theme);
check(`${entry.id} laps are in range`, json.laps >= 1 && json.laps <= MAX_LAPS);
check(`${entry.id} stays under the hurdle cap`, json.hurdles.length <= MAX_HURDLES);
check(`${entry.id} wall time is inside the qualifier`, bestWallMs(model) < model.qualifyMs);
bank.push({ entry, json, model });
}
// The five recreations must keep the original courses' lengths.
for (const [n, len] of Object.entries(ORIGINAL_LENGTHS)) {
const t = bank.find((b) => b.entry.n === Number(n));
check(`track ${n} keeps the NES course length`, t && t.json.length === len,
t ? `${t.json.length} != ${len}` : 'missing');
}
// Terrain sampling has to be sane everywhere, including across the lap seam.
for (const { entry, model } of bank) {
let badHeight = 0;
let badSlope = 0;
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
for (let x = 0; x < model.length; x += 7) {
// Terrain stands up out of the lane band into the infield. Anything that
// reached the stadium wall would be drawn over the BEST time.
const h = groundAt(model, lane, x);
if (!Number.isFinite(h) || h < -0.001 || h > LANES_Y - INFIELD_Y) badHeight += 1;
const s = slopeAt(model, lane, x);
if (!Number.isFinite(s)) badSlope += 1;
}
}
check(`${entry.id} heights are finite and bounded`, badHeight === 0, `${badHeight} samples`);
check(`${entry.id} slopes are finite`, badSlope === 0, `${badSlope} samples`);
check(`${entry.id} wraps at the lap seam`,
groundAt(model, 0, model.length) === groundAt(model, 0, 0));
check(`${entry.id} negative x wraps too`,
groundAt(model, 0, -1) === groundAt(model, 0, model.length - 1));
// Every course has to give the rider a way to manage heat.
let coolPx = 0;
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
for (let x = 0; x < model.length; x += 1) {
if (model.surface[lane][x] === SURFACE.COOL) coolPx += 1;
}
}
check(`${entry.id} has cool zones to manage heat with`, coolPx > 0);
}
// The compiler itself: overlapping hurdles add, gaps beat everything.
{
const stacked = buildTrackModel({
id: 'stack', length: 2000, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'A', x: 400 }, { t: 'A', x: 400 }],
});
const single = buildTrackModel({
id: 'single', length: 2000, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'A', x: 400 }],
});
check('stacked hurdles add their heights',
Math.abs(groundAt(stacked, 0, 420) - 2 * groundAt(single, 0, 420)) < 0.01);
const paved = buildTrackModel({
id: 'paved', length: 2000, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'Q', x: 500 }, { t: 'K', x: 500 }],
});
check('a gap cannot be paved over', surfaceAt(paved, 0, 510) === SURFACE.GAP);
const cliff = buildTrackModel({
id: 'cliff', length: 2000, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'E', x: 400 }],
});
const edge = 400 + HURDLES.E.profile.length - 1;
check('a cliff-ended ramp reads as a launch edge', isLaunchEdge(cliff, 0, edge));
check('flat ground does not read as a launch edge', !isLaunchEdge(cliff, 0, 200));
}
// Validation must reject the things it exists to reject.
{
const bad = (json) => validateTrack(buildTrackModel(json), json).length > 0;
const base = { id: 'x', length: 3000, laps: 1, mainLaps: 1, qualifyMs: 30000, hurdles: [] };
check('a hurdle in the start run-up is rejected', bad({ ...base, hurdles: [{ t: 'A', x: 10 }] }));
check('a hurdle past the finish run-out is rejected',
bad({ ...base, hurdles: [{ t: 'A', x: 2990 }] }));
check('an unknown hurdle letter is rejected', bad({ ...base, hurdles: [{ t: 'Z', x: 500 }] }));
check('an unjumpable full-width gap is rejected',
bad({ ...base, hurdles: [{ t: 'Q', x: 900 }] }));
check('a full-width gap with a launcher in front of it is accepted',
!bad({ ...base, hurdles: [{ t: 'E', x: 885 }, { t: 'Q', x: 900 }] }));
check('too many laps is rejected', bad({ ...base, laps: MAX_LAPS + 1 }));
check('an empty flat track is accepted', !bad(base));
}
// ---------------------------------------------------------------------------
// 4. Physics invariants
// ---------------------------------------------------------------------------
section('4. Physics invariants');
{
const { model } = bank[Math.min(3, bank.length - 1)];
const rng = mulberry32(1234);
const state = createRace({ model, mode: MODE.RACE, rivalCount: 5, seed: 99 });
let nan = 0;
let overSpeed = 0;
let badTemp = 0;
let badLane = 0;
let sunk = 0;
const seenStates = new Set();
for (let f = 0; f < 60 * 240; f += 1) {
// Random inputs, deliberately: the sim has to survive a mashing monkey,
// not just a rider who does sensible things.
const inp = {
a: rng() < 0.85, b: rng() < 0.5,
up: rng() < 0.08, down: rng() < 0.08,
left: rng() < 0.15, right: rng() < 0.15,
};
step(state, inp);
for (const b of state.bikes) {
seenStates.add(b.state);
if (![b.x, b.vx, b.y, b.vy, b.lane, b.pitch, b.temp].every(Number.isFinite)) nan += 1;
if (b.vx > TUNE.speedTurbo + 1 || b.vx < -0.001) overSpeed += 1;
if (b.temp < -0.001 || b.temp > 1.001) badTemp += 1;
if (b.lane < -0.001 || b.lane > LANE_COUNT - 1 + 0.001) badLane += 1;
if (b.state === STATE.RIDING && b.y < -0.5) sunk += 1;
}
if (state.phase === STATE.FINISHED) break;
}
check('nothing ever goes NaN', nan === 0, `${nan} samples`);
check('speed never exceeds the turbo cap', overSpeed === 0, `${overSpeed} samples`);
check('temperature stays in 0..1', badTemp === 0, `${badTemp} samples`);
check('bikes stay on the four lanes', badLane === 0, `${badLane} samples`);
check('a riding bike never sinks below the ground', sunk === 0, `${sunk} samples`);
check('random play reaches the airborne state', seenStates.has(STATE.AIRBORNE));
check('random play reaches the crashed state', seenStates.has(STATE.CRASHED));
}
// A crash must always resolve — tumble, then run, then back on the bike.
{
const model = buildTrackModel({
id: 'crashy', length: 3000, laps: 1, mainLaps: 1, qualifyMs: 60000,
hurdles: [{ t: 'J', x: 600 }],
});
const state = createRace({ model, mode: MODE.SOLO, seed: 4 });
const inp = neutralInput();
inp.a = true;
let crashed = false;
let recovered = false;
let ranOnFoot = false;
for (let f = 0; f < 60 * 120; f += 1) {
// Hold A the whole way: the mash bonus is optional, recovery is not.
step(state, inp);
if (state.player.state === STATE.CRASHED) crashed = true;
if (state.player.state === STATE.RUNNING) ranOnFoot = true;
if (crashed && state.player.state === STATE.RIDING) { recovered = true; break; }
}
check('riding into an obstacle crashes you', crashed);
check('a crash puts the rider on foot', ranOnFoot);
check('a crash always resolves back to riding', recovered);
}
// Overheating must stall you, and cool zones must be the answer.
{
const hot = buildTrackModel({
id: 'hot', length: 4000, laps: 1, mainLaps: 1, qualifyMs: 60000, hurdles: [],
});
const state = createRace({ model: hot, mode: MODE.SOLO, seed: 2 });
const full = { ...neutralInput(), a: true, b: true };
let overheated = false;
for (let f = 0; f < 60 * 30; f += 1) {
step(state, full);
if (state.player.state === STATE.OVERHEATED) { overheated = true; break; }
}
check('unbroken turbo overheats the engine', overheated);
const cool = buildTrackModel({
id: 'cool', length: 4000, laps: 1, mainLaps: 1, qualifyMs: 60000,
hurdles: [{ t: 'M', x: 200 }, { t: 'N', x: 200 }],
});
const c = createRace({ model: cool, mode: MODE.SOLO, seed: 2 });
c.player.temp = 0.9;
c.phase = STATE.RIDING;
for (const b of c.bikes) b.state = STATE.RIDING;
c.player.x = 210;
const before = c.player.temp;
for (let f = 0; f < 20; f += 1) step(c, { ...neutralInput(), a: true });
check('a cool zone drops engine temperature', c.player.temp < before,
`${before.toFixed(2)} -> ${c.player.temp.toFixed(2)}`);
}
// The landing-angle rule is the heart of the game, so pin it directly.
{
const ramp = buildTrackModel({
id: 'ramp', length: 4000, laps: 1, mainLaps: 1, qualifyMs: 60000,
hurdles: [{ t: 'H', x: 400 }],
});
const fly = (holdLeft, holdRight) => {
const state = createRace({ model: ramp, mode: MODE.SOLO, seed: 8 });
state.phase = STATE.RIDING;
for (const b of state.bikes) b.state = STATE.RIDING;
state.player.x = 300;
let sawAir = false;
let outcome = null;
for (let f = 0; f < 60 * 30 && !outcome; f += 1) {
const inp = { ...neutralInput(), a: true, b: true, left: holdLeft, right: holdRight };
for (const ev of step(state, inp)) {
if (ev.type === 'launch') sawAir = true;
if (sawAir && ev.type === 'land') outcome = ev.quality;
if (sawAir && ev.type === 'crash') outcome = 'crash';
}
}
return { sawAir, outcome };
};
const neutral = fly(false, false);
check('a jumping ramp launches the bike', neutral.sawAir);
const pinnedUp = fly(true, false);
check('holding the nose up all the way down ends badly',
pinnedUp.outcome === 'crash' || pinnedUp.outcome === 'hard',
`got ${pinnedUp.outcome}`);
check('an uncontrolled landing is not a clean one',
neutral.outcome !== null, `got ${neutral.outcome}`);
}
// Contact: catching a leader from behind at speed puts YOU down.
{
const flat = buildTrackModel({
id: 'flat', length: 4000, laps: 1, mainLaps: 1, qualifyMs: 60000, hurdles: [],
});
const state = createRace({ model: flat, mode: MODE.RACE, rivalCount: 1, seed: 6 });
state.phase = STATE.RIDING;
for (const b of state.bikes) b.state = STATE.RIDING;
const [me, rival] = state.bikes;
// I am ahead and slow; the rival is behind me and closing hard.
me.x = 480; me.lane = 1; me.vx = 40;
rival.x = 470; rival.lane = 1; rival.vx = 210;
let victim = null;
for (const ev of step(state, { ...neutralInput(), a: true })) {
if (ev.type === 'knockdown') victim = ev.bike;
}
check('the bike closing from behind is the one that goes down',
victim === rival.index, `victim ${victim}`);
const state2 = createRace({ model: flat, mode: MODE.RACE, rivalCount: 1, seed: 6 });
state2.phase = STATE.RIDING;
for (const b of state2.bikes) b.state = STATE.RIDING;
const [a2, b2] = state2.bikes;
a2.x = 480; a2.lane = 1; a2.vx = 150;
b2.x = 490; b2.lane = 1; b2.vx = 150;
let anyDown = false;
for (const ev of step(state2, { ...neutralInput(), a: true })) {
if (ev.type === 'knockdown') anyDown = true;
}
check('running nose-to-tail at the same pace does not wipe you out', !anyDown);
}
// ---------------------------------------------------------------------------
// 5. Determinism
// ---------------------------------------------------------------------------
section('5. Determinism');
const raceFingerprint = (run) => run.order.map((b) => `${b.index}:${b.finishMs ?? Math.round(b.x)}`).join('|');
for (const { entry, model } of bank.slice(0, 3)) {
const a = runAuto(model, { mode: MODE.RACE, seed: 21 });
const b = runAuto(model, { mode: MODE.RACE, seed: 21 });
check(`${entry.id} replays identically from the same seed`,
raceFingerprint(a) === raceFingerprint(b), `${a.ms} vs ${b.ms}`);
// A different seed has to change the race. It need not change the player's
// own time — a clean run through untouched traffic is legitimately identical
// — so compare the whole field, which is what the seed actually drives.
const c = runAuto(model, { mode: MODE.RACE, seed: 22 });
check(`${entry.id} seeds a different field`, raceFingerprint(c) !== raceFingerprint(a));
const soloRun = (seed) => {
const run = runAuto(model, { mode: MODE.SOLO, skill: AUTO_SKILL.human, seed });
return `${run.ms}|${JSON.stringify(run.events)}`;
};
check(`${entry.id} a jittered rider replays identically from the same seed`,
soloRun(21) === soloRun(21));
// Finish times are quantised to a frame, so two seeds can legitimately tie.
// What must not happen is every seed producing the same ride.
const rides = new Set([21, 22, 23, 24].map(soloRun));
check(`${entry.id} a jittered rider rides differently across seeds`, rides.size > 1,
`${rides.size} distinct rides from 4 seeds`);
}
// ---------------------------------------------------------------------------
// 6. The difficulty gate
// ---------------------------------------------------------------------------
section('6. Difficulty gate');
const gate = [];
for (const { entry, model } of bank) {
const expert = runAuto(model, { skill: AUTO_SKILL.expert, seed: 9 });
const naive = runAuto(model, { skill: AUTO_SKILL.naive, seed: 9 });
const probe = probeTrack(model);
check(`${entry.id}: the reference rider finishes`, expert.finished && !expert.timedOut);
check(`${entry.id}: the reference rider qualifies`, expert.qualified,
`${formatTime(expert.ms)} vs target ${formatTime(model.qualifyMs)}`);
check(`${entry.id}: the target leaves the expert a real margin`,
expert.ms < model.qualifyMs * 0.95, `${formatTime(expert.ms)}`);
// The target is priced off a fallible rider, so that rider has to be able to
// make it — on most seeds, not just a lucky one.
check(`${entry.id}: a fallible rider finishes every attempt`,
probe.finishedCount === probe.runs.length, `${probe.finishedCount}/${probe.runs.length}`);
const madeIt = probe.runs.filter((r) => r.finished && r.ms <= model.qualifyMs).length;
check(`${entry.id}: a fallible rider qualifies most attempts`,
madeIt >= Math.ceil(probe.runs.length / 2), `${madeIt}/${probe.runs.length}`);
// Tracks 4 and up must ask something of the player. Holding the throttle down
// and steering at nothing has to fail there, or the mechanics are decoration.
if (entry.n >= 4) {
check(`${entry.id}: a naive rider fails to qualify`, !naive.qualified,
naive.finished ? formatTime(naive.ms) : 'DNF');
}
gate.push({ n: entry.n, id: entry.id, expert, naive, probe, model });
}
// Difficulty has to rise across the bank. The expert is a poor yardstick for
// this — with perfect information it is barely slowed by hazards at all — so
// difficulty is measured two ways that do respond to it: how much raw terrain
// punishes a rider who does not manage it, and how thickly hurdles are laid.
{
const half = (pred, f) => {
const rows = gate.filter(pred);
return rows.reduce((s, g) => s + f(g), 0) / rows.length;
};
const early = (g) => g.n <= 5;
const late = (g) => g.n >= 6;
const naiveEarly = half(early, (g) => g.naive.ms / g.model.length);
const naiveLate = half(late, (g) => g.naive.ms / g.model.length);
check('the back half of the bank punishes an unmanaged rider harder',
naiveLate > naiveEarly,
`${(naiveEarly * 1000).toFixed(2)} -> ${(naiveLate * 1000).toFixed(2)} ms/px`);
const densEarly = half(early, (g) => (g.model.hurdles.length / g.model.length) * 1000);
const densLate = half(late, (g) => (g.model.hurdles.length / g.model.length) * 1000);
check('the back half of the bank is laid out more densely', densLate > densEarly,
`${densEarly.toFixed(2)} -> ${densLate.toFixed(2)} hurdles/kpx`);
check('the hardest track asks more than the first',
gate[gate.length - 1].naive.ms / gate[gate.length - 1].model.length
> gate[0].naive.ms / gate[0].model.length);
}
// ---------------------------------------------------------------------------
// 7. Rival soak
// ---------------------------------------------------------------------------
section('7. Rival soak');
// SELECTION B advances you on third or better, so what matters is not one
// win rate but the gradient across ability. Riding well has to be rewarded and
// riding badly has to be punished, with the boundary somewhere a player can
// move across by getting better. These bands are the regression guard: a change
// that makes the pack trivial or impossible breaks one end of them.
const SOAK_BANDS = {
expert: { wins: [0.50, 1.00], podium: [0.85, 1.00] },
human: { wins: [0.00, 0.35], podium: [0.20, 0.70] },
steady: { wins: [0.00, 0.20], podium: [0.00, 0.35] },
naive: { wins: [0.00, 0.02], podium: [0.00, 0.10] },
};
{
let stalled = 0;
let offTrack = 0;
let neverFinished = 0;
let races = 0;
for (const name of Object.keys(SOAK_BANDS)) {
let wins = 0;
let podiums = 0;
let n = 0;
for (const { model } of bank) {
for (let s = 0; s < SEEDS; s += 1) {
const run = runAuto(model, {
mode: MODE.RACE, seed: 1000 + s * 7, rivalCount: 5, skill: AUTO_SKILL[name],
});
n += 1;
races += 1;
if (!run.finished) neverFinished += 1;
if (run.place <= 1) wins += 1;
if (run.place <= 3) podiums += 1;
for (const b of run.state.bikes) {
if (b.lane < -0.01 || b.lane > LANE_COUNT - 1 + 0.01) offTrack += 1;
// A rival that covered almost no ground is stuck, not merely slow.
if (!b.isPlayer && b.x < model.length * 0.25) stalled += 1;
}
}
}
const band = SOAK_BANDS[name];
const winRate = wins / n;
const podRate = podiums / n;
check(`${name}: win rate inside its band`,
winRate >= band.wins[0] && winRate <= band.wins[1],
`${(winRate * 100).toFixed(0)}% outside ${band.wins.map((v) => `${v * 100}%`).join('-')}`);
check(`${name}: podium rate inside its band`,
podRate >= band.podium[0] && podRate <= band.podium[1],
`${(podRate * 100).toFixed(0)}% outside ${band.podium.map((v) => `${v * 100}%`).join('-')}`);
console.log(` ${name.padEnd(7)} ${n} races: won ${String(wins).padStart(3)}`
+ ` (${(winRate * 100).toFixed(0)}%), podium ${String(podiums).padStart(3)} (${(podRate * 100).toFixed(0)}%)`);
}
check('every race reaches a finish', neverFinished === 0, `${neverFinished}/${races} did not`);
check('no rival ever stalls', stalled === 0, `${stalled} stuck`);
check('no bike ever leaves the four lanes', offTrack === 0, `${offTrack} samples`);
}
// Standings must be a total order that agrees with the finish times.
{
const run = runAuto(bank[0].model, { mode: MODE.RACE, seed: 55 });
const order = standings(run.state);
check('standings list every bike once', new Set(order).size === run.state.bikes.length);
let ok = true;
for (let i = 1; i < order.length; i += 1) {
const a = order[i - 1];
const b = order[i];
if (a.finishMs != null && b.finishMs != null && a.finishMs > b.finishMs) ok = false;
if (a.finishMs == null && b.finishMs != null) ok = false;
}
check('standings are ordered by finish time, then by distance', ok);
}
// ---------------------------------------------------------------------------
// 8. Raster output
// ---------------------------------------------------------------------------
section('8. Raster output');
{
const { model } = bank[0];
const canvas = rasterizeTrack(model, model.theme);
check('the track canvas is the length of the track', canvas.width === model.length,
`${canvas.width}`);
check('the track canvas is the height of the playfield', canvas.height === PLAYFIELD_H);
const px = canvasPixels(canvas);
let offPalette = 0;
let transparent = 0;
const used = new Set();
// Sample rather than sweep: 1.2M pixels is a lot to walk twice.
for (let i = 0; i < px.length; i += 4 * 37) {
const rgb = (px[i] << 16) | (px[i + 1] << 8) | px[i + 2];
if (px[i + 3] !== 255) transparent += 1;
if (!PALETTE_SET.has(rgb)) offPalette += 1;
used.add(rgb);
}
check('every track pixel is a NES master-palette colour', offPalette === 0,
`${offPalette} off-palette samples`);
check('the track is fully opaque', transparent === 0, `${transparent} samples`);
check('the track uses a real spread of colours', used.size >= 8, `${used.size}`);
// The lanes must actually differ from the infield, or the art is a flat field.
const rowRgb = (y, x) => {
const i = (y * canvas.width + x) * 4;
return (px[i] << 16) | (px[i + 1] << 8) | px[i + 2];
};
check('the racing surface differs from the crowd band', rowRgb(LANES_Y + 8, 300) !== rowRgb(4, 300));
const hud = buildHudCanvas();
check('the HUD canvas is one NES frame wide', hud.width === SCREEN_W);
const hudPx = canvasPixels(hud);
let hudOff = 0;
for (let i = 0; i < hudPx.length; i += 4 * 13) {
const rgb = (hudPx[i] << 16) | (hudPx[i + 1] << 8) | hudPx[i + 2];
if (hudPx[i + 3] === 255 && !PALETTE_SET.has(rgb)) hudOff += 1;
}
check('every HUD pixel is a NES master-palette colour', hudOff === 0, `${hudOff} samples`);
const fontCanvas = buildFontCanvas();
check('the font atlas is 16 glyphs wide', fontCanvas.canvas.width === 16 * fontCanvas.cell);
const fpx = canvasPixels(fontCanvas.canvas);
let opaqueGlyphPixels = 0;
for (let i = 3; i < fpx.length; i += 4) if (fpx[i] === 255) opaqueGlyphPixels += 1;
check('the font atlas has glyphs drawn in it', opaqueGlyphPixels > 500,
`${opaqueGlyphPixels} lit pixels`);
// Sprites must keep their transparency, or every bike gets a black box.
const sprite = gridToCanvas(buildBikeFrame(0, 0), THEMES.day.sprites[0]);
const spx = canvasPixels(sprite);
let clear = 0;
for (let i = 3; i < spx.length; i += 4) if (spx[i] === 0) clear += 1;
check('a bike sprite is mostly transparent', clear > spx.length / 4 / 2, `${clear} clear px`);
}
// Every theme must rasterise, not just the default one.
for (const id of THEME_IDS) {
const small = buildTrackModel({
id: `theme-${id}`, theme: id, length: 1200, laps: 1, mainLaps: 1, qualifyMs: 30000,
hurdles: [{ t: 'C', x: 300 }, { t: 'M', x: 600 }, { t: 'K', x: 800 }],
});
const c = rasterizeTrack(small, id);
const p = canvasPixels(c);
let off = 0;
for (let i = 0; i < p.length; i += 4 * 17) {
const rgb = (p[i] << 16) | (p[i + 1] << 8) | p[i + 2];
if (!PALETTE_SET.has(rgb)) off += 1;
}
check(`theme ${id} rasterises inside the palette`, off === 0, `${off} samples`);
}
// ---------------------------------------------------------------------------
// 9. DESIGN mode
// ---------------------------------------------------------------------------
section('9. DESIGN mode');
{
check('the palette strip is the 19 hurdles plus CL, END and LP',
TOOLS.length === 22 && TOOLS.slice(0, 19).join('') === EXPECTED_IDS.join('')
&& TOOLS.slice(19).join(',') === 'CL,END,LP', TOOLS.join(''));
const blank = blankTrack();
check('a blank design is a legal track', validateTrack(buildTrackModel(blank), blank).length === 0);
check('a blank design is the design-mode length', blank.length === DESIGN_LENGTH);
// A designed track has to survive the same round-trip a save/load does.
const designed = blankTrack();
designed.hurdles = [
{ t: 'A', x: 300 }, { t: 'M', x: 600 }, { t: 'C', x: 900 },
{ t: 'K', x: 1300 }, { t: 'H', x: 1700 }, { t: 'N', x: 2200 },
];
const roundTripped = JSON.parse(JSON.stringify(designed));
const m1 = buildTrackModel(designed);
const m2 = buildTrackModel(roundTripped);
check('a designed track survives a save/load round-trip',
validateTrack(m2, roundTripped).length === 0
&& m1.height[0].every((v, i) => v === m2.height[0][i]));
const run = runAuto(m2, { skill: AUTO_SKILL.expert, seed: 3, maxSeconds: 400 });
check('a designed track can be ridden to the finish', run.finished && !run.timedOut);
// The caps the manual states.
const over = blankTrack();
for (let i = 0; i < MAX_HURDLES + 5; i += 1) over.hurdles.push({ t: 'A', x: 200 + i * 40 });
check('the fifty-hurdle cap is enforced',
validateTrack(buildTrackModel(over), over).some((e) => e.includes('cap')));
check('the lap ceiling is nine', MAX_LAPS === 9);
}
// ---------------------------------------------------------------------------
section('Summary');
console.log(' track len hurdles expert human target naive crash/kpx');
for (const g of gate) {
const s = (ms) => (ms == null ? ' DNF' : `${(ms / 1000).toFixed(1)}s`.padStart(7));
console.log(
` ${g.id} ${String(g.model.length).padStart(5)} ${String(g.model.hurdles.length).padStart(7)}`
+ ` ${s(g.expert.ms)} ${s(g.probe.medianMs)} ${s(g.model.qualifyMs)}`
+ ` ${g.naive.finished ? s(g.naive.ms) : ' DNF'}`
+ ` ${g.probe.crashesPerKpx.toFixed(2).padStart(9)}`,
);
}
if (failures) {
console.error(`\n${failures} FAILED of ${checks} checks`);
process.exit(1);
}
console.log(`\nall ${checks} checks passed`);