diff --git a/assets/gamedata/superkart/cups.json b/assets/gamedata/superkart/cups.json new file mode 100644 index 0000000..62974ca --- /dev/null +++ b/assets/gamedata/superkart/cups.json @@ -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" + } + ] +} diff --git a/assets/gamedata/superkart/track-001.json b/assets/gamedata/superkart/track-001.json new file mode 100644 index 0000000..8209041 --- /dev/null +++ b/assets/gamedata/superkart/track-001.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-002.json b/assets/gamedata/superkart/track-002.json new file mode 100644 index 0000000..37ff930 --- /dev/null +++ b/assets/gamedata/superkart/track-002.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-003.json b/assets/gamedata/superkart/track-003.json new file mode 100644 index 0000000..07036f3 --- /dev/null +++ b/assets/gamedata/superkart/track-003.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-004.json b/assets/gamedata/superkart/track-004.json new file mode 100644 index 0000000..c606201 --- /dev/null +++ b/assets/gamedata/superkart/track-004.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-005.json b/assets/gamedata/superkart/track-005.json new file mode 100644 index 0000000..cd99d5d --- /dev/null +++ b/assets/gamedata/superkart/track-005.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-006.json b/assets/gamedata/superkart/track-006.json new file mode 100644 index 0000000..f563370 --- /dev/null +++ b/assets/gamedata/superkart/track-006.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-007.json b/assets/gamedata/superkart/track-007.json new file mode 100644 index 0000000..ca3a192 --- /dev/null +++ b/assets/gamedata/superkart/track-007.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-008.json b/assets/gamedata/superkart/track-008.json new file mode 100644 index 0000000..8609e79 --- /dev/null +++ b/assets/gamedata/superkart/track-008.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-009.json b/assets/gamedata/superkart/track-009.json new file mode 100644 index 0000000..11943d4 --- /dev/null +++ b/assets/gamedata/superkart/track-009.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-010.json b/assets/gamedata/superkart/track-010.json new file mode 100644 index 0000000..f0c139a --- /dev/null +++ b/assets/gamedata/superkart/track-010.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-011.json b/assets/gamedata/superkart/track-011.json new file mode 100644 index 0000000..29b1287 --- /dev/null +++ b/assets/gamedata/superkart/track-011.json @@ -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 + } + ] +} diff --git a/assets/gamedata/superkart/track-012.json b/assets/gamedata/superkart/track-012.json new file mode 100644 index 0000000..72a8e86 --- /dev/null +++ b/assets/gamedata/superkart/track-012.json @@ -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 + } + ] +} diff --git a/data/superkart-artwork.json b/data/superkart-artwork.json new file mode 100644 index 0000000..33dd5d8 --- /dev/null +++ b/data/superkart-artwork.json @@ -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 } +} diff --git a/data/superkart-racers.json b/data/superkart-racers.json new file mode 100644 index 0000000..f22d555 --- /dev/null +++ b/data/superkart-racers.json @@ -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" + } + ] +} diff --git a/data/superkart-rules.json b/data/superkart-rules.json new file mode 100644 index 0000000..23789ac --- /dev/null +++ b/data/superkart-rules.json @@ -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"] + } + } +} diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 3aa0bd3..13c9eaa 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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 diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 4c2140a..87e33cc 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -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 }); diff --git a/src/games/superkart/SuperKartEditor.js b/src/games/superkart/SuperKartEditor.js new file mode 100644 index 0000000..82b99a9 --- /dev/null +++ b/src/games/superkart/SuperKartEditor.js @@ -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 .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)); +} diff --git a/src/games/superkart/SuperKartGame.js b/src/games/superkart/SuperKartGame.js new file mode 100644 index 0000000..72c4c9c --- /dev/null +++ b/src/games/superkart/SuperKartGame.js @@ -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]; +} diff --git a/src/games/superkart/SuperKartLogic.js b/src/games/superkart/SuperKartLogic.js new file mode 100644 index 0000000..bb902de --- /dev/null +++ b/src/games/superkart/SuperKartLogic.js @@ -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), + })); +} diff --git a/src/games/superkart/SuperKartMode7.js b/src/games/superkart/SuperKartMode7.js new file mode 100644 index 0000000..4cd2d3d --- /dev/null +++ b/src/games/superkart/SuperKartMode7.js @@ -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; + } +} diff --git a/src/games/superkart/SuperKartRaster.js b/src/games/superkart/SuperKartRaster.js new file mode 100644 index 0000000..1c71f91 --- /dev/null +++ b/src/games/superkart/SuperKartRaster.js @@ -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; +} diff --git a/src/games/superkart/SuperKartTrack.js b/src/games/superkart/SuperKartTrack.js new file mode 100644 index 0000000..6882a2c --- /dev/null +++ b/src/games/superkart/SuperKartTrack.js @@ -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; +} diff --git a/src/main.js b/src/main.js index 01d8fff..7a28f20 100644 --- a/src/main.js +++ b/src/main.js @@ -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, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index fc46557..0eff251 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame' }; + 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 = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index e6cc171..55f232c 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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= jumps straight to the same place a // main-menu click would (GameMenuScene.openGame's branching logic). const deepLinkGame = getGame(params.get('game')); diff --git a/tools/genSuperKartTracks.js b/tools/genSuperKartTracks.js new file mode 100644 index 0000000..b7e10b9 --- /dev/null +++ b/tools/genSuperKartTracks.js @@ -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); diff --git a/tools/verifySuperKart.js b/tools/verifySuperKart.js new file mode 100644 index 0000000..233d987 --- /dev/null +++ b/tools/verifySuperKart.js @@ -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);