feat: add Super Kart — SNES-style Mode 7 kart racing game

Complete kart racing game featuring 12 procedurally generated tracks
across 3 cups, 9 unique racers with distinct stats, headless physics
simulation, and a secret track editor (?superkart-editor=1).

Core features:
- Mode 7 ground renderer with parallax horizon backdrops
- 12 hand-tuned tracks (speedway, beach, swamp, scrapyard, volcano, nightcity)
- 9-racer roster with balanced archetypes (all-rounder, drift pirate, etc.)
- Headless race simulation for deterministic AI and headless verification
- Item system (bolt, seeker, oil, turbo, overdrive, emp, coins)
- Position-weighted item roulette and rubber-banding
- AI opponents with skill-based driving and stuck-recovery
- Full track editor with live preview, validation, and export
- 275 verification checks covering schema, physics, AI soak, and determinism

Data files: data/superkart-racers.json, superkart-rules.json, superkart-artwork.json
Game data: assets/gamedata/superkart/ (cups.json + 12 track files)
Tools: genSuperKartTracks.js, verifySuperKart.js
This commit is contained in:
Brian Fertig 2026-07-17 14:25:18 -06:00
parent d6d3bafc7f
commit 5380a8eab4
29 changed files with 8221 additions and 1 deletions

View File

@ -0,0 +1,109 @@
{
"version": 1,
"cups": [
{
"id": "cup-1",
"name": "Sprocket Cup",
"tracks": [
"track-001",
"track-002",
"track-003",
"track-004"
]
},
{
"id": "cup-2",
"name": "Flywheel Cup",
"tracks": [
"track-005",
"track-006",
"track-007",
"track-008"
]
},
{
"id": "cup-3",
"name": "Nitro Cup",
"tracks": [
"track-009",
"track-010",
"track-011",
"track-012"
]
}
],
"tracks": [
{
"id": "track-001",
"name": "Gerome Speedway",
"theme": "speedway",
"file": "track-001.json"
},
{
"id": "track-002",
"name": "Kona Cove",
"theme": "beach",
"file": "track-002.json"
},
{
"id": "track-003",
"name": "Bayou Bend",
"theme": "swamp",
"file": "track-003.json"
},
{
"id": "track-004",
"name": "Scrap Run",
"theme": "scrapyard",
"file": "track-004.json"
},
{
"id": "track-005",
"name": "Sunwash Shore",
"theme": "beach",
"file": "track-005.json"
},
{
"id": "track-006",
"name": "Cinder Peaks",
"theme": "volcano",
"file": "track-006.json"
},
{
"id": "track-007",
"name": "Neon Nights",
"theme": "nightcity",
"file": "track-007.json"
},
{
"id": "track-008",
"name": "Speedway Classic",
"theme": "speedway",
"file": "track-008.json"
},
{
"id": "track-009",
"name": "Croc's Crossing",
"theme": "swamp",
"file": "track-009.json"
},
{
"id": "track-010",
"name": "Smasher's Junction",
"theme": "scrapyard",
"file": "track-010.json"
},
{
"id": "track-011",
"name": "Fireball Furnace",
"theme": "volcano",
"file": "track-011.json"
},
{
"id": "track-012",
"name": "Blackwind Boulevard",
"theme": "nightcity",
"file": "track-012.json"
}
]
}

View File

@ -0,0 +1,299 @@
{
"version": 1,
"id": "track-001",
"name": "Gerome Speedway",
"theme": "speedway",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3136,
"y": 2048,
"w": 138
},
{
"x": 2989,
"y": 2591,
"w": 137
},
{
"x": 2688,
"y": 3156,
"w": 124
},
{
"x": 2048,
"y": 3520,
"w": 132
},
{
"x": 1311,
"y": 3324,
"w": 125
},
{
"x": 939,
"y": 2688,
"w": 129
},
{
"x": 960,
"y": 2048,
"w": 138
},
{
"x": 1107,
"y": 1505,
"w": 128
},
{
"x": 1408,
"y": 940,
"w": 130
},
{
"x": 2048,
"y": 576,
"w": 123
},
{
"x": 2785,
"y": 772,
"w": 139
},
{
"x": 3157,
"y": 1408,
"w": 133
}
],
"startIndex": 0,
"surfaces": [
{
"type": "deep",
"circle": [
3545,
3008,
207
]
},
{
"type": "deep",
"circle": [
772,
932,
182
]
}
],
"walls": [],
"boosts": [
{
"s": 1360,
"lane": -0.2
},
{
"s": 3057,
"lane": -0.1
},
{
"s": 5489,
"lane": 0
}
],
"itemRows": [
{
"s": 2312,
"count": 4
},
{
"s": 4790,
"count": 4
},
{
"s": 7102,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "grandstand",
"x": 2342,
"y": 299
},
{
"sprite": "billboard",
"x": 1313,
"y": 1804
},
{
"sprite": "flagpole",
"x": 1316,
"y": 1522
},
{
"sprite": "grandstand",
"x": 1350,
"y": 660
},
{
"sprite": "billboard",
"x": 1457,
"y": 589
},
{
"sprite": "flagpole",
"x": 1769,
"y": 3157
},
{
"sprite": "grandstand",
"x": 1133,
"y": 2251
},
{
"sprite": "billboard",
"x": 1671,
"y": 1044
},
{
"sprite": "flagpole",
"x": 1223,
"y": 2731
},
{
"sprite": "grandstand",
"x": 494,
"y": 2524
},
{
"sprite": "billboard",
"x": 2121,
"y": 947
},
{
"sprite": "flagpole",
"x": 530,
"y": 2078
},
{
"sprite": "grandstand",
"x": 865,
"y": 3115
},
{
"sprite": "billboard",
"x": 3025,
"y": 3016
},
{
"sprite": "flagpole",
"x": 1230,
"y": 637
},
{
"sprite": "grandstand",
"x": 1860,
"y": 3267
}
],
"coins": [
{
"x": 3050,
"y": 2225
},
{
"x": 3145,
"y": 2314
},
{
"x": 3011,
"y": 2344
},
{
"x": 3103,
"y": 2439
},
{
"x": 2991,
"y": 2469
},
{
"x": 3031,
"y": 2551
},
{
"x": 2981,
"y": 2601
},
{
"x": 2946,
"y": 2656
},
{
"x": 2970,
"y": 2734
},
{
"x": 2869,
"y": 2763
},
{
"x": 2942,
"y": 2863
},
{
"x": 892,
"y": 2200
},
{
"x": 973,
"y": 2147
},
{
"x": 947,
"y": 2078
},
{
"x": 952,
"y": 2014
},
{
"x": 1007,
"y": 1959
},
{
"x": 941,
"y": 1881
},
{
"x": 1057,
"y": 1841
},
{
"x": 960,
"y": 1750
},
{
"x": 1089,
"y": 1721
},
{
"x": 1012,
"y": 1629
},
{
"x": 1105,
"y": 1594
},
{
"x": 1089,
"y": 1519
},
{
"x": 1116,
"y": 1461
}
]
}

View File

@ -0,0 +1,293 @@
{
"version": 1,
"id": "track-002",
"name": "Kona Cove",
"theme": "beach",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3569,
"y": 2048,
"w": 95
},
{
"x": 3467,
"y": 2731,
"w": 110
},
{
"x": 2907,
"y": 3125,
"w": 91
},
{
"x": 2260,
"y": 2975,
"w": 113
},
{
"x": 1865,
"y": 2850,
"w": 114
},
{
"x": 1255,
"y": 3043,
"w": 117
},
{
"x": 596,
"y": 2747,
"w": 97
},
{
"x": 671,
"y": 2048,
"w": 101
},
{
"x": 991,
"y": 1539,
"w": 120
},
{
"x": 1211,
"y": 998,
"w": 111
},
{
"x": 1751,
"y": 745,
"w": 95
},
{
"x": 2277,
"y": 1045,
"w": 115
},
{
"x": 2658,
"y": 1283,
"w": 112
},
{
"x": 3196,
"y": 1495,
"w": 104
}
],
"startIndex": 0,
"surfaces": [
{
"type": "water",
"circle": [
2425,
2246,
179
]
},
{
"type": "water",
"circle": [
2869,
671,
236
]
}
],
"walls": [],
"boosts": [
{
"s": 1663,
"lane": -0.2
},
{
"s": 2670,
"lane": -0.2
},
{
"s": 4189,
"lane": -0.4
}
],
"itemRows": [
{
"s": 2444,
"count": 4
},
{
"s": 5063,
"count": 4
},
{
"s": 7507,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "palm",
"x": 2296,
"y": 3383
},
{
"sprite": "umbrella",
"x": 748,
"y": 2390
},
{
"sprite": "rock",
"x": 2544,
"y": 2888
},
{
"sprite": "palm",
"x": 2715,
"y": 1588
},
{
"sprite": "umbrella",
"x": 3269,
"y": 3335
},
{
"sprite": "rock",
"x": 2097,
"y": 3160
},
{
"sprite": "palm",
"x": 3622,
"y": 3131
},
{
"sprite": "umbrella",
"x": 204,
"y": 2661
},
{
"sprite": "rock",
"x": 3351,
"y": 3128
},
{
"sprite": "palm",
"x": 2936,
"y": 2895
},
{
"sprite": "umbrella",
"x": 1900,
"y": 3262
},
{
"sprite": "rock",
"x": 2742,
"y": 2914
},
{
"sprite": "palm",
"x": 975,
"y": 2648
},
{
"sprite": "umbrella",
"x": 1451,
"y": 2671
},
{
"sprite": "rock",
"x": 662,
"y": 1346
},
{
"sprite": "palm",
"x": 1553,
"y": 1124
}
],
"coins": [
{
"x": 2462,
"y": 3067
},
{
"x": 2422,
"y": 2992
},
{
"x": 2335,
"y": 3046
},
{
"x": 2304,
"y": 2935
},
{
"x": 1582,
"y": 2990
},
{
"x": 1504,
"y": 2971
},
{
"x": 571,
"y": 2219
},
{
"x": 673,
"y": 2182
},
{
"x": 618,
"y": 2098
},
{
"x": 829,
"y": 1691
},
{
"x": 948,
"y": 1699
},
{
"x": 1129,
"y": 1275
},
{
"x": 2159,
"y": 907
},
{
"x": 2162,
"y": 1011
},
{
"x": 2412,
"y": 1099
},
{
"x": 2427,
"y": 1191
},
{
"x": 2529,
"y": 1153
},
{
"x": 2531,
"y": 1271
},
{
"x": 2919,
"y": 1343
},
{
"x": 2963,
"y": 1418
}
]
}

View File

@ -0,0 +1,309 @@
{
"version": 1,
"id": "track-003",
"name": "Bayou Bend",
"theme": "swamp",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3385,
"y": 2048,
"w": 97
},
{
"x": 3515,
"y": 2754,
"w": 102
},
{
"x": 3004,
"y": 3246,
"w": 93
},
{
"x": 2277,
"y": 3053,
"w": 113
},
{
"x": 1833,
"y": 2990,
"w": 131
},
{
"x": 1341,
"y": 2935,
"w": 102
},
{
"x": 892,
"y": 2604,
"w": 113
},
{
"x": 658,
"y": 2048,
"w": 98
},
{
"x": 840,
"y": 1466,
"w": 118
},
{
"x": 1261,
"y": 1061,
"w": 112
},
{
"x": 1736,
"y": 682,
"w": 112
},
{
"x": 2360,
"y": 681,
"w": 107
},
{
"x": 2646,
"y": 1299,
"w": 105
},
{
"x": 2740,
"y": 1715,
"w": 96
}
],
"startIndex": 0,
"surfaces": [
{
"type": "water",
"circle": [
2517,
2276,
271
]
},
{
"type": "water",
"circle": [
2380,
2265,
179
]
}
],
"walls": [],
"boosts": [
{
"s": 224,
"lane": -0.1
},
{
"s": 1729,
"lane": -0.4
},
{
"s": 7076,
"lane": 0.3
}
],
"itemRows": [
{
"s": 2429,
"count": 4
},
{
"s": 5032,
"count": 4
},
{
"s": 7462,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "cypress",
"x": 1266,
"y": 1426
},
{
"sprite": "reeds",
"x": 1614,
"y": 2593
},
{
"sprite": "log",
"x": 1921,
"y": 2707
},
{
"sprite": "cypress",
"x": 3166,
"y": 2642
},
{
"sprite": "reeds",
"x": 2441,
"y": 1412
},
{
"sprite": "log",
"x": 1996,
"y": 3321
},
{
"sprite": "cypress",
"x": 1543,
"y": 2733
},
{
"sprite": "reeds",
"x": 3760,
"y": 2197
},
{
"sprite": "log",
"x": 2834,
"y": 971
},
{
"sprite": "cypress",
"x": 1056,
"y": 2320
},
{
"sprite": "reeds",
"x": 1628,
"y": 3255
},
{
"sprite": "log",
"x": 3026,
"y": 2850
},
{
"sprite": "cypress",
"x": 1672,
"y": 2620
},
{
"sprite": "reeds",
"x": 2148,
"y": 931
},
{
"sprite": "log",
"x": 2356,
"y": 1636
},
{
"sprite": "cypress",
"x": 574,
"y": 1385
}
],
"coins": [
{
"x": 2583,
"y": 3127
},
{
"x": 2495,
"y": 3176
},
{
"x": 2469,
"y": 3066
},
{
"x": 2375,
"y": 3133
},
{
"x": 2077,
"y": 3039
},
{
"x": 2021,
"y": 2972
},
{
"x": 1948,
"y": 3054
},
{
"x": 1895,
"y": 2940
},
{
"x": 1819,
"y": 3046
},
{
"x": 1570,
"y": 2978
},
{
"x": 1135,
"y": 1192
},
{
"x": 1149,
"y": 1109
},
{
"x": 1250,
"y": 1130
},
{
"x": 1236,
"y": 1016
},
{
"x": 1350,
"y": 1046
},
{
"x": 1332,
"y": 935
},
{
"x": 1430,
"y": 943
},
{
"x": 1443,
"y": 864
},
{
"x": 1501,
"y": 832
},
{
"x": 2626,
"y": 1090
},
{
"x": 2557,
"y": 1179
},
{
"x": 2664,
"y": 1212
},
{
"x": 2607,
"y": 1299
},
{
"x": 3091,
"y": 1855
}
]
}

View File

@ -0,0 +1,291 @@
{
"version": 1,
"id": "track-004",
"name": "Scrap Run",
"theme": "scrapyard",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3489,
"y": 2048,
"w": 135
},
{
"x": 3427,
"y": 2844,
"w": 130
},
{
"x": 2764,
"y": 3288,
"w": 125
},
{
"x": 2048,
"y": 3167,
"w": 123
},
{
"x": 1564,
"y": 2886,
"w": 140
},
{
"x": 1071,
"y": 2612,
"w": 124
},
{
"x": 607,
"y": 2048,
"w": 127
},
{
"x": 669,
"y": 1252,
"w": 128
},
{
"x": 1332,
"y": 808,
"w": 136
},
{
"x": 2048,
"y": 929,
"w": 138
},
{
"x": 2532,
"y": 1210,
"w": 131
},
{
"x": 3025,
"y": 1484,
"w": 125
}
],
"startIndex": 0,
"surfaces": [
{
"type": "deep",
"circle": [
2673,
2380,
263
]
},
{
"type": "deep",
"circle": [
629,
3307,
161
]
}
],
"walls": [],
"boosts": [
{
"s": 224,
"lane": -0.3
},
{
"s": 1905,
"lane": 0.1
},
{
"s": 4371,
"lane": -0.2
}
],
"itemRows": [
{
"s": 2371,
"count": 4
},
{
"s": 4912,
"count": 4
},
{
"s": 7283,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "tirepile",
"x": 868,
"y": 2827
},
{
"sprite": "crane",
"x": 2871,
"y": 2933
},
{
"sprite": "wreck",
"x": 2043,
"y": 515
},
{
"sprite": "tirepile",
"x": 324,
"y": 2121
},
{
"sprite": "crane",
"x": 2011,
"y": 1346
},
{
"sprite": "wreck",
"x": 924,
"y": 703
},
{
"sprite": "tirepile",
"x": 801,
"y": 1724
},
{
"sprite": "crane",
"x": 3896,
"y": 2484
},
{
"sprite": "wreck",
"x": 832,
"y": 660
},
{
"sprite": "tirepile",
"x": 1632,
"y": 1250
},
{
"sprite": "crane",
"x": 927,
"y": 2097
},
{
"sprite": "wreck",
"x": 3393,
"y": 3432
},
{
"sprite": "tirepile",
"x": 535,
"y": 1084
},
{
"sprite": "crane",
"x": 1838,
"y": 506
},
{
"sprite": "wreck",
"x": 144,
"y": 1849
},
{
"sprite": "tirepile",
"x": 1577,
"y": 499
}
],
"coins": [
{
"x": 2242,
"y": 3184
},
{
"x": 2157,
"y": 3237
},
{
"x": 2111,
"y": 3173
},
{
"x": 2047,
"y": 3162
},
{
"x": 1740,
"y": 2938
},
{
"x": 1637,
"y": 2977
},
{
"x": 1355,
"y": 2841
},
{
"x": 1343,
"y": 2715
},
{
"x": 976,
"y": 2485
},
{
"x": 876,
"y": 2499
},
{
"x": 899,
"y": 2383
},
{
"x": 775,
"y": 2414
},
{
"x": 1867,
"y": 844
},
{
"x": 1920,
"y": 890
},
{
"x": 1976,
"y": 923
},
{
"x": 2054,
"y": 893
},
{
"x": 2427,
"y": 1153
},
{
"x": 2794,
"y": 1276
},
{
"x": 3171,
"y": 1542
},
{
"x": 3144,
"y": 1667
},
{
"x": 3268,
"y": 1629
},
{
"x": 3240,
"y": 1747
}
]
}

View File

@ -0,0 +1,303 @@
{
"version": 1,
"id": "track-005",
"name": "Sunwash Shore",
"theme": "beach",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3097,
"y": 2048,
"w": 101
},
{
"x": 3384,
"y": 2602,
"w": 72
},
{
"x": 3031,
"y": 3031,
"w": 102
},
{
"x": 2563,
"y": 3291,
"w": 99
},
{
"x": 2048,
"y": 3266,
"w": 72
},
{
"x": 1709,
"y": 2865,
"w": 74
},
{
"x": 1256,
"y": 2840,
"w": 106
},
{
"x": 544,
"y": 2671,
"w": 72
},
{
"x": 617,
"y": 2048,
"w": 93
},
{
"x": 1093,
"y": 1652,
"w": 96
},
{
"x": 1278,
"y": 1278,
"w": 85
},
{
"x": 1614,
"y": 1000,
"w": 100
},
{
"x": 2048,
"y": 786,
"w": 103
},
{
"x": 2658,
"y": 574,
"w": 72
},
{
"x": 3009,
"y": 1087,
"w": 88
},
{
"x": 2827,
"y": 1725,
"w": 81
}
],
"startIndex": 0,
"surfaces": [
{
"type": "water",
"circle": [
1942,
1848,
196
]
},
{
"type": "water",
"circle": [
3524,
1558,
190
]
}
],
"walls": [],
"boosts": [
{
"s": 2497,
"lane": 0.2
},
{
"s": 8178,
"lane": 0.3
}
],
"itemRows": [
{
"s": 2527,
"count": 4
},
{
"s": 5235,
"count": 4
},
{
"s": 7763,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "palm",
"x": 1679,
"y": 1347
},
{
"sprite": "umbrella",
"x": 388,
"y": 2191
},
{
"sprite": "rock",
"x": 241,
"y": 2124
},
{
"sprite": "palm",
"x": 1008,
"y": 2982
},
{
"sprite": "umbrella",
"x": 1998,
"y": 1163
},
{
"sprite": "rock",
"x": 1361,
"y": 3061
},
{
"sprite": "palm",
"x": 2639,
"y": 729
},
{
"sprite": "umbrella",
"x": 3642,
"y": 2469
},
{
"sprite": "rock",
"x": 2933,
"y": 2690
},
{
"sprite": "palm",
"x": 2712,
"y": 1476
},
{
"sprite": "umbrella",
"x": 3208,
"y": 2627
},
{
"sprite": "rock",
"x": 2324,
"y": 3585
},
{
"sprite": "palm",
"x": 3268,
"y": 950
},
{
"sprite": "umbrella",
"x": 1474,
"y": 826
},
{
"sprite": "rock",
"x": 1942,
"y": 599
},
{
"sprite": "palm",
"x": 2564,
"y": 265
}
],
"coins": [
{
"x": 3176,
"y": 2226
},
{
"x": 3274,
"y": 2238
},
{
"x": 3243,
"y": 2334
},
{
"x": 3066,
"y": 2995
},
{
"x": 3014,
"y": 3033
},
{
"x": 2985,
"y": 3099
},
{
"x": 1060,
"y": 2799
},
{
"x": 994,
"y": 2812
},
{
"x": 929,
"y": 2813
},
{
"x": 871,
"y": 2770
},
{
"x": 1598,
"y": 958
},
{
"x": 1699,
"y": 1001
},
{
"x": 1713,
"y": 895
},
{
"x": 1803,
"y": 929
},
{
"x": 1839,
"y": 857
},
{
"x": 1906,
"y": 849
},
{
"x": 1969,
"y": 833
},
{
"x": 2012,
"y": 771
},
{
"x": 2236,
"y": 652
},
{
"x": 2323,
"y": 683
},
{
"x": 2852,
"y": 1515
}
]
}

View File

@ -0,0 +1,285 @@
{
"version": 1,
"id": "track-006",
"name": "Cinder Peaks",
"theme": "volcano",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3428,
"y": 2048,
"w": 92
},
{
"x": 3296,
"y": 2649,
"w": 106
},
{
"x": 2902,
"y": 3119,
"w": 105
},
{
"x": 2350,
"y": 3372,
"w": 102
},
{
"x": 1765,
"y": 3289,
"w": 126
},
{
"x": 1285,
"y": 3005,
"w": 121
},
{
"x": 1032,
"y": 2537,
"w": 99
},
{
"x": 1087,
"y": 2048,
"w": 103
},
{
"x": 1007,
"y": 1547,
"w": 105
},
{
"x": 1033,
"y": 775,
"w": 110
},
{
"x": 1686,
"y": 461,
"w": 103
},
{
"x": 2298,
"y": 954,
"w": 109
},
{
"x": 2538,
"y": 1434,
"w": 113
},
{
"x": 3024,
"y": 1578,
"w": 105
}
],
"startIndex": 0,
"surfaces": [
{
"type": "deep",
"circle": [
2985,
821,
237
]
},
{
"type": "deep",
"circle": [
2446,
2562,
159
]
}
],
"walls": [],
"boosts": [
{
"s": 3795,
"lane": 0.3
},
{
"s": 6373,
"lane": 0.1
},
{
"s": 7670,
"lane": -0.2
}
],
"itemRows": [
{
"s": 2403,
"count": 4
},
{
"s": 4978,
"count": 4
},
{
"s": 7381,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "boulder",
"x": 3749,
"y": 2212
},
{
"sprite": "vent",
"x": 1556,
"y": 2909
},
{
"sprite": "spire",
"x": 2097,
"y": 368
},
{
"sprite": "boulder",
"x": 806,
"y": 2027
},
{
"sprite": "vent",
"x": 3180,
"y": 2205
},
{
"sprite": "spire",
"x": 2285,
"y": 3713
},
{
"sprite": "boulder",
"x": 3521,
"y": 1589
},
{
"sprite": "vent",
"x": 2488,
"y": 3681
},
{
"sprite": "spire",
"x": 2474,
"y": 3048
},
{
"sprite": "boulder",
"x": 3181,
"y": 2058
},
{
"sprite": "vent",
"x": 690,
"y": 1042
},
{
"sprite": "spire",
"x": 1839,
"y": 2901
},
{
"sprite": "boulder",
"x": 1598,
"y": 715
},
{
"sprite": "vent",
"x": 709,
"y": 2401
},
{
"sprite": "spire",
"x": 3613,
"y": 2740
},
{
"sprite": "boulder",
"x": 1485,
"y": 2746
}
],
"coins": [
{
"x": 3315,
"y": 2602
},
{
"x": 1847,
"y": 3311
},
{
"x": 1777,
"y": 3317
},
{
"x": 1738,
"y": 3238
},
{
"x": 1645,
"y": 3297
},
{
"x": 1630,
"y": 3174
},
{
"x": 1077,
"y": 1739
},
{
"x": 989,
"y": 1432
},
{
"x": 985,
"y": 1368
},
{
"x": 1008,
"y": 1301
},
{
"x": 945,
"y": 1242
},
{
"x": 1020,
"y": 1173
},
{
"x": 925,
"y": 1113
},
{
"x": 2116,
"y": 714
},
{
"x": 2112,
"y": 807
},
{
"x": 2219,
"y": 795
},
{
"x": 2191,
"y": 906
},
{
"x": 2303,
"y": 893
}
]
}

View File

@ -0,0 +1,305 @@
{
"version": 1,
"id": "track-007",
"name": "Neon Nights",
"theme": "nightcity",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3676,
"y": 2048,
"w": 89
},
{
"x": 3578,
"y": 2605,
"w": 72
},
{
"x": 2836,
"y": 2709,
"w": 95
},
{
"x": 2488,
"y": 2810,
"w": 85
},
{
"x": 2224,
"y": 3048,
"w": 72
},
{
"x": 1916,
"y": 2797,
"w": 87
},
{
"x": 1598,
"y": 2827,
"w": 104
},
{
"x": 1039,
"y": 2894,
"w": 82
},
{
"x": 518,
"y": 2605,
"w": 72
},
{
"x": 466,
"y": 2048,
"w": 80
},
{
"x": 841,
"y": 1609,
"w": 90
},
{
"x": 938,
"y": 1116,
"w": 72
},
{
"x": 1531,
"y": 1152,
"w": 82
},
{
"x": 1950,
"y": 1494,
"w": 72
},
{
"x": 2198,
"y": 1195,
"w": 85
},
{
"x": 2617,
"y": 1063,
"w": 91
},
{
"x": 3044,
"y": 1212,
"w": 86
},
{
"x": 3354,
"y": 1573,
"w": 91
}
],
"startIndex": 0,
"surfaces": [
{
"type": "water",
"circle": [
2095,
2280,
232
]
},
{
"type": "water",
"circle": [
1421,
2099,
169
]
}
],
"walls": [],
"boosts": [
{
"s": 800,
"lane": -0.2
},
{
"s": 1952,
"lane": -0.4
},
{
"s": 5377,
"lane": -0.1
}
],
"itemRows": [
{
"s": 2568,
"count": 4
},
{
"s": 5319,
"count": 4
},
{
"s": 7886,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "tower",
"x": 4030,
"y": 1968
},
{
"sprite": "neon",
"x": 1736,
"y": 2612
},
{
"sprite": "lamppost",
"x": 3357,
"y": 2366
},
{
"sprite": "tower",
"x": 2559,
"y": 3029
},
{
"sprite": "neon",
"x": 1279,
"y": 1370
},
{
"sprite": "lamppost",
"x": 857,
"y": 2093
},
{
"sprite": "tower",
"x": 3202,
"y": 2309
},
{
"sprite": "neon",
"x": 2504,
"y": 1283
},
{
"sprite": "lamppost",
"x": 2164,
"y": 1530
},
{
"sprite": "tower",
"x": 1552,
"y": 1603
},
{
"sprite": "neon",
"x": 2960,
"y": 2856
},
{
"sprite": "lamppost",
"x": 3322,
"y": 1125
},
{
"sprite": "tower",
"x": 1729,
"y": 832
},
{
"sprite": "neon",
"x": 3020,
"y": 2413
},
{
"sprite": "lamppost",
"x": 609,
"y": 2275
},
{
"sprite": "tower",
"x": 2890,
"y": 2935
}
],
"coins": [
{
"x": 3224,
"y": 2720
},
{
"x": 3157,
"y": 2661
},
{
"x": 3095,
"y": 2709
},
{
"x": 3030,
"y": 2688
},
{
"x": 2966,
"y": 2687
},
{
"x": 1399,
"y": 2881
},
{
"x": 1328,
"y": 2854
},
{
"x": 1276,
"y": 2926
},
{
"x": 733,
"y": 1792
},
{
"x": 1431,
"y": 1084
},
{
"x": 3300,
"y": 1438
},
{
"x": 3276,
"y": 1535
},
{
"x": 3378,
"y": 1539
},
{
"x": 3359,
"y": 1635
},
{
"x": 3446,
"y": 1646
},
{
"x": 3458,
"y": 1719
},
{
"x": 3509,
"y": 1760
},
{
"x": 3561,
"y": 1801
}
]
}

View File

