diff --git a/assets/gamedata/excitebike/track-01.json b/assets/gamedata/excitebike/track-01.json new file mode 100644 index 0000000..151cd9d --- /dev/null +++ b/assets/gamedata/excitebike/track-01.json @@ -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": [] +} diff --git a/assets/gamedata/excitebike/track-02.json b/assets/gamedata/excitebike/track-02.json new file mode 100644 index 0000000..994ceba --- /dev/null +++ b/assets/gamedata/excitebike/track-02.json @@ -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": [] +} diff --git a/assets/gamedata/excitebike/track-03.json b/assets/gamedata/excitebike/track-03.json new file mode 100644 index 0000000..96bd1b6 --- /dev/null +++ b/assets/gamedata/excitebike/track-03.json @@ -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": [] +} diff --git a/assets/gamedata/excitebike/track-04.json b/assets/gamedata/excitebike/track-04.json new file mode 100644 index 0000000..e5f97b2 --- /dev/null +++ b/assets/gamedata/excitebike/track-04.json @@ -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": [] +} diff --git a/assets/gamedata/excitebike/track-05.json b/assets/gamedata/excitebike/track-05.json new file mode 100644 index 0000000..74797f0 --- /dev/null +++ b/assets/gamedata/excitebike/track-05.json @@ -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": [] +} diff --git a/assets/gamedata/excitebike/track-06.json b/assets/gamedata/excitebike/track-06.json new file mode 100644 index 0000000..e14aa88 --- /dev/null +++ b/assets/gamedata/excitebike/track-06.json @@ -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 + ] + } + ] +} diff --git a/assets/gamedata/excitebike/track-07.json b/assets/gamedata/excitebike/track-07.json new file mode 100644 index 0000000..73c268f --- /dev/null +++ b/assets/gamedata/excitebike/track-07.json @@ -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 + ] + } + ] +} diff --git a/assets/gamedata/excitebike/track-08.json b/assets/gamedata/excitebike/track-08.json new file mode 100644 index 0000000..3d32750 --- /dev/null +++ b/assets/gamedata/excitebike/track-08.json @@ -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 + ] + } + ] +} diff --git a/assets/gamedata/excitebike/track-09.json b/assets/gamedata/excitebike/track-09.json new file mode 100644 index 0000000..eb2ab7e --- /dev/null +++ b/assets/gamedata/excitebike/track-09.json @@ -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 + ] + } + ] +} diff --git a/assets/gamedata/excitebike/track-10.json b/assets/gamedata/excitebike/track-10.json new file mode 100644 index 0000000..abda7be --- /dev/null +++ b/assets/gamedata/excitebike/track-10.json @@ -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 + ] + } + ] +} diff --git a/assets/gamedata/excitebike/tracks.json b/assets/gamedata/excitebike/tracks.json new file mode 100644 index 0000000..8f81da7 --- /dev/null +++ b/assets/gamedata/excitebike/tracks.json @@ -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" + } + ] +} diff --git a/docs/excitebike-build-plan.md b/docs/excitebike-build-plan.md new file mode 100644 index 0000000..dd05f65 --- /dev/null +++ b/docs/excitebike-build-plan.md @@ -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 diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index b9fc9ad..40834c6 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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). diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index ad65c0b..0904458 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -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 }); diff --git a/src/games/excitebike/ExcitebikeArt.js b/src/games/excitebike/ExcitebikeArt.js new file mode 100644 index 0000000..0de195b --- /dev/null +++ b/src/games/excitebike/ExcitebikeArt.js @@ -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 }; diff --git a/src/games/excitebike/ExcitebikeAuto.js b/src/games/excitebike/ExcitebikeAuto.js new file mode 100644 index 0000000..a058fc2 --- /dev/null +++ b/src/games/excitebike/ExcitebikeAuto.js @@ -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 }; diff --git a/src/games/excitebike/ExcitebikeDesign.js b/src/games/excitebike/ExcitebikeDesign.js new file mode 100644 index 0000000..48b536a --- /dev/null +++ b/src/games/excitebike/ExcitebikeDesign.js @@ -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 }; diff --git a/src/games/excitebike/ExcitebikeDesignData.js b/src/games/excitebike/ExcitebikeDesignData.js new file mode 100644 index 0000000..0433cdf --- /dev/null +++ b/src/games/excitebike/ExcitebikeDesignData.js @@ -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 */ } +} diff --git a/src/games/excitebike/ExcitebikeGame.js b/src/games/excitebike/ExcitebikeGame.js new file mode 100644 index 0000000..f35f7d3 --- /dev/null +++ b/src/games/excitebike/ExcitebikeGame.js @@ -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}`; +} diff --git a/src/games/excitebike/ExcitebikeLogic.js b/src/games/excitebike/ExcitebikeLogic.js new file mode 100644 index 0000000..e7205e6 --- /dev/null +++ b/src/games/excitebike/ExcitebikeLogic.js @@ -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 }; diff --git a/src/games/excitebike/ExcitebikeNES.js b/src/games/excitebike/ExcitebikeNES.js new file mode 100644 index 0000000..c254608 --- /dev/null +++ b/src/games/excitebike/ExcitebikeNES.js @@ -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)); +} diff --git a/src/games/excitebike/ExcitebikeRaster.js b/src/games/excitebike/ExcitebikeRaster.js new file mode 100644 index 0000000..e411531 --- /dev/null +++ b/src/games/excitebike/ExcitebikeRaster.js @@ -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 }; diff --git a/src/games/excitebike/ExcitebikeTrack.js b/src/games/excitebike/ExcitebikeTrack.js new file mode 100644 index 0000000..b478b66 --- /dev/null +++ b/src/games/excitebike/ExcitebikeTrack.js @@ -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 }; diff --git a/src/games/excitebike/sprites.md b/src/games/excitebike/sprites.md new file mode 100644 index 0000000..8b894c8 --- /dev/null +++ b/src/games/excitebike/sprites.md @@ -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 diff --git a/src/games/shift/ShiftGame.js b/src/games/shift/ShiftGame.js index 4b0ffc6..10444a3 100644 --- a/src/games/shift/ShiftGame.js +++ b/src/games/shift/ShiftGame.js @@ -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', + }); + }, + }); + }); } } diff --git a/src/main.js b/src/main.js index 97a142f..5e7c900 100644 --- a/src/main.js +++ b/src/main.js @@ -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, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 3324e42..8d664c6 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -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 = { diff --git a/src/services/soundtrack.js b/src/services/soundtrack.js index 421949f..bc96c65 100644 --- a/src/services/soundtrack.js +++ b/src/services/soundtrack.js @@ -20,6 +20,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = { superkart: 'nintendo', advancewars: 'nintendo', tetrisattack: 'nintendo', + excitebike: 'nintendo', totalannihilation: 'hacker', coloradodefense: 'arcadedark', tempest: 'arcadedark', diff --git a/tools/data/excitebikeCourses.js b/tools/data/excitebikeCourses.js new file mode 100644 index 0000000..70b5669 --- /dev/null +++ b/tools/data/excitebikeCourses.js @@ -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; diff --git a/tools/genExcitebikeTracks.js b/tools/genExcitebikeTracks.js new file mode 100644 index 0000000..98d26c2 --- /dev/null +++ b/tools/genExcitebikeTracks.js @@ -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(); diff --git a/tools/lib/canvasStub.js b/tools/lib/canvasStub.js new file mode 100644 index 0000000..255066c --- /dev/null +++ b/tools/lib/canvasStub.js @@ -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 }; diff --git a/tools/lib/png.js b/tools/lib/png.js new file mode 100644 index 0000000..e2a06cb --- /dev/null +++ b/tools/lib/png.js @@ -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]; +} diff --git a/tools/readExcitebikeMaps.js b/tools/readExcitebikeMaps.js new file mode 100644 index 0000000..d5a84f9 --- /dev/null +++ b/tools/readExcitebikeMaps.js @@ -0,0 +1,368 @@ +// Transcribe the five original NES Excitebike courses from their track maps. +// +// node tools/readExcitebikeMaps.js