Excitebike
This commit is contained in:
parent
a6573726ff
commit
4e818ad104
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 0–4 done 2026-07-29.** Ten tracks in the bank — the five original NES courses transcribed
|
||||
from their track maps, plus five new ones. SELECTION A, SELECTION B and DESIGN all implemented.
|
||||
`node tools/verifyExcitebike.js` → **579 checks green**. Wave 5 (art polish, icon frame 91) is
|
||||
**NEXT**.
|
||||
|
||||
**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 A–S, 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 256–512px 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 0–39, sky and wall 40–63, infield 64–127, four
|
||||
12px lanes 128–175, apron 176–191. The first build guessed 24px lanes in a 56–152 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 1–9
|
||||
- [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 | 50–100% | 85–100% |
|
||||
| human | 0–35% | 20–70% |
|
||||
| steady | 0–20% | 0–35% |
|
||||
| naive | 0–2% | 0–10% |
|
||||
|
||||
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 A–S and their names
|
||||
- nesmaps.com full-course track maps for Tracks 1–5 — the source for every length and hurdle position
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 */ }
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 1–3 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 | 0–39 | two spectator tiles, alternating |
|
||||
| sky and wall | 40–63 | the BEST time is painted here |
|
||||
| infield | 64–127 | terrain rises into this; nothing may reach the wall |
|
||||
| lanes | 128–175 | **four lanes of 12px** |
|
||||
| apron | 176–191 | rutted ground |
|
||||
| status bar | 192–239 | `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
|
||||
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
|
|||
superkart: 'nintendo',
|
||||
advancewars: 'nintendo',
|
||||
tetrisattack: 'nintendo',
|
||||
excitebike: 'nintendo',
|
||||
totalannihilation: 'hacker',
|
||||
coloradodefense: 'arcadedark',
|
||||
tempest: 'arcadedark',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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`);
|
||||
Loading…
Reference in New Issue