@ -0,0 +1,299 @@
{
"version": 1,
"id": "track-008",
"name": "Speedway Classic",
"theme": "speedway",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3415,
"y": 2048,
"w": 153
},
{
"x": 3060,
"y": 2632,
"w": 144
},
{
"x": 2603,
"y": 3009,
"w": 154
},
{
"x": 2048,
"y": 3412,
"w": 155
},
{
"x": 1245,
"y": 3439,
"w": 145
},
{
"x": 767,
"y": 2787,
"w": 153
},
{
"x": 867,
"y": 2048,
"w": 149
},
{
"x": 1074,
"y": 1486,
"w": 154
},
{
"x": 1400,
"y": 926,
"w": 145
},
{
"x": 2048,
"y": 640,
"w": 145
},
{
"x": 2758,
"y": 818,
"w": 154
},
{
"x": 3291,
"y": 1330,
"w": 151
}
],
"startIndex": 0,
"surfaces": [
{
"type": "deep",
"circle": [
2213,
2656,
253
]
},
{
"type": "deep",
"circle": [
2157,
2101,
257
]
}
],
"walls": [],
"boosts": [
{
"s": 304,
"lane": 0.1
},
{
"s": 2097,
"lane": -0.1
},
{
"s": 3137,
"lane": 0.1
}
],
"itemRows": [
{
"s": 2402,
"count": 4
},
{
"s": 4976,
"count": 4
},
{
"s": 7379,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "grandstand",
"x": 3872,
"y": 1797
},
{
"sprite": "billboard",
"x": 637,
"y": 1626
},
{
"sprite": "flagpole",
"x": 2804,
"y": 3140
},
{
"sprite": "grandstand",
"x": 1381,
"y": 3841
},
{
"sprite": "billboard",
"x": 2036,
"y": 3115
},
{
"sprite": "flagpole",
"x": 1803,
"y": 3212
},
{
"sprite": "grandstand",
"x": 609,
"y": 2168
},
{
"sprite": "billboard",
"x": 577,
"y": 1523
},
{
"sprite": "flagpole",
"x": 2944,
"y": 3228
},
{
"sprite": "grandstand",
"x": 3845,
"y": 1561
},
{
"sprite": "billboard",
"x": 1284,
"y": 3831
},
{
"sprite": "flagpole",
"x": 602,
"y": 2116
},
{
"sprite": "grandstand",
"x": 2409,
"y": 3600
},
{
"sprite": "billboard",
"x": 1236,
"y": 2414
},
{
"sprite": "flagpole",
"x": 2398,
"y": 275
},
{
"sprite": "grandstand",
"x": 2855,
"y": 1468
}
],
"coins": [
{
"x": 3131,
"y": 2541
},
{
"x": 3079,
"y": 2582
},
{
"x": 2780,
"y": 2918
},
{
"x": 2704,
"y": 2923
},
{
"x": 2650,
"y": 2956
},
{
"x": 2629,
"y": 3035
},
{
"x": 2457,
"y": 3223
},
{
"x": 2330,
"y": 3176
},
{
"x": 1843,
"y": 3407
},
{
"x": 1797,
"y": 3512
},
{
"x": 798,
"y": 2253
},
{
"x": 863,
"y": 2204
},
{
"x": 795,
"y": 2119
},
{
"x": 923,
"y": 2086
},
{
"x": 811,
"y": 1992
},
{
"x": 954,
"y": 1964
},
{
"x": 862,
"y": 1870
},
{
"x": 964,
"y": 1836
},
{
"x": 941,
"y": 1760
},
{
"x": 967,
"y": 1701
},
{
"x": 1031,
"y": 1658
},
{
"x": 981,
"y": 1566
},
{
"x": 1111,
"y": 1556
},
{
"x": 1018,
"y": 1442
}
]
}

View File

@ -0,0 +1,295 @@
{
"version": 1,
"id": "track-009",
"name": "Croc's Crossing",
"theme": "swamp",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3058,
"y": 2048,
"w": 112
},
{
"x": 3160,
"y": 2509,
"w": 89
},
{
"x": 2974,
"y": 2974,
"w": 105
},
{
"x": 2633,
"y": 3460,
"w": 80
},
{
"x": 2048,
"y": 3419,
"w": 81
},
{
"x": 1716,
"y": 2850,
"w": 86
},
{
"x": 1352,
"y": 2744,
"w": 100
},
{
"x": 683,
"y": 2613,
"w": 72
},
{
"x": 578,
"y": 2048,
"w": 89
},
{
"x": 869,
"y": 1559,
"w": 107
},
{
"x": 1221,
"y": 1221,
"w": 77
},
{
"x": 1684,
"y": 1169,
"w": 95
},
{
"x": 2048,
"y": 939,
"w": 100
},
{
"x": 2665,
"y": 559,
"w": 72
},
{
"x": 3105,
"y": 991,
"w": 72
},
{
"x": 2975,
"y": 1664,
"w": 85
}
],
"startIndex": 0,
"surfaces": [
{
"type": "water",
"circle": [
1606,
2085,
278
]
},
{
"type": "water",
"circle": [
1974,
1746,
278
]
}
],
"walls": [],
"boosts": [
{
"s": 2449,
"lane": 0.1
},
{
"s": 5827,
"lane": -0.2
},
{
"s": 8149,
"lane": -0.3
}
],
"itemRows": [
{
"s": 2510,
"count": 4
},
{
"s": 5200,
"count": 4
},
{
"s": 7710,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "cypress",
"x": 1162,
"y": 1705
},
{
"sprite": "reeds",
"x": 1683,
"y": 861
},
{
"sprite": "log",
"x": 1602,
"y": 2386
},
{
"sprite": "cypress",
"x": 2802,
"y": 953
},
{
"sprite": "reeds",
"x": 2446,
"y": 924
},
{
"sprite": "log",
"x": 2275,
"y": 565
},
{
"sprite": "cypress",
"x": 2790,
"y": 791
},
{
"sprite": "reeds",
"x": 441,
"y": 1775
},
{
"sprite": "log",
"x": 1969,
"y": 551
},
{
"sprite": "cypress",
"x": 1409,
"y": 2437
},
{
"sprite": "reeds",
"x": 2641,
"y": 925
},
{
"sprite": "log",
"x": 2868,
"y": 2805
},
{
"sprite": "cypress",
"x": 939,
"y": 3066
},
{
"sprite": "reeds",
"x": 644,
"y": 1498
},
{
"sprite": "log",
"x": 2631,
"y": 731
},
{
"sprite": "cypress",
"x": 3426,
"y": 1223
}
],
"coins": [
{
"x": 3021,
"y": 2978
},
{
"x": 2923,
"y": 2998
},
{
"x": 2942,
"y": 3080
},
{
"x": 2889,
"y": 3125
},
{
"x": 2857,
"y": 3180
},
{
"x": 1864,
"y": 3033
},
{
"x": 1075,
"y": 2716
},
{
"x": 1010,
"y": 2719
},
{
"x": 878,
"y": 1622
},
{
"x": 851,
"y": 1519
},
{
"x": 942,
"y": 1509
},
{
"x": 953,
"y": 1436
},
{
"x": 2025,
"y": 957
},
{
"x": 2305,
"y": 794
},
{
"x": 2309,
"y": 695
},
{
"x": 2398,
"y": 708
},
{
"x": 3046,
"y": 1431
},
{
"x": 2988,
"y": 1480
}
]
}

View File

@ -0,0 +1,297 @@
{
"version": 1,
"id": "track-010",
"name": "Smasher's Junction",
"theme": "scrapyard",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3586,
"y": 2048,
"w": 81
},
{
"x": 3226,
"y": 2477,
"w": 95
},
{
"x": 2909,
"y": 2770,
"w": 86
},
{
"x": 2570,
"y": 2952,
"w": 81
},
{
"x": 2196,
"y": 2889,
"w": 92
},
{
"x": 1922,
"y": 2763,
"w": 72
},
{
"x": 1415,
"y": 3144,
"w": 77
},
{
"x": 884,
"y": 3025,
"w": 72
},
{
"x": 783,
"y": 2508,
"w": 90
},
{
"x": 513,
"y": 2048,
"w": 77
},
{
"x": 643,
"y": 1537,
"w": 72
},
{
"x": 1175,
"y": 1316,
"w": 78
},
{
"x": 1649,
"y": 1358,
"w": 94
},
{
"x": 1904,
"y": 1234,
"w": 101
},
{
"x": 2244,
"y": 935,
"w": 72
},
{
"x": 2559,
"y": 1163,
"w": 89
},
{
"x": 3006,
"y": 1244,
"w": 96
},
{
"x": 3578,
"y": 1491,
"w": 75
}
],
"startIndex": 0,
"surfaces": [
{
"type": "deep",
"circle": [
1323,
763,
157
]
},
{
"type": "deep",
"circle": [
1393,
790,
201
]
}
],
"walls": [],
"boosts": [
{
"s": 2257,
"lane": 0.3
},
{
"s": 3538,
"lane": -0.4
},
{
"s": 6915,
"lane": 0.1
}
],
"itemRows": [
{
"s": 2456,
"count": 4
},
{
"s": 5088,
"count": 4
},
{
"s": 7544,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "tirepile",
"x": 1899,
"y": 3034
},
{
"sprite": "crane",
"x": 221,
"y": 1568
},
{
"sprite": "wreck",
"x": 1585,
"y": 2673
},
{
"sprite": "tirepile",
"x": 457,
"y": 2597
},
{
"sprite": "crane",
"x": 3366,
"y": 1147
},
{
"sprite": "wreck",
"x": 1571,
"y": 1629
},
{
"sprite": "tirepile",
"x": 3790,
"y": 2078
},
{
"sprite": "crane",
"x": 2933,
"y": 3026
},
{
"sprite": "wreck",
"x": 2228,
"y": 1397
},
{
"sprite": "tirepile",
"x": 1354,
"y": 2754
},
{
"sprite": "crane",
"x": 2137,
"y": 3104
},
{
"sprite": "wreck",
"x": 927,
"y": 2349
},
{
"sprite": "tirepile",
"x": 2658,
"y": 2644
},
{
"sprite": "crane",
"x": 3218,
"y": 985
},
{
"sprite": "wreck",
"x": 552,
"y": 2617
},
{
"sprite": "tirepile",
"x": 1542,
"y": 2627
}
],
"coins": [
{
"x": 3289,
"y": 2375
},
{
"x": 3274,
"y": 2451
},
{
"x": 3216,
"y": 2484
},
{
"x": 3166,
"y": 2524
},
{
"x": 3146,
"y": 2595
},
{
"x": 3060,
"y": 2598
},
{
"x": 3063,
"y": 2695
},
{
"x": 2963,
"y": 2678
},
{
"x": 2960,
"y": 2777
},
{
"x": 2870,
"y": 2763
},
{
"x": 2845,
"y": 2838
},
{
"x": 1663,
"y": 2992
},
{
"x": 1127,
"y": 1301
},
{
"x": 3187,
"y": 1276
},
{
"x": 3234,
"y": 1341
},
{
"x": 3315,
"y": 1299
}
]
}

View File

@ -0,0 +1,307 @@
{
"version": 1,
"id": "track-011",
"name": "Fireball Furnace",
"theme": "volcano",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 3390,
"y": 2048,
"w": 72
},
{
"x": 3552,
"y": 2671,
"w": 72
},
{
"x": 2984,
"y": 2984,
"w": 104
},
{
"x": 2458,
"y": 3038,
"w": 119
},
{
"x": 2048,
"y": 3068,
"w": 112
},
{
"x": 1651,
"y": 3007,
"w": 100
},
{
"x": 1011,
"y": 3085,
"w": 72
},
{
"x": 544,
"y": 2671,
"w": 72
},
{
"x": 910,
"y": 2048,
"w": 91
},
{
"x": 1308,
"y": 1742,
"w": 80
},
{
"x": 1230,
"y": 1230,
"w": 97
},
{
"x": 1509,
"y": 747,
"w": 94
},
{
"x": 2048,
"y": 588,
"w": 94
},
{
"x": 2600,
"y": 716,
"w": 72
},
{
"x": 2764,
"y": 1332,
"w": 102
},
{
"x": 2775,
"y": 1747,
"w": 72
}
],
"startIndex": 0,
"surfaces": [
{
"type": "deep",
"circle": [
821,
688,
183
]
},
{
"type": "deep",
"circle": [
3371,
1354,
178
]
}
],
"walls": [],
"boosts": [
{
"s": 960,
"lane": -0.4
},
{
"s": 4241,
"lane": 0.2
},
{
"s": 5442,
"lane": 0.1
}
],
"itemRows": [
{
"s": 2586,
"count": 4
},
{
"s": 5356,
"count": 4
},
{
"s": 7942,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "boulder",
"x": 1565,
"y": 1232
},
{
"sprite": "vent",
"x": 2344,
"y": 982
},
{
"sprite": "spire",
"x": 2395,
"y": 910
},
{
"sprite": "boulder",
"x": 2960,
"y": 1618
},
{
"sprite": "vent",
"x": 2034,
"y": 983
},
{
"sprite": "spire",
"x": 3214,
"y": 1596
},
{
"sprite": "boulder",
"x": 2383,
"y": 1405
},
{
"sprite": "vent",
"x": 1574,
"y": 3180
},
{
"sprite": "spire",
"x": 826,
"y": 2654
},
{
"sprite": "boulder",
"x": 1907,
"y": 212
},
{
"sprite": "vent",
"x": 1495,
"y": 1348
},
{
"sprite": "spire",
"x": 3196,
"y": 2324
},
{
"sprite": "boulder",
"x": 989,
"y": 1480
},
{
"sprite": "vent",
"x": 1233,
"y": 512
},
{
"sprite": "spire",
"x": 3812,
"y": 2265
},
{
"sprite": "boulder",
"x": 1098,
"y": 2805
}
],
"coins": [
{
"x": 3151,
"y": 2921
},
{
"x": 3089,
"y": 2939
},
{
"x": 3039,
"y": 2991
},
{
"x": 2590,
"y": 3022
},
{
"x": 2526,
"y": 3027
},
{
"x": 2465,
"y": 3062
},
{
"x": 2395,
"y": 3005
},
{
"x": 2341,
"y": 3099
},
{
"x": 2266,
"y": 3006
},
{
"x": 2212,
"y": 3113
},
{
"x": 1327,
"y": 3083
},
{
"x": 793,
"y": 2249
},
{
"x": 781,
"y": 2158
},
{
"x": 860,
"y": 2140
},
{
"x": 879,
"y": 2073
},
{
"x": 2204,
"y": 594
},
{
"x": 2698,
"y": 1164
},
{
"x": 2790,
"y": 1217
},
{
"x": 2721,
"y": 1291
},
{
"x": 3100,
"y": 1882
},
{
"x": 3150,
"y": 1933
}
]
}

View File

@ -0,0 +1,273 @@
{
"version": 1,
"id": "track-012",
"name": "Blackwind Boulevard",
"theme": "nightcity",
"laps": 5,
"world": 4096,
"spline": [
{
"x": 2858,
"y": 2048,
"w": 109
},
{
"x": 3052,
"y": 2413,
"w": 84
},
{
"x": 3063,
"y": 2900,
"w": 73
},
{
"x": 2687,
"y": 3155,
"w": 95
},
{
"x": 2331,
"y": 3651,
"w": 74
},
{
"x": 1765,
"y": 3651,
"w": 76
},
{
"x": 1496,
"y": 3005,
"w": 88
},
{
"x": 1291,
"y": 2683,
"w": 95
},
{
"x": 1142,
"y": 2378,
"w": 86
},
{
"x": 1201,
"y": 2048,
"w": 89
},
{
"x": 1198,
"y": 1738,
"w": 92
},
{
"x": 1156,
"y": 1300,
"w": 90
},
{
"x": 1234,
"y": 638,
"w": 72
},
{
"x": 1780,
"y": 528,
"w": 87
},
{
"x": 2276,
"y": 757,
"w": 86
},
{
"x": 2785,
"y": 771,
"w": 72
},
{
"x": 2863,
"y": 1364,
"w": 82
},
{
"x": 2729,
"y": 1800,
"w": 72
}
],
"startIndex": 0,
"surfaces": [
{
"type": "water",
"circle": [
1998,
1431,
154
]
},
{
"type": "water",
"circle": [
2034,
2483,
260
]
}
],
"walls": [],
"boosts": [
{
"s": 1135,
"lane": 0.4
},
{
"s": 2829,
"lane": 0.3
},
{
"s": 4220,
"lane": 0.3
}
],
"itemRows": [
{
"s": 2457,
"count": 4
},
{
"s": 5090,
"count": 4
},
{
"s": 7547,
"count": 4
}
],
"hazards": [],
"decor": [
{
"sprite": "tower",
"x": 3212,
"y": 3151
},
{
"sprite": "neon",
"x": 3010,
"y": 1026
},
{
"sprite": "lamppost",
"x": 3360,
"y": 2283
},
{
"sprite": "tower",
"x": 2985,
"y": 1667
},
{
"sprite": "neon",
"x": 2804,
"y": 2355
},
{
"sprite": "lamppost",
"x": 2495,
"y": 1352
},
{
"sprite": "tower",
"x": 2870,
"y": 2545
},
{
"sprite": "neon",
"x": 3269,
"y": 2488
},
{
"sprite": "lamppost",
"x": 1045,
"y": 2064
},
{
"sprite": "tower",
"x": 990,
"y": 2231
},
{
"sprite": "neon",
"x": 1482,
"y": 2339
},
{
"sprite": "lamppost",
"x": 2199,
"y": 3234
},
{
"sprite": "tower",
"x": 3272,
"y": 2860
},
{
"sprite": "neon",
"x": 2713,
"y": 2731
},
{
"sprite": "lamppost",
"x": 2936,
"y": 3329
},
{
"sprite": "tower",
"x": 1996,
"y": 3379
}
],
"coins": [
{
"x": 2493,
"y": 3452
},
{
"x": 1573,
"y": 3275
},
{
"x": 1590,
"y": 3203
},
{
"x": 1517,
"y": 3158
},
{
"x": 1146,
"y": 1155
},
{
"x": 1153,
"y": 1091
},
{
"x": 1171,
"y": 1027
},
{
"x": 1132,
"y": 962
},
{
"x": 2831,
"y": 1260
},
{
"x": 2900,
"y": 1327
}
]
}

View File

@ -0,0 +1,59 @@
{
"_readme": [
"Drop-in artwork manifest for Super Kart. Entries with path:null render",
"procedurally (tinted kart silhouettes, painted terrain) until art is",
"supplied - set the path once the PNG exists and the game uses it with no",
"code changes. Loaded lazily on game entry via src/data/assetManifest.js.",
"",
"racerSheets - one spritesheet per racer, 18 frames of 64x64 in a single row",
" (1152x64 png). Frames 0..15 are the kart seen from 16 view angles:",
" frame 0 = viewed square from BEHIND, rotating CLOCKWISE (seen from above)",
" in 22.5-degree steps - frame 4 = kart's left side, frame 8 = head-on,",
" frame 12 = right side. Frames 16/17 are optional player-view lean frames",
" (16 = leaning left, 17 = leaning right); if the sheet is only 16 frames",
" wide the game reuses frame 0 with a tilt instead.",
" Transparent background, kart centered, wheels touching the frame bottom.",
"",
"themeSheets - one 8-frame sheet per location theme, 64x64 frames in a row",
" (512x64 png): frame order matches the theme's decor list in",
" data/superkart-rules.json (frame 0 = first decor sprite, etc.); trailing",
" frames are spare. Drawn perspective-scaled beside the track.",
"",
"backdrops - one wide horizon strip per theme, 1024x300 png, tiles",
" horizontally (left and right edges must match). Drawn above the horizon",
" and parallax-scrolled as the camera rotates. The bottom edge color should",
" blend toward the theme's fog color in superkart-rules.json.",
"",
"itemSheet - 48x48 frames in a single row, one frame per item in",
" data/superkart-rules.json items order (bolt, seeker, oil, turbo,",
" overdrive, emp, coins), then frame 7 = item box, frame 8 = coin."
],
"racerSheets": {
"mario": { "key": "superkart-kart-mario", "path": null, "frameWidth": 64, "frameHeight": 64, "angles": 16 },
"kona": { "key": "superkart-kart-kona", "path": null, "frameWidth": 64, "frameHeight": 64, "angles": 16 },
"fireball": { "key": "superkart-kart-fireball", "path": null, "frameWidth": 64, "frameHeight": 64, "angles": 16 },
"smasher": { "key": "superkart-kart-smasher", "path": null, "frameWidth": 64, "frameHeight": 64, "angles": 16 },
"croc": { "key": "superkart-kart-croc", "path": null, "frameWidth": 64, "frameHeight": 64, "angles": 16 },
"gerome": { "key": "superkart-kart-gerome", "path": null, "frameWidth": 64, "frameHeight": 64, "angles": 16 },
"blackwind": { "key": "superkart-kart-blackwind", "path": null, "frameWidth": 64, "frameHeight": 64, "angles": 16 },
"dv-8-2303": { "key": "superkart-kart-dv-8-2303", "path": null, "frameWidth": 64, "frameHeight": 64, "angles": 16 },
"zanthor": { "key": "superkart-kart-zanthor", "path": null, "frameWidth": 64, "frameHeight": 64, "angles": 16 }
},
"themeSheets": {
"beach": { "key": "superkart-theme-beach", "path": null, "frameWidth": 64, "frameHeight": 64 },
"volcano": { "key": "superkart-theme-volcano", "path": null, "frameWidth": 64, "frameHeight": 64 },
"scrapyard": { "key": "superkart-theme-scrapyard", "path": null, "frameWidth": 64, "frameHeight": 64 },
"swamp": { "key": "superkart-theme-swamp", "path": null, "frameWidth": 64, "frameHeight": 64 },
"speedway": { "key": "superkart-theme-speedway", "path": null, "frameWidth": 64, "frameHeight": 64 },
"nightcity": { "key": "superkart-theme-nightcity", "path": null, "frameWidth": 64, "frameHeight": 64 }
},
"backdrops": {
"beach": { "key": "superkart-backdrop-beach", "path": null },
"volcano": { "key": "superkart-backdrop-volcano", "path": null },
"scrapyard": { "key": "superkart-backdrop-scrapyard", "path": null },
"swamp": { "key": "superkart-backdrop-swamp", "path": null },
"speedway": { "key": "superkart-backdrop-speedway", "path": null },
"nightcity": { "key": "superkart-backdrop-nightcity", "path": null }
},
"itemSheet": { "key": "superkart-items", "path": null, "frameWidth": 48, "frameHeight": 48 }
}

155
data/superkart-racers.json Normal file
View File

@ -0,0 +1,155 @@
{
"_readme": [
"Super Kart racer roster. Each id MUST match an opponent id in data/opponents.json",
"(portraits/videos/speech come from the shared opponent assets).",
"Stats are 0..1 and feed the physics formulas in SuperKartLogic.js:",
" topSpeed - straight-line speed ceiling",
" accel - how fast the kart reaches that ceiling",
" handling - steering rate (higher turns tighter, especially at speed)",
" weight - who shoves whom in kart-vs-kart bumps; heavy karts barely move",
" offroad - how well the kart keeps speed off the road (1.0 = barely slows)",
" drift - grip while powersliding; high drift holds tighter, longer slides",
"aiSkill is the racer's base driving skill 0..1 when computer controlled",
"(scaled by the engine class aiSkillMult in superkart-rules.json).",
"Add, remove, or retune racers freely - the racer select grid and race grid",
"color is the racer accent used for minimap dots, HUD rows, and the procedural fallback kart sprite tint.",
"adapt to the list. 9 racers fill the classic 3x3 select screen."
],
"racers": [
{
"id": "mario",
"archetype": "All-Rounder",
"stats": {
"topSpeed": 0.5,
"accel": 0.5,
"handling": 0.55,
"weight": 0.5,
"offroad": 0.5,
"drift": 0.5
},
"aiSkill": 0.6,
"blurb": "Balanced in every stat. The kart you learn the game with.",
"color": "#e04838"
},
{
"id": "kona",
"archetype": "Featherweight",
"stats": {
"topSpeed": 0.35,
"accel": 0.7,
"handling": 0.95,
"weight": 0.15,
"offroad": 0.5,
"drift": 0.55
},
"aiSkill": 0.55,
"blurb": "Tiny, nimble, and turns on a dime - but everyone can shove him around.",
"color": "#c87838"
},
{
"id": "fireball",
"archetype": "Rocket",
"stats": {
"topSpeed": 0.5,
"accel": 0.95,
"handling": 0.45,
"weight": 0.35,
"offroad": 0.45,
"drift": 0.4
},
"aiSkill": 0.55,
"blurb": "Off the line before the light turns. Recovery from every mistake is instant.",
"color": "#ff6a00"
},
{
"id": "smasher",
"archetype": "Heavyweight",
"stats": {
"topSpeed": 0.9,
"accel": 0.2,
"handling": 0.35,
"weight": 0.95,
"offroad": 0.35,
"drift": 0.3
},
"aiSkill": 0.6,
"blurb": "Slow to spool up, unstoppable at speed. Bumping The Smasher is a mistake.",
"color": "#b03cc8"
},
{
"id": "croc",
"archetype": "Bruiser",
"stats": {
"topSpeed": 0.75,
"accel": 0.35,
"handling": 0.25,
"weight": 0.85,
"offroad": 0.45,
"drift": 0.35
},
"aiSkill": 0.5,
"blurb": "Fast, heavy, and sloppy in the corners. Party foul on wheels.",
"color": "#38b048"
},
{
"id": "gerome",
"archetype": "Speed Demon",
"stats": {
"topSpeed": 1.0,
"accel": 0.45,
"handling": 0.4,
"weight": 0.55,
"offroad": 0.3,
"drift": 0.45
},
"aiSkill": 0.65,
"blurb": "The fastest thing on the track - as long as the track is straight.",
"color": "#2888e0"
},
{
"id": "blackwind",
"archetype": "Drift Pirate",
"stats": {
"topSpeed": 0.55,
"accel": 0.55,
"handling": 0.6,
"weight": 0.5,
"offroad": 0.4,
"drift": 0.95
},
"aiSkill": 0.6,
"blurb": "Lives sideways. Powerslides hold like the kart is on rails.",
"color": "#206878"
},
{
"id": "dv-8-2303",
"archetype": "Precision Machine",
"stats": {
"topSpeed": 0.6,
"accel": 0.5,
"handling": 0.9,
"weight": 0.6,
"offroad": 0.35,
"drift": 0.6
},
"aiSkill": 0.7,
"blurb": "Racing line calculated. Apex acquired. Human error not detected.",
"color": "#9aa4b0"
},
{
"id": "zanthor",
"archetype": "Mystic Wildcard",
"stats": {
"topSpeed": 0.45,
"accel": 0.5,
"handling": 0.5,
"weight": 0.45,
"offroad": 0.95,
"drift": 0.5
},
"aiSkill": 0.55,
"blurb": "The grass, the sand, the swamp - all roads are roads to Zanthor.",
"color": "#e8d048"
}
]
}

136
data/superkart-rules.json Normal file
View File

