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