@ -0,0 +1,136 @@
{
"_readme": [
"Super Kart tuning. Everything the sim reads lives here so the feel can be",
"adjusted without code changes. Distances are world units (tracks live in a",
"4096x4096 world), speeds are units/second, times are milliseconds.",
"After editing, run `node tools/verifySuperKart.js` to re-check invariants."
],
"engineClasses": [
{ "id": "cruiser", "label": "CRUISER CLASS", "desc": "Relaxed speeds, forgiving rivals.", "speedMult": 0.8, "aiSkillMult": 0.75 },
{ "id": "racer", "label": "RACER CLASS", "desc": "The intended Super Kart experience.", "speedMult": 1.0, "aiSkillMult": 1.0 },
{ "id": "turbo", "label": "TURBO CLASS", "desc": "Everything faster, rivals ruthless.", "speedMult": 1.15, "aiSkillMult": 1.25 }
],
"physics": {
"kartRadius": 14,
"baseTopSpeed": 560,
"topSpeedSpread": 160,
"baseAccel": 320,
"accelSpread": 240,
"brakeDecel": 760,
"coastDrag": 1.1,
"reverseTopSpeed": 170,
"turnRateBase": 2.3,
"turnRateSpread": 1.3,
"speedTurnPenalty": 0.55,
"driftTurnMult": 1.6,
"driftSlipRate": 240,
"driftGripSpread": 170,
"slipDecay": 3.2,
"hopMs": 260,
"hopCooldownMs": 160,
"offroadMultMin": 0.5,
"offroadMultMax": 0.92,
"deepMult": 0.25,
"boostPadMult": 1.4,
"boostPadMs": 900,
"spinMs": 1100,
"spinSpeedMult": 0.35,
"squashMs": 2600,
"squashSpeedMult": 0.5,
"invulnMs": 1600,
"coinTopSpeedBonus": 0.004,
"maxCoins": 10,
"coinsLostOnHit": 2,
"coinPickupRadius": 22,
"wallBumpSpeedLoss": 0.45,
"bumpImpulse": 190,
"respawnMs": 1600,
"offroadRescueMs": 6000,
"gridSpacing": 46,
"itemBoxRespawnMs": 2200,
"itemBoxRadius": 20,
"rouletteMs": 1500,
"rubberBandGain": 0.08,
"rubberBandRange": 1600,
"aiLookaheadMin": 4,
"aiLookaheadMax": 14,
"aiSteerGain": 3.4,
"aiBrakeCurvature": 1.1,
"aiStuckMs": 1500,
"aiReverseMs": 800
},
"items": [
{ "id": "bolt", "name": "Bolt", "type": "projectile", "desc": "Straight shot, bounces off walls three times.",
"params": { "speed": 900, "bounces": 3, "radius": 10, "lifeMs": 6000, "homing": false } },
{ "id": "seeker", "name": "Seeker", "type": "projectile", "desc": "Locks onto the next kart ahead and hunts it down.",
"params": { "speed": 800, "bounces": 0, "radius": 10, "lifeMs": 9000, "homing": true, "homingTurnRate": 3.2 } },
{ "id": "oil", "name": "Oil Slick", "type": "hazard", "desc": "Drop it behind you (or lob it ahead). Spins whoever touches it.",
"params": { "radius": 18, "throwSpeed": 700, "throwMs": 600 } },
{ "id": "turbo", "name": "Turbo Cell", "type": "boost", "desc": "A burst of raw speed. Works everywhere, even off-road.",
"params": { "ms": 1400, "mult": 1.5 } },
{ "id": "overdrive", "name": "Overdrive", "type": "status", "desc": "Invincible and faster for a few glorious seconds.",
"params": { "ms": 7000, "mult": 1.2 } },
{ "id": "emp", "name": "EMP", "type": "global", "desc": "Shrinks and slows every other racer. Heavies reboot last.",
"params": { "ms": 4200, "mult": 0.6, "weightExtraMs": 1600 } },
{ "id": "coins", "name": "Coin Pack", "type": "pickup", "desc": "Two coins, straight into the tank.",
"params": { "coins": 2 } }
],
"itemWeights": {
"_note": "One row per item id; 9 columns = race positions 1st..9th. Leaders get defensive scraps, back markers get the comeback tools.",
"byPosition": {
"bolt": [30, 25, 22, 18, 15, 12, 10, 8, 6],
"seeker": [ 0, 18, 20, 22, 22, 20, 16, 12, 8],
"oil": [30, 20, 15, 12, 8, 6, 4, 3, 2],
"turbo": [ 5, 15, 20, 22, 25, 26, 28, 28, 26],
"overdrive": [ 0, 0, 2, 4, 8, 12, 16, 20, 24],
"emp": [ 0, 0, 1, 2, 4, 8, 12, 16, 22],
"coins": [35, 22, 20, 20, 18, 16, 14, 13, 12]
}
},
"pointsTable": [10, 8, 6, 4, 3, 2, 1, 0, 0],
"themes": {
"_note": "Rasterizer palettes + backdrop fallbacks per location theme. Colors are hex strings. fog should match the backdrop's bottom edge so the draw-distance fade lands on it.",
"beach": {
"name": "Sunwash Shore",
"terrain": "#d9b878", "terrainSpeckle": "#c8a55e", "road": "#8a8378", "roadEdge": "#6e685e",
"offroad": "#e6cd90", "deep": "#1d6e9e", "water": "#2b86bd",
"skyTop": "#7ec8e8", "skyBottom": "#ffe9c2", "fog": "#ffe9c2",
"decor": ["palm", "umbrella", "rock"]
},
"volcano": {
"name": "Cinder Peaks",
"terrain": "#4a3230", "terrainSpeckle": "#5c3a32", "road": "#5e5450", "roadEdge": "#453d3a",
"offroad": "#6e4038", "deep": "#e05a1e", "water": "#ff7a28",
"skyTop": "#2a1418", "skyBottom": "#ff9a52", "fog": "#ff9a52",
"decor": ["boulder", "vent", "spire"]
},
"scrapyard": {
"name": "Smasher's Scrapyard",
"terrain": "#6b5f4c", "terrainSpeckle": "#7a6c55", "road": "#71716e", "roadEdge": "#54544f",
"offroad": "#8a7a5e", "deep": "#3a3a36", "water": "#4a5a52",
"skyTop": "#8a92a0", "skyBottom": "#d8c8a8", "fog": "#d8c8a8",
"decor": ["tirepile", "crane", "wreck"]
},
"swamp": {
"name": "Bayou Bend",
"terrain": "#3f5a35", "terrainSpeckle": "#4a6a3c", "road": "#6e6252", "roadEdge": "#524a3e",
"offroad": "#55703e", "deep": "#233a2e", "water": "#3a5f52",
"skyTop": "#5a7a68", "skyBottom": "#c2d0a8", "fog": "#c2d0a8",
"decor": ["cypress", "reeds", "log"]
},
"speedway": {
"name": "Gerome Speedway",
"terrain": "#4a7a42", "terrainSpeckle": "#548a48", "road": "#565b60", "roadEdge": "#3e4246",
"offroad": "#6a9a58", "deep": "#2e2e2e", "water": "#3a6a9a",
"skyTop": "#6ab0e8", "skyBottom": "#e8f0f8", "fog": "#e8f0f8",
"decor": ["grandstand", "billboard", "flagpole"]
},
"nightcity": {
"name": "Blackwind Boulevard",
"terrain": "#1e2230", "terrainSpeckle": "#2a3044", "road": "#3c4048", "roadEdge": "#2a2d33",
"offroad": "#2e3448", "deep": "#101420", "water": "#182a4a",
"skyTop": "#0a0c18", "skyBottom": "#4a3a6a", "fog": "#4a3a6a",
"decor": ["tower", "neon", "lamppost"]
}
}
}

View File

@ -166,6 +166,20 @@ export const MANIFEST = {
(scene) => sheetsFrom(scene, 'civilization-artwork',
['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet', 'citySheets']),
],
// Cups/tracks index always loads (individual track-NNN.json files are
// fetched lazily at race start). Racer/theme/item sheets and backdrops are
// optional drop-ins declared in data/superkart-artwork.json — path:null
// entries stay procedural (tinted kart silhouettes, painted horizon).
superkart: [
{ type: 'json', key: 'superkart-cups', path: 'assets/gamedata/superkart/cups.json' },
(scene) => sheetsFrom(scene, 'superkart-artwork', ['racerSheets', 'themeSheets', 'itemSheet']),
(scene) => {
const art = scene.cache.json.get('superkart-artwork');
return Object.values(art?.backdrops ?? {})
.filter((b) => b && b.key && b.path)
.map((b) => image(b.key, b.path));
},
],
};
// Returns the full (normalized) descriptor list for a slug — including assets

View File

@ -112,3 +112,4 @@ registerGame({ slug: 'coloradodefense', name: 'Colorado Defense', category: 'arc
registerGame({ slug: 'starcontrol', name: 'Star Control', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 0, iconFrame: 82 });
registerGame({ slug: 'civilization', name: 'Civilization', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 83 });
registerGame({ slug: 'tempest', name: 'Tempest', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 84 });
registerGame({ slug: 'superkart', name: 'Super Kart', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 85 });

View File

@ -0,0 +1,609 @@
// Super Kart track editor. Secret entrance: index.html?superkart-editor=1
// (see PreloadScene). Mirrors the Peggle editor workflow: edit → Test Play →
// ⬇ Export Track (track-NNN.json) + ⬇ Export Cups Index (cups.json), then
// hand-drop the files into assets/gamedata/superkart/.
//
// The track is a closed centerline spline with per-point width; item rows and
// boost pads are arc-length addressed so they ride along with spline edits.
// A live validation strip runs the same checks tools/verifySuperKart.js does.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { buildTrackModel, validateTrack, projectToSpline, roadPoint } from './SuperKartTrack.js';
import { rasterizeTrack } from './SuperKartRaster.js';
const FONT = 'm6x11, "Julius Sans One"';
const BOARD_X = 70;
const BOARD_Y = 70;
const BOARD_SIZE = 940;
const GAMEDATA = 'assets/gamedata/superkart';
const TOOLS = [
['move', 'Move'],
['point', 'Add Point'],
['start', 'Start Line'],
['itemrow', 'Item Row'],
['boost', 'Boost Pad'],
['coin', 'Coin'],
['water', 'Water Poly'],
['deep', 'Deep Poly'],
['wall', 'Wall Line'],
['decor', 'Decor'],
['hazard', 'Hazard'],
['delete', 'Delete'],
];
export default class SuperKartEditor extends Phaser.Scene {
constructor() { super('SuperKartEditor'); }
init(data) {
this.resume = !!data?.resume;
}
create() {
this.rules = this.cache.json.get('superkart-rules');
this.themes = Object.keys(this.rules?.themes ?? { speedway: 1 }).filter((t) => !t.startsWith('_'));
if (this.resume) {
this.track = this.registry.get('superkart-editor-state') ?? this.defaultTrack();
} else {
this.track = this.defaultTrack();
}
this.tool = 'move';
this.selected = -1; // selected spline point index
this.draftPoly = []; // in-progress water/deep/wall points
this.undoStack = [];
this.model = null;
this.issues = [];
this.previewDirty = true;
this.previewTimer = 0;
this.cameraBg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018);
this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000)
.setStrokeStyle(2, COLORS.accent);
this.previewImg = null;
this.g = this.add.graphics().setDepth(5);
// cups.json is lazy-loaded by the game, so the editor fetches it itself.
this.cupsIndex = { version: 1, cups: [], tracks: [] };
fetch(`${GAMEDATA}/cups.json`).then((r) => r.json())
.then((d) => { this.cupsIndex = d; })
.catch(() => {});
this.buildToolbar();
this.bindPointer();
this.bindKeys();
this.rebuildModel();
}
defaultTrack() {
const spline = [];
for (let i = 0; i < 10; i += 1) {
const a = (i / 10) * Math.PI * 2;
spline.push({
x: Math.round(2048 + Math.cos(a) * 1250),
y: Math.round(2048 + Math.sin(a) * 1250),
w: 110,
});
}
return {
version: 1,
id: 'track-013',
name: 'New Track',
theme: this.themes?.[0] ?? 'speedway',
laps: 5,
world: 4096,
spline,
startIndex: 0,
surfaces: [],
walls: [],
boosts: [],
itemRows: [],
hazards: [],
decor: [],
coins: [],
};
}
// ── Coordinate transforms ────────────────────────────────────────────────
toBoard(wx, wy) {
const k = BOARD_SIZE / this.track.world;
return [BOARD_X + wx * k, BOARD_Y + wy * k];
}
toWorld(bx, by) {
const k = this.track.world / BOARD_SIZE;
return [(bx - BOARD_X) * k, (by - BOARD_Y) * k];
}
onBoard(p) {
return p.x >= BOARD_X && p.x <= BOARD_X + BOARD_SIZE && p.y >= BOARD_Y && p.y <= BOARD_Y + BOARD_SIZE;
}
// ── Toolbar / chrome ─────────────────────────────────────────────────────
buildToolbar() {
const tx = BOARD_X + BOARD_SIZE + 90;
const bw = 250;
this.add.text(tx + bw / 2, 34, 'SUPER KART EDITOR', {
fontFamily: FONT, fontSize: '30px', color: '#ffd028',
}).setOrigin(0.5);
this.toolButtons = {};
TOOLS.forEach(([id, label], i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const b = new Button(this, tx + col * (bw / 2 + 8) + bw / 4, 92 + row * 62, label,
() => this.setTool(id), { width: bw / 2, height: 52, fontSize: 18 });
this.toolButtons[id] = b;
});
this.setTool('move');
let y = 92 + Math.ceil(TOOLS.length / 2) * 62 + 20;
const half = bw / 2 + 8;
this.metaName = new Button(this, tx + bw / 2, y, `Name: ${this.track.name}`, () => {
const name = window.prompt('Track name:', this.track.name);
if (name) { this.track.name = name; this.metaName.setLabel(`Name: ${name}`); }
}, { width: bw + 8, height: 48, fontSize: 17, variant: 'ghost' });
y += 58;
this.metaFile = new Button(this, tx + bw / 2, y, `File: ${this.track.id}.json`, () => {
const id = window.prompt('Track id (file will be <id>.json):', this.track.id);
if (id) { this.track.id = id; this.metaFile.setLabel(`File: ${id}.json`); }
}, { width: bw + 8, height: 48, fontSize: 17, variant: 'ghost' });
y += 58;
this.metaTheme = new Button(this, tx + half / 2 - 4, y, `Theme: ${this.track.theme}`, () => {
const i = (this.themes.indexOf(this.track.theme) + 1) % this.themes.length;
this.track.theme = this.themes[i];
this.metaTheme.setLabel(`Theme: ${this.track.theme}`);
this.markDirty();
}, { width: half - 8, height: 48, fontSize: 15, variant: 'ghost' });
this.metaLaps = new Button(this, tx + half + half / 2 - 4, y, `Laps: ${this.track.laps}`, () => {
this.track.laps = { 3: 5, 5: 7, 7: 3 }[this.track.laps] ?? 5;
this.metaLaps.setLabel(`Laps: ${this.track.laps}`);
}, { width: half - 8, height: 48, fontSize: 15, variant: 'ghost' });
y += 70;
new Button(this, tx + bw / 2, y, '▶ Test Play', () => this.testPlay(), { width: bw + 8, height: 56, fontSize: 22 });
y += 66;
new Button(this, tx + bw / 2, y, '⬇ Export Track', () => this.exportTrack(), { width: bw + 8, height: 52, fontSize: 19 });
y += 62;
new Button(this, tx + bw / 2, y, '⬇ Export Cups Index', () => this.exportCups(), { width: bw + 8, height: 52, fontSize: 19 });
y += 62;
new Button(this, tx + half / 2 - 4, y, 'Undo (Z)', () => this.undo(), { width: half - 8, height: 48, fontSize: 16, variant: 'ghost' });
new Button(this, tx + half + half / 2 - 4, y, 'New Track', () => {
this.pushUndo();
this.track = this.defaultTrack();
this.selected = -1;
this.rebuildModel();
}, { width: half - 8, height: 48, fontSize: 16, variant: 'ghost' });
y += 58;
new Button(this, tx + bw / 2, y, 'Exit Editor', () => { window.location.search = ''; },
{ width: bw + 8, height: 48, fontSize: 17, variant: 'ghost' });
this.helpText = this.add.text(tx, y + 46, '', {
fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex, wordWrap: { width: bw + 20 },
});
this.issueText = this.add.text(BOARD_X, BOARD_Y + BOARD_SIZE + 8, '', {
fontFamily: FONT, fontSize: '18px', color: COLORS.dangerHex,
});
}
setTool(id) {
this.tool = id;
this.draftPoly = [];
for (const [tid, b] of Object.entries(this.toolButtons)) b.setActive(tid === id);
const help = {
move: 'Drag spline points. Drag the small square handle to set road width. Click a point to select; Del removes it.',
point: 'Click near a segment to insert a control point there; click elsewhere to append at the nearest end.',
start: 'Click a control point to put the start/finish line there.',
itemrow: 'Click the road to drop a row of 4 item boxes at that spot.',
boost: 'Click the road to place a boost pad (uses the clicked lane).',
coin: 'Click the road to place a single coin.',
water: 'Click to outline a water pool. Press Enter to close it.',
deep: 'Click to outline a deep pit. Press Enter to close it.',
wall: 'Click to draw a wall polyline. Press Enter to finish.',
decor: 'Click off-road to stamp scenery (cycles through the theme set).',
hazard: 'Click to place a static hazard object.',
delete: 'Click near any placed object (row, boost, coin, poly, wall, decor, hazard) to remove it.',
};
this.helpText?.setText(help[id] ?? '');
}
// ── Input ────────────────────────────────────────────────────────────────
bindPointer() {
this.dragging = -1; // spline point being dragged
this.draggingWidth = -1; // width handle being dragged
this.input.on('pointerdown', (p) => {
if (!this.onBoard(p)) return;
const [wx, wy] = this.toWorld(p.x, p.y);
this.handleClick(wx, wy, p);
});
this.input.on('pointermove', (p) => {
if (this.dragging >= 0) {
const [wx, wy] = this.toWorld(p.x, p.y);
const pt = this.track.spline[this.dragging];
pt.x = Math.round(Phaser.Math.Clamp(wx, 160, this.track.world - 160));
pt.y = Math.round(Phaser.Math.Clamp(wy, 160, this.track.world - 160));
this.markDirty();
} else if (this.draggingWidth >= 0) {
const [wx, wy] = this.toWorld(p.x, p.y);
const pt = this.track.spline[this.draggingWidth];
pt.w = Math.round(Phaser.Math.Clamp(Math.hypot(wx - pt.x, wy - pt.y), 50, 260));
this.markDirty();
}
});
this.input.on('pointerup', () => {
if (this.dragging >= 0 || this.draggingWidth >= 0) this.rebuildModel();
this.dragging = -1;
this.draggingWidth = -1;
});
}
bindKeys() {
this.input.keyboard.on('keydown-Z', () => this.undo());
this.input.keyboard.on('keydown-ENTER', () => this.closeDraft());
this.input.keyboard.on('keydown-DELETE', () => this.deleteSelected());
this.input.keyboard.on('keydown-BACKSPACE', () => this.deleteSelected());
this.input.keyboard.on('keydown-OPEN_BRACKET', () => this.nudgeWidth(-10));
this.input.keyboard.on('keydown-CLOSED_BRACKET', () => this.nudgeWidth(10));
}
nudgeWidth(d) {
if (this.selected < 0) return;
const pt = this.track.spline[this.selected];
pt.w = Phaser.Math.Clamp(pt.w + d, 50, 260);
this.markDirty();
this.rebuildModel();
}
deleteSelected() {
if (this.selected < 0 || this.track.spline.length <= 4) return;
this.pushUndo();
this.track.spline.splice(this.selected, 1);
if (this.track.startIndex >= this.track.spline.length) this.track.startIndex = 0;
this.selected = -1;
this.rebuildModel();
}
handleClick(wx, wy, pointer) {
const spline = this.track.spline;
const nearPoint = () => {
let best = -1;
let bd = 90 * (this.track.world / BOARD_SIZE) / 10; // ~40 world units
spline.forEach((pt, i) => {
const d = Math.hypot(pt.x - wx, pt.y - wy);
if (d < bd) { bd = d; best = i; }
});
return best;
};
switch (this.tool) {
case 'move': {
const i = nearPoint();
if (i >= 0) {
// Width handle sits at the point's road edge; grabbing near the edge
// ring drags width, grabbing the center drags position.
const pt = spline[i];
const d = Math.hypot(pt.x - wx, pt.y - wy);
this.selected = i;
if (Math.abs(d - pt.w) < 26) this.draggingWidth = i;
else this.dragging = i;
this.pushUndo();
} else this.selected = -1;
this.draw();
break;
}
case 'point': {
this.pushUndo();
// Insert on the nearest segment (projected), else append after nearest.
let bestSeg = -1;
let bd = Infinity;
for (let i = 0; i < spline.length; i += 1) {
const a = spline[i];
const b = spline[(i + 1) % spline.length];
const d = distToSeg(wx, wy, a, b);
if (d < bd) { bd = d; bestSeg = i; }
}
const w = Math.round((spline[bestSeg].w + spline[(bestSeg + 1) % spline.length].w) / 2);
spline.splice(bestSeg + 1, 0, { x: Math.round(wx), y: Math.round(wy), w });
if (this.track.startIndex > bestSeg) this.track.startIndex += 1;
this.selected = bestSeg + 1;
this.rebuildModel();
break;
}
case 'start': {
const i = nearPoint();
if (i >= 0) {
this.pushUndo();
this.track.startIndex = i;
this.rebuildModel();
}
break;
}
case 'itemrow':
case 'boost':
case 'coin': {
if (!this.model) break;
const proj = projectToSpline(this.model, wx, wy);
if (proj.d > 300) break;
this.pushUndo();
if (this.tool === 'itemrow') {
this.track.itemRows.push({ s: Math.round(proj.s), count: 4 });
} else if (this.tool === 'boost') {
this.track.boosts.push({ s: Math.round(proj.s), lane: Math.round(Phaser.Math.Clamp(proj.laneT, -0.7, 0.7) * 10) / 10 });
} else {
this.track.coins.push({ x: Math.round(wx), y: Math.round(wy) });
}
this.rebuildModel();
break;
}
case 'water':
case 'deep':
case 'wall':
this.draftPoly.push([Math.round(wx), Math.round(wy)]);
if (pointer.event?.detail >= 2) this.closeDraft(); // double click also closes
this.draw();
break;
case 'decor': {
this.pushUndo();
const list = this.rules?.themes?.[this.track.theme]?.decor ?? ['tree'];
const sprite = list[(this.track.decor.length) % list.length];
this.track.decor.push({ sprite, x: Math.round(wx), y: Math.round(wy) });
this.markDirty();
this.draw();
break;
}
case 'hazard':
this.pushUndo();
this.track.hazards.push({ type: 'pipe', x: Math.round(wx), y: Math.round(wy) });
this.draw();
break;
case 'delete': this.deleteNearest(wx, wy); break;
default: break;
}
}
closeDraft() {
if (this.draftPoly.length < 2) { this.draftPoly = []; this.draw(); return; }
this.pushUndo();
if (this.tool === 'wall') {
this.track.walls.push({ pts: this.draftPoly, closed: false });
} else if (this.draftPoly.length >= 3) {
this.track.surfaces.push({ type: this.tool === 'water' ? 'water' : 'deep', poly: this.draftPoly });
}
this.draftPoly = [];
this.rebuildModel();
}
deleteNearest(wx, wy) {
const candidates = [];
this.track.itemRows.forEach((row, i) => {
const p = this.model ? roadPoint(this.model, row.s, 0) : null;
if (p) candidates.push({ d: Math.hypot(p.x - wx, p.y - wy), del: () => this.track.itemRows.splice(i, 1) });
});
this.track.boosts.forEach((bp, i) => {
const p = this.model ? roadPoint(this.model, bp.s, bp.lane) : null;
if (p) candidates.push({ d: Math.hypot(p.x - wx, p.y - wy), del: () => this.track.boosts.splice(i, 1) });
});
this.track.coins.forEach((c, i) => candidates.push({ d: Math.hypot(c.x - wx, c.y - wy), del: () => this.track.coins.splice(i, 1) }));
this.track.decor.forEach((dc, i) => candidates.push({ d: Math.hypot(dc.x - wx, dc.y - wy), del: () => this.track.decor.splice(i, 1) }));
this.track.hazards.forEach((hz, i) => candidates.push({ d: Math.hypot(hz.x - wx, hz.y - wy), del: () => this.track.hazards.splice(i, 1) }));
this.track.surfaces.forEach((sf, i) => {
const pts = sf.poly ?? [];
for (const [px, py] of pts) candidates.push({ d: Math.hypot(px - wx, py - wy), del: () => this.track.surfaces.splice(i, 1) });
});
this.track.walls.forEach((wl, i) => {
for (const [px, py] of wl.pts) candidates.push({ d: Math.hypot(px - wx, py - wy), del: () => this.track.walls.splice(i, 1) });
});
candidates.sort((a, b) => a.d - b.d);
if (candidates[0] && candidates[0].d < 220) {
this.pushUndo();
candidates[0].del();
this.rebuildModel();
}
}
// ── Undo / model / preview ───────────────────────────────────────────────
pushUndo() {
this.undoStack.push(JSON.stringify(this.track));
if (this.undoStack.length > 60) this.undoStack.shift();
}
undo() {
const snap = this.undoStack.pop();
if (!snap) return;
this.track = JSON.parse(snap);
this.selected = -1;
this.rebuildModel();
}
markDirty() {
this.previewDirty = true;
this.previewTimer = 0;
this.draw();
}
rebuildModel() {
try {
this.model = buildTrackModel(this.track);
this.issues = validateTrack(this.model);
} catch (err) {
this.model = null;
this.issues = [`track invalid: ${err.message}`];
}
this.issueText?.setText(this.issues.length ? `${this.issues.join(' • ')}` : '');
this.markDirty();
}
refreshPreview() {
if (!this.model) return;
const theme = this.rules?.themes?.[this.track.theme] ?? {
terrain: '#3a5a35', terrainSpeckle: '#4a6a3c', road: '#6e6252', roadEdge: '#524a3e',
water: '#3a5f52', deep: '#233a2e', fog: '#c2d0a8',
};
const canvas = rasterizeTrack(this.model, theme, 1024);
if (this.textures.exists('superkart-editor-preview')) this.textures.remove('superkart-editor-preview');
this.textures.addCanvas('superkart-editor-preview', canvas);
this.previewImg?.destroy();
this.previewImg = this.add.image(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, 'superkart-editor-preview')
.setDisplaySize(BOARD_SIZE, BOARD_SIZE).setDepth(1);
}
update(time, delta) {
if (this.previewDirty) {
this.previewTimer += delta;
if (this.previewTimer > 350) { // debounce: rasterize after edits settle
this.previewDirty = false;
this.refreshPreview();
}
}
}
// ── Overlay drawing ──────────────────────────────────────────────────────
draw() {
const g = this.g;
g.clear();
const spline = this.track.spline;
// Centerline through the model samples (smooth) or raw points as fallback.
g.lineStyle(2, 0x68d0ff, 0.8);
const pts = this.model?.samples ?? spline;
g.beginPath();
pts.forEach((p, i) => {
const [bx, by] = this.toBoard(p.x, p.y);
if (i) g.lineTo(bx, by); else g.moveTo(bx, by);
});
g.closePath();
g.strokePath();
// Control points + width rings + start marker.
spline.forEach((pt, i) => {
const [bx, by] = this.toBoard(pt.x, pt.y);
const k = BOARD_SIZE / this.track.world;
g.lineStyle(1.5, i === this.selected ? 0xffd028 : 0xffffff, 0.5);
g.strokeCircle(bx, by, pt.w * k);
g.fillStyle(i === this.track.startIndex ? 0x38b048 : i === this.selected ? 0xffd028 : 0xffffff, 1);
g.fillCircle(bx, by, i === this.selected ? 7 : 5);
});
// Item rows / boosts / coins.
if (this.model) {
g.fillStyle(0xb078ff, 1);
for (const row of this.track.itemRows) {
const p = roadPoint(this.model, row.s, 0);
const [bx, by] = this.toBoard(p.x, p.y);
g.fillRect(bx - 5, by - 5, 10, 10);
}
g.fillStyle(0xffd028, 1);
for (const bp of this.track.boosts) {
const p = roadPoint(this.model, bp.s, bp.lane);
const [bx, by] = this.toBoard(p.x, p.y);
g.fillTriangle(bx - 6, by + 5, bx + 6, by + 5, bx, by - 7);
}
}
g.fillStyle(0xffe878, 1);
for (const c of this.track.coins) {
const [bx, by] = this.toBoard(c.x, c.y);
g.fillCircle(bx, by, 3);
}
g.fillStyle(0x88ff88, 0.9);
for (const dc of this.track.decor) {
const [bx, by] = this.toBoard(dc.x, dc.y);
g.fillRect(bx - 4, by - 4, 8, 8);
}
g.fillStyle(0xff8888, 0.9);
for (const hz of this.track.hazards) {
const [bx, by] = this.toBoard(hz.x, hz.y);
g.fillCircle(bx, by, 5);
}
// Draft polygon in progress.
if (this.draftPoly.length) {
g.lineStyle(2, this.tool === 'wall' ? 0xff6a00 : 0x68d0ff, 1);
g.beginPath();
this.draftPoly.forEach(([x, y], i) => {
const [bx, by] = this.toBoard(x, y);
if (i) g.lineTo(bx, by); else g.moveTo(bx, by);
});
g.strokePath();
for (const [x, y] of this.draftPoly) {
const [bx, by] = this.toBoard(x, y);
g.fillStyle(0xffffff, 1);
g.fillCircle(bx, by, 3);
}
}
}
// ── Test play / export ───────────────────────────────────────────────────
testPlay() {
if (this.issues.length) {
playSound(this, SFX.MASTERMIND_DENIED);
return;
}
this.registry.set('superkart-editor-state', JSON.parse(JSON.stringify(this.track)));
this.scene.start('SuperKartGame', {
game: { slug: 'superkart', name: 'Super Kart' },
testTrack: JSON.parse(JSON.stringify(this.track)),
returnToEditor: true,
});
}
download(name, obj) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([`${JSON.stringify(obj, null, 2)}\n`], { type: 'application/json' }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
exportTrack() {
if (this.issues.length) {
playSound(this, SFX.MASTERMIND_DENIED);
return;
}
this.download(`${this.track.id}.json`, this.track);
playSound(this, SFX.UI_CHIME);
this.flash(`Saved ${this.track.id}.json — drop it into ${GAMEDATA}/`);
}
exportCups() {
const idx = JSON.parse(JSON.stringify(this.cupsIndex ?? { version: 1, cups: [], tracks: [] }));
idx.tracks = (idx.tracks ?? []).filter((t) => t.id !== this.track.id);
idx.tracks.push({ id: this.track.id, name: this.track.name, theme: this.track.theme, file: `${this.track.id}.json` });
// If no cup lists this track yet, append it to the emptiest cup.
const inCup = (idx.cups ?? []).some((c) => c.tracks.includes(this.track.id));
if (!inCup && idx.cups?.length) {
idx.cups.sort((a, b) => a.tracks.length - b.tracks.length)[0].tracks.push(this.track.id);
idx.cups.sort((a, b) => a.id.localeCompare(b.id));
}
this.download('cups.json', idx);
playSound(this, SFX.UI_CHIME);
this.flash(`Saved cups.json — drop it into ${GAMEDATA}/ (review cup assignment)`);
}
flash(msg) {
this.flashText?.destroy();
this.flashText = this.add.text(BOARD_X + BOARD_SIZE / 2, BOARD_Y + 24, msg, {
fontFamily: FONT, fontSize: '20px', color: '#ffd028',
backgroundColor: '#000000cc', padding: { x: 12, y: 6 },
}).setOrigin(0.5).setDepth(20);
this.time.delayedCall(4200, () => this.flashText?.destroy());
}
}
function distToSeg(x, y, a, b) {
const abx = b.x - a.x;
const aby = b.y - a.y;
const len2 = abx * abx + aby * aby || 1e-6;
const t = Phaser.Math.Clamp(((x - a.x) * abx + (y - a.y) * aby) / len2, 0, 1);
return Math.hypot(x - (a.x + abx * t), y - (a.y + aby * t));
}

View File

@ -0,0 +1,997 @@
// Super Kart — SNES-style Mode 7 kart racing starring the site's opponent
// roster. Views: menu → engine class → racer select → cup/track select →
// race → results → podium, all in one scene (view-state pattern).
//
// Data: data/superkart-racers.json (roster+stats), data/superkart-rules.json
// (classes/physics/items/themes), data/superkart-artwork.json (drop-in art),
// assets/gamedata/superkart/cups.json + track-NNN.json (content).
// Headless sim in SuperKartLogic.js; track math in SuperKartTrack.js.
//
// Debug keys during a race: O = overhead debug overlay, F = flip the ground
// shader's Y orientation, C = swap the track texture for a checkerboard.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
import { enqueue as enqueueSpeech, resetQueue } from '../../ui/SpeechQueue.js';
import { createOpponentPortrait } from '../../ui/Portrait.js';
import { buildTrackModel } from './SuperKartTrack.js';
import {
createRace, step, finalizeRace, kartInputsNeutral, STEP_MS, COUNTDOWN_MS,
topSpeedOf, accelOf, turnRateOf, pointsFor,
} from './SuperKartLogic.js';
import {
rasterizeTrack, drawMinimapCanvas, buildKartSheetCanvas, buildItemSheetCanvas,
buildBackdropCanvas, buildDecorSheetCanvas,
} from './SuperKartRaster.js';
import { SuperKartMode7, Mode7Backdrop, MODE7 } from './SuperKartMode7.js';
const FONT = 'm6x11, "Julius Sans One"';
const TIMES_KEY = 'superkart-times';
const GAMEDATA = 'assets/gamedata/superkart';
const D = {
backdrop: -2, ground: -1, world: 10, playerKart: 900, hud: 1000,
banner: 1100, view: 1200, debug: 1500,
};
const ITEM_FRAME = { bolt: 0, seeker: 1, oil: 2, turbo: 3, overdrive: 4, emp: 5, coins: 6 };
const BOX_FRAME = 7;
const COIN_FRAME = 8;
export default class SuperKartGame extends Phaser.Scene {
constructor() { super('SuperKartGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'superkart', name: 'Super Kart' };
this.testTrack = data.testTrack ?? null; // editor test-play round trip
this.returnToEditor = !!data.returnToEditor;
this.trackCache = new Map(); // file -> track json
this.raceState = null;
this.mode7 = null;
this.gp = null;
this.chosen = { classIdx: 1, racerIdx: 0, mode: 'gp' };
this.viewObjs = [];
this.debugOn = false;
this.accum = 0;
this.lastTauntMs = 0;
}
create() {
this.rules = this.cache.json.get('superkart-rules');
this.racers = this.cache.json.get('superkart-racers')?.racers ?? [];
this.artwork = this.cache.json.get('superkart-artwork') ?? {};
this.cups = this.cache.json.get('superkart-cups') ?? { cups: [], tracks: [] };
try {
const music = this.cache.json.get('music');
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xffd028, curveAmount: 0.35 });
this.events.once('shutdown', () => {
this.crt.destroy();
resetQueue();
this.teardownRace();
});
this.bg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018)
.setDepth(-10);
if (this.renderer.type !== Phaser.WEBGL) {
this.showWebGLNotice();
return;
}
// Roster details (portraits/speech) come from the shared opponents file.
this.opponentById = {};
fetch('data/opponents.json')
.then((r) => r.json())
.then((d) => {
for (const op of d.opponents ?? []) this.opponentById[op.id] = op;
})
.catch(() => {});
this.buildFallbackTextures();
this.bindKeys();
if (this.testTrack) {
this.chosen = { classIdx: 1, racerIdx: 0, mode: 'test' };
this.startRace({ json: this.testTrack, entry: { id: this.testTrack.id ?? 'editor-track', name: this.testTrack.name ?? 'Editor Track' } });
} else {
this.showMenu();
}
}
// ── Textures ──────────────────────────────────────────────────────────────
ensureCanvasSheet(key, canvas, fw, fh) {
if (this.textures.exists(key)) return;
const tex = this.textures.addCanvas(key, canvas);
const cols = Math.floor(canvas.width / fw);
for (let i = 0; i < cols; i += 1) tex.add(i, 0, i * fw, 0, fw, fh);
}
buildFallbackTextures() {
for (const racer of this.racers) {
const art = this.artwork.racerSheets?.[racer.id];
if (!(art?.key && this.textures.exists(art.key))) {
this.ensureCanvasSheet(`superkart-kart-fb-${racer.id}`, buildKartSheetCanvas(racer.color ?? '#e04838'), 64, 64);
}
}
if (!(this.artwork.itemSheet?.key && this.textures.exists(this.artwork.itemSheet.key))) {
this.ensureCanvasSheet('superkart-items-fb', buildItemSheetCanvas(), 48, 48);
}
for (const [id, theme] of Object.entries(this.rules.themes ?? {})) {
if (id.startsWith('_')) continue;
const bd = this.artwork.backdrops?.[id];
if (!(bd?.key && this.textures.exists(bd.key)) && !this.textures.exists(`superkart-bd-fb-${id}`)) {
this.textures.addCanvas(`superkart-bd-fb-${id}`, buildBackdropCanvas(theme));
}
const ts = this.artwork.themeSheets?.[id];
if (!(ts?.key && this.textures.exists(ts.key))) {
this.ensureCanvasSheet(`superkart-decor-fb-${id}`, buildDecorSheetCanvas(theme), 64, 64);
}
}
// Checkerboard for shader debugging (C key).
if (!this.textures.exists('superkart-checker')) {
const c = document.createElement('canvas');
c.width = c.height = 1024;
const ctx = c.getContext('2d');
for (let y = 0; y < 16; y += 1) {
for (let x = 0; x < 16; x += 1) {
ctx.fillStyle = (x + y) % 2 === 0 ? '#d0d0d0' : '#282838';
ctx.fillRect(x * 64, y * 64, 64, 64);
}
}
ctx.fillStyle = '#d42a20';
ctx.fillRect(0, 0, 1024, 24); // north edge marker
this.textures.addCanvas('superkart-checker', c);
}
}
kartTexKey(racerId) {
const art = this.artwork.racerSheets?.[racerId];
if (art?.key && this.textures.exists(art.key)) return art.key;
return `superkart-kart-fb-${racerId}`;
}
itemTexKey() {
const art = this.artwork.itemSheet;
if (art?.key && this.textures.exists(art.key)) return art.key;
return 'superkart-items-fb';
}
backdropTexKey(themeId) {
const bd = this.artwork.backdrops?.[themeId];
if (bd?.key && this.textures.exists(bd.key)) return bd.key;
return `superkart-bd-fb-${themeId}`;
}
decorTexKey(themeId) {
const ts = this.artwork.themeSheets?.[themeId];
if (ts?.key && this.textures.exists(ts.key)) return ts.key;
return `superkart-decor-fb-${themeId}`;
}
// ── Input ─────────────────────────────────────────────────────────────────
bindKeys() {
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, d2: K.D,
hop: K.SPACE, item: K.SHIFT, itemX: K.X,
debug: K.O, flip: K.F, checker: K.C, esc: K.ESC,
});
this.input.keyboard.on('keydown-O', () => { this.debugOn = !this.debugOn; this.debugG?.setVisible(this.debugOn); });
this.input.keyboard.on('keydown-F', () => this.mode7?.setFlipY(!(this.mode7.ground.uniforms.uFlipY.value > 0)));
this.input.keyboard.on('keydown-C', () => {
if (!this.mode7) return;
this.checkerOn = !this.checkerOn;
this.mode7.ground.setChannel0(this.checkerOn ? 'superkart-checker' : this.trackTexKey);
});
this.input.keyboard.on('keydown-ESC', () => {
if (this.raceState) this.exitRace();
});
}
readInputs() {
const k = this.keys;
const inputs = kartInputsNeutral();
inputs.accel = k.up.isDown || k.w.isDown;
inputs.brake = k.down.isDown || k.s.isDown;
inputs.steer = (k.left.isDown || k.a.isDown ? -1 : 0) + (k.right.isDown || k.d2.isDown ? 1 : 0);
inputs.hop = k.hop.isDown;
inputs.item = k.item.isDown || k.itemX.isDown;
return inputs;
}
// ── View plumbing ─────────────────────────────────────────────────────────
clearView() {
for (const o of this.viewObjs) o?.destroy();
this.viewObjs = [];
}
vAdd(obj) {
this.viewObjs.push(obj);
return obj;
}
text(x, y, str, size, color = COLORS.textHex, origin = 0.5) {
const t = this.add.text(x, y, str, {
fontFamily: FONT, fontSize: `${size}px`, color,
}).setDepth(D.view);
if (typeof origin === 'object') t.setOrigin(origin.x, origin.y);
else t.setOrigin(origin);
return this.vAdd(t);
}
showWebGLNotice() {
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40,
'SUPER KART NEEDS WEBGL', {
fontFamily: FONT, fontSize: '48px', color: COLORS.textHex,
}).setOrigin(0.5);
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20,
'The Mode 7 track renderer requires a WebGL-capable browser.', {
fontFamily: FONT, fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(0.5);
new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 120, 'Leave', () => this.scene.start('GameMenu'));
}
// ── Menu / class / racer / cup / track views ──────────────────────────────
showMenu() {
this.teardownRace();
this.clearView();
const cx = GAME_WIDTH / 2;
this.text(cx, 200, 'SUPER KART', 110, '#ffd028');
this.text(cx, 290, 'MODE 7 KART RACING • STARRING THE FERTIG GAMES CREW', 24, COLORS.mutedHex);
this.text(cx, 380,
'ARROWS / WASD DRIVE • SPACE HOP+DRIFT • SHIFT OR X FIRES ITEMS', 22, COLORS.mutedHex);
this.vAdd(new Button(this, cx, 520, 'Grand Prix', () => { this.chosen.mode = 'gp'; this.showClassSelect(); }, { width: 380 }));
this.vAdd(new Button(this, cx, 610, 'Time Trial', () => { this.chosen.mode = 'tt'; this.showClassSelect(); }, { width: 380 }));
this.vAdd(new Button(this, cx, 740, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost' }));
}
showClassSelect() {
this.clearView();
const cx = GAME_WIDTH / 2;
this.text(cx, 180, 'CHOOSE ENGINE CLASS', 56, '#ffd028');
this.rules.engineClasses.forEach((cls, i) => {
const y = 340 + i * 130;
this.vAdd(new Button(this, cx, y, cls.label, () => {
this.chosen.classIdx = i;
playSound(this, SFX.UI_PICK);
this.showRacerSelect();
}, { width: 480, height: 84, fontSize: 34 }));
this.text(cx, y + 56, cls.desc, 20, COLORS.mutedHex);
});
this.vAdd(new Button(this, cx, 820, 'Back', () => this.showMenu(), { variant: 'ghost' }));
}
showRacerSelect() {
this.clearView();
const cx = GAME_WIDTH / 2;
this.text(cx, 120, 'CHOOSE YOUR RACER', 56, '#ffd028');
const cell = 250;
const gx0 = cx - cell;
const gy0 = 320;
const detail = { name: null, bars: null };
const drawDetail = (racer) => {
detail.name?.destroy();
detail.bars?.destroy();
const c = this.add.container(0, 0).setDepth(D.view);
this.vAdd(c);
detail.bars = c;
detail.name = this.text(cx, GAME_HEIGHT - 232, `${racer.id === this.playerRacer()?.id ? '' : ''}${(this.opponentById[racer.id]?.name ?? racer.id).toUpperCase()}${racer.archetype.toUpperCase()}`, 32, '#ffd028');
const stats = [
['SPEED', racer.stats.topSpeed], ['ACCEL', racer.stats.accel], ['TURN', racer.stats.handling],
['WEIGHT', racer.stats.weight], ['DRIFT', racer.stats.drift], ['OFFROAD', racer.stats.offroad],
];
stats.forEach(([label, v], i) => {
const bx = cx - 460 + (i % 3) * 320;
const by = GAME_HEIGHT - 180 + Math.floor(i / 3) * 44;
c.add(this.add.text(bx, by, label, { fontFamily: FONT, fontSize: '20px', color: COLORS.mutedHex }).setOrigin(0, 0.5));
c.add(this.add.rectangle(bx + 110, by, 160, 14, 0x2a2a34).setOrigin(0, 0.5));
c.add(this.add.rectangle(bx + 110, by, 160 * v, 14, 0xffd028).setOrigin(0, 0.5));
});
c.add(this.add.text(cx, GAME_HEIGHT - 90, racer.blurb, {
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5));
};
this.racerTiles = this.racers.map((racer, i) => {
const x = gx0 + (i % 3) * cell;
const y = gy0 + Math.floor(i / 3) * cell * 0.86;
const ring = this.vAdd(this.add.rectangle(x, y, 196, 196, 0x1c1c26)
.setStrokeStyle(3, i === this.chosen.racerIdx ? 0xffd028 : 0x3a3a46).setDepth(D.view - 1));
const op = this.opponentById[racer.id];
let face;
if (this.textures.exists('opponents')) {
face = this.add.image(x, y, 'opponents', op?.spriteIndex ?? 0).setDisplaySize(180, 180);
} else {
face = this.add.rectangle(x, y, 180, 180, Phaser.Display.Color.HexStringToColor(racer.color ?? '#888').color);
}
this.vAdd(face.setDepth(D.view));
const zone = this.vAdd(this.add.rectangle(x, y, 196, 196, 0xffffff, 0.001)
.setInteractive({ useHandCursor: true }).setDepth(D.view + 1));
zone.on('pointerover', () => drawDetail(racer));
zone.on('pointerdown', () => {
this.chosen.racerIdx = i;
playSound(this, SFX.UI_PICK);
this.racerTiles.forEach((t, j) => t.ring.setStrokeStyle(3, j === i ? 0xffd028 : 0x3a3a46));
drawDetail(racer);
const clip = op?.speech?.pick?.[0];
if (clip) { resetQueue(); enqueueSpeech(clip); }
});
return { ring };
});
drawDetail(this.racers[this.chosen.racerIdx]);
this.vAdd(new Button(this, cx - 170, GAME_HEIGHT - 34, 'Back', () => this.showClassSelect(),
{ variant: 'ghost', width: 200, height: 52 }));
this.vAdd(new Button(this, cx + 170, GAME_HEIGHT - 34, 'Continue', () => {
if (this.chosen.mode === 'gp') this.showCupSelect();
else this.showTrackSelect();
}, { width: 200, height: 52 }));
}
playerRacer() { return this.racers[this.chosen.racerIdx]; }
showCupSelect() {
this.clearView();
const cx = GAME_WIDTH / 2;
this.text(cx, 150, 'CHOOSE A CUP', 56, '#ffd028');
this.cups.cups.forEach((cup, i) => {
const x = cx + (i - (this.cups.cups.length - 1) / 2) * 460;
this.vAdd(new Button(this, x, 320, cup.name, () => {
playSound(this, SFX.UI_ACTIVATE);
this.startGrandPrix(cup);
}, { width: 400, height: 90, fontSize: 32 }));
cup.tracks.forEach((tid, j) => {
const tr = this.trackEntry(tid);
this.text(x, 410 + j * 36, `${j + 1}. ${tr?.name ?? tid}`, 22, COLORS.mutedHex);
});
});
this.vAdd(new Button(this, cx, 820, 'Back', () => this.showRacerSelect(), { variant: 'ghost' }));
}
showTrackSelect() {
this.clearView();
const cx = GAME_WIDTH / 2;
this.text(cx, 120, 'TIME TRIAL — CHOOSE A TRACK', 48, '#ffd028');
const cls = this.rules.engineClasses[this.chosen.classIdx];
(this.cups.tracks ?? []).forEach((tr, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
const x = cx + (col === 0 ? -420 : 420);
const y = 230 + row * 100;
this.vAdd(new Button(this, x, y, tr.name, () => this.startTimeTrial(tr),
{ width: 560, height: 70, fontSize: 26 }));
const best = this.bestTimes(tr.id, cls.id)[0];
this.text(x, y + 46, best ? `BEST ${fmtTime(best)}` : 'NO RECORD', 18,
best ? COLORS.goldHex : COLORS.mutedHex);
});
this.vAdd(new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.showRacerSelect(), { variant: 'ghost' }));
}
trackEntry(id) { return (this.cups.tracks ?? []).find((t) => t.id === id); }
// ── Race setup / teardown ─────────────────────────────────────────────────
startGrandPrix(cup) {
this.gp = { cup, raceIdx: 0, points: {} };
for (const r of this.racers) this.gp.points[r.id] = 0;
this.startCupRace();
}
startCupRace() {
const tid = this.gp.cup.tracks[this.gp.raceIdx];
const entry = this.trackEntry(tid);
this.startRace({ entry });
}
startTimeTrial(entry) {
this.gp = null;
this.startRace({ entry, tt: true });
}
async loadTrackJson(entry) {
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;
}
async startRace({ entry, json = null, tt = false }) {
this.clearView();
this.teardownRace();
const trackJson = json ?? await this.loadTrackJson(entry);
const model = buildTrackModel(trackJson);
const theme = this.rules.themes[model.theme] ?? Object.values(this.rules.themes).find((t) => typeof t === 'object');
const cls = this.rules.engineClasses[this.chosen.classIdx];
// Track texture (regenerated per race; canvas textures are cheap to drop).
this.trackTexKey = 'superkart-track';
if (this.textures.exists(this.trackTexKey)) this.textures.remove(this.trackTexKey);
this.textures.addCanvas(this.trackTexKey, rasterizeTrack(model, theme));
const fogC = Phaser.Display.Color.HexStringToColor(theme.fog);
this.mode7 = new SuperKartMode7(this, {
trackTextureKey: this.trackTexKey,
worldSize: model.world,
fogColor: { x: fogC.red / 255, y: fogC.green / 255, z: fogC.blue / 255 },
depthBase: D.ground,
});
this.backdrop = new Mode7Backdrop(this, this.backdropTexKey(model.theme), this.mode7.horizonY, D.backdrop);
this.checkerOn = false;
// Sim. Player's racer keeps their roster slot so GP fields are stable.
const mode = tt ? 'tt' : 'gp';
this.raceState = createRace({
trackModel: model,
rules: this.rules,
engineClass: cls,
racers: this.racers,
playerIndex: this.chosen.racerIdx,
mode,
seed: (Date.now() & 0xffff) | 1,
});
this.raceEntry = entry;
this.raceResultsShown = false;
this.accum = 0;
this.buildWorldSprites(model);
this.buildRaceHud(model, entry, cls);
this.mode7.follow(this.playerKartState(), true);
playSound(this, SFX.SCIFI_RISER);
}
playerKartState() { return this.raceState.karts[this.raceState.playerIndex]; }
teardownRace() {
this.raceState = null;
this.mode7?.destroy();
this.mode7 = null;
this.backdrop?.destroy();
this.backdrop = null;
for (const s of this.worldSprites ?? []) s.sprite.destroy();
this.worldSprites = null;
this.playerSprite?.destroy();
this.playerSprite = null;
this.hud?.destroy();
this.hud = null;
this.debugG?.destroy();
this.debugG = null;
this.minimapDots = null;
}
exitRace() {
this.teardownRace();
if (this.returnToEditor) this.scene.start('SuperKartEditor', { resume: true });
else this.showMenu();
}
// ── World sprites (projected each frame) ─────────────────────────────────
buildWorldSprites(model) {
const itemTex = this.itemTexKey();
const decorTex = this.decorTexKey(model.theme);
const themeDecorList = this.rules.themes[model.theme]?.decor ?? [];
this.worldSprites = [];
const add = (wx, wy, tex, frame, sizeMult, tag, ref) => {
const sprite = this.add.sprite(0, 0, tex, frame).setDepth(D.world).setVisible(false);
this.worldSprites.push({ sprite, wx, wy, sizeMult, tag, ref });
return sprite;
};
// AI karts (player kart is the fixed bottom-center sprite instead).
for (const kart of this.raceState.karts) {
if (kart.isPlayer) continue;
add(kart.x, kart.y, this.kartTexKey(kart.racer.id), 0, 1.0, 'kart', kart);
}
for (const box of this.raceState.itemBoxes) add(box.x, box.y, itemTex, BOX_FRAME, 0.85, 'box', box);
for (const coin of this.raceState.coins) add(coin.x, coin.y, itemTex, COIN_FRAME, 0.6, 'coin', coin);
for (const dec of model.decor) {
const frame = Math.max(0, themeDecorList.indexOf(dec.sprite)) % 8;
add(dec.x, dec.y, decorTex, frame, 2.2, 'decor', dec);
}
// Projectiles and dropped hazards appear mid-race; pools grow on demand.
this.dynamicPool = [];
// Player kart, fixed at the bottom center like the SNES original.
const pk = this.playerKartState();
this.playerSprite = this.add.sprite(GAME_WIDTH / 2, GAME_HEIGHT * 0.75,
this.kartTexKey(pk.racer.id), 0).setDepth(D.playerKart).setScale(3.1);
this.debugG = this.add.graphics().setDepth(D.debug).setVisible(this.debugOn);
}
dynamicSprite(i, tex, frame) {
while (this.dynamicPool.length <= i) {
this.dynamicPool.push(this.add.sprite(0, 0, tex, frame).setDepth(D.world).setVisible(false));
}
const s = this.dynamicPool[i];
s.setTexture(tex, frame);
return s;
}
// ── HUD ───────────────────────────────────────────────────────────────────
buildRaceHud(model, entry, cls) {
this.hud = this.add.container(0, 0).setDepth(D.hud);
const t = (x, y, str, size, color = COLORS.textHex, origin = 0.5) => {
const obj = this.add.text(x, y, str, { fontFamily: FONT, fontSize: `${size}px`, color }).setOrigin(origin);
this.hud.add(obj);
return obj;
};
t(GAME_WIDTH / 2, 28, `${entry?.name ?? model.name}${cls.label}`, 24, COLORS.mutedHex);
this.hudLap = t(40, 40, 'LAP 1/5', 40, '#ffd028', 0);
this.hudTimer = t(40, 92, '0:00.00', 30, COLORS.textHex, 0);
this.hudCoins = t(40, 136, '', 26, COLORS.goldHex, 0);
// Item panel.
const ip = this.add.rectangle(120, 250, 110, 110, 0x14141c, 0.85).setStrokeStyle(3, 0xffd028);
this.hud.add(ip);
this.hudItem = this.add.sprite(120, 250, this.itemTexKey(), BOX_FRAME).setScale(1.7).setVisible(false);
this.hud.add(this.hudItem);
// Position numeral.
this.hudPos = t(GAME_WIDTH - 130, GAME_HEIGHT - 120, '1', 170, '#ffd028');
this.hudPosSuffix = t(GAME_WIDTH - 60, GAME_HEIGHT - 80, 'ST', 44, '#ffd028');
// Minimap + live dots.
const mm = drawMinimapCanvas(model, 230);
if (this.textures.exists('superkart-minimap')) this.textures.remove('superkart-minimap');
this.textures.addCanvas('superkart-minimap', mm.canvas);
this.minimapToMap = mm.toMap;
const mapImg = this.add.image(GAME_WIDTH - 140, 150, 'superkart-minimap').setAlpha(0.92);
this.hud.add(mapImg);
this.minimapOrigin = { x: GAME_WIDTH - 140 - 115, y: 150 - 115 };
this.minimapDots = this.raceState.karts.map((kart) => {
const color = Phaser.Display.Color.HexStringToColor(kart.racer.color ?? '#ffffff').color;
const dot = this.add.circle(0, 0, kart.isPlayer ? 6 : 4.5, color)
.setStrokeStyle(1.5, kart.isPlayer ? 0xffffff : 0x000000);
this.hud.add(dot);
return dot;
});
// Mini rankings column.
this.hudRank = this.raceState.karts.map((_, i) => {
const y = 300 + i * 30;
const chip = this.add.rectangle(GAME_WIDTH - 250, y, 16, 16, 0xffffff);
const name = this.add.text(GAME_WIDTH - 232, y, '', { fontFamily: FONT, fontSize: '19px', color: COLORS.textHex }).setOrigin(0, 0.5);
this.hud.add(chip);
this.hud.add(name);
return { chip, name };
});
if (this.raceState.mode === 'tt') this.hudRank.forEach((r) => { r.chip.setVisible(false); r.name.setVisible(false); });
// Countdown lights.
this.hudLights = [0, 1, 2].map((i) => {
const l = this.add.circle(GAME_WIDTH / 2 + (i - 1) * 90, 200, 32, 0x33333d).setStrokeStyle(4, 0x101014);
this.hud.add(l);
return l;
});
this.hudGo = t(GAME_WIDTH / 2, 200, '', 90, '#38b048');
this.countTicked = new Set();
this.hud.add(this.add.text(GAME_WIDTH - 24, 12, 'ESC QUITS', { fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex }).setOrigin(1, 0));
}
// ── Frame update ──────────────────────────────────────────────────────────
update(time, delta) {
if (!this.raceState) return;
this.accum += Math.min(delta, 100);
const prevPhase = this.raceState.phase;
while (this.accum >= STEP_MS) {
this.accum -= STEP_MS;
step(this.raceState, this.readInputs());
this.processEvents(this.raceState.events);
}
this.renderRace();
if (this.raceState.phase === 'finished' && !this.raceResultsShown) {
this.raceResultsShown = true;
this.time.delayedCall(900, () => this.showResults());
} else if (prevPhase === 'countdown') {
this.renderCountdown();
}
}
renderCountdown() {
const ms = this.raceState.countdownMs;
const lit = ms < 2600 ? Math.min(3, Math.ceil((2600 - ms) / 800) + 0) : 0;
this.hudLights?.forEach((l, i) => l.setFillStyle(i < lit ? 0xd42a20 : 0x33333d));
for (const th of [2600, 1800, 1000]) {
if (ms < th && !this.countTicked.has(th)) {
this.countTicked.add(th);
playSound(this, SFX.COUNTDOWN_TICK);
}
}
}
renderRace() {
const state = this.raceState;
const player = this.playerKartState();
this.mode7.follow(player);
this.backdrop.update(this.mode7.cam.angle);
// Static + kart world sprites.
for (const ws of this.worldSprites) {
const { sprite, tag, ref } = ws;
let wx = ws.wx;
let wy = ws.wy;
let visible = true;
let frame = null;
if (tag === 'kart') {
wx = ref.x; wy = ref.y;
frame = this.mode7.angleFrame(ref.spinMs > 0 ? ref.heading + ref.spinSpin : ref.heading);
visible = ref.rescueMs <= 0;
} else if (tag === 'box') {
visible = ref.respawnAt <= state.timeMs;
} else if (tag === 'coin') {
visible = !ref.taken;
}
if (!visible) { sprite.setVisible(false); continue; }
const p = this.mode7.project(wx, wy);
if (!p) { sprite.setVisible(false); continue; }
sprite.setVisible(true);
let scale = p.scale * ws.sizeMult;
if (tag === 'kart') {
if (ref.empMs > 0) scale *= 0.55;
if (ref.squashMs > 0) sprite.setScale(scale * 1.25, scale * 0.45);
else sprite.setScale(scale);
sprite.setFrame(frame);
sprite.setAlpha(ref.invulnMs > 0 ? (Math.floor(state.timeMs / 80) % 2 ? 0.35 : 1) : 1);
if (ref.starMs > 0) sprite.setTint(starTint(state.timeMs));
else sprite.clearTint();
} else {
sprite.setScale(scale);
if (tag === 'box') sprite.setAngle(Math.sin(state.timeMs / 300 + wx) * 14);
sprite.setAlpha(1 - p.fog * 0.85);
}
sprite.setPosition(p.x, p.y - sprite.displayHeight * 0.42);
sprite.setDepth(D.world + Math.max(0, 800 - p.z));
}
// Projectiles + dropped hazards through the dynamic pool.
const itemTex = this.itemTexKey();
let di = 0;
for (const pr of state.projectiles) {
if (pr.dead) continue;
const p = this.mode7.project(pr.x, pr.y);
const s = this.dynamicSprite(di, itemTex, ITEM_FRAME[pr.id] ?? 0);
di += 1;
if (!p) { s.setVisible(false); continue; }
s.setVisible(true).setPosition(p.x, p.y - 10 * p.scale).setScale(p.scale * 0.7)
.setDepth(D.world + Math.max(0, 800 - p.z));
}
for (const hz of state.hazardsDropped) {
if (hz.dead) continue;
const p = this.mode7.project(hz.x, hz.y);
const s = this.dynamicSprite(di, itemTex, ITEM_FRAME.oil);
di += 1;
if (!p) { s.setVisible(false); continue; }
s.setVisible(true).setPosition(p.x, p.y).setScale(p.scale * 0.8, p.scale * 0.5)
.setDepth(D.world + Math.max(0, 800 - p.z));
}
for (let i = di; i < this.dynamicPool.length; i += 1) this.dynamicPool[i].setVisible(false);
this.renderPlayerKart(player);
this.renderHud(state, player);
if (this.debugOn) this.renderDebug(state);
}
renderPlayerKart(kart) {
const s = this.playerSprite;
const sheet = this.textures.get(this.kartTexKey(kart.racer.id));
const hasLean = sheet.frameTotal > 17;
const inputs = this.readInputs();
let frame = 0;
let angle = 0;
if (kart.spinMs > 0) {
frame = Math.floor((this.raceState.timeMs / 60)) % 16;
} else if (kart.drifting) {
frame = kart.driftDir > 0 ? 1 : 15;
angle = kart.driftDir * 6;
} else if (inputs.steer !== 0 && hasLean) {
frame = inputs.steer < 0 ? 16 : 17;
} else if (inputs.steer !== 0) {
angle = inputs.steer * 5;
}
s.setFrame(frame);
s.setAngle(angle);
let scale = 3.1;
if (kart.empMs > 0) scale *= 0.55;
if (kart.squashMs > 0) s.setScale(scale * 1.2, scale * 0.5);
else s.setScale(scale);
const hopH = kart.airborne ? Math.sin((1 - kart.hopMs / this.rules.physics.hopMs) * Math.PI) * 46 : 0;
s.setPosition(GAME_WIDTH / 2, GAME_HEIGHT * 0.75 - hopH);
s.setAlpha(kart.rescueMs > 0 ? 0.25 : kart.invulnMs > 0 ? (Math.floor(this.raceState.timeMs / 80) % 2 ? 0.35 : 1) : 1);
if (kart.starMs > 0) s.setTint(starTint(this.raceState.timeMs));
else s.clearTint();
}
renderHud(state, player) {
this.hudLap.setText(`LAP ${Math.min(player.lap, state.laps)}/${state.laps}`);
const raceMs = Math.max(0, state.timeMs - COUNTDOWN_MS);
this.hudTimer.setText(fmtTime(raceMs));
this.hudCoins.setText(state.mode === 'tt' ? '' : `COINS ${player.coins}`);
if (player.rouletteMs > 0) {
this.hudItem.setVisible(true).setFrame(Math.floor(state.timeMs / 80) % 7);
} else if (player.item) {
this.hudItem.setVisible(true).setFrame(ITEM_FRAME[player.item] ?? 0);
} else {
this.hudItem.setVisible(false);
}
const pos = player.position;
this.hudPos.setText(String(pos));
this.hudPosSuffix.setText(['ST', 'ND', 'RD'][pos - 1] ?? 'TH');
const posColor = pos === 1 ? '#ffd028' : pos <= 4 ? '#f2f2f2' : '#e06c75';
this.hudPos.setColor(posColor);
this.hudPosSuffix.setColor(posColor);
this.hudPos.setVisible(state.mode !== 'tt');
this.hudPosSuffix.setVisible(state.mode !== 'tt');
// Minimap dots.
state.karts.forEach((kart, i) => {
const [mx, my] = this.minimapToMap(kart.x, kart.y);
this.minimapDots[i].setPosition(this.minimapOrigin.x + mx, this.minimapOrigin.y + my);
});
// Rankings.
if (state.mode !== 'tt') {
const order = [...state.karts].sort((a, b) => a.position - b.position);
order.forEach((kart, i) => {
const row = this.hudRank[i];
row.chip.setFillStyle(Phaser.Display.Color.HexStringToColor(kart.racer.color ?? '#fff').color);
const nm = (this.opponentById[kart.racer.id]?.name ?? kart.racer.id).slice(0, 12).toUpperCase();
row.name.setText(`${i + 1} ${nm}${kart.isPlayer ? ' ◄' : ''}`);
row.name.setColor(kart.isPlayer ? '#ffd028' : COLORS.textHex);
});
}
if (state.phase !== 'countdown' && this.hudLights) {
if (state.timeMs - COUNTDOWN_MS < 900) {
this.hudLights.forEach((l) => l.setFillStyle(0x38b048));
this.hudGo.setText('GO!');
} else {
this.hudLights.forEach((l) => l.setVisible(false));
this.hudGo.setText('');
}
}
}
renderDebug(state) {
const g = this.debugG;
g.clear();
const sc = 0.16;
const ox = GAME_WIDTH / 2 - (state.model.world * sc) / 2;
const oy = GAME_HEIGHT / 2 - (state.model.world * sc) / 2;
g.fillStyle(0x000000, 0.55);
g.fillRect(ox, oy, state.model.world * sc, state.model.world * sc);
g.lineStyle(2, 0xffffff, 0.8);
g.beginPath();
state.model.samples.forEach((p, i) => {
if (i) g.lineTo(ox + p.x * sc, oy + p.y * sc);
else g.moveTo(ox + p.x * sc, oy + p.y * sc);
});
g.closePath();
g.strokePath();
for (const kart of state.karts) {
const color = Phaser.Display.Color.HexStringToColor(kart.racer.color ?? '#fff').color;
g.fillStyle(color, 1);
g.fillCircle(ox + kart.x * sc, oy + kart.y * sc, kart.isPlayer ? 6 : 4);
g.lineStyle(1.5, color, 1);
g.lineBetween(ox + kart.x * sc, oy + kart.y * sc,
ox + (kart.x + Math.cos(kart.heading) * 120) * sc,
oy + (kart.y + Math.sin(kart.heading) * 120) * sc);
}
}
// ── Events → sound + speech ───────────────────────────────────────────────
processEvents(events) {
const state = this.raceState;
const pIdx = state.playerIndex;
for (const ev of events) {
switch (ev.type) {
case 'go': playSound(this, SFX.COUNTDOWN_GO); break;
case 'item-box': if (ev.kart === pIdx) playSound(this, SFX.UI_FLIP); break;
case 'item-get': if (ev.kart === pIdx) playSound(this, SFX.UI_CHIME); break;
case 'item-use': if (ev.kart === pIdx) playSound(this, SFX.WOOSH); break;
case 'boost': if (ev.kart === pIdx) playSound(this, SFX.SCIFI_WOOSH); break;
case 'star': playSound(this, SFX.EIGHTBIT_ACTIVATE); break;
case 'emp': playSound(this, SFX.LASER_ZAP); break;
case 'spin': if (ev.kart === pIdx) playSound(this, SFX.SQUISH); this.taunt(ev.kart, ev.kart === pIdx); break;
case 'wallhit': if (ev.kart === pIdx) playSound(this, SFX.EIGHTBIT_MOVE); break;
case 'bump': if (ev.a === pIdx || ev.b === pIdx) playSound(this, SFX.PIECE_CLICK); break;
case 'coin': if (ev.kart === pIdx) playSound(this, SFX.COINS); break;
case 'lap': if (ev.kart === pIdx) playSound(this, SFX.UI_CHIME); break;
case 'splash': if (ev.kart === pIdx) playSound(this, 'sfx-water-splash'); break;
case 'shatter': playSound(this, SFX.EIGHTBIT_EXPLODE); break;
case 'finish':
if (ev.kart === pIdx) playSound(this, ev.place <= 3 ? SFX.VICTORY_SHORT : SFX.CASINO_LOSE);
break;
case 'player-position':
if (ev.to > ev.from) this.tauntFromLeader(ev.to);
break;
default: break;
}
}
}
// A rival mouths off when the player gets passed / hit. Throttled hard.
taunt(kartIdx, playerWasVictim) {
if (!playerWasVictim || this.raceState.mode === 'tt') return;
this.tauntFromLeader(this.playerKartState().position);
}
tauntFromLeader(playerPos) {
const state = this.raceState;
if (state.timeMs - this.lastTauntMs < 10000) return;
const rival = state.karts.find((k) => !k.isPlayer && k.position === playerPos - 1);
const op = rival && this.opponentById[rival.racer.id];
const lines = op?.speech?.happy;
if (!lines?.length) return;
this.lastTauntMs = state.timeMs;
enqueueSpeech(lines[Math.floor(Math.random() * lines.length)]);
}
// ── Results / podium ──────────────────────────────────────────────────────
bestTimes(trackId, classId) {
try {
const all = JSON.parse(localStorage.getItem(TIMES_KEY) ?? '{}');
return all[`${trackId}|${classId}`] ?? [];
} catch (_) { return []; }
}
recordTime(trackId, classId, ms) {
try {
const all = JSON.parse(localStorage.getItem(TIMES_KEY) ?? '{}');
const key = `${trackId}|${classId}`;
const list = all[key] ?? [];
list.push(ms);
list.sort((a, b) => a - b);
all[key] = list.slice(0, 5);
localStorage.setItem(TIMES_KEY, JSON.stringify(all));
return list.indexOf(ms) === 0;
} catch (_) { return false; }
}
showResults() {
const state = this.raceState;
const results = finalizeRace(state);
const cls = this.rules.engineClasses[this.chosen.classIdx];
const player = this.playerKartState();
const cx = GAME_WIDTH / 2;
this.teardownRace();
this.clearView();
if (this.returnToEditor) {
this.text(cx, 240, 'TEST COMPLETE', 64, '#ffd028');
this.text(cx, 330, `TIME ${fmtTime(player.finishTimeMs - COUNTDOWN_MS)} BEST LAP ${fmtTime(player.bestLapMs)}`, 30);
this.vAdd(new Button(this, cx, 460, 'Back to Editor', () => this.scene.start('SuperKartEditor', { resume: true })));
return;
}
if (state.mode === 'tt') {
const ms = player.finishTimeMs - COUNTDOWN_MS;
const isRecord = this.recordTime(this.raceEntry.id, cls.id, ms);
this.text(cx, 180, isRecord ? 'NEW RECORD!' : 'RUN COMPLETE', 64, isRecord ? '#ffd028' : COLORS.textHex);
this.text(cx, 270, `TIME ${fmtTime(ms)} BEST LAP ${fmtTime(player.bestLapMs)}`, 34);
const times = this.bestTimes(this.raceEntry.id, cls.id);
this.text(cx, 360, 'BEST TIMES', 28, COLORS.mutedHex);
times.forEach((tms, i) => this.text(cx, 410 + i * 40, `${i + 1}. ${fmtTime(tms)}`, 26,
tms === ms ? '#ffd028' : COLORS.textHex));
this.vAdd(new Button(this, cx - 170, 760, 'Retry', () => this.startTimeTrial(this.raceEntry), { width: 220 }));
this.vAdd(new Button(this, cx + 170, 760, 'Tracks', () => this.showTrackSelect(), { width: 220, variant: 'ghost' }));
return;
}
// Grand Prix race results + cumulative standings.
for (const r of results) this.gp.points[r.racerId] += r.points;
const lastRace = this.gp.raceIdx >= this.gp.cup.tracks.length - 1;
this.text(cx, 110, `${this.raceEntry.name} — RESULTS`, 48, '#ffd028');
const header = (x, s) => this.text(x, 180, s, 22, COLORS.mutedHex);
header(cx - 420, 'POS');
header(cx - 240, 'RACER');
header(cx + 90, 'TIME');
header(cx + 260, 'PTS');
header(cx + 400, 'TOTAL');
results.forEach((r, i) => {
const y = 226 + i * 52;
const racer = this.racers.find((rc) => rc.id === r.racerId);
const isP = r.racerId === this.playerRacer().id;
const color = isP ? '#ffd028' : COLORS.textHex;
this.text(cx - 420, y, String(r.position), 28, color);
if (this.textures.exists('opponents')) {
this.vAdd(this.add.image(cx - 330, y, 'opponents', this.opponentById[r.racerId]?.spriteIndex ?? 0)
.setDisplaySize(44, 44).setDepth(D.view));
}
this.text(cx - 240, y, (this.opponentById[r.racerId]?.name ?? r.racerId).toUpperCase(), 26, color, { x: 0, y: 0.5 });
this.text(cx + 90, y, r.finished ? fmtTime(r.timeMs - COUNTDOWN_MS) : '—', 24, color);
this.text(cx + 260, y, `+${r.points}`, 24, color);
this.text(cx + 400, y, String(this.gp.points[r.racerId]), 26, color);
void racer;
});
this.vAdd(new Button(this, cx, GAME_HEIGHT - 90, lastRace ? 'Final Standings' : 'Next Race', () => {
if (lastRace) this.showPodium();
else { this.gp.raceIdx += 1; this.startCupRace(); }
}, { width: 340 }));
}
showPodium() {
this.clearView();
const cx = GAME_WIDTH / 2;
const standings = Object.entries(this.gp.points)
.sort((a, b) => b[1] - a[1]);
const playerId = this.playerRacer().id;
const playerPlace = standings.findIndex(([id]) => id === playerId) + 1;
this.text(cx, 100, `${this.gp.cup.name} — FINAL STANDINGS`, 48, '#ffd028');
this.text(cx, 170, playerPlace === 1 ? 'CHAMPION!' : `YOU FINISHED ${ordinal(playerPlace).toUpperCase()}`, 34,
playerPlace === 1 ? '#ffd028' : COLORS.textHex);
// Podium top 3 with live portraits (video/mood if assets exist).
const podX = [cx, cx - 330, cx + 330];
const podY = [340, 400, 430];
standings.slice(0, 3).forEach(([id], i) => {
const op = this.opponentById[id];
if (op) {
const portrait = createOpponentPortrait(this, op, podX[i], podY[i], i === 0 ? 110 : 85, D.view, { playIntro: false });
this.viewObjs.push(portrait);
}
this.text(podX[i], podY[i] + (i === 0 ? 150 : 125), `${i + 1}. ${(op?.name ?? id).toUpperCase()}`, 28,
id === playerId ? '#ffd028' : COLORS.textHex);
this.text(podX[i], podY[i] + (i === 0 ? 186 : 161), `${this.gp.points[id]} PTS`, 22, COLORS.mutedHex);
});
const winner = this.opponentById[standings[0][0]];
const clip = winner?.speech?.happy?.[0];
if (clip) { resetQueue(); enqueueSpeech(clip); }
standings.slice(3).forEach(([id, pts], i) => {
this.text(cx - 200 + Math.floor(i / 3) * 400, 660 + (i % 3) * 40,
`${i + 4}. ${(this.opponentById[id]?.name ?? id).toUpperCase()} ${pts} PTS`, 22,
id === playerId ? '#ffd028' : COLORS.mutedHex);
});
playSound(this, playerPlace === 1 ? SFX.CASINO_WIN : SFX.VICTORY_SHORT);
this.vAdd(new Button(this, cx - 180, GAME_HEIGHT - 70, 'Race Again', () => this.showCupSelect(), { width: 260 }));
this.vAdd(new Button(this, cx + 180, GAME_HEIGHT - 70, 'Main Menu', () => this.showMenu(), { width: 260, variant: 'ghost' }));
}
}
// ── Small helpers ─────────────────────────────────────────────────────────
function fmtTime(ms) {
if (!ms || ms < 0) ms = 0;
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
const cs = Math.floor((ms % 1000) / 10);
return `${m}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
}
function ordinal(n) {
return `${n}${['st', 'nd', 'rd'][n - 1] ?? 'th'}`;
}
function starTint(timeMs) {
const hues = [0xffd028, 0xff6a90, 0x68d0ff, 0x8aff78];
return hues[Math.floor(timeMs / 90) % hues.length];
}

View File

@ -0,0 +1,820 @@
// Super Kart headless race simulation. Pure ESM with no Phaser imports so
// tools/verifySuperKart.js can soak-test full races in Node. All tuning comes
// from data/superkart-rules.json; all track geometry from SuperKartTrack.js.
//
// The scene drives it at a fixed 60Hz:
// const state = createRace({ trackModel, rules, engineClass, racers, playerIndex, mode, seed });
// step(state, playerInputs); // playerInputs from kartInputsNeutral()
// ...then reads state.karts / state.events for rendering.
import {
SURFACE, surfaceAt, projectToSplineNear, normAngle,
} from './SuperKartTrack.js';
const TAU = Math.PI * 2;
export const STEP_MS = 1000 / 60;
export const COUNTDOWN_MS = 3400;
export function mulberry32(seed) {
let a = seed >>> 0;
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;
};
}
export function kartInputsNeutral() {
return { steer: 0, accel: false, brake: false, hop: false, item: false };
}
// ── Stat formulas (exported for the verify script and select-screen bars) ───
export function topSpeedOf(stats, physics, classMult = 1) {
return (physics.baseTopSpeed + (stats.topSpeed - 0.5) * physics.topSpeedSpread) * classMult;
}
export function accelOf(stats, physics) {
return physics.baseAccel + (stats.accel - 0.5) * physics.accelSpread;
}
export function turnRateOf(stats, physics, speedFrac) {
const base = physics.turnRateBase + (stats.handling - 0.5) * physics.turnRateSpread;
return base * (1 - physics.speedTurnPenalty * Math.min(1, Math.max(0, speedFrac)));
}
export function offroadMultOf(stats, physics) {
return physics.offroadMultMin + (physics.offroadMultMax - physics.offroadMultMin) * stats.offroad;
}
export function surfaceMult(code, stats, physics) {
if (code === SURFACE.OFFROAD) return offroadMultOf(stats, physics);
if (code === SURFACE.DEEP) return physics.deepMult;
return 1; // road, curb, and boost pads are all full speed
}
export function pointsFor(position, rules) {
return rules.pointsTable[Math.min(position - 1, rules.pointsTable.length - 1)] ?? 0;
}
// Position-weighted item roulette. position is 1-based.
export function rollItem(rng, position, rules) {
const col = Math.max(0, Math.min(8, position - 1));
const table = rules.itemWeights.byPosition;
const ids = rules.items.map((it) => it.id).filter((id) => table[id]);
let total = 0;
for (const id of ids) total += table[id][col];
let roll = rng() * total;
for (const id of ids) {
roll -= table[id][col];
if (roll <= 0) return id;
}
return ids[ids.length - 1];
}
// ── Race construction ───────────────────────────────────────────────────────
function makeKart(index, racer, isPlayer, slot, physics) {
return {
index,
racer,
isPlayer,
aiSkill: racer.aiSkill ?? 0.5,
x: slot.x,
y: slot.y,
heading: slot.heading,
velAngle: slot.heading, // velocity direction chases heading (drift feel)
speed: 0,
hopMs: 0,
hopCooldown: 0,
airborne: false,
drifting: false,
driftDir: 0,
prevHop: false,
prevItem: false,
surface: SURFACE.ROAD,
offroadMs: 0,
spinMs: 0,
spinSpin: 0, // visual rotation accumulator while spinning
squashMs: 0,
invulnMs: 0,
boostMs: 0,
boostMult: 1,
starMs: 0,
empMs: 0,
wallHitCooldown: 0,
coins: 0,
item: null,
rouletteMs: 0,
lap: 1,
cpIndex: 0,
splineS: slot.s, // continuity anchor for windowed spline projection
sRel: 0,
progress: 0,
position: index + 1,
finished: false,
finishTimeMs: 0,
lapStartMs: 0,
bestLapMs: 0,
rescueMs: 0,
stuckMs: 0,
reverseMs: 0,
deepMs: 0,
aiLane: 0,
aiLaneLap: 0,
aiItemDelay: 0,
radius: physics.kartRadius,
};
}
// racers: array of racer defs (from superkart-racers.json), one per grid slot,
// in grid order. playerIndex marks which of them the human drives (-1 = none,
// e.g. verify soaks). mode 'tt' skips AI karts entirely.
export function createRace({ trackModel, rules, engineClass, racers, playerIndex = 0, mode = 'gp', seed = 1 }) {
const rng = mulberry32(seed);
const physics = rules.physics;
const roster = mode === 'tt' ? [racers[playerIndex]] : racers;
const pIdx = mode === 'tt' ? 0 : playerIndex;
const state = {
mode,
rules,
physics,
engineClass,
model: trackModel,
laps: trackModel.laps,
rng,
seed,
tick: 0,
timeMs: 0,
phase: 'countdown',
countdownMs: COUNTDOWN_MS,
playerIndex: pIdx,
karts: roster.map((r, i) => makeKart(i, r, i === pIdx, trackModel.gridSlots[i], physics)),
itemBoxes: trackModel.itemBoxes.map((b) => ({ x: b.x, y: b.y, respawnAt: 0 })),
coins: trackModel.coins.map((c) => ({ x: c.x, y: c.y, taken: false })),
hazardsDropped: [],
projectiles: [],
finishOrder: [],
events: [],
};
for (const k of state.karts) {
k.aiLane = (rng() * 2 - 1) * 0.5;
k.aiItemDelay = 400 + rng() * 1200;
}
return state;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function emit(state, type, data) {
state.events.push({ type, ...data });
}
function classSpeedMult(state) { return state.engineClass?.speedMult ?? 1; }
function effTopSpeed(state, kart) {
const { physics } = state;
let mult = classSpeedMult(state);
mult *= 1 + kart.coins * physics.coinTopSpeedBonus;
if (kart.starMs > 0) mult *= itemParams(state, 'overdrive').mult ?? 1.2;
if (kart.empMs > 0) mult *= itemParams(state, 'emp').mult ?? 0.6;
if (kart.squashMs > 0) mult *= physics.squashSpeedMult;
if (!kart.airborne && kart.starMs <= 0) {
mult *= surfaceMult(kart.surface, kart.racer.stats, physics);
}
if (kart.boostMs > 0) mult *= kart.boostMult;
return topSpeedOf(kart.racer.stats, physics, mult);
}
function itemParams(state, id) {
return state.rules.items.find((it) => it.id === id)?.params ?? {};
}
function respawnAtCheckpoint(state, kart) {
const cps = state.model.checkpoints;
const cp = cps[kart.cpIndex % cps.length];
kart.x = cp.x;
kart.y = cp.y;
kart.heading = Math.atan2(cp.ty, cp.tx);
kart.velAngle = kart.heading;
kart.speed = 0;
kart.drifting = false;
kart.airborne = false;
kart.hopMs = 0;
kart.offroadMs = 0;
kart.deepMs = 0;
kart.splineS = cp.s;
kart.invulnMs = state.physics.invulnMs;
}
function startSpin(state, kart, cause) {
if (kart.invulnMs > 0 || kart.starMs > 0 || kart.spinMs > 0) return false;
kart.spinMs = state.physics.spinMs;
kart.spinSpin = 0;
kart.drifting = false;
const dropped = Math.min(kart.coins, state.physics.coinsLostOnHit);
kart.coins -= dropped;
emit(state, 'spin', { kart: kart.index, cause });
return true;
}
// ── AI ──────────────────────────────────────────────────────────────────────
function aiSkillOf(state, kart) {
return Math.min(1, kart.aiSkill * (state.engineClass?.aiSkillMult ?? 1));
}
function computeAiInputs(state, kart) {
const inputs = kartInputsNeutral();
if (kart.finished && state.phase === 'finished') return inputs;
const { model, physics } = state;
const skill = aiSkillOf(state, kart);
const idx = Math.round(kart.splineS / model.step) % model.samples.length;
const top = effTopSpeed(state, kart);
const speedFrac = top > 0 ? kart.speed / top : 0;
const lookahead = Math.round(
physics.aiLookaheadMin + (physics.aiLookaheadMax - physics.aiLookaheadMin)
* Math.min(1, speedFrac) * (0.7 + 0.3 * skill),
);
const target = model.samples[(idx + Math.max(2, lookahead)) % model.samples.length];
const tx = target.x + -target.ty * kart.aiLane * target.w * 0.8;
const ty = target.y + target.tx * kart.aiLane * target.w * 0.8;
let want = Math.atan2(ty - kart.y, tx - kart.x);
if (kart.reverseMs > 0) {
// Backing out of a stuck spot: reverse while steering toward the target.
inputs.brake = true;
inputs.steer = Math.max(-1, Math.min(1, -normAngle(want - kart.heading)));
return inputs;
}
// Steering noise shrinks with skill.
want += (state.rng() * 2 - 1) * 0.06 * (1 - skill);
inputs.steer = Math.max(-1, Math.min(1, physics.aiSteerGain * normAngle(want - kart.heading)));
// Brake when the curvature over the lookahead window outruns our speed.
let curv = 0;
for (let i = 0; i < lookahead; i += 1) {
const a = model.samples[(idx + i) % model.samples.length];
const b = model.samples[(idx + i + 1) % model.samples.length];
curv += Math.abs(normAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)));
}
const curvPerUnit = curv / Math.max(1, lookahead * model.step);
const limit = physics.aiBrakeCurvature * (0.8 + 0.6 * skill);
inputs.accel = true;
if (curvPerUnit * kart.speed > limit) {
inputs.accel = false;
if (curvPerUnit * kart.speed > limit * 1.6) inputs.brake = true;
}
// Item use.
if (kart.item && kart.rouletteMs <= 0) {
kart.aiItemDelay -= STEP_MS;
if (kart.aiItemDelay <= 0) {
inputs.item = aiWantsItem(state, kart);
if (inputs.item) kart.aiItemDelay = 600 + state.rng() * 1400;
}
}
return inputs;
}
function aiWantsItem(state, kart) {
const straight = isOnStraight(state, kart);
switch (kart.item) {
case 'bolt':
case 'seeker': {
const target = nextKartAhead(state, kart);
if (!target) return kart.item === 'bolt' && state.rng() < 0.002;
const dist = Math.hypot(target.x - kart.x, target.y - kart.y);
return dist < 700;
}
case 'oil': return straight && state.rng() < 0.02;
case 'turbo': return straight && kart.boostMs <= 0;
case 'overdrive': return true;
case 'emp': return kart.position > 3;
case 'coins': return true;
default: return false;
}
}
function isOnStraight(state, kart) {
const { model } = state;
const idx = Math.round(kart.splineS / model.step) % model.samples.length;
let curv = 0;
for (let i = 0; i < 6; i += 1) {
const a = model.samples[(idx + i) % model.samples.length];
const b = model.samples[(idx + i + 1) % model.samples.length];
curv += Math.abs(normAngle(Math.atan2(b.ty, b.tx) - Math.atan2(a.ty, a.tx)));
}
return curv < 0.25;
}
function nextKartAhead(state, kart) {
let best = null;
for (const other of state.karts) {
if (other === kart || other.finished) continue;
if (other.progress <= kart.progress) continue;
if (!best || other.progress < best.progress) best = other;
}
return best;
}
// ── Per-kart physics step ───────────────────────────────────────────────────
function stepKart(state, kart, inputs, dt) {
const { physics, model } = state;
for (const key of ['spinMs', 'squashMs', 'invulnMs', 'boostMs', 'starMs', 'empMs', 'hopCooldown', 'wallHitCooldown']) {
if (kart[key] > 0) kart[key] = Math.max(0, kart[key] - STEP_MS);
}
if (kart.rescueMs > 0) {
kart.rescueMs -= STEP_MS;
if (kart.rescueMs <= 0) respawnAtCheckpoint(state, kart);
return;
}
const locked = state.phase === 'countdown' || kart.spinMs > 0;
const steer = locked ? 0 : Math.max(-1, Math.min(1, inputs.steer || 0));
const accel = locked ? false : !!inputs.accel;
const brake = locked ? false : !!inputs.brake;
// Hop / powerslide.
if (!locked && inputs.hop && !kart.prevHop && kart.hopMs <= 0 && kart.hopCooldown <= 0 && !kart.airborne) {
kart.hopMs = physics.hopMs;
kart.airborne = true;
kart.hopCooldown = physics.hopMs + physics.hopCooldownMs;
if (kart.drifting) kart.drifting = false;
emit(state, 'hop', { kart: kart.index });
}
kart.prevHop = !!inputs.hop;
if (kart.airborne) {
kart.hopMs -= STEP_MS;
if (kart.hopMs <= 0) {
kart.airborne = false;
// Landing with hop held and a direction committed starts the powerslide.
if (inputs.hop && steer !== 0 && kart.speed > effTopSpeed(state, kart) * 0.3) {
kart.drifting = true;
kart.driftDir = Math.sign(steer);
emit(state, 'drift-start', { kart: kart.index });
}
}
}
if (kart.drifting) {
const top = effTopSpeed(state, kart);
if (!inputs.hop || kart.speed < top * 0.25 || (steer !== 0 && Math.sign(steer) !== kart.driftDir)) {
kart.drifting = false;
emit(state, 'drift-end', { kart: kart.index });
}
}
// Longitudinal speed.
const top = effTopSpeed(state, kart);
if (kart.spinMs > 0) {
kart.speed = Math.max(0, kart.speed - physics.brakeDecel * 0.8 * dt);
kart.spinSpin += dt * 9;
} else if (accel && !brake) {
kart.speed += accelOf(kart.racer.stats, physics) * classSpeedMult(state) * dt;
} else if (brake) {
kart.speed -= physics.brakeDecel * dt;
if (kart.speed < 0) kart.speed = Math.max(kart.speed, -physics.reverseTopSpeed);
} else {
kart.speed -= kart.speed * physics.coastDrag * dt;
}
if (kart.speed > top) {
// Ease down toward the cap (boost expiry / entering grass) instead of snapping.
kart.speed = Math.max(top, kart.speed - physics.brakeDecel * 1.5 * dt);
}
if (kart.speed < -physics.reverseTopSpeed) kart.speed = -physics.reverseTopSpeed;
// Steering. Drifting multiplies the turn rate in the committed direction.
if (kart.spinMs <= 0 && Math.abs(kart.speed) > 1) {
const baseTop = topSpeedOf(kart.racer.stats, physics, classSpeedMult(state));
let rate = turnRateOf(kart.racer.stats, physics, Math.abs(kart.speed) / baseTop);
if (kart.drifting && Math.sign(steer || kart.driftDir) === kart.driftDir) {
rate *= physics.driftTurnMult;
}
const dir = kart.speed >= 0 ? 1 : -1;
kart.heading = normAngle(kart.heading + steer * rate * dir * dt);
}
// Velocity direction chases the heading; grip is what separates a clean
// line from a powerslide. High drift stat = tighter, more controlled slides.
const grip = kart.drifting
? 2.2 + 3.2 * kart.racer.stats.drift
: physics.slipDecay + 5.5;
kart.velAngle = normAngle(kart.velAngle + normAngle(kart.heading - kart.velAngle) * Math.min(1, grip * dt));
// Integrate + wall handling (axis-separated slide).
const vx = Math.cos(kart.velAngle) * kart.speed * dt;
const vy = Math.sin(kart.velAngle) * kart.speed * dt;
const blocked = (x, y) => {
const c = surfaceAt(model, x, y);
return c === SURFACE.WALL || c === SURFACE.OOB;
};
if (!blocked(kart.x + vx, kart.y + vy)) {
kart.x += vx;
kart.y += vy;
} else if (!blocked(kart.x + vx, kart.y)) {
kart.x += vx;
hitWall(state, kart);
} else if (!blocked(kart.x, kart.y + vy)) {
kart.y += vy;
hitWall(state, kart);
} else {
hitWall(state, kart);
kart.speed = 0;
}
// Surface effects.
kart.surface = surfaceAt(model, kart.x, kart.y);
if (!kart.airborne) {
if (kart.surface === SURFACE.BOOST && kart.boostMs <= 0) {
kart.boostMs = physics.boostPadMs;
kart.boostMult = physics.boostPadMult;
emit(state, 'boost', { kart: kart.index, pad: true });
}
if (kart.surface === SURFACE.WATER) {
if (kart.rescueMs <= 0) {
kart.rescueMs = physics.respawnMs;
emit(state, 'splash', { kart: kart.index });
}
} else if (kart.surface === SURFACE.DEEP) {
kart.deepMs += STEP_MS;
if (kart.deepMs > 700 && kart.rescueMs <= 0) {
kart.deepMs = 0;
kart.rescueMs = physics.respawnMs;
emit(state, 'sink', { kart: kart.index });
}
} else {
kart.deepMs = 0;
}
if (kart.surface === SURFACE.OFFROAD) kart.offroadMs += STEP_MS;
else kart.offroadMs = 0;
if (kart.offroadMs > physics.offroadRescueMs) {
kart.offroadMs = 0;
kart.rescueMs = physics.respawnMs;
emit(state, 'rescue', { kart: kart.index });
}
}
// Stuck detection feeds the AI reverse-out behavior.
if (!kart.isPlayer) {
if (Math.abs(kart.speed) < 25 && state.phase === 'racing' && kart.spinMs <= 0 && !kart.finished) {
kart.stuckMs += STEP_MS;
} else kart.stuckMs = 0;
if (kart.reverseMs > 0) kart.reverseMs -= STEP_MS;
if (kart.stuckMs > physics.aiStuckMs && kart.reverseMs <= 0) {
kart.stuckMs = 0;
kart.reverseMs = physics.aiReverseMs;
kart.reverseCount = (kart.reverseCount ?? 0) + 1;
if (kart.reverseCount % 3 === 0) {
// Three failed reverse-outs: give up and take the rescue.
kart.rescueMs = physics.respawnMs;
emit(state, 'rescue', { kart: kart.index });
}
}
}
// Lap / checkpoint / progress tracking (windowed projection keeps pinched
// track sections from flipping a kart onto the wrong branch).
const proj = projectToSplineNear(model, kart.x, kart.y, kart.splineS);
kart.splineS = proj.s;
const L = model.totalLength;
const sRel = ((proj.s - model.startS) % L + L) % L;
kart.sRel = sRel;
const cps = model.checkpoints.length;
const cpSize = L / cps;
const cp = Math.floor(sRel / cpSize) % cps;
if (cp === (kart.cpIndex + 1) % cps) {
kart.cpIndex = cp;
if (cp === 0) {
kart.lap += 1;
const lapMs = state.timeMs - kart.lapStartMs;
if (kart.bestLapMs === 0 || lapMs < kart.bestLapMs) kart.bestLapMs = lapMs;
kart.lapStartMs = state.timeMs;
if (kart.lap > state.laps && !kart.finished) {
kart.finished = true;
kart.finishTimeMs = state.timeMs;
state.finishOrder.push(kart.index);
emit(state, 'finish', { kart: kart.index, place: state.finishOrder.length });
} else if (!kart.finished) {
emit(state, 'lap', { kart: kart.index, lap: kart.lap });
}
}
}
const within = Math.max(0, Math.min(sRel - kart.cpIndex * cpSize, 2 * cpSize));
kart.progress = (kart.lap - 1) * L + kart.cpIndex * cpSize + within;
// Item pickup + roulette.
if (kart.rouletteMs > 0) {
kart.rouletteMs -= STEP_MS;
if (kart.rouletteMs <= 0) {
kart.item = rollItem(state.rng, kart.position, state.rules);
emit(state, 'item-get', { kart: kart.index, item: kart.item });
}
} else if (!kart.item && state.mode !== 'tt') {
for (const box of state.itemBoxes) {
if (box.respawnAt > state.timeMs) continue;
const dx = box.x - kart.x;
const dy = box.y - kart.y;
const r = physics.itemBoxRadius + kart.radius;
if (dx * dx + dy * dy < r * r) {
box.respawnAt = state.timeMs + physics.itemBoxRespawnMs;
kart.rouletteMs = physics.rouletteMs;
emit(state, 'item-box', { kart: kart.index });
break;
}
}
}
// Coins.
for (const coin of state.coins) {
if (coin.taken || kart.coins >= physics.maxCoins) continue;
const dx = coin.x - kart.x;
const dy = coin.y - kart.y;
const r = physics.coinPickupRadius;
if (dx * dx + dy * dy < r * r) {
coin.taken = true;
kart.coins += 1;
emit(state, 'coin', { kart: kart.index });
}
}
// Dropped hazards (oil slicks).
for (const hz of state.hazardsDropped) {
if (hz.dead) continue;
if (hz.owner === kart.index && hz.armMs > 0) continue; // just dropped it
const dx = hz.x - kart.x;
const dy = hz.y - kart.y;
const r = hz.radius + kart.radius;
if (dx * dx + dy * dy < r * r && startSpin(state, kart, 'oil')) hz.dead = true;
}
// Item use (edge-triggered).
if (inputs.item && !kart.prevItem && kart.item && kart.rouletteMs <= 0 && !locked) {
useItem(state, kart);
}
kart.prevItem = !!inputs.item;
}
function hitWall(state, kart) {
if (kart.wallHitCooldown > 0) return;
kart.wallHitCooldown = 250;
kart.speed *= 1 - state.physics.wallBumpSpeedLoss;
emit(state, 'wallhit', { kart: kart.index });
}
// ── Items ───────────────────────────────────────────────────────────────────
function useItem(state, kart) {
const id = kart.item;
const params = itemParams(state, id);
kart.item = null;
emit(state, 'item-use', { kart: kart.index, item: id });
switch (id) {
case 'bolt':
case 'seeker': {
const target = id === 'seeker' ? nextKartAhead(state, kart) : null;
state.projectiles.push({
id,
owner: kart.index,
x: kart.x + Math.cos(kart.heading) * (kart.radius + 12),
y: kart.y + Math.sin(kart.heading) * (kart.radius + 12),
heading: kart.heading,
speed: params.speed ?? 850,
bounces: params.bounces ?? 0,
homing: !!params.homing,
target: target ? target.index : -1,
lifeMs: params.lifeMs ?? 7000,
radius: params.radius ?? 10,
graceMs: 350, // can't hit its own thrower immediately
dead: false,
});
break;
}
case 'oil':
state.hazardsDropped.push({
id: 'oil',
owner: kart.index,
x: kart.x - Math.cos(kart.heading) * (kart.radius + 16),
y: kart.y - Math.sin(kart.heading) * (kart.radius + 16),
radius: params.radius ?? 18,
armMs: 600,
dead: false,
});
break;
case 'turbo':
kart.boostMs = params.ms ?? 1400;
kart.boostMult = params.mult ?? 1.5;
emit(state, 'boost', { kart: kart.index, pad: false });
break;
case 'overdrive':
kart.starMs = params.ms ?? 7000;
emit(state, 'star', { kart: kart.index });
break;
case 'emp': {
for (const other of state.karts) {
if (other === kart || other.finished) continue;
if (other.starMs > 0 || other.invulnMs > 0) continue;
other.empMs = (params.ms ?? 4200) + other.racer.stats.weight * (params.weightExtraMs ?? 1600);
other.drifting = false;
}
emit(state, 'emp', { kart: kart.index });
break;
}
case 'coins':
kart.coins = Math.min(state.physics.maxCoins, kart.coins + (params.coins ?? 2));
emit(state, 'coin', { kart: kart.index, pack: true });
break;
default: break;
}
}
function stepProjectiles(state, dt) {
const { model } = state;
for (const p of state.projectiles) {
if (p.dead) continue;
p.lifeMs -= STEP_MS;
if (p.graceMs > 0) p.graceMs -= STEP_MS;
if (p.lifeMs <= 0) { p.dead = true; continue; }
if (p.homing && p.target >= 0) {
const t = state.karts[p.target];
if (t && !t.finished) {
const want = Math.atan2(t.y - p.y, t.x - p.x);
const rate = itemParams(state, 'seeker').homingTurnRate ?? 3.2;
const diff = normAngle(want - p.heading);
p.heading = normAngle(p.heading + Math.max(-rate * dt, Math.min(rate * dt, diff)));
}
}
const nx = p.x + Math.cos(p.heading) * p.speed * dt;
const ny = p.y + Math.sin(p.heading) * p.speed * dt;
const code = surfaceAt(model, nx, ny);
if (code === SURFACE.WALL || code === SURFACE.OOB) {
if (p.bounces > 0) {
p.bounces -= 1;
// Reflect off whichever axis is blocked (cheap but effective on the grid).
const xBlocked = [SURFACE.WALL, SURFACE.OOB].includes(surfaceAt(model, nx, p.y));
const yBlocked = [SURFACE.WALL, SURFACE.OOB].includes(surfaceAt(model, p.x, ny));
if (xBlocked) p.heading = normAngle(Math.PI - p.heading);
if (yBlocked || (!xBlocked && !yBlocked)) p.heading = normAngle(-p.heading);
emit(state, 'bounce', { x: p.x, y: p.y });
} else {
p.dead = true;
emit(state, 'shatter', { x: p.x, y: p.y });
continue;
}
} else {
p.x = nx;
p.y = ny;
}
for (const kart of state.karts) {
if (kart.finished) continue;
if (p.graceMs > 0 && kart.index === p.owner) continue;
const dx = kart.x - p.x;
const dy = kart.y - p.y;
const r = kart.radius + p.radius;
if (dx * dx + dy * dy < r * r) {
p.dead = true;
if (kart.starMs > 0 || kart.invulnMs > 0) emit(state, 'shatter', { x: p.x, y: p.y });
else startSpin(state, kart, p.id);
break;
}
}
}
if (state.tick % 60 === 0) {
state.projectiles = state.projectiles.filter((p) => !p.dead);
state.hazardsDropped = state.hazardsDropped.filter((h) => !h.dead);
}
for (const hz of state.hazardsDropped) if (hz.armMs > 0) hz.armMs -= STEP_MS;
}
// Kart-vs-kart bumping: separation shared by inverse weight, plus a shove.
function stepCollisions(state) {
const { karts, physics } = state;
for (let i = 0; i < karts.length; i += 1) {
for (let j = i + 1; j < karts.length; j += 1) {
const a = karts[i];
const b = karts[j];
if (a.finished || b.finished || a.rescueMs > 0 || b.rescueMs > 0) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const minDist = a.radius + b.radius;
const d2 = dx * dx + dy * dy;
if (d2 >= minDist * minDist || d2 === 0) continue;
const d = Math.sqrt(d2);
const nx = dx / d;
const ny = dy / d;
const overlap = minDist - d;
const wa = 0.2 + a.racer.stats.weight;
const wb = 0.2 + b.racer.stats.weight;
const shareA = wb / (wa + wb);
const shareB = wa / (wa + wb);
a.x -= nx * overlap * shareA;
a.y -= ny * overlap * shareA;
b.x += nx * overlap * shareB;
b.y += ny * overlap * shareB;
// The lighter kart's velocity direction gets knocked away from the
// contact normal (a → b); the heavier one barely notices.
const kick = physics.bumpImpulse / 600;
const away = (kart, sign) => {
const cross = Math.cos(kart.velAngle) * ny * sign - Math.sin(kart.velAngle) * nx * sign;
return Math.sign(cross || 1);
};
a.velAngle = normAngle(a.velAngle + away(a, -1) * kick * shareA);
b.velAngle = normAngle(b.velAngle + away(b, 1) * kick * shareB);
// Star karts flatten whoever they touch.
if (a.starMs > 0 && b.starMs <= 0) startSpin(state, b, 'star');
else if (b.starMs > 0 && a.starMs <= 0) startSpin(state, a, 'star');
// A heavy kart running over an EMP-shrunk one squashes it.
if (a.empMs > 0 && b.empMs <= 0 && b.squashMs <= 0 && a.squashMs <= 0) a.squashMs = physics.squashMs;
else if (b.empMs > 0 && a.empMs <= 0 && a.squashMs <= 0 && b.squashMs <= 0) b.squashMs = physics.squashMs;
emit(state, 'bump', { a: a.index, b: b.index });
}
}
}
function updatePositions(state) {
const order = [...state.karts].sort((a, b) => {
const af = a.finished ? state.finishOrder.indexOf(a.index) : Infinity;
const bf = b.finished ? state.finishOrder.indexOf(b.index) : Infinity;
if (af !== bf) return af - bf;
return b.progress - a.progress;
});
const prevPlayerPos = state.karts[state.playerIndex]?.position;
order.forEach((kart, i) => { kart.position = i + 1; });
const nowPlayerPos = state.karts[state.playerIndex]?.position;
if (prevPlayerPos !== undefined && nowPlayerPos !== prevPlayerPos && state.phase === 'racing') {
emit(state, 'player-position', { from: prevPlayerPos, to: nowPlayerPos });
}
}
// ── Public step ─────────────────────────────────────────────────────────────
export function step(state, playerInputs = kartInputsNeutral()) {
state.events = [];
const dt = STEP_MS / 1000;
state.tick += 1;
state.timeMs += STEP_MS;
if (state.phase === 'countdown') {
state.countdownMs -= STEP_MS;
if (state.countdownMs <= 0) {
state.phase = 'racing';
for (const k of state.karts) k.lapStartMs = state.timeMs;
emit(state, 'go', {});
}
}
for (const kart of state.karts) {
// Fresh racing line each lap keeps the AI field from single-filing.
if (!kart.isPlayer && kart.lap !== kart.aiLaneLap) {
kart.aiLaneLap = kart.lap;
kart.aiLane = (state.rng() * 2 - 1) * 0.5;
}
const inputs = kart.isPlayer ? playerInputs : computeAiInputs(state, kart);
stepKart(state, kart, inputs, dt);
}
stepCollisions(state);
stepProjectiles(state, dt);
updatePositions(state);
if (state.phase === 'racing') {
const player = state.karts[state.playerIndex];
if (state.mode === 'tt') {
if (player?.finished) state.phase = 'finished';
} else if (state.karts.every((k) => k.finished)) {
state.phase = 'finished';
} else if (player?.finished) {
// Player done: let the field coast briefly, then call it.
state.postFinishMs = (state.postFinishMs ?? 0) + STEP_MS;
if (state.postFinishMs > 3500) state.phase = 'finished';
}
}
return state;
}
// Final standings. Unfinished karts are ranked by progress and given
// extrapolated finish times so the results table always fills in.
export function finalizeRace(state) {
const ranked = [...state.karts].sort((a, b) => {
const af = a.finished ? state.finishOrder.indexOf(a.index) : Infinity;
const bf = b.finished ? state.finishOrder.indexOf(b.index) : Infinity;
if (af !== bf) return af - bf;
return b.progress - a.progress;
});
return ranked.map((kart, i) => ({
kartIndex: kart.index,
racerId: kart.racer.id,
position: i + 1,
finished: kart.finished,
timeMs: kart.finished ? kart.finishTimeMs : Math.round(state.timeMs + (i * 800)),
bestLapMs: kart.bestLapMs,
points: pointsFor(i + 1, state.rules),
}));
}

View File

@ -0,0 +1,189 @@
// Super Kart Mode 7 renderer: a Phaser Shader GameObject covering the
// below-horizon band, sampling the rasterized top-down track texture with the
// classic per-pixel inverse projection, plus the matching world→screen sprite
// projector. The ground shader and the projector share ONE tuning object so
// they can never disagree about where the ground is.
//
// WebGL only — SuperKartGame shows a notice card on the Canvas renderer.
// Debug aids (wired to keys in the scene): setFlipY() flips the quad's y
// orientation and a checkerboard texture can be substituted for the track.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
const TAU = Math.PI * 2;
// One source of truth for the projection. horizonFrac is the fraction of the
// screen above the ground plane; focal/camHeight set how "low" the camera
// feels; followDist puts the player kart near the lower third of the screen.
export const MODE7 = {
horizonFrac: 0.40,
focal: 330,
camHeight: 40,
followDist: 42,
nearZ: 6,
farZ: 1700,
fogStart: 950,
fogEnd: 1650,
camLerp: 0.12, // per-frame heading smoothing
spriteBase: 34, // world size (units) a 1.0-scale 64px sprite frame represents
};
const FRAG = `
precision mediump float;
uniform sampler2D iChannel0;
uniform vec2 uQuad;
uniform vec2 uCam;
uniform float uAngle;
uniform float uCamH;
uniform float uFocal;
uniform float uWorld;
uniform vec3 uFog;
uniform vec2 uFogRange;
uniform float uFlipY;
varying vec2 fragCoord;
void main () {
vec2 pc = fragCoord;
float py = mix(pc.y, uQuad.y - pc.y, uFlipY);
py = max(py, 0.75);
float z = uCamH * uFocal / py;
float xoff = (pc.x - uQuad.x * 0.5) * z / uFocal;
vec2 fwd = vec2(cos(uAngle), sin(uAngle));
vec2 rgt = vec2(-fwd.y, fwd.x);
vec2 world = uCam + fwd * z + rgt * xoff;
vec2 uv = world / uWorld;
vec3 col;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
col = uFog;
} else {
col = texture2D(iChannel0, vec2(uv.x, 1.0 - uv.y)).rgb;
}
float fog = smoothstep(uFogRange.x, uFogRange.y, z);
gl_FragColor = vec4(mix(col, uFog, fog), 1.0);
}
`;
export class SuperKartMode7 {
// trackTextureKey must already exist (scene.textures.addCanvas result).
// fogColor: {r,g,b} 0..1 floats matching the theme's fog hex.
constructor(scene, { trackTextureKey, worldSize, fogColor, depthBase = 0 }) {
this.scene = scene;
this.worldSize = worldSize;
this.horizonY = Math.round(GAME_HEIGHT * MODE7.horizonFrac);
const groundH = GAME_HEIGHT - this.horizonY;
this.cam = { x: worldSize / 2, y: worldSize / 2, angle: 0 };
const base = new Phaser.Display.BaseShader('SuperKartMode7', FRAG, undefined, {
uQuad: { type: '2f', value: { x: GAME_WIDTH, y: groundH } },
uCam: { type: '2f', value: { x: this.cam.x, y: this.cam.y } },
uAngle: { type: '1f', value: 0 },
uCamH: { type: '1f', value: MODE7.camHeight },
uFocal: { type: '1f', value: MODE7.focal },
uWorld: { type: '1f', value: worldSize },
uFog: { type: '3f', value: fogColor },
uFogRange: { type: '2f', value: { x: MODE7.fogStart, y: MODE7.fogEnd } },
uFlipY: { type: '1f', value: 1 },
});
this.ground = scene.add.shader(base, GAME_WIDTH / 2, this.horizonY + groundH / 2,
GAME_WIDTH, groundH, [trackTextureKey]);
this.ground.setDepth(depthBase);
}
// Follow camera: smoothly chase the kart's heading, sit followDist behind.
follow(kart, snap = false) {
const lerp = snap ? 1 : MODE7.camLerp;
let diff = (kart.heading - this.cam.angle) % TAU;
if (diff > Math.PI) diff -= TAU;
if (diff < -Math.PI) diff += TAU;
this.cam.angle += diff * lerp;
const fx = Math.cos(this.cam.angle);
const fy = Math.sin(this.cam.angle);
this.cam.x = kart.x - fx * MODE7.followDist;
this.cam.y = kart.y - fy * MODE7.followDist;
this.syncUniforms();
}
// Free-fly debug camera (arrow keys in the scene's debug mode).
setCamera(x, y, angle) {
this.cam.x = x;
this.cam.y = y;
this.cam.angle = angle;
this.syncUniforms();
}
syncUniforms() {
const u = this.ground.uniforms;
u.uCam.value.x = this.cam.x;
u.uCam.value.y = this.cam.y;
u.uAngle.value = this.cam.angle;
}
setFlipY(flip) {
this.ground.uniforms.uFlipY.value = flip ? 1 : 0;
}
// World point → screen. Returns null when culled (behind camera / past fog).
// scale is for a 64px frame representing MODE7.spriteBase world units;
// multiply for larger objects.
project(wx, wy) {
const fx = Math.cos(this.cam.angle);
const fy = Math.sin(this.cam.angle);
const rx = -fy;
const ry = fx;
const dx = wx - this.cam.x;
const dy = wy - this.cam.y;
const zc = dx * fx + dy * fy;
if (zc < MODE7.nearZ || zc > MODE7.farZ) return null;
const xc = dx * rx + dy * ry;
const px = GAME_WIDTH / 2 + (xc * MODE7.focal) / zc;
if (px < -160 || px > GAME_WIDTH + 160) return null;
return {
x: px,
y: this.horizonY + (MODE7.camHeight * MODE7.focal) / zc,
scale: (MODE7.spriteBase / 64) * (MODE7.focal / zc),
z: zc,
fog: Phaser.Math.Clamp((zc - MODE7.fogStart) / (MODE7.fogEnd - MODE7.fogStart), 0, 1),
};
}
// View-angle frame for a kart heading (16-frame sheets, frame 0 = rear view).
angleFrame(heading, angles = 16) {
let rel = (heading - this.cam.angle) % TAU;
if (rel < 0) rel += TAU;
return Math.round((rel / TAU) * angles) % angles;
}
destroy() {
this.ground?.destroy();
this.ground = null;
}
}
// Horizon backdrop: a TileSprite strip above the ground band whose horizontal
// scroll tracks the camera angle (full turn = repeatsPerTurn texture widths).
export class Mode7Backdrop {
constructor(scene, textureKey, horizonY, depth = -1) {
this.texWidth = scene.textures.get(textureKey).getSourceImage().width || 1024;
this.sprite = scene.add.tileSprite(GAME_WIDTH / 2, horizonY / 2, GAME_WIDTH, horizonY, textureKey)
.setDepth(depth);
const texH = scene.textures.get(textureKey).getSourceImage().height || 300;
this.sprite.tileScaleX = this.sprite.tileScaleY = horizonY / texH;
this.repeatsPerTurn = 3;
}
update(camAngle) {
this.sprite.tilePositionX = ((camAngle / TAU) * this.texWidth * this.repeatsPerTurn) / this.sprite.tileScaleX;
}
destroy() {
this.sprite?.destroy();
this.sprite = null;
}
}

View File

@ -0,0 +1,549 @@
// Super Kart Canvas2D painters (browser-only — the headless modules never
// import this). Three jobs:
// 1. rasterizeTrack(): the top-down world texture the Mode 7 shader samples,
// including the procedural white/red/black striped curbs that sell the
// sense of speed.
// 2. drawMinimapCanvas(): the HUD minimap outline.
// 3. Procedural fallback art: kart rotation sheets, item sheet, and theme
// backdrops, generated when the drop-in PNGs in
// data/superkart-artwork.json are still path:null.
import { sampleAt, STRIPE_LEN, CURB_WIDTH } from './SuperKartTrack.js';
export const TRACK_TEXTURE_SIZE = 2048;
function makeCanvas(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
// Deterministic tiny rng for speckle so re-rasterizing looks identical.
function speckleRng(seed) {
let a = seed >>> 0;
return () => {
a = (a * 1664525 + 1013904223) >>> 0;
return a / 4294967296;
};
}
// ── Track texture ───────────────────────────────────────────────────────────
export function rasterizeTrack(model, theme, size = TRACK_TEXTURE_SIZE) {
const canvas = makeCanvas(size, size);
const ctx = canvas.getContext('2d');
const k = size / model.world; // world → texture scale
const S = model.samples;
const n = S.length;
// Edge points once: left/right road edge and outer curb edge per sample.
const left = [];
const right = [];
const leftOut = [];
const rightOut = [];
for (const p of S) {
const nx = -p.ty;
const ny = p.tx;
left.push([p.x + nx * p.w, p.y + ny * p.w]);
right.push([p.x - nx * p.w, p.y - ny * p.w]);
leftOut.push([p.x + nx * (p.w + CURB_WIDTH), p.y + ny * (p.w + CURB_WIDTH)]);
rightOut.push([p.x - nx * (p.w + CURB_WIDTH), p.y - ny * (p.w + CURB_WIDTH)]);
}
// 1. Terrain fill + speckle noise (theme sheet tile fill could replace this
// later — the texture is regenerated per race so no code change needed).
ctx.fillStyle = theme.terrain;
ctx.fillRect(0, 0, size, size);
const rng = speckleRng(0xBADD00D);
ctx.fillStyle = theme.terrainSpeckle;
for (let i = 0; i < 9000; i += 1) {
const x = rng() * size;
const y = rng() * size;
const r = 1 + rng() * 2.5;
ctx.globalAlpha = 0.25 + rng() * 0.5;
ctx.fillRect(x, y, r, r);
}
ctx.globalAlpha = 1;
// 2. Painted surface patches (water / deep).
for (const surf of model.json.surfaces ?? []) {
ctx.fillStyle = surf.type === 'water' ? theme.water : theme.deep;
if (surf.poly) {
ctx.beginPath();
surf.poly.forEach(([x, y], i) => (i ? ctx.lineTo(x * k, y * k) : ctx.moveTo(x * k, y * k)));
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
ctx.lineWidth = 3;
ctx.stroke();
} else if (surf.circle) {
const [cx, cy, r] = surf.circle;
ctx.beginPath();
ctx.arc(cx * k, cy * k, r * k, 0, Math.PI * 2);
ctx.fill();
}
}
// 3. Black outer hairline FIRST (a slightly wider dark ribbon the curbs and
// road paint over — what survives is the 2-unit contrast edge).
fillRibbon(ctx, leftOut, rightOut, k, '#0a0a0a', 2.5 * k);
// 4. Striped curbs: the band between road edge and curb edge, alternating
// white/red per STRIPE_LEN of arc — the classic kart-racer rumble strip.
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
const stripe = Math.floor(S[i].s / STRIPE_LEN) % 2;
ctx.fillStyle = stripe === 0 ? '#f2f2f2' : '#d42a20';
quad(ctx, left[i], leftOut[i], leftOut[j], left[j], k);
const stripeR = (Math.floor(S[i].s / STRIPE_LEN) + 1) % 2; // offset phase on the right edge
ctx.fillStyle = stripeR === 0 ? '#f2f2f2' : '#d42a20';
quad(ctx, right[i], rightOut[i], rightOut[j], right[j], k);
}
// 5. Road ribbon (over the curb band's inner overlap so edges stay crisp).
fillRibbon(ctx, left, right, k, theme.road, 0);
// Subtle asphalt texture + a faint center dash.
const rng2 = speckleRng(0xF00DFACE);
ctx.fillStyle = theme.roadEdge;
for (let i = 0; i < 2600; i += 1) {
const p = S[Math.floor(rng2() * n)];
const off = (rng2() * 2 - 1) * p.w * 0.92;
ctx.globalAlpha = 0.12 + rng2() * 0.2;
ctx.fillRect((p.x + -p.ty * off) * k, (p.y + p.tx * off) * k, 2, 2);
}
ctx.globalAlpha = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = Math.max(1, 8 * k);
ctx.setLineDash([56 * k, 88 * k]);
ctx.beginPath();
S.forEach((p, i) => (i ? ctx.lineTo(p.x * k, p.y * k) : ctx.moveTo(p.x * k, p.y * k)));
ctx.closePath();
ctx.stroke();
ctx.setLineDash([]);
// 6. Boost chevrons.
for (const b of model.boosts) {
ctx.save();
ctx.translate(b.x * k, b.y * k);
ctx.rotate(b.angle);
ctx.fillStyle = '#ffd028';
for (let c = -1; c <= 1; c += 1) { // three chevrons, ~26 world units apart
ctx.beginPath();
ctx.moveTo((c * 26 - 14) * k, -20 * k);
ctx.lineTo((c * 26 + 8) * k, 0);
ctx.lineTo((c * 26 - 14) * k, 20 * k);
ctx.lineTo((c * 26 - 3) * k, 0);
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
// 7. Item pad diamonds (the spinning box itself is a projected sprite).
for (const box of model.itemBoxes) {
ctx.save();
ctx.translate(box.x * k, box.y * k);
ctx.rotate(Math.PI / 4);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 2;
const r = 40 * k;
ctx.strokeRect(-r / 2, -r / 2, r, r);
ctx.restore();
}
// 8. Start/finish checkerboard + grid slot ticks.
drawStartLine(ctx, model, k);
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 2;
for (const g of model.gridSlots) {
ctx.save();
ctx.translate(g.x * k, g.y * k);
ctx.rotate(g.heading);
const w = 34 * k;
ctx.strokeRect(-w * 0.6, -w / 2, w * 1.2, w);
ctx.restore();
}
return canvas;
}
function quad(ctx, a, b, c, d, k) {
ctx.beginPath();
ctx.moveTo(a[0] * k, a[1] * k);
ctx.lineTo(b[0] * k, b[1] * k);
ctx.lineTo(c[0] * k, c[1] * k);
ctx.lineTo(d[0] * k, d[1] * k);
ctx.closePath();
ctx.fill();
// Hairline stroke of the same fill kills antialias seams between quads.
ctx.strokeStyle = ctx.fillStyle;
ctx.lineWidth = 0.75;
ctx.stroke();
}
// Fills the closed band between two edge polylines as one even-odd path,
// optionally stroked outward (used for the black contrast edge).
function fillRibbon(ctx, leftPts, rightPts, k, color, strokeW) {
ctx.beginPath();
leftPts.forEach(([x, y], i) => (i ? ctx.lineTo(x * k, y * k) : ctx.moveTo(x * k, y * k)));
ctx.closePath();
rightPts.forEach(([x, y], i) => (i ? ctx.lineTo(x * k, y * k) : ctx.moveTo(x * k, y * k)));
ctx.closePath();
ctx.fillStyle = color;
ctx.fill('evenodd');
if (strokeW > 0) {
ctx.strokeStyle = color;
ctx.lineWidth = strokeW;
ctx.stroke();
}
}
function drawStartLine(ctx, model, k) {
const p = sampleAt(model, model.startS);
const nx = -p.ty;
const ny = p.tx;
const cols = 10;
const rows = 2;
const bandDepth = 14; // world units along travel direction
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const lane = -1 + (2 * c) / cols;
const laneNext = -1 + (2 * (c + 1)) / cols;
const d0 = (r - rows / 2) * bandDepth;
const d1 = d0 + bandDepth;
ctx.fillStyle = (r + c) % 2 === 0 ? '#f2f2f2' : '#151515';
ctx.beginPath();
const pt = (laneT, depth) => [
(p.x + nx * laneT * p.w + p.tx * depth) * k,
(p.y + ny * laneT * p.w + p.ty * depth) * k,
];
const q = [pt(lane, d0), pt(laneNext, d0), pt(laneNext, d1), pt(lane, d1)];
ctx.moveTo(q[0][0], q[0][1]);
q.slice(1).forEach(([x, y]) => ctx.lineTo(x, y));
ctx.closePath();
ctx.fill();
}
}
}
// ── Minimap ─────────────────────────────────────────────────────────────────
// Returns { canvas, toMap } — toMap(x, y) maps world → minimap pixels so the
// scene can overlay live kart dots with the same transform.
export function drawMinimapCanvas(model, size = 220) {
const canvas = makeCanvas(size, size);
const ctx = canvas.getContext('2d');
const pts = model.samples;
let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
}
const pad = 14;
const scale = (size - pad * 2) / Math.max(maxX - minX, maxY - minY);
const ox = (size - (maxX - minX) * scale) / 2;
const oy = (size - (maxY - minY) * scale) / 2;
const toMap = (x, y) => [ox + (x - minX) * scale, oy + (y - minY) * scale];
const stroke = (width, style) => {
ctx.strokeStyle = style;
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.beginPath();
pts.forEach((p, i) => {
const [x, y] = toMap(p.x, p.y);
if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
});
ctx.closePath();
ctx.stroke();
};
stroke(9, 'rgba(0,0,0,0.75)');
stroke(5, 'rgba(240,240,240,0.9)');
// Start-line tick.
const sp = sampleAt(model, model.startS);
const [sx, sy] = toMap(sp.x, sp.y);
ctx.strokeStyle = '#ffd028';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(sx - -sp.ty * 7, sy - sp.tx * 7);
ctx.lineTo(sx + -sp.ty * 7, sy + sp.tx * 7);
ctx.stroke();
return { canvas, toMap };
}
// ── Procedural fallback sprites ─────────────────────────────────────────────
// 18-frame kart rotation strip (16 view angles + 2 lean frames) drawn as a
// simple tinted kart. Frame 0 = seen from behind, rotating clockwise — the
// same contract as drop-in racer sheets in data/superkart-artwork.json.
export function buildKartSheetCanvas(color, frame = 64) {
const angles = 16;
const canvas = makeCanvas(frame * (angles + 2), frame);
const ctx = canvas.getContext('2d');
for (let i = 0; i < angles; i += 1) {
drawKart(ctx, i * frame + frame / 2, frame / 2, (i * Math.PI * 2) / angles, color, frame, 0);
}
drawKart(ctx, 16 * frame + frame / 2, frame / 2, 0, color, frame, -0.35);
drawKart(ctx, 17 * frame + frame / 2, frame / 2, 0, color, frame, 0.35);
return canvas;
}
// A top-down kart drawn pointing "away from the viewer" at angle 0, then
// rotated. lean tilts the body for the player's steering frames.
function drawKart(ctx, cx, cy, angle, color, frame, lean) {
const u = frame / 64; // proportions tuned at 64px
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle + lean * 0.5);
ctx.scale(u, u);
// Shadow.
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, 4, 20, 16, 0, 0, Math.PI * 2);
ctx.fill();
// Wheels (dark, slightly outside the body).
ctx.fillStyle = '#181818';
for (const [wx, wy] of [[-15, -12], [15, -12], [-16, 12], [16, 12]]) {
ctx.beginPath();
ctx.roundRect(wx - 5, wy - 7, 10, 14, 3);
ctx.fill();
}
// Body: tinted rounded shell, brighter nose (nose points up = away).
const grad = ctx.createLinearGradient(0, 16, 0, -18);
grad.addColorStop(0, color);
grad.addColorStop(1, lighten(color, 0.35));
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(0, -20);
ctx.quadraticCurveTo(14, -16, 13, 2);
ctx.quadraticCurveTo(13, 16, 0, 17);
ctx.quadraticCurveTo(-13, 16, -13, 2);
ctx.quadraticCurveTo(-14, -16, 0, -20);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.45)';
ctx.lineWidth = 1.5;
ctx.stroke();
// Driver helmet + rear bumper stripe.
ctx.fillStyle = darken(color, 0.35);
ctx.beginPath();
ctx.arc(0, 2, 6.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.beginPath();
ctx.arc(0, 0.5, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#202020';
ctx.fillRect(-9, 13, 18, 3.5);
ctx.restore();
}
function lighten(hex, amt) { return shade(hex, amt); }
function darken(hex, amt) { return shade(hex, -amt); }
function shade(hex, amt) {
const v = parseInt(hex.slice(1), 16);
const ch = (sh) => {
let c = (v >> sh) & 0xff;
c = Math.round(amt >= 0 ? c + (255 - c) * amt : c * (1 + amt));
return Math.max(0, Math.min(255, c));
};
return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
}
// Item sheet fallback: frames match data/superkart-artwork.json itemSheet
// order — bolt, seeker, oil, turbo, overdrive, emp, coins, item box, coin.
export function buildItemSheetCanvas(frame = 48) {
const canvas = makeCanvas(frame * 9, frame);
const ctx = canvas.getContext('2d');
const at = (i, fn) => {
ctx.save();
ctx.translate(i * frame + frame / 2, frame / 2);
fn();
ctx.restore();
};
const orb = (color) => {
const g = ctx.createRadialGradient(-4, -4, 2, 0, 0, 16);
g.addColorStop(0, '#ffffff');
g.addColorStop(0.35, color);
g.addColorStop(1, darken(color, 0.5));
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, 15, 0, Math.PI * 2);
ctx.fill();
};
const boltGlyph = () => {
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.moveTo(2, -9); ctx.lineTo(-5, 2); ctx.lineTo(-1, 2);
ctx.lineTo(-2, 9); ctx.lineTo(5, -2); ctx.lineTo(1, -2);
ctx.closePath();
ctx.fill();
};
at(0, () => { orb('#38b048'); boltGlyph(); });
at(1, () => { orb('#d42a20'); boltGlyph(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, 10, 0.6, 2.6); ctx.stroke(); });
at(2, () => { // oil slick
ctx.fillStyle = '#141418';
ctx.beginPath();
ctx.ellipse(0, 2, 17, 12, 0.3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(120,120,255,0.35)';
ctx.beginPath();
ctx.ellipse(-4, -1, 7, 4, -0.4, 0, Math.PI * 2);
ctx.fill();
});
at(3, () => { // turbo cell
ctx.fillStyle = '#ffd028';
ctx.beginPath();
ctx.roundRect(-8, -14, 16, 28, 6);
ctx.fill();
ctx.strokeStyle = darken('#ffd028', 0.4);
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#e04838';
boltGlyph();
});
at(4, () => { // overdrive star
ctx.fillStyle = '#ffd028';
ctx.strokeStyle = '#a06a00';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < 10; i += 1) {
const r = i % 2 === 0 ? 16 : 7;
const a = -Math.PI / 2 + (i * Math.PI) / 5;
const x = Math.cos(a) * r;
const y = Math.sin(a) * r;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
});
at(5, () => { // emp burst
orb('#2888e0');
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
for (let i = 0; i < 6; i += 1) {
const a = (i * Math.PI) / 3;
ctx.beginPath();
ctx.moveTo(Math.cos(a) * 8, Math.sin(a) * 8);
ctx.lineTo(Math.cos(a) * 17, Math.sin(a) * 17);
ctx.stroke();
}
});
const coin = (x, y, r) => {
ctx.fillStyle = '#ffd028';
ctx.strokeStyle = '#a06a00';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = 'rgba(160,106,0,0.8)';
ctx.beginPath();
ctx.arc(x, y, r * 0.55, 0, Math.PI * 2);
ctx.stroke();
};
at(6, () => { coin(-6, 3, 10); coin(7, -4, 10); });
at(7, () => { // item box
ctx.rotate(Math.PI / 8);
const g = ctx.createLinearGradient(-14, -14, 14, 14);
g.addColorStop(0, '#68d0ff');
g.addColorStop(0.5, '#b078ff');
g.addColorStop(1, '#ff78c8');
ctx.fillStyle = g;
ctx.fillRect(-13, -13, 26, 26);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2.5;
ctx.strokeRect(-13, -13, 26, 26);
ctx.rotate(-Math.PI / 8);
ctx.fillStyle = '#fff';
ctx.font = 'bold 20px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('?', 0, 1);
});
at(8, () => coin(0, 0, 12));
return canvas;
}
// Horizon backdrop fallback: sky gradient + two silhouette hill layers,
// horizontally tileable (hills are built from a wrapped sine mix).
export function buildBackdropCanvas(theme, w = 1024, h = 300) {
const canvas = makeCanvas(w, h);
const ctx = canvas.getContext('2d');
const sky = ctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, theme.skyTop);
sky.addColorStop(1, theme.skyBottom);
ctx.fillStyle = sky;
ctx.fillRect(0, 0, w, h);
const hills = (baseY, amp, freqs, color) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(0, h);
for (let x = 0; x <= w; x += 4) {
const t = (x / w) * Math.PI * 2;
let y = baseY;
freqs.forEach(([f, a], i) => { y -= Math.abs(Math.sin(t * f + i * 1.7)) * amp * a; });
ctx.lineTo(x, y);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
};
hills(h * 0.85, h * 0.5, [[3, 0.6], [5, 0.4]], withAlpha(theme.skyTop, 0.55));
hills(h * 0.95, h * 0.35, [[4, 0.5], [7, 0.5]], withAlpha(darkenHexStr(theme.skyTop), 0.75));
return canvas;
}
function withAlpha(hex, a) {
const v = parseInt(hex.slice(1), 16);
return `rgba(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255},${a})`;
}
function darkenHexStr(hex) {
const v = parseInt(hex.slice(1), 16);
const f = (sh) => Math.round(((v >> sh) & 255) * 0.55);
return `#${((f(16) << 16) | (f(8) << 8) | f(0)).toString(16).padStart(6, '0')}`;
}
// Theme decor fallback: one 8-frame 64×64 strip of simple silhouettes so
// tracks have roadside objects before real theme sheets are painted.
export function buildDecorSheetCanvas(theme, frame = 64) {
const canvas = makeCanvas(frame * 8, frame);
const ctx = canvas.getContext('2d');
const dark = darkenHexStr(theme.skyTop);
for (let i = 0; i < 8; i += 1) {
ctx.save();
ctx.translate(i * frame + frame / 2, frame);
ctx.fillStyle = i % 2 === 0 ? dark : withAlpha(theme.terrainSpeckle ?? theme.terrain, 1);
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 2;
ctx.beginPath();
if (i % 3 === 0) { // tree-ish
ctx.moveTo(-4, 0); ctx.lineTo(-4, -18); ctx.lineTo(4, -18); ctx.lineTo(4, 0);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(0, -34, 18, 0, Math.PI * 2);
ctx.fill();
} else if (i % 3 === 1) { // boulder / mound
ctx.moveTo(-24, 0);
ctx.quadraticCurveTo(-18, -34, 2, -30);
ctx.quadraticCurveTo(24, -26, 22, 0);
ctx.closePath();
ctx.fill();
ctx.stroke();
} else { // post / tower
ctx.fillRect(-7, -52, 14, 52);
ctx.fillRect(-12, -58, 24, 10);
}
ctx.restore();
}
return canvas;
}

View File

@ -0,0 +1,454 @@
// Super Kart headless track model. Pure ESM with no Phaser or canvas imports so
// tools/verifySuperKart.js and tools/genSuperKartTracks.js can drive it in Node.
//
// A track JSON (assets/gamedata/superkart/track-NNN.json) is a closed
// Catmull-Rom centerline with a per-control-point half-width. Everything that
// belongs to the road (start line, item rows, boost pads) is addressed by
// arc-length `s` plus a lane offset (-1..1 across the width) so spline edits
// carry them along. buildTrackModel() turns that JSON into the one structure
// the rasterizer, the physics sim, the AI, and the minimap all share.
export const SURFACE = {
OOB: 0, // outside the world
OFFROAD: 1, // theme terrain — slow
ROAD: 2,
CURB: 3, // striped edge band — drivable, slight grip change only visually
BOOST: 4, // boost pad on the road
DEEP: 5, // pit/lava/deep sand — crawl + rescue
WATER: 6, // water — rescue
WALL: 7, // impassable
};
export const CURB_WIDTH = 8; // world units, each road edge
export const STRIPE_LEN = 32; // world units per white/red curb stripe
export const SAMPLE_STEP = 16; // centerline resample spacing
export const CHECKPOINT_STEP = 64;
export const GRID_CELLS = 256; // surfaceGrid is GRID_CELLS × GRID_CELLS
const TAU = Math.PI * 2;
// ── Catmull-Rom (closed, centripetal-ish uniform) ───────────────────────────
function catmull(p0, p1, p2, p3, t) {
const t2 = t * t;
const t3 = t2 * t;
return 0.5 * ((2 * p1) + (-p0 + p2) * t
+ (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2
+ (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
}
// Evaluates the closed loop through pts at segment i, local t in [0,1).
function evalSpline(pts, i, t) {
const n = pts.length;
const a = pts[(i - 1 + n) % n];
const b = pts[i % n];
const c = pts[(i + 1) % n];
const d = pts[(i + 2) % n];
return {
x: catmull(a.x, b.x, c.x, d.x, t),
y: catmull(a.y, b.y, c.y, d.y, t),
w: b.w + (c.w - b.w) * t,
};
}
// ── Model construction ──────────────────────────────────────────────────────
// Resamples the control loop into evenly spaced samples {x, y, tx, ty, w, s}.
function resample(pts) {
const SUBDIV = 32;
const fine = [];
const ctrlS = new Array(pts.length); // arc-length at each control point
let acc = 0;
let prev = null;
for (let i = 0; i < pts.length; i += 1) {
ctrlS[i] = acc;
for (let k = 0; k < SUBDIV; k += 1) {
const p = evalSpline(pts, i, k / SUBDIV);
if (prev) acc += Math.hypot(p.x - prev.x, p.y - prev.y);
p.s = acc;
fine.push(p);
prev = p;
}
}
const total = acc + Math.hypot(fine[0].x - prev.x, fine[0].y - prev.y);
const count = Math.max(32, Math.round(total / SAMPLE_STEP));
const step = total / count;
const samples = new Array(count);
let fi = 0;
for (let i = 0; i < count; i += 1) {
const target = i * step;
while (fi < fine.length - 1 && fine[fi + 1].s < target) fi += 1;
const a = fine[fi];
const b = fine[(fi + 1) % fine.length];
const span = Math.max(1e-6, (fi === fine.length - 1 ? total : b.s) - a.s);
const t = Math.min(1, Math.max(0, (target - a.s) / span));
samples[i] = {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
w: a.w + (b.w - a.w) * t,
s: target,
tx: 0, ty: 0,
};
}
// Central-difference tangents around the loop.
for (let i = 0; i < count; i += 1) {
const p = samples[(i - 1 + count) % count];
const q = samples[(i + 1) % count];
const dx = q.x - p.x;
const dy = q.y - p.y;
const len = Math.hypot(dx, dy) || 1;
samples[i].tx = dx / len;
samples[i].ty = dy / len;
}
return { samples, totalLength: count * step, step, ctrlS };
}
// Spatial hash of sample indices for nearest-centerline queries.
function buildHash(samples, world) {
const cell = 64;
const cols = Math.ceil(world / cell);
const hash = new Map();
for (let i = 0; i < samples.length; i += 1) {
const cx = Math.min(cols - 1, Math.max(0, Math.floor(samples[i].x / cell)));
const cy = Math.min(cols - 1, Math.max(0, Math.floor(samples[i].y / cell)));
const key = cy * cols + cx;
if (!hash.has(key)) hash.set(key, []);
hash.get(key).push(i);
}
return { hash, cell, cols };
}
function nearbySampleIndices(model, x, y, ring) {
const { hash, cell, cols } = model._hash;
const cx = Math.floor(x / cell);
const cy = Math.floor(y / cell);
const out = [];
for (let dy = -ring; dy <= ring; dy += 1) {
for (let dx = -ring; dx <= ring; dx += 1) {
const gx = cx + dx;
const gy = cy + dy;
if (gx < 0 || gy < 0 || gx >= cols || gy >= cols) continue;
const bucket = hash.get(gy * cols + gx);
if (bucket) out.push(...bucket);
}
}
return out;
}
// Distance from (x,y) to the segment samples[i] → samples[i+1].
function segDist(samples, i, x, y) {
const a = samples[i];
const b = samples[(i + 1) % samples.length];
const abx = b.x - a.x;
const aby = b.y - a.y;
const len2 = abx * abx + aby * aby || 1e-6;
let t = ((x - a.x) * abx + (y - a.y) * aby) / len2;
t = Math.max(0, Math.min(1, t));
const px = a.x + abx * t;
const py = a.y + aby * t;
return { d: Math.hypot(x - px, y - py), t, px, py };
}
function pointInPoly(poly, x, y) {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i += 1) {
const [xi, yi] = poly[i];
const [xj, yj] = poly[j];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
// Interpolated centerline point at arc-length s (wraps).
export function sampleAt(model, s) {
const { samples, totalLength, step } = model;
let ss = s % totalLength;
if (ss < 0) ss += totalLength;
const i = Math.floor(ss / step) % samples.length;
const a = samples[i];
const b = samples[(i + 1) % samples.length];
const t = (ss - i * step) / step;
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
tx: a.tx + (b.tx - a.tx) * t,
ty: a.ty + (b.ty - a.ty) * t,
w: a.w + (b.w - a.w) * t,
s: ss,
};
}
// World position for an (s, lane) road address. lane -1..1 spans the width.
export function roadPoint(model, s, lane = 0) {
const p = sampleAt(model, s);
return {
x: p.x + -p.ty * lane * p.w,
y: p.y + p.tx * lane * p.w,
tx: p.tx, ty: p.ty, w: p.w,
};
}
export function surfaceAt(model, x, y) {
if (x < 0 || y < 0 || x >= model.world || y >= model.world) return SURFACE.OOB;
const cs = model.world / GRID_CELLS;
const gx = Math.floor(x / cs);
const gy = Math.floor(y / cs);
return model.surfaceGrid[gy * GRID_CELLS + gx];
}
// Continuity-aware projection: searches only a window of samples around a
// known previous arc-length. Where two track sections pinch close together
// (pretzel lobes, hairpins) the global nearest-point search can flip to the
// wrong branch — karts track their own splineS and use this instead.
export function projectToSplineNear(model, x, y, prevS, windowSamples = 40) {
const { samples } = model;
const n = samples.length;
const i0 = Math.round((((prevS % model.totalLength) + model.totalLength) % model.totalLength) / model.step) % n;
let best = null;
for (let di = -windowSamples; di <= windowSamples; di += 1) {
const i = (i0 + di + n) % n;
const r = segDist(samples, i, x, y);
if (!best || r.d < best.d) best = { ...r, i };
}
// Way off the window (post-rescue teleports, etc.): fall back to global.
if (!best || best.d > 500) return projectToSpline(model, x, y);
const a = samples[best.i];
const cross = a.tx * (y - best.py) - a.ty * (x - best.px);
const s = (a.s + best.t * model.step) % model.totalLength;
const w = Math.max(1, a.w);
return { s, d: best.d, laneT: Math.sign(cross) * (best.d / w) };
}
// Nearest point on the centerline: {s, d, laneT} where laneT is the signed
// lateral offset in units of the local half-width (negative = left of travel).
export function projectToSpline(model, x, y) {
const { samples } = model;
let ring = 1;
let cands = nearbySampleIndices(model, x, y, ring);
while (cands.length === 0 && ring < 40) {
ring += ring; // expand until something is in range (far off-track queries)
cands = nearbySampleIndices(model, x, y, ring);
}
let best = null;
for (const i of cands) {
const r = segDist(samples, i, x, y);
if (!best || r.d < best.d) best = { ...r, i };
}
if (!best) return { s: 0, d: Infinity, laneT: 0 };
const a = samples[best.i];
const cross = a.tx * (y - best.py) - a.ty * (x - best.px);
const s = (a.s + best.t * model.step) % model.totalLength;
const w = Math.max(1, a.w);
return { s, d: best.d, laneT: Math.sign(cross) * (best.d / w) };
}
function buildSurfaceGrid(model, json) {
const grid = new Uint8Array(GRID_CELLS * GRID_CELLS).fill(SURFACE.OFFROAD);
const cs = model.world / GRID_CELLS;
const maxW = model.samples.reduce((m, p) => Math.max(m, p.w), 0) + CURB_WIDTH;
const reach = Math.ceil((maxW + cs) / 64) + 1;
for (let gy = 0; gy < GRID_CELLS; gy += 1) {
for (let gx = 0; gx < GRID_CELLS; gx += 1) {
const x = (gx + 0.5) * cs;
const y = (gy + 0.5) * cs;
let best = null;
for (const i of nearbySampleIndices(model, x, y, reach)) {
const r = segDist(model.samples, i, x, y);
if (!best || r.d < best.d) best = { ...r, i };
}
if (!best) continue;
const a = model.samples[best.i];
const b = model.samples[(best.i + 1) % model.samples.length];
const w = a.w + (b.w - a.w) * best.t;
if (best.d < w) grid[gy * GRID_CELLS + gx] = SURFACE.ROAD;
else if (best.d < w + CURB_WIDTH) grid[gy * GRID_CELLS + gx] = SURFACE.CURB;
}
}
// Painted patches only override terrain — the road stays drivable.
const paint = (test, code) => {
for (let gy = 0; gy < GRID_CELLS; gy += 1) {
for (let gx = 0; gx < GRID_CELLS; gx += 1) {
const idx = gy * GRID_CELLS + gx;
if (grid[idx] !== SURFACE.OFFROAD) continue;
if (test((gx + 0.5) * cs, (gy + 0.5) * cs)) grid[idx] = code;
}
}
};
for (const surf of json.surfaces ?? []) {
const code = surf.type === 'water' ? SURFACE.WATER : SURFACE.DEEP;
if (surf.poly) paint((x, y) => pointInPoly(surf.poly, x, y), code);
else if (surf.circle) {
const [cx, cy, r] = surf.circle;
paint((x, y) => (x - cx) * (x - cx) + (y - cy) * (y - cy) < r * r, code);
}
}
// Boost pads stamp BOOST onto road cells around their road point.
for (const boost of model.boosts) {
const r = Math.ceil(24 / cs);
const gx0 = Math.floor(boost.x / cs);
const gy0 = Math.floor(boost.y / cs);
for (let gy = gy0 - r; gy <= gy0 + r; gy += 1) {
for (let gx = gx0 - r; gx <= gx0 + r; gx += 1) {
if (gx < 0 || gy < 0 || gx >= GRID_CELLS || gy >= GRID_CELLS) continue;
const idx = gy * GRID_CELLS + gx;
if (grid[idx] === SURFACE.ROAD) grid[idx] = SURFACE.BOOST;
}
}
}
// Wall polylines rasterize as thick impassable bands (override everything).
for (const wall of json.walls ?? []) {
const pts = wall.pts ?? [];
const end = wall.closed ? pts.length : pts.length - 1;
for (let i = 0; i < end; i += 1) {
const [ax, ay] = pts[i];
const [bx, by] = pts[(i + 1) % pts.length];
const len = Math.hypot(bx - ax, by - ay);
const steps = Math.max(1, Math.ceil(len / (cs * 0.5)));
for (let k = 0; k <= steps; k += 1) {
const x = ax + ((bx - ax) * k) / steps;
const y = ay + ((by - ay) * k) / steps;
const gx = Math.floor(x / cs);
const gy = Math.floor(y / cs);
if (gx < 0 || gy < 0 || gx >= GRID_CELLS || gy >= GRID_CELLS) continue;
grid[gy * GRID_CELLS + gx] = SURFACE.WALL;
}
}
}
return grid;
}
export function buildTrackModel(json) {
const world = json.world ?? 4096;
const pts = json.spline.map((p) => ({ x: p.x, y: p.y, w: p.w }));
const { samples, totalLength, step, ctrlS } = resample(pts);
const model = {
id: json.id,
name: json.name ?? json.id,
theme: json.theme ?? 'speedway',
laps: json.laps ?? 5,
world,
samples,
totalLength,
step,
json,
};
model._hash = buildHash(samples, world);
// Start line + grid. startIndex picks the control point the line sits at.
const startS = ctrlS[Math.min(json.startIndex ?? 0, pts.length - 1)] % totalLength;
const sp = sampleAt(model, startS);
model.startS = startS;
model.startPose = { x: sp.x, y: sp.y, heading: Math.atan2(sp.ty, sp.tx) };
model.gridSlots = [];
for (let i = 0; i < 9; i += 1) {
const row = Math.floor(i / 2);
const lane = i % 2 === 0 ? -0.4 : 0.4;
const s = ((startS - 70 - row * 46) % totalLength + totalLength) % totalLength;
const p = roadPoint(model, s, lane);
model.gridSlots.push({ x: p.x, y: p.y, heading: Math.atan2(p.ty, p.tx), s });
}
// Checkpoints: ordered gates the sim uses for lap validation, positions,
// and AI targets. Index 0 sits on the start line.
const cpCount = Math.max(8, Math.round(totalLength / CHECKPOINT_STEP));
model.checkpoints = [];
for (let i = 0; i < cpCount; i += 1) {
const s = (startS + (i * totalLength) / cpCount) % totalLength;
const p = sampleAt(model, s);
model.checkpoints.push({ x: p.x, y: p.y, tx: p.tx, ty: p.ty, w: p.w, s });
}
// Item boxes from itemRows {s, count}: a row of boxes across the road.
model.itemBoxes = [];
for (const row of json.itemRows ?? []) {
const count = Math.max(1, row.count ?? 4);
for (let j = 0; j < count; j += 1) {
const lane = count === 1 ? 0 : -0.6 + (1.2 * j) / (count - 1);
const p = roadPoint(model, row.s, lane);
model.itemBoxes.push({ x: p.x, y: p.y, s: row.s, lane });
}
}
// Boost pads {s, lane} → world points (surface stamping happens below).
model.boosts = (json.boosts ?? []).map((bp) => {
const p = roadPoint(model, bp.s, bp.lane ?? 0);
return { x: p.x, y: p.y, angle: Math.atan2(p.ty, p.tx), s: bp.s, lane: bp.lane ?? 0 };
});
model.coins = (json.coins ?? []).map((c) => ({ x: c.x, y: c.y }));
model.hazards = (json.hazards ?? []).map((hz) => ({ ...hz }));
model.decor = (json.decor ?? []).map((d) => ({ ...d }));
model.surfaceGrid = buildSurfaceGrid(model, json);
return model;
}
// ── Validation (shared by the editor strip, the generator, and verify) ──────
function segsIntersect(a, b, c, d) {
const orient = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x));
return orient(a, b, c) !== orient(a, b, d) && orient(c, d, a) !== orient(c, d, b)
&& orient(a, b, c) !== 0 && orient(c, d, a) !== 0;
}
export function validateTrack(model) {
const issues = [];
const { samples } = model;
const n = samples.length;
if (model.json.spline.length < 4) issues.push('spline needs at least 4 control points');
// Self-intersection: non-adjacent centerline segments must not cross.
outer:
for (let i = 0; i < n; i += 1) {
for (let j = i + 2; j < n; j += 1) {
if (i === 0 && j === n - 1) continue; // loop closure adjacency
if (segsIntersect(samples[i], samples[(i + 1) % n],
samples[j], samples[(j + 1) % n])) {
issues.push(`centerline crosses itself near s=${Math.round(samples[i].s)}`);
break outer;
}
}
}
// Width sanity + turn radius vs width: a hairpin tighter than the road is
// wide folds the ribbon over itself.
for (let i = 0; i < n; i += 1) {
const p = samples[i];
if (p.w < 40) { issues.push(`road too narrow (${Math.round(p.w)}) at s=${Math.round(p.s)}`); break; }
}
for (let i = 0; i < n; i += 1) {
const a = samples[(i - 1 + n) % n];
const b = samples[i];
const c = samples[(i + 1) % n];
const angle = Math.abs(normAngle(Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x)));
const radius = angle > 1e-4 ? model.step / angle : Infinity;
if (radius < b.w * 0.8) {
issues.push(`turn tighter than road width at s=${Math.round(b.s)} (radius ${Math.round(radius)} vs width ${Math.round(b.w)})`);
break;
}
}
// Track must fit inside the world with margin for curbs.
for (const p of samples) {
const m = p.w + CURB_WIDTH + 16;
if (p.x < m || p.y < m || p.x > model.world - m || p.y > model.world - m) {
issues.push(`track leaves the world near s=${Math.round(p.s)}`);
break;
}
}
return issues;
}
export function normAngle(a) {
let r = a % TAU;
if (r > Math.PI) r -= TAU;
if (r < -Math.PI) r += TAU;
return r;
}

View File

@ -93,6 +93,8 @@ import ColoradoDefenseGame from './games/coloradodefense/ColoradoDefenseGame.js'
import StarControlGame from './games/starcontrol/StarControlGame.js';
import CivilizationGame from './games/civilization/CivilizationGame.js';
import TempestGame from './games/tempest/TempestGame.js';
import SuperKartGame from './games/superkart/SuperKartGame.js';
import SuperKartEditor from './games/superkart/SuperKartEditor.js';
const config = {
type: Phaser.AUTO,
@ -199,6 +201,8 @@ const config = {
StarControlGame,
CivilizationGame,
TempestGame,
SuperKartGame,
SuperKartEditor,
],
};

View File

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

View File

@ -61,6 +61,9 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('balatro-artwork', 'data/balatro-artwork.json');
this.load.json('civilization-artwork', 'data/civilization-artwork.json');
this.load.json('peggle-levels', 'assets/gamedata/peggle/levels.json');
this.load.json('superkart-racers', 'data/superkart-racers.json');
this.load.json('superkart-rules', 'data/superkart-rules.json');
this.load.json('superkart-artwork', 'data/superkart-artwork.json');
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
this.load.json('star-control-ships', 'data/star-control-ships.json');
@ -189,6 +192,13 @@ export default class PreloadScene extends Phaser.Scene {
return;
}
// Same trick for the Super Kart track editor: ?superkart-editor=1.
if (params.has('superkart-editor')) {
window.history.replaceState(null, '', window.location.pathname + window.location.hash);
this.scene.start('SuperKartEditor');
return;
}
// Deep link: index.html?game=<slug> jumps straight to the same place a
// main-menu click would (GameMenuScene.openGame's branching logic).
const deepLinkGame = getGame(params.get('game'));

283
tools/genSuperKartTracks.js Normal file
View File

@ -0,0 +1,283 @@
// Generates the 12 launch tracks + cups.json for Super Kart into
// assets/gamedata/superkart/. Run: node tools/genSuperKartTracks.js [--force]
//
// Contract (same as genPeggleLevels.js): tracks are generated ONCE and then
// hand-tuned in the editor (?superkart-editor=1). Without --force, existing
// track files are left untouched so tuning is never clobbered.
//
// Layouts are star-shaped polar curves r(θ) = base + Σ aᵢ·sin(iθ+φᵢ): any
// curve monotone in θ around the center cannot self-intersect, so every
// family is safe by construction and only width/turn-radius interplay needs
// validation (buildTrackModel + validateTrack, the same checks the editor
// and verifier run).
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import {
buildTrackModel, validateTrack, projectToSpline, SURFACE, surfaceAt, normAngle,
} from '../src/games/superkart/SuperKartTrack.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'assets', 'gamedata', 'superkart');
const FORCE = process.argv.includes('--force');
const WORLD = 4096;
const CENTER = WORLD / 2;
const TAU = Math.PI * 2;
const rules = JSON.parse(readFileSync(join(ROOT, 'data', 'superkart-rules.json'), 'utf8'));
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
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;
};
}
// ── Layout families ─────────────────────────────────────────────────────────
// Each returns { spline, laps } — control points around the world center.
function polarSpline(rng, { points, base, harmonics, width, widthJitter = 0, narrowAtCurves = 0 }) {
const phases = harmonics.map(() => rng() * TAU);
const rAt = (theta) => {
let r = base;
harmonics.forEach(([freq, amp], i) => { r += Math.sin(theta * freq + phases[i]) * amp; });
return Math.max(560, Math.min(r, CENTER - 420));
};
const spline = [];
for (let i = 0; i < points; i += 1) {
const theta = (i / points) * TAU;
const r = rAt(theta);
spline.push({
x: Math.round(CENTER + Math.cos(theta) * r),
y: Math.round(CENTER + Math.sin(theta) * r),
w: 0,
});
}
// Width: base ± jitter, narrowed where the local polyline turns hard.
for (let i = 0; i < points; i += 1) {
const a = spline[(i - 1 + points) % points];
const b = spline[i];
const c = spline[(i + 1) % points];
const turn = Math.abs(normAngle(
Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x),
));
let w = width + (rng() * 2 - 1) * widthJitter;
w -= turn * narrowAtCurves;
spline[i].w = Math.round(Math.max(72, Math.min(180, w)));
}
return spline;
}
const FAMILIES = {
oval: (rng) => polarSpline(rng, {
points: 12, base: 1280, width: 132, widthJitter: 10,
harmonics: [[2, 220 + rng() * 120]],
}),
kidney: (rng) => polarSpline(rng, {
points: 14, base: 1260, width: 120, widthJitter: 14, narrowAtCurves: 20,
harmonics: [[2, 180 + rng() * 90], [3, 150 + rng() * 110], [4, 60 + rng() * 60]],
}),
hairpin: (rng) => polarSpline(rng, {
points: 16, base: 1240, width: 108, widthJitter: 12, narrowAtCurves: 34,
harmonics: [[3, 260 + rng() * 120], [5, 130 + rng() * 90]],
}),
pretzel: (rng) => polarSpline(rng, {
points: 18, base: 1200, width: 102, widthJitter: 10, narrowAtCurves: 30,
harmonics: [[2, 340 + rng() * 130], [5, 150 + rng() * 80], [7, 60 + rng() * 40]],
}),
speedway: (rng) => polarSpline(rng, {
points: 12, base: 1330, width: 150, widthJitter: 6,
harmonics: [[2, 160 + rng() * 80], [3, 70 + rng() * 50]],
}),
};
// ── Furniture: item rows, coins, boosts, decor, patches ─────────────────────
function curvatureAt(model, idx, span = 4) {
const n = model.samples.length;
const a = model.samples[(idx - span + n) % n];
const b = model.samples[idx];
const c = model.samples[(idx + span) % n];
const turn = Math.abs(normAngle(
Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(b.y - a.y, b.x - a.x),
));
return turn / (2 * span * model.step);
}
function furnish(track, model, rng, theme) {
const L = model.totalLength;
const n = model.samples.length;
// Item rows at thirds of the lap, measured from the start line.
track.itemRows = [0.28, 0.58, 0.86].map((f) => ({
s: Math.round((model.startS + f * L) % L),
count: 4,
}));
// Curvature thresholds adapt to each layout: "straight" is the gentlest
// 30% of the lap, "hard corner" the sharpest 10%.
const curvs = [];
for (let i = 0; i < n; i += 1) curvs.push(curvatureAt(model, i));
const sorted = [...curvs].sort((a, b) => a - b);
const straightThresh = sorted[Math.floor(n * 0.30)];
const hardThresh = sorted[Math.floor(n * 0.90)];
// Coins on straights: runs of low curvature get a small arc of coins.
track.coins = [];
let run = 0;
for (let i = 0; i < n && track.coins.length < 24; i += 1) {
if (curvs[i] <= straightThresh) run += 1;
else run = 0;
if (run >= 10 && i % 4 === 0) {
const p = model.samples[i];
const lane = Math.sin(i * 0.7) * 0.45;
track.coins.push({
x: Math.round(p.x + -p.ty * lane * p.w),
y: Math.round(p.y + p.tx * lane * p.w),
});
}
}
// Boost pads on corner exits: hard corner followed by an easing section.
track.boosts = [];
for (let i = 0; i < n && track.boosts.length < 3; i += 1) {
const hard = curvs[i] >= hardThresh;
const clearAhead = curvs[(i + 12) % n] <= straightThresh * 1.5;
if (hard && clearAhead) {
const p = model.samples[(i + 14) % n];
track.boosts.push({ s: Math.round(p.s), lane: Math.round((rng() * 2 - 1) * 4) / 10 });
i += 60; // one pad per corner complex
}
}
// Roadside decor: offroad points near the track edge.
track.decor = [];
const decorList = theme.decor ?? ['tree'];
let guard = 0;
while (track.decor.length < 16 && guard < 400) {
guard += 1;
const p = model.samples[Math.floor(rng() * n)];
const side = rng() < 0.5 ? -1 : 1;
const dist = p.w + 60 + rng() * 240;
const x = Math.round(p.x + -p.ty * side * dist);
const y = Math.round(p.y + p.tx * side * dist);
if (surfaceAt(model, x, y) !== SURFACE.OFFROAD) continue;
track.decor.push({ sprite: decorList[track.decor.length % decorList.length], x, y });
}
// One or two scenic hazard patches well clear of the racing line.
track.surfaces = [];
const patchType = ['beach', 'swamp', 'nightcity'].includes(track.theme) ? 'water' : 'deep';
guard = 0;
while (track.surfaces.length < 2 && guard < 300) {
guard += 1;
const x = 500 + rng() * (WORLD - 1000);
const y = 500 + rng() * (WORLD - 1000);
const proj = projectToSpline(model, x, y);
const maxW = 200;
if (proj.d < maxW + 320) continue;
track.surfaces.push({ type: patchType, circle: [Math.round(x), Math.round(y), Math.round(140 + rng() * 140)] });
}
track.hazards = [];
track.walls = [];
}
// ── Track plan ──────────────────────────────────────────────────────────────
const PLAN = [
// Sprocket Cup — wide, forgiving.
{ id: 'track-001', cup: 0, name: 'Gerome Speedway', theme: 'speedway', family: 'oval', laps: 5 },
{ id: 'track-002', cup: 0, name: 'Kona Cove', theme: 'beach', family: 'kidney', laps: 5 },
{ id: 'track-003', cup: 0, name: 'Bayou Bend', theme: 'swamp', family: 'kidney', laps: 5 },
{ id: 'track-004', cup: 0, name: 'Scrap Run', theme: 'scrapyard', family: 'oval', laps: 5 },
// Flywheel Cup — quicker corners.
{ id: 'track-005', cup: 1, name: 'Sunwash Shore', theme: 'beach', family: 'hairpin', laps: 5 },
{ id: 'track-006', cup: 1, name: 'Cinder Peaks', theme: 'volcano', family: 'kidney', laps: 5 },
{ id: 'track-007', cup: 1, name: 'Neon Nights', theme: 'nightcity', family: 'pretzel', laps: 5 },
{ id: 'track-008', cup: 1, name: 'Speedway Classic', theme: 'speedway', family: 'speedway', laps: 5 },
// Nitro Cup — narrow and technical.
{ id: 'track-009', cup: 2, name: "Croc's Crossing", theme: 'swamp', family: 'hairpin', laps: 5 },
{ id: 'track-010', cup: 2, name: "Smasher's Junction", theme: 'scrapyard', family: 'pretzel', laps: 5 },
{ id: 'track-011', cup: 2, name: 'Fireball Furnace', theme: 'volcano', family: 'hairpin', laps: 5 },
{ id: 'track-012', cup: 2, name: 'Blackwind Boulevard', theme: 'nightcity', family: 'pretzel', laps: 5 },
];
const CUPS = [
{ id: 'cup-1', name: 'Sprocket Cup' },
{ id: 'cup-2', name: 'Flywheel Cup' },
{ id: 'cup-3', name: 'Nitro Cup' },
];
// ── Main ────────────────────────────────────────────────────────────────────
mkdirSync(OUT, { recursive: true });
let written = 0;
let skipped = 0;
let failed = 0;
PLAN.forEach((plan, ti) => {
const file = join(OUT, `${plan.id}.json`);
if (existsSync(file) && !FORCE) {
console.log(`skip ${plan.id} (exists — hand-tuned files are never rewritten; use --force)`);
skipped += 1;
return;
}
const theme = rules.themes[plan.theme];
let track = null;
for (let attempt = 0; attempt < 40; attempt += 1) {
const rng = mulberry32(0xC0FFEE + ti * 977 + attempt * 131071);
const spline = FAMILIES[plan.family](rng);
const candidate = {
version: 1,
id: plan.id,
name: plan.name,
theme: plan.theme,
laps: plan.laps,
world: WORLD,
spline,
startIndex: 0,
surfaces: [], walls: [], boosts: [], itemRows: [], hazards: [], decor: [], coins: [],
};
let model;
try {
model = buildTrackModel(candidate);
} catch (_) { continue; }
if (validateTrack(model).length) continue;
furnish(candidate, model, rng, theme);
// Furniture can't break geometry, but re-validate with everything in place.
const finalModel = buildTrackModel(candidate);
const issues = validateTrack(finalModel);
if (issues.length) continue;
track = candidate;
console.log(`write ${plan.id} ${plan.name.padEnd(20)} ${plan.family.padEnd(9)} lap ${Math.round(finalModel.totalLength)}u, ${candidate.coins.length} coins, ${candidate.boosts.length} boosts (attempt ${attempt + 1})`);
break;
}
if (!track) {
console.error(`FAIL ${plan.id} — no valid layout found`);
failed += 1;
return;
}
writeFileSync(file, `${JSON.stringify(track, null, 2)}\n`);
written += 1;
});
const cupsJson = {
version: 1,
cups: CUPS.map((cup, i) => ({
...cup,
tracks: PLAN.filter((p) => p.cup === i).map((p) => p.id),
})),
tracks: PLAN.map((p) => ({ id: p.id, name: p.name, theme: p.theme, file: `${p.id}.json` })),
};
const cupsFile = join(OUT, 'cups.json');
if (!existsSync(cupsFile) || FORCE || written > 0) {
writeFileSync(cupsFile, `${JSON.stringify(cupsJson, null, 2)}\n`);
console.log('write cups.json');
}
console.log(`\ndone: ${written} written, ${skipped} skipped, ${failed} failed`);
process.exit(failed ? 1 : 0);

275
tools/verifySuperKart.js Normal file
View File

@ -0,0 +1,275 @@
// Headless verification for Super Kart. Run: node tools/verifySuperKart.js
//
// Covers: JSON schemas (racers/rules/artwork/cups/tracks), track model
// invariants (checkpoints, grid, surfaces), physics invariants (speed caps,
// NaN, off-road, weight bumping), the position-weighted item roulette, a
// 9-AI soak on every track, and a full 3-class × 3-cup GP soak with a
// determinism check.
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import {
buildTrackModel, validateTrack, surfaceAt, SURFACE,
} from '../src/games/superkart/SuperKartTrack.js';
import {
createRace, step, finalizeRace, kartInputsNeutral, mulberry32, rollItem,
topSpeedOf, offroadMultOf, surfaceMult, pointsFor, COUNTDOWN_MS,
} from '../src/games/superkart/SuperKartLogic.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const GAMEDATA = join(ROOT, 'assets', 'gamedata', 'superkart');
let pass = 0;
let fail = 0;
function check(name, cond, detail = '') {
if (cond) { pass += 1; return; }
fail += 1;
console.error(`FAIL: ${name}${detail ? `${detail}` : ''}`);
}
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
// ── 1. Schemas ──────────────────────────────────────────────────────────────
const racers = readJson(join(ROOT, 'data', 'superkart-racers.json')).racers;
const rules = readJson(join(ROOT, 'data', 'superkart-rules.json'));
const artwork = readJson(join(ROOT, 'data', 'superkart-artwork.json'));
const cups = readJson(join(GAMEDATA, 'cups.json'));
const opponents = readJson(join(ROOT, 'data', 'opponents.json')).opponents;
const opIds = new Set(opponents.map((o) => o.id));
check('9 racers', racers.length === 9, `got ${racers.length}`);
for (const r of racers) {
check(`racer ${r.id} exists in opponents.json`, opIds.has(r.id));
check(`racer ${r.id} has color`, /^#[0-9a-f]{6}$/i.test(r.color ?? ''));
for (const [k, v] of Object.entries(r.stats)) {
check(`racer ${r.id} stat ${k} in 0..1`, v >= 0 && v <= 1, String(v));
}
check(`racer ${r.id} aiSkill in 0..1`, r.aiSkill >= 0 && r.aiSkill <= 1);
check(`racer ${r.id} has artwork sheet entry`, !!artwork.racerSheets?.[r.id]);
}
const statKeys = ['topSpeed', 'accel', 'handling', 'weight', 'offroad', 'drift'];
for (const key of statKeys) {
const vals = racers.map((r) => r.stats[key]);
check(`stat ${key} varies across roster`, Math.max(...vals) - Math.min(...vals) > 0.2);
}
check('3 engine classes', rules.engineClasses.length === 3);
check('classes ordered by speed', rules.engineClasses.every((c, i, a) => i === 0 || c.speedMult > a[i - 1].speedMult));
check('points table has 9 entries', rules.pointsTable.length === 9);
check('points table descending', rules.pointsTable.every((p, i, a) => i === 0 || p <= a[i - 1]));
for (const item of rules.items) {
const row = rules.itemWeights.byPosition[item.id];
check(`item ${item.id} has 9 weight columns`, Array.isArray(row) && row.length === 9);
}
for (let col = 0; col < 9; col += 1) {
const total = rules.items.reduce((sum, it) => sum + (rules.itemWeights.byPosition[it.id]?.[col] ?? 0), 0);
check(`weight column ${col + 1} sums > 0`, total > 0);
}
const physKeys = ['baseTopSpeed', 'baseAccel', 'turnRateBase', 'hopMs', 'offroadMultMin',
'boostPadMult', 'spinMs', 'gridSpacing', 'rouletteMs', 'rubberBandGain'];
for (const k of physKeys) check(`physics.${k} present`, typeof rules.physics[k] === 'number');
check('3 cups', cups.cups.length === 3);
check('12 tracks in index', cups.tracks.length === 12);
check('cup tracks are 4 each', cups.cups.every((c) => c.tracks.length === 4));
const trackIds = new Set(cups.tracks.map((t) => t.id));
check('track ids unique', trackIds.size === cups.tracks.length);
for (const cup of cups.cups) {
for (const tid of cup.tracks) check(`cup ${cup.id} track ${tid} in index`, trackIds.has(tid));
}
for (const t of cups.tracks) {
check(`track ${t.id} theme defined`, !!rules.themes[t.theme]);
check(`track file ${t.file} exists`, existsSync(join(GAMEDATA, t.file)));
}
// ── 2. Track models ─────────────────────────────────────────────────────────
const models = new Map();
for (const t of cups.tracks) {
const json = readJson(join(GAMEDATA, t.file));
check(`${t.id} id matches file`, json.id === t.id);
const model = buildTrackModel(json);
models.set(t.id, model);
const issues = validateTrack(model);
check(`${t.id} validates`, issues.length === 0, issues.join('; '));
check(`${t.id} lap length sane`, model.totalLength > 5000 && model.totalLength < 16000,
String(Math.round(model.totalLength)));
check(`${t.id} has checkpoints`, model.checkpoints.length >= 60);
const cpS = model.checkpoints.map((cp) => (cp.s - model.startS + model.totalLength) % model.totalLength);
check(`${t.id} checkpoints ordered`, cpS.every((s, i, a) => i === 0 || s > a[i - 1]));
const roadish = [SURFACE.ROAD, SURFACE.CURB, SURFACE.BOOST];
const gridOk = model.gridSlots.filter((g) => roadish.includes(surfaceAt(model, g.x, g.y)));
check(`${t.id} 9 grid slots on road`, gridOk.length === 9, `${gridOk.length}/9`);
const centerOk = model.samples.filter((p) => roadish.includes(surfaceAt(model, p.x, p.y)));
check(`${t.id} centerline ≥95% on road`, centerOk.length / model.samples.length >= 0.95,
`${centerOk.length}/${model.samples.length}`);
const boxOk = model.itemBoxes.filter((b) => roadish.includes(surfaceAt(model, b.x, b.y)));
check(`${t.id} item boxes on road`, boxOk.length === model.itemBoxes.length);
const coinOk = model.coins.filter((c) => roadish.includes(surfaceAt(model, c.x, c.y)));
check(`${t.id} coins on road`, coinOk.length === model.coins.length,
`${coinOk.length}/${model.coins.length}`);
check(`${t.id} has item rows`, model.itemBoxes.length >= 8);
}
// ── 3. Physics invariants ───────────────────────────────────────────────────
const cls = rules.engineClasses[1];
const model1 = models.get('track-001');
// Speed cap + NaN across a full AI race, checked every step.
{
const state = createRace({
trackModel: model1, rules, engineClass: cls, racers, playerIndex: -1, mode: 'gp', seed: 7,
});
const absCap = topSpeedOf({ ...racers.find((r) => r.id === 'gerome').stats, topSpeed: 1 }, rules.physics, cls.speedMult)
* (1 + rules.physics.maxCoins * rules.physics.coinTopSpeedBonus)
* Math.max(rules.physics.boostPadMult, 1.5) * 1.25 + 1;
let capOk = true;
let nanOk = true;
for (let i = 0; i < 60 * 150 && !state.karts.every((k) => k.finished); i += 1) {
step(state, kartInputsNeutral());
for (const k of state.karts) {
if (k.speed > absCap) capOk = false;
if (!Number.isFinite(k.x) || !Number.isFinite(k.y) || !Number.isFinite(k.speed)) nanOk = false;
}
}
check('speed never exceeds absolute cap', capOk);
check('no NaN/Infinity in kart state', nanOk);
}
check('offroad slows a default racer', offroadMultOf(racers[0].stats, rules.physics) < 0.75);
const zan = racers.find((r) => r.id === 'zanthor');
const mario = racers.find((r) => r.id === 'mario');
check('zanthor beats mario off-road',
surfaceMult(SURFACE.OFFROAD, zan.stats, rules.physics) > surfaceMult(SURFACE.OFFROAD, mario.stats, rules.physics));
check('deep is a crawl', surfaceMult(SURFACE.DEEP, mario.stats, rules.physics) <= 0.3);
// Weight bump: heavy kart displaces the light one.
{
const smasher = racers.find((r) => r.id === 'smasher');
const kona = racers.find((r) => r.id === 'kona');
const state = createRace({
trackModel: model1, rules, engineClass: cls, racers: [smasher, kona], playerIndex: -1, mode: 'gp', seed: 3,
});
const [a, b] = state.karts;
a.x = 2000; a.y = 2000;
b.x = 2000 + rules.physics.kartRadius; b.y = 2000; // overlapping
const ax0 = a.x;
const bx0 = b.x;
step(state, kartInputsNeutral());
const aMove = Math.abs(a.x - ax0);
const bMove = Math.abs(b.x - bx0);
check('lighter kart displaced more in a bump', bMove > aMove, `smasher ${aMove.toFixed(1)} vs kona ${bMove.toFixed(1)}`);
}
// pointsFor sanity.
check('winner gets top points', pointsFor(1, rules) === rules.pointsTable[0]);
check('9th gets bottom points', pointsFor(9, rules) === rules.pointsTable[8]);
// ── 4. Item roulette distribution ───────────────────────────────────────────
for (const position of [1, 5, 9]) {
const rng = mulberry32(1234 + position);
const counts = {};
const N = 20000;
for (let i = 0; i < N; i += 1) {
const id = rollItem(rng, position, rules);
counts[id] = (counts[id] ?? 0) + 1;
}
const col = position - 1;
const total = rules.items.reduce((s, it) => s + rules.itemWeights.byPosition[it.id][col], 0);
for (const it of rules.items) {
const w = rules.itemWeights.byPosition[it.id][col];
const expected = (w / total) * N;
const got = counts[it.id] ?? 0;
if (w === 0) check(`P${position} never rolls ${it.id}`, got === 0, String(got));
else if (w >= 5) {
check(`P${position} ${it.id} frequency ≈ weight`, got > expected * 0.85 && got < expected * 1.15,
`expected ~${Math.round(expected)}, got ${got}`);
}
}
}
// ── 5. AI soak on every track ───────────────────────────────────────────────
console.log('AI soak: 9-kart race on all 12 tracks…');
for (const t of cups.tracks) {
const model = models.get(t.id);
const state = createRace({
trackModel: model, rules, engineClass: cls, racers, playerIndex: -1, mode: 'gp', seed: 11,
});
const cap = 60 * 60 * 8; // 8 sim minutes
let ticks = 0;
let permOk = true;
let sawItems = 0;
while (ticks < cap && !state.karts.every((k) => k.finished)) {
step(state, kartInputsNeutral());
ticks += 1;
if (ticks % 600 === 0) {
const positions = new Set(state.karts.map((k) => k.position));
if (positions.size !== 9) permOk = false;
}
for (const ev of state.events) if (ev.type === 'item-use') sawItems += 1;
}
check(`${t.id} all 9 finish`, state.karts.every((k) => k.finished),
`${state.karts.filter((k) => k.finished).length}/9 in ${Math.round(ticks / 60)}s sim`);
check(`${t.id} positions always a permutation`, permOk);
check(`${t.id} AI uses items`, sawItems > 3, String(sawItems));
const worstReverse = Math.max(...state.karts.map((k) => k.reverseCount ?? 0));
check(`${t.id} no chronic stuck loops`, worstReverse <= 15, `worst kart reversed ${worstReverse}x`);
const winner = finalizeRace(state)[0];
check(`${t.id} winner has best lap`, winner.bestLapMs > 5000 && winner.bestLapMs < 120000,
`${Math.round(winner.bestLapMs / 100) / 10}s`);
}
// ── 6. Full GP soak + determinism ───────────────────────────────────────────
console.log('GP soak: 3 classes × 3 cups…');
for (const [ci, engineClass] of rules.engineClasses.entries()) {
for (const cup of cups.cups) {
const points = Object.fromEntries(racers.map((r) => [r.id, 0]));
for (const [ri, tid] of cup.tracks.entries()) {
const state = createRace({
trackModel: models.get(tid), rules, engineClass, racers, playerIndex: -1, mode: 'gp',
seed: 100 + ci * 37 + ri,
});
let ticks = 0;
while (ticks < 60 * 60 * 8 && !state.karts.every((k) => k.finished)) {
step(state, kartInputsNeutral());
ticks += 1;
}
const results = finalizeRace(state);
const sum = results.reduce((s, r) => s + r.points, 0);
const expectedSum = rules.pointsTable.reduce((a, b) => a + b, 0);
check(`${engineClass.id}/${cup.id}/${tid} points sum invariant`, sum === expectedSum, `${sum} vs ${expectedSum}`);
for (const r of results) points[r.racerId] += r.points;
}
const standings = Object.values(points);
check(`${engineClass.id}/${cup.id} standings accumulate`, Math.max(...standings) > 0);
}
}
// Determinism: identical seed → identical outcome.
{
const run = () => {
const state = createRace({
trackModel: model1, rules, engineClass: cls, racers, playerIndex: -1, mode: 'gp', seed: 424242,
});
let ticks = 0;
while (ticks < 60 * 60 * 8 && !state.karts.every((k) => k.finished)) {
step(state, kartInputsNeutral());
ticks += 1;
}
return state.karts.map((k) => `${k.racer.id}:${k.finishTimeMs}`).join('|');
};
check('deterministic for identical seed', run() === run());
}
// Countdown export used by the scene HUD timer.
check('COUNTDOWN_MS sane', COUNTDOWN_MS > 2000 && COUNTDOWN_MS < 6000);
console.log(`\n${pass + fail} checks: ${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);