Pursuit, drag and derby modes; crash-damage paint; junction fixes

Three new modes, all our own code:
- Pursuit (Simpsons Hit & Run's meter): smashing in cruise fills heat, at full
  the police hunt you. Open 250 m for 6 s to escape, get pinned 4 s to be busted.
  Heat decays, so picking a fight is a choice not a timer.
- Drag (NFSU2's): the gearbox IS the event. The builder finds each level's
  longest genuinely straight run of wide road (442-628 m across the five OSM
  levels) and drops DragStart/DragEnd, so no level needs hand-authored coords.
- Derby: five opponents in the carpark, last one running. Reuses the pursuit AI,
  each hunting a different car so it's a brawl not a mob.

Crash damage now shows: total control-point travel drives paint dulling,
roughness and a scratch layer, so scuffs and crumple can never disagree.

Junction generator fixes, all found by measurement rather than inspection:
- streams[:6] truncated after street A's ways, so junctions where A yielded 3+
  usable ways shipped with ZERO cross traffic. Interleaved before capping;
  every event now has 2-4 crossing streams.
- RADIUS 170 -> 95 spread the convoy over a 340 m window, so you threaded the
  intersection through a 20 m gap. Margaret x Edward was unwinnable: 27 convoy
  cars alive, zero collisions, $0.
- Launch points now walk the street to exactly SPAWN_DIST and pick the busiest
  shared node, and _clear_spawn() unblocks a buried start. Albert x Adelaide
  went from $0 (drove 1 m into a building) to $91k.
- build_all_junctions.sh records the street pairs, which existed nowhere before.

Fixes from an adversarial review (23 confirmed findings), the worst being:
- Derby could only ever be LOST: wrecked opponents un-wrecked 2.2 s later via
  Car._recover, so "last one running" was unreachable and the kill bounty could
  be farmed to the boost cap off one revived car. They now hold their wreck.
- Drag reported WON to a beaten player: the AI's lookahead wrapped on a 2-point
  path, U-turning before the line so its distance-to-finish grew again.
- The scratch layer used BLEND_MODE_MIX, whose mask defaults to white -- it
  replaced the paint entirely rather than scuffing it. Now MUL.
- Drag on a level with no strip left the car at the origin, half-buried, unable
  to end. Cop spawns used an unflattened basis. Stale "WANTED" HUD text.

Test harness: smoke covers all seven modes, and failures now exit instead of
hanging -- a failed assert in a headless SceneTree halts the script but leaves
the tree spinning, which cost 53 minutes of wall clock to notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-28 23:07:41 +10:00
parent 59202c222c
commit 0c13c82eac
31 changed files with 1679 additions and 900 deletions

View File

@ -22,15 +22,46 @@ Controls: WASD/arrows drive, Shift drift, Space boost, R reset (or retry after C
- **Crash Junctions** — pick a "CRASH JCT" entry in the level list: launch into scripted - **Crash Junctions** — pick a "CRASH JCT" entry in the level list: launch into scripted
cross-traffic convoys at a real intersection, then Aftertouch-steer your wreck (arrows) cross-traffic convoys at a real intersection, then Aftertouch-steer your wreck (arrows)
and detonate the Crashbreaker (space). Damage tally + bronze/silver/gold. Events live in and detonate the Crashbreaker (space). Damage tally + bronze/silver/gold. Events live in
`levels/<id>/junctions.json`; generate more from real intersections with `levels/<id>/junctions.json`, regenerated in one go by
`tools/osm_junction.py <area.json> <out.json> <lat0> <lon0> "Name|Street A|Street B"`. [tools/build_all_junctions.sh](tools/build_all_junctions.sh) — which is also the only
record of *which two streets each event is*, so keep the spec list in it current.
Medal targets come from [tools/tune_junctions.gd](tools/tune_junctions.gd), which plays
every event headless with varied launches and sets bronze/silver/gold at the
35th/65th/88th percentile of what was actually achieved. They were previously one
uniform 15/35/60k guess across every event, which measurement showed was nonsense —
achievable damage ranges from a couple of thousand to nearly $100k depending on the
intersection. Percentiles rather than the max, so gold is a good run and not a freak one.
```
Godot --headless --path . --script tools/tune_junctions.gd -- runs=14
```
Two traps worth knowing. It reloads the level for every run, so a full pass is slow —
budget tens of minutes. And **nothing else may touch Godot or the levels while it
runs**: a second Godot instance contends for the `.godot` import lock, and a level
rebuild mid-flight silently feeds it half-written levels, which reads as "this event
scores zero" rather than as an error.
- **Race** — 3 laps vs 3 rubber-banded rivals on the level's `RacePath`. - **Race** — 3 laps vs 3 rubber-banded rivals on the level's `RacePath`.
- **Drag** — NFSU2's event, our code: a straight-line run down the level's longest
genuinely straight stretch of wide road (the builder finds it and drops
`DragStart`/`DragEnd` markers, so no level needs hand-authored coordinates).
The gearbox *is* the game — revs climb, **space shifts**, and shifting inside
the window pays while an early shift or sitting on the limiter bogs you. One
opponent alongside.
- **Derby** — Destruction Derby in the Woolies carpark: five opponents, last one
running wins. They use the pursuit AI, each hunting a different car so it's a
brawl rather than a mob, and leaving the ring for 8 s forfeits.
- **Pursuit** — not a menu mode; it happens *to* you in Cruise. Smashing things
fills a heat meter, and at full heat the police turn up and hunt you. Open
250 m for 6 s to lose them, or get pinned for 4 s and you're busted. Heat
decays if you behave, so picking a fight is a choice rather than a timer.
The boost loop is Burnout 3's: earn boost by drifting, near-missing traffic, and smashing The boost loop is Burnout 3's: earn boost by drifting, near-missing traffic, and smashing
things. Wrecking a rival near you = TAKEDOWN — refills the meter and grows it (up to 4x). things. Wrecking a rival near you = TAKEDOWN — refills the meter and grows it (up to 4x).
Hard crashes shed body panels, crumple the bodywork and trigger Impact Time slow-mo. Hard crashes shed body panels, crumple the bodywork and trigger Impact Time slow-mo.
## Crash deformation ## Crash deformation and damage
[src/deform.gd](src/deform.gd) implements the control-hull model from the design [src/deform.gd](src/deform.gd) implements the control-hull model from the design
PDF: a 3×3×5 lattice of control points encloses the car, an impact shoves the PDF: a 3×3×5 lattice of control points encloses the car, an impact shoves the
@ -44,6 +75,12 @@ so they're precomputed once per car and each impact is a plain weighted sum —
instead of folding through itself. Only the player and rivals carry a deformer; instead of folding through itself. Only the player and rivals carry a deformer;
traffic and parked cars are plain bodies. traffic and parked cars are plain bodies.
Paint damage rides the same numbers: total control-point travel drives the albedo
dulling toward dirty grey, the roughness climbing, and (past 35% damage) a scratch
detail layer switching on — so the scuffs and the crumple can never disagree.
Meshes *and* materials are deep-copied per car; share either and denting one
Kingswood dents every Kingswood in the level.
## Cars ## Cars
A car is a folder: `cars/<id>/stats.tres` + optional `car.glb`. Models are built A car is a folder: `cars/<id>/stats.tres` + optional `car.glb`. Models are built

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cs6ren4juu8hi"
path="res://.godot/imported/scratches.jpg-cf4940209544b792b522de339ec4412b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/textures/scratches.jpg"
dest_files=["res://.godot/imported/scratches.jpg-cf4940209544b792b522de339ec4412b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -2,10 +2,10 @@
{ {
"name": "Alice x George", "name": "Alice x George",
"spawn": { "spawn": {
"x": -143.83199552181551, "x": -165.3192678228276,
"z": 142.01018819980536, "z": 159.9955423589618,
"dx": 0.7668348571083106, "dx": 0.7668331983120437,
"dz": -0.6418444530598335 "dz": -0.6418464348787193
}, },
"streams": [ "streams": [
{ {
@ -56,7 +56,29 @@
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
},
{
"pts": [
[
-75.361941989609,
84.82292365250544
],
[
-80.93953886827424,
78.61163770120406
],
[
-82.56599989409156,
76.7348960854059
],
[
-115.40558143561937,
38.74155522137386
]
],
"speed": 12.0,
"interval": 1.5
}, },
{ {
"pts": [ "pts": [
@ -106,65 +128,7 @@
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
},
{
"pts": [
[
29.76081815300818,
-3.9437773782503602
],
[
0.8522815554834703,
21.169938953797764
],
[
-51.1224413806449,
67.0201498038004
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
-54.694766613638436,
62.97064459578822
],
[
-2.7090703425578857,
17.11077984650507
],
[
26.219388577689813,
-8.020329421713114
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
-75.361941989609,
84.82292365250544
],
[
-80.93953886827424,
78.61163770120406
],
[
-82.56599989409156,
76.7348960854059
],
[
-115.40558143561937,
38.74155522137386
]
],
"speed": 12.0,
"interval": 2.2
}, },
{ {
"pts": [ "pts": [
@ -186,7 +150,147 @@
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
},
{
"pts": [
[
-72.11668883510822,
80.61865688274891
],
[
-63.31551687082244,
85.23196646473183
],
[
-54.55178175870453,
95.53822905343011
],
[
-39.78320194928109,
112.96784568335697
],
[
-38.24953472930413,
114.64142478239386
],
[
-37.466790819459995,
115.77089209241056
],
[
-36.90754547102499,
116.86830840435711
],
[
-36.64154284861633,
117.96012844030032
],
[
-36.54962586770791,
118.92829018795193
],
[
-36.58380403095183,
120.63163096695364
],
[
-36.733516672801336,
121.77324800253669
],
[
-36.949600973076684,
122.55506784741175
],
[
-37.31118692587169,
123.40422578212599
],
[
-38.02064964168787,
124.38554265247478
],
[
-39.45793344555888,
125.80100694689808
],
[
-41.96015977939887,
127.8781767661959
]
],
"speed": 12.0,
"interval": 1.5
},
{
"pts": [
[
-45.40767874247836,
123.72190283402932
],
[
-43.03089623055088,
121.75206425317394
],
[
-41.9792929338678,
120.7127857471952
],
[
-41.99739811738572,
120.72105141795794
],
[
-42.06268798731611,
120.81830615252781
],
[
-42.041735423545425,
120.78187839726885
],
[
-41.974164266802894,
120.30911503284231
],
[
-41.94908321684239,
119.00484301205215
],
[
-41.975684667956976,
118.80092035936316
],
[
-41.94671891520851,
118.80911519521383
],
[
-42.0788310934576,
118.57964350736775
],
[
-42.38250615411705,
118.1168452176333
],
[
-43.89081206390128,
116.47320431640294
],
[
-58.67282832403665,
99.02778134654227
],
[
-66.81772305118318,
89.3422647349603
],
[
-74.5893786955468,
85.41926151696784
]
],
"speed": 11.0,
"interval": 1.8
} }
], ],
"targets": [ "targets": [
@ -198,64 +302,12 @@
{ {
"name": "Margaret x Edward", "name": "Margaret x Edward",
"spawn": { "spawn": {
"x": 88.92833122889411, "x": 276.7158840463856,
"z": -205.50177900009658, "z": -371.11587218595514,
"dx": 0.7566215209215891, "dx": -0.7427057931255263,
"dz": -0.6538530982401947 "dz": 0.6696178797327494
}, },
"streams": [ "streams": [
{
"pts": [
[
87.16391537755105,
-207.5455100742669
],
[
176.66505381848225,
-284.81459374842393
],
[
179.4786506494242,
-287.246171304069
],
[
182.2405613884401,
-289.6339705318028
],
[
185.85530062949178,
-292.8318579572001
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
189.43331887674478,
-288.78738204278
],
[
185.7986806034979,
-285.57197746787017
],
[
183.00997804674455,
-283.16086509609204
],
[
180.19394928641634,
-280.72718665144987
],
[
90.69274708023717,
-203.45804792592625
]
],
"speed": 11.0,
"interval": 2.6
},
{ {
"pts": [ "pts": [
[ [
@ -284,7 +336,41 @@
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
},
{
"pts": [
[
179.81812157632743,
-304.16623584225226
],
[
181.35983927566346,
-302.4069925620872
],
[
184.2133034529848,
-299.1448137854387
],
[
189.71555797124375,
-292.5416508365839
],
[
194.9234531376747,
-286.3366793395667
],
[
234.05675616721896,
-241.1315253602194
],
[
244.50993125963066,
-229.32934036140196
]
],
"speed": 12.0,
"interval": 1.5
}, },
{ {
"pts": [ "pts": [
@ -314,71 +400,13 @@
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
}, },
{ {
"pts": [ "pts": [
[ [
116.8650308963045, 240.4674984004257,
-376.11651856442666 -225.74901403845038
],
[
122.97019010007931,
-369.0715631207818
],
[
179.81860554472172,
-304.1656834379468
],
[
181.35983927566346,
-302.4069925620872
],
[
184.2133034529848,
-299.1448137854387
],
[
189.71555797124375,
-292.5416508365839
],
[
194.9234531376747,
-286.3366793395667
],
[
234.05675616721896,
-241.1315253602194
],
[
244.51403825990147,
-229.32469253293408
],
[
255.05386474930117,
-217.31435380098776
],
[
256.9119332285974,
-215.07700198364842
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
252.7568367319326,
-211.628064016186
],
[
250.9803399157384,
-213.769442598948
],
[
240.4633914001549,
-225.75366186691826
], ],
[ [
229.982416914263, 229.982416914263,
@ -401,20 +429,12 @@
-298.8501898379666 -298.8501898379666
], ],
[ [
175.7564478621494, 175.7569318305437,
-300.60775216234316 -300.6071997580377
],
[
118.90617949034443,
-365.5157484793863
],
[
112.7841869787125,
-372.58003543563154
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
} }
], ],
"targets": [ "targets": [

Binary file not shown.

View File

@ -3,7 +3,7 @@
importer="scene" importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://lekue6r6vs5u" uid="uid://dm4gmb5igf0n5"
path="res://.godot/imported/level.glb-4aa597fe23a02438c7c91aac8329adb8.scn" path="res://.godot/imported/level.glb-4aa597fe23a02438c7c91aac8329adb8.scn"
[deps] [deps]

View File

@ -2,21 +2,17 @@
{ {
"name": "Cliffs Corner", "name": "Cliffs Corner",
"spawn": { "spawn": {
"x": 49.98333461330541, "x": 19.82910866163153,
"z": -68.64433920000972, "z": -91.88863639399733,
"dx": 0.4680499904857942, "dx": 0.7116289476302671,
"dz": -0.8837019895905226 "dz": -0.7025555073406219
}, },
"streams": [ "streams": [
{ {
"pts": [ "pts": [
[ [
47.4831595229508, 63.92619388342862,
-69.66371377177926 -109.98937518013152
],
[
63.94149741985269,
-110.02953643297833
], ],
[ [
78.3750416498091, 78.3750416498091,
@ -24,7 +20,21 @@
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
},
{
"pts": [
[
106.13660245700423,
-179.68219050814878
],
[
105.14898470103478,
-213.87167130817437
]
],
"speed": 12.0,
"interval": 1.5
}, },
{ {
"pts": [ "pts": [
@ -33,96 +43,102 @@
-146.95142862004474 -146.95142862004474
], ],
[ [
68.97210013309922, 68.98740366952329,
-108.06662116698321 -108.10678241983003
],
[
52.48350970366002,
-67.62496462824019
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
}, },
{ {
"pts": [ "pts": [
[ [
114.66387685267605, 110.54673313420321,
-57.53027727981913 -214.02759389225906
], ],
[ [
109.86666098023801, 111.53435089017266,
-74.50891216449172 -179.83811309223347
]
],
"speed": 11.0,
"interval": 1.8
},
{
"pts": [
[
110.03793689684629,
-168.49583563849748
], ],
[ [
107.12471250932794, 105.07701815835642,
-155.39569742995997 -166.64105720795808
], ],
[ [
106.88904877360271, 102.76621908994164,
-160.41551590552262 -165.6886852292976
], ],
[ [
106.3952482221942, 100.0562323833477,
-170.89775425802756 -163.76233037299872
],
[
96.9873892973125,
-160.78656603278262
],
[
94.50499850150788,
-158.03413164414619
],
[
91.4342291831665,
-153.71805517412884
],
[
82.70848350683406,
-145.88280488205245
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
}, },
{ {
"pts": [ "pts": [
[ [
111.78926635550437, 79.10280957887883,
-171.15185734246612 -149.90264511833848
], ],
[ [
112.28308355725834, 87.56161285248508,
-160.66926529510476 -157.4814104264721
], ],
[ [
112.52147634417389, 90.25853122720883,
-155.58262017041363 -161.369924756482
], ],
[ [
115.2509302403802, 93.10927630970947,
-74.92079143598734 -164.5442567671812
], ],
[ [
119.8558354049959, 96.57846276532892,
-59.01471872056136 -167.89332522702423
],
[
100.07071556655998,
-170.36781557108517
],
[
103.11280473039093,
-171.67115319207213
],
[
108.14657768085227,
-173.5537759619962
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
},
{
"pts": [
[
105.14797091687734,
-213.92504025483834
],
[
104.12084845022,
-326.6884054546313
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
109.5206244517033,
-326.7375901453881
],
[
110.54774691836064,
-213.9742249455951
]
],
"speed": 11.0,
"interval": 2.6
} }
], ],
"targets": [ "targets": [

Binary file not shown.

View File

@ -3,7 +3,7 @@
importer="scene" importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://cik0c7knt33ep" uid="uid://dik381ld32een"
path="res://.godot/imported/level.glb-30461b4810ef38297351fb89cbe9aa18.scn" path="res://.godot/imported/level.glb-30461b4810ef38297351fb89cbe9aa18.scn"
[deps] [deps]

View File

@ -2,155 +2,33 @@
{ {
"name": "Albert x Adelaide", "name": "Albert x Adelaide",
"spawn": { "spawn": {
"x": -141.55578237343948, "x": 18.671705882497893,
"z": -187.08015060009473, "z": -22.919507778110138,
"dx": 0.735035843445057, "dx": -0.6633868531476461,
"dz": 0.6780282507764803 "dz": -0.7482766086621067
}, },
"streams": [ "streams": [
{ {
"pts": [ "pts": [
[ [
-63.049013950748005, -58.84614846630014,
-111.03404120330288 -114.42417420924829
], ],
[ [
-67.67311197746825, -53.880474905008,
-116.88556835863362 -108.39185473942744
], ],
[ [
-69.33640432781439, -51.24051503498836,
-119.01651536179543 -105.52578654474546
], ],
[ [
-72.74965354025018, -20.352984240690695,
-122.55658461736515 -70.76962163514554
],
[
-98.24408864239251,
-142.51997682349207
],
[
-106.03262882232805,
-149.46281622691504
],
[
-127.33652985317063,
-167.58066870505155
],
[
-136.23707672722756,
-177.3382472125045
],
[
-138.0495681108421,
-179.37835778679877
],
[
-139.33017336122177,
-180.76880262140824
],
[
-143.5234010906565,
-185.23123832968892
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
},
{
"pts": [
[
-139.58816365622246,
-188.92906287050053
],
[
-135.38602332573416,
-184.45711937808235
],
[
-134.03937221770562,
-182.99475501313762
],
[
-132.23691688556315,
-180.9657423870203
],
[
-123.67509820126826,
-171.5497892951127
],
[
-102.50831375382124,
-153.55417337313145
],
[
-94.84937670776534,
-146.71949237661244
],
[
-69.3394816295263,
-126.74355578269574
],
[
-65.32529933336386,
-122.63190423762802
],
[
-63.432906391639634,
-120.22931724123647
],
[
-58.81222264887106,
-114.38211519656227
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
48.262595664052256,
14.981139500351844
],
[
44.3597027328176,
10.810878499923838
],
[
12.513337259306525,
-23.281770877978737
],
[
-24.437975734562166,
-67.23871181646255
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
-20.30439756562987,
-70.71341058345321
],
[
16.56426693178581,
-26.85248072178107
],
[
48.305406828335755,
7.124224299892156
],
[
52.205267737000426,
11.291242899470646
]
],
"speed": 11.0,
"interval": 2.6
}, },
{ {
"pts": [ "pts": [
@ -191,66 +69,40 @@
-163.05430391717795 -163.05430391717795
], ],
[ [
7.529561567214451, 7.537426213222782,
-176.87932594543773 -176.88629254786764
],
[
21.92572970063743,
-189.79048578560977
],
[
25.62426293364586,
-193.04071169887433
],
[
26.615516895233576,
-193.87905456229723
],
[
27.606646263868104,
-194.72792646553222
],
[
32.11664235322457,
-198.8840883667601
],
[
57.105103286489076,
-221.92761298838238
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
}, },
{ {
"pts": [ "pts": [
[ [
60.765864254843116, -24.38938905950134,
-217.9578738114873 -67.18250076477023
], ],
[ [
35.77719298273772, -55.272013374374126,
-194.91415523271732 -101.93315225504524
], ],
[ [
31.239945374075152, -57.98533525129133,
-190.73303753393867 -104.88327646076375
], ],
[ [
30.11741737461444, -63.01508813331892,
-189.76849583719522 -110.99198219061685
], ]
],
"speed": 11.0,
"interval": 1.8
},
{
"pts": [
[ [
29.172798227938216, 11.110167779562063,
-188.97034350113577 -172.83715465195058
],
[
25.522915182059183,
-185.76304781436195
],
[
11.118032425570394,
-172.8441212543805
], ],
[ [
-4.543064055336218, -4.543064055336218,
@ -290,7 +142,75 @@
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
},
{
"pts": [
[
-63.049013950748005,
-111.03404120330288
],
[
-67.67311197746825,
-116.88556835863362
],
[
-69.33640432781439,
-119.01651536179543
],
[
-72.74965354025018,
-122.55658461736515
],
[
-98.24408864239251,
-142.51997682349207
],
[
-106.03262882232805,
-149.46281622691504
],
[
-127.2548410984026,
-167.5083127878196
]
],
"speed": 12.0,
"interval": 1.5
},
{
"pts": [
[
-123.7567869560363,
-171.62214521234466
],
[
-102.50831375382124,
-153.55417337313145
],
[
-94.84937670776534,
-146.71949237661244
],
[
-69.3394816295263,
-126.74355578269574
],
[
-65.32529933336386,
-122.63190423762802
],
[
-63.432906391639634,
-120.22931724123647
],
[
-58.81222264887106,
-114.38211519656227
]
],
"speed": 11.0,
"interval": 1.8
} }
], ],
"targets": [ "targets": [
@ -302,105 +222,39 @@
{ {
"name": "Edward x Elizabeth", "name": "Edward x Elizabeth",
"spawn": { "spawn": {
"x": 329.71079592281694, "x": 173.87950117253465,
"z": -5.595044399780328, "z": -177.50987452854054,
"dx": -0.6532357656212038, "dx": 0.688388502507091,
"dz": -0.7571545644802516 "dz": 0.7253421741606128
}, },
"streams": [ "streams": [
{ {
"pts": [ "pts": [
[ [
258.4805413787254, 228.7426016851313,
-92.28866542876725 -123.80938841141814
], ],
[ [
262.2737122591504, 250.48785179439338,
-88.11954201221562 -101.12418691157934
], ],
[ [
266.8273793037693, 253.19184475779528,
-83.1100839184909 -98.13216139604938
], ],
[ [
270.87333374760516, 258.48528328991137,
-78.65784777138674 -92.28343983096748
],
[
298.1107780903454,
-47.27921270488467
],
[
323.17112576090676,
-16.89985163934848
],
[
325.81187978583756,
-13.69226732374769
],
[
331.6793038120916,
-7.443009954076085
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
}, },
{ {
"pts": [ "pts": [
[ [
327.7422880335423, 184.8196819904455,
-3.7470788454845705 -31.59973983603264
],
[
321.7969353555861,
-10.08114267594156
],
[
319.0053923982548,
-13.463768760358409
],
[
293.9898388277115,
-43.789533694763236
],
[
266.80554434016864,
-75.10635662867679
],
[
262.8314530609969,
-79.47792568097934
],
[
258.27861498952456,
-84.48647198726033
],
[
254.4863239075441,
-88.6546281707152
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
142.50264789077926,
5.258364995815184
],
[
148.94725068367612,
-0.3323178390103432
],
[
154.9526328669413,
-5.595836199336438
],
[
184.81445478686962,
-31.59521315098408
], ],
[ [
200.9901724381037, 200.9901724381037,
@ -428,7 +282,29 @@
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
},
{
"pts": [
[
254.4815819963581,
-88.65985376851496
],
[
249.1869798707451,
-94.50986140343801
],
[
246.5771986992584,
-97.40037268829819
],
[
224.8443191668579,
-120.07262598818004
]
],
"speed": 11.0,
"interval": 1.8
}, },
{ {
"pts": [ "pts": [
@ -457,76 +333,60 @@
-41.47689161686228 -41.47689161686228
], ],
[ [
188.3547169687786, 188.34948976520272,
-27.51764724920578 -27.51312056415722
],
[
158.5007797295594,
-1.5251294005982539
],
[
152.49584713714452,
3.737997039525666
],
[
146.0412138327473,
9.337403004260793
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
}, },
{ {
"pts": [ "pts": [
[ [
299.8317240905086, 258.4805413787254,
-131.8999654994903 -92.28866542876725
], ],
[ [
302.3544939757109, 262.2737122591504,
-134.11003589468572 -88.11954201221562
], ],
[ [
328.3524909104502, 266.8273793037693,
-156.3039359135965 -83.1100839184909
], ],
[ [
335.8998393149425, 270.87333374760516,
-162.7566931458765 -78.65784777138674
], ],
[ [
376.4710012181815, 298.08928934155523,
-198.14867992704066 -47.30427629341134
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
}, },
{ {
"pts": [ "pts": [
[ [
380.02079919024186, 254.70925209424783,
-194.079412872944 -92.50690682734246
], ],
[ [
339.4433211293332, 259.70303711688206,
-158.68192485417183 -96.8600914480509
], ],
[ [
331.85925322106283, 263.66545095997486,
-152.19752408611663 -100.3447248693
], ],
[ [
305.86522961245754, 288.61557906385735,
-130.0070205048928 -122.1176793727558
],
[
303.390286276,
-127.8383604999869
] ]
], ],
"speed": 11.0, "speed": 12.0,
"interval": 2.6 "interval": 1.5
} }
], ],
"targets": [ "targets": [

Binary file not shown.

View File

@ -3,7 +3,7 @@
importer="scene" importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://bo4jjuhgers8e" uid="uid://djsujf76tbo3k"
path="res://.godot/imported/level.glb-f093d0a503a60c4e982eefdc88ad879e.scn" path="res://.godot/imported/level.glb-f093d0a503a60c4e982eefdc88ad879e.scn"
[deps] [deps]

View File

@ -2,25 +2,17 @@
{ {
"name": "Melbourne x Merivale", "name": "Melbourne x Merivale",
"spawn": { "spawn": {
"x": -590.6178507503562, "x": -598.2786454342968,
"z": -217.37742659981197, "z": -212.65545062278278,
"dx": 0.8530074497663933, "dx": 0.8528773292725991,
"dz": -0.5218987360044418 "dz": -0.5221113494388324
}, },
"streams": [ "streams": [
{ {
"pts": [ "pts": [
[ [
-592.80332396911, -522.2431469994345,
-218.96289397888663 -263.2916099068527
],
[
-586.6662380952372,
-227.08658029182723
],
[
-522.2006803362468,
-263.3161280710875
], ],
[ [
-513.2984743659174, -513.2984743659174,
@ -36,7 +28,37 @@
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
},
{
"pts": [
[
-518.3319604245614,
-316.20816625786284
],
[
-502.53998920863535,
-289.11772214349674
],
[
-501.0310720126822,
-286.5339989119062
],
[
-499.3074920997309,
-283.76972490400925
],
[
-498.93054530357944,
-283.2413778417831
],
[
-494.34501447411924,
-276.414559193545
]
],
"speed": 12.0,
"interval": 1.5
}, },
{ {
"pts": [ "pts": [
@ -53,20 +75,42 @@
-263.8934194939837 -263.8934194939837
], ],
[ [
-519.5432562746829, -519.5007896114952,
-258.6152667290545 -258.6397848932893
],
[
-583.8043810107387,
-222.50730370835848
],
[
-588.4323775316024,
-215.7919592207373
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
},
{
"pts": [
[
-498.82766047156883,
-273.4035984064291
],
[
-503.4072640108861,
-280.2216113581537
],
[
-503.85991989590116,
-280.8653486954825
],
[
-505.6522751490968,
-283.7403486882755
],
[
-507.20498688872703,
-286.3978330566516
],
[
-522.997174842978,
-313.4886489421985
]
],
"speed": 11.0,
"interval": 1.8
}, },
{ {
"pts": [ "pts": [
@ -96,89 +140,29 @@
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
}, },
{ {
"pts": [ "pts": [
[ [
-425.28794348242786, -490.9127030755138,
-315.13222698127333 -271.2416907037699
], ],
[ [
-435.78284848324455, -487.07948135301893,
-308.49056735250105 -265.2321814476218
], ],
[ [
-450.4943046193153, -460.79611403958603,
-298.8163541564887 -221.7057389459239
], ],
[ [
-467.3265789435252, -449.78766148749827,
-288.3796882049204 -203.62165684766288
],
[
-486.6041919466704,
-276.5182512930966
],
[
-495.46295396354014,
-272.4538783008375
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
-511.28940466251,
-256.3197151971008
],
[
-539.5118789529853,
-239.934377879393
],
[
-576.453313765919,
-219.8022382435751
],
[
-581.3296752702165,
-217.31316962414144
],
[
-589.8527843522658,
-214.78808818884423
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
},
{
"pts": [
[
-591.3829171484466,
-219.9667650107797
],
[
-583.2152104060733,
-222.37328397575936
],
[
-579.0251997651409,
-224.55043815585836
],
[
-542.1517485157323,
-244.64511972030104
],
[
-514.0007454638794,
-260.98968640245414
]
],
"speed": 11.0,
"interval": 2.6
} }
], ],
"targets": [ "targets": [
@ -190,10 +174,10 @@
{ {
"name": "Grey x Glenelg", "name": "Grey x Glenelg",
"spawn": { "spawn": {
"x": -132.09447432867904, "x": -135.06485953132193,
"z": -92.67206939992113, "z": -97.50127625771249,
"dx": 0.5885516813877528, "dx": 0.5279471843098005,
"dz": 0.8084595959821672 "dz": 0.8492772047920241
}, },
"streams": [ "streams": [
{ {
@ -215,26 +199,52 @@
-1.692678191469608 -1.692678191469608
], ],
[ [
-82.8231789453831, -82.82069253186671,
-8.34834069992463 -8.344352365976752
],
[
-134.38713557935665,
-91.24598932165014
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
}, },
{ {
"pts": [ "pts": [
[ [
-129.80181307800143, -33.05020815528413,
-94.09814947819211 -14.758620186231095
], ],
[ [
-78.23825562104584, -35.06751528318885,
-11.201142499900957 -14.599409411489315
],
[
-37.27001626186296,
-13.996146050544787
],
[
-43.35852481930847,
-10.760668273887504
],
[
-53.694918113870564,
-4.204520462618362
],
[
-56.331462588402815,
-2.4702458690280507
],
[
-63.091151948462084,
2.2185465904455017
]
],
"speed": 12.0,
"interval": 1.5
},
{
"pts": [
[
-78.24074203456223,
-11.205130833848836
], ],
[ [
-74.07981104388904, -74.07981104388904,
@ -254,7 +264,41 @@
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
},
{
"pts": [
[
-66.1688465571084,
-2.2185465904455017
],
[
-59.37835881390333,
-6.9285441308125515
],
[
-56.602733425687326,
-8.754752337189586
],
[
-46.12004993919494,
-15.401140126214159
],
[
-39.50773651812935,
-18.910676349026538
],
[
-36.1005431869604,
-19.899678588618844
],
[
-33.51553068408273,
-20.138534213814854
]
],
"speed": 11.0,
"interval": 1.8
}, },
{ {
"pts": [ "pts": [
@ -267,74 +311,30 @@
7.176315693408943 7.176315693408943
], ],
[ [
-31.591830618167315, -31.608294574960304,
46.425585467880346 46.399012606383145
],
[
5.553177653998906,
108.18727469018124
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
}, },
{ {
"pts": [ "pts": [
[ [
0.9256242241840709, -105.44415325837058,
110.9703933104001 17.29338138921854
], ],
[ [
-36.19855979655569, -124.85549758561747,
49.24303933209625 29.02803771609806
], ],
[ [
-60.768388814750516, -145.9651989732041,
10.095343106510734 41.680234308480806
],
[
-66.8313293113839,
1.5633764655737972
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
-146.4043520315188,
-133.05664071966743
],
[
-108.51077823239306,
-74.87735033479748
],
[
-83.23180011197921,
-33.360028872910426
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
},
{
"pts": [
[
-87.84412394798038,
-30.551743126791347
],
[
-113.07214707658274,
-71.98703646477304
],
[
-150.92919892918795,
-130.10947927979782
]
],
"speed": 11.0,
"interval": 2.6
} }
], ],
"targets": [ "targets": [

Binary file not shown.

View File

@ -3,7 +3,7 @@
importer="scene" importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://ce6dmcne00017" uid="uid://dw52qeb8csi30"
path="res://.godot/imported/level.glb-17af9cdeb05fd15a816097b7ae0fb96c.scn" path="res://.godot/imported/level.glb-17af9cdeb05fd15a816097b7ae0fb96c.scn"
[deps] [deps]

View File

@ -2,191 +2,179 @@
{ {
"name": "Bridge Approach", "name": "Bridge Approach",
"spawn": { "spawn": {
"x": 8.830464544568363, "x": 32.83372074498513,
"z": 869.4323045999147, "z": -118.28842427027045,
"dx": 0.07444215026982055, "dx": -0.11409585864571734,
"dz": -0.9972253337451898 "dz": -0.9934697454074263
}, },
"streams": [ "streams": [
{ {
"pts": [ "pts": [
[ [
6.138933818706997, 21.82446567810751,
869.2186170903494 -237.81387509878263
], ],
[ [
7.71576357448385, 23.145720027383163,
849.3684911078449 -226.45520770652828
], ],
[ [
10.638374835867648, 24.788209612143817,
826.3950177708408 -213.84815371354867
], ],
[ [
13.290793665081416, 26.145569174952232,
807.4161707785927 -200.29741976993301
],
[
16.464164221809273,
786.8204240256596
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
}, },
{ {
"pts": [ "pts": [
[ [
21.80118214360166, 2.199941588527042,
787.6427619747212 -240.32717119002737
], ],
[ [
18.633212388594718, -2.5248172223755736,
808.2026580211177 -243.91950536774928
], ],
[ [
15.991303480558578, -6.325349530807717,
827.1064634292346 -247.60965512377
], ],
[ [
13.086203238107553, -15.183983237537262,
849.932740892406 -255.2613583327868
], ],
[ [
11.521995270429729, -19.822722086570074,
869.64599210948 -258.6729952966697
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
18.99248078399424,
762.39669386784
], ],
[ [
25.743285889885765, -24.279515332983824,
718.3587501785464 -261.70335578638884
], ],
[ [
28.769387854660618, -29.011437974400625,
692.1866319902829 -263.89076106496833
],
[
30.0890967826306,
666.9831668179552
],
[
31.42169559470248,
651.6049662957291
],
[
31.81270185839052,
638.4108005958807
],
[
32.07909068323288,
624.4813765582306
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
}, },
{ {
"pts": [ "pts": [
[ [
37.478103203918025, 20.772447151780117,
624.5846422421965 -199.75931223039953
], ],
[ [
37.211108262175465, 19.423377981138138,
638.5419810043787 -213.23286388675078
], ],
[ [
36.81191635813593, 17.78654515972297,
651.9298049040424 -225.7924522932427
], ],
[ [
35.47759634851261, 16.46063564037367,
667.3354059817195 -237.18991410136036
],
[
34.150141407857646,
692.6421440096167
],
[
31.09171746204034,
719.1032498212462
],
[
24.33013387059421,
763.2148989320567
] ]
], ],
"speed": 11.0, "speed": 11.0,
"interval": 2.6 "interval": 1.8
}, },
{ {
"pts": [ "pts": [
[ [
46.61372413580953, -26.737042902514784,
629.091352780875 -268.7884265350654
], ],
[ [
46.0791068558773, -21.61124337794858,
644.9124964101077 -266.3980682135746
], ],
[ [
45.148393336775854, -16.704188313209045,
661.3968152993656 -263.0814811029762
], ],
[ [
43.42646160590967, -11.761774350471743,
688.423534995915 -259.43849686691397
], ],
[ [
41.167633319989406, -2.7224195143774796,
712.1819019166054 -251.63195487598963
], ],
[ [
39.13812925040989, 0.9839307874462617,
728.9192605875077 -248.02422063236523
],
[
5.4649806575202335,
-244.6282780101317
]
],
"speed": 11.0,
"interval": 1.8
},
{
"pts": [
[
11.27070084290225,
-197.84784324166512
],
[
10.896175804718348,
-204.76641770228525
],
[
10.476477879675205,
-211.20975788808624
],
[
9.92304078347784,
-216.20700696593718
],
[
8.932958301853615,
-221.62531766816238
],
[
7.508738699199866,
-226.8653717079938
] ]
], ],
"speed": 12.0, "speed": 12.0,
"interval": 2.2 "interval": 1.5
}, },
{ {
"pts": [ "pts": [
[ [
33.77740686081252, 28.875188928696993,
728.2691410126066 -229.49242546244793
], ],
[ [
35.797668406451436, 28.72548025209394,
711.6131516837517 -214.05366159277472
], ],
[ [
38.04296969005352, 28.150874632761848,
688.0016170039884 -209.45956029627345
], ],
[ [
39.75839999553985, 27.279147353059784,
661.068224700852 -204.7812989955361
], ],
[ [
40.68465217733579, 26.094879529030933,
644.6678359900116 -199.44340263065618
],
[
41.21680242495871,
628.9090452194655
] ]
], ],
"speed": 11.0, "speed": 12.0,
"interval": 2.6 "interval": 1.5
} }
], ],
"targets": [ "targets": [

Binary file not shown.

View File

@ -3,7 +3,7 @@
importer="scene" importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://dah3x674j2af" uid="uid://bjd3utm2spgs0"
path="res://.godot/imported/level.glb-045fc5cf8c0e529ecdc2678ee7acde2a.scn" path="res://.godot/imported/level.glb-045fc5cf8c0e529ecdc2678ee7acde2a.scn"
[deps] [deps]

50
src/cop.gd Normal file
View File

@ -0,0 +1,50 @@
class_name Cop
extends Car
## Pursuit AI: hunts a target car directly instead of following a path, so it
## works anywhere in free roam rather than only on the RacePath.
##
## Deliberately dumber than a racing line -- it drives at where you are, not
## where you're going. That's what makes side streets and hard turns an actual
## escape rather than a formality (see Game._pursuit_tick).
var target: Node3D
var power_boost := 1.0 # scales with heat: more wanted = more committed
var _stuck := 0.0
var _unstick := 0.0
func _physics_process(delta: float) -> void:
if target and not wrecked:
_hunt(delta)
super(delta)
func _hunt(delta: float) -> void:
var to := target.global_position - global_position
var flat := Vector3(to.x, 0.0, to.z)
var dist := flat.length()
var local := to_local(target.global_position)
var angle := atan2(-local.x, -local.z)
if _unstick > 0.0:
# reversing out of whatever we buried ourselves in
_unstick -= delta
throttle_in = -1.0
steer_in = signf(angle)
drift_in = false
boost_in = false
return
steer_in = clampf(angle * 1.7, -1.0, 1.0)
throttle_in = 0.4 if absf(angle) > 1.3 else 1.0
drift_in = absf(angle) > 0.8 and linear_velocity.length() > 15.0
# close the gap hard, but don't burn boost nose-to-tail
boost_in = boost > 0.3 and absf(angle) < 0.35 and dist > 14.0
power_scale = power_boost
if linear_velocity.length() < 2.0:
_stuck += delta
if _stuck > 1.6:
_stuck = 0.0
_unstick = 1.1
else:
_stuck = 0.0

1
src/cop.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://bbwscf3nqqpe5

8
src/cop.tscn Normal file
View File

@ -0,0 +1,8 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="PackedScene" path="res://src/car.tscn" id="1"]
[ext_resource type="Script" path="res://src/cop.gd" id="2"]
[node name="Cop" instance=ExtResource("1")]
script = ExtResource("2")
is_player = false

View File

@ -23,16 +23,36 @@ const LATTICE := Vector3i(3, 3, 5) # x, y, z resolution of the hull
const MAX_PULL := 0.42 # metres any control point may travel, total const MAX_PULL := 0.42 # metres any control point may travel, total
const DENT_RADIUS := 1.5 # how far along the shell an impact is felt const DENT_RADIUS := 1.5 # how far along the shell an impact is felt
const SCRATCH := "res://assets/textures/scratches.jpg"
## setup() is O(verts x control points) on the main thread, and derby spawns five
## opponents at once. The procedural fleet is ~1k verts (~40k ops, imperceptible),
## but an imported model can be far denser -- past this budget the car simply goes
## without deformation rather than stalling the frame everyone spawns in.
const VERT_BUDGET := 6000
var _meshes: Array = [] # one entry per MeshInstance3D, all its surfaces together var _meshes: Array = [] # one entry per MeshInstance3D, all its surfaces together
var _cp := PackedVector3Array() var _cp := PackedVector3Array()
var _disp := PackedVector3Array() var _disp := PackedVector3Array()
var _ok := false var _ok := false
var _skins: Array = [] # {mat, albedo, rough}: per-car material copies to dirty up
var damage := 0.0 # 0..1, drives the paint getting scratched and dulled
func setup(root: Node3D) -> bool: func setup(root: Node3D) -> bool:
## Snapshot every mesh under `root`, then build the control hull around them. ## Snapshot every mesh under `root`, then build the control hull around them.
var pending: Array = [] var pending: Array = []
var bounds := AABB() var bounds := AABB()
var first := true var first := true
var budget := 0
for n in root.find_children("*", "MeshInstance3D", true, false):
var am0 := (n as MeshInstance3D).mesh as ArrayMesh
if am0:
for s in am0.get_surface_count():
budget += (am0.surface_get_arrays(s)[Mesh.ARRAY_VERTEX] as PackedVector3Array).size()
if budget > VERT_BUDGET:
push_warning("deform: %d verts over the %d budget, skipping" % [budget, VERT_BUDGET])
return false
var seen_mats := {}
for n in root.find_children("*", "MeshInstance3D", true, false): for n in root.find_children("*", "MeshInstance3D", true, false):
var mi := n as MeshInstance3D var mi := n as MeshInstance3D
var am := mi.mesh as ArrayMesh var am := mi.mesh as ArrayMesh
@ -52,8 +72,32 @@ func setup(root: Node3D) -> bool:
first = false first = false
else: else:
bounds = bounds.expand(base[i]) bounds = bounds.expand(base[i])
surfs.append({"arrays": arr, "base": base, # the GLB's materials are shared too -- copy them or denting one
"mat": am.surface_get_material(s)}) # Kingswood scuffs the paint on every other Kingswood
var mat := am.surface_get_material(s)
if mat is BaseMaterial3D:
# one copy per unique source material, not per surface: a model
# sharing one paint material across 20 surfaces would otherwise
# get 20 copies, all being re-tuned on every impact
if seen_mats.has(mat):
surfs.append({"arrays": arr, "base": base, "mat": seen_mats[mat]})
continue
var src := mat
mat = (mat as BaseMaterial3D).duplicate()
seen_mats[src] = mat
var bm := mat as BaseMaterial3D
var skin := {"mat": bm, "albedo": bm.albedo_color, "rough": bm.roughness}
if ResourceLoader.exists(SCRATCH):
bm.detail_enabled = false
# MUL, not MIX: detail_mask defaults to white, so MIX makes the
# scratch texture *replace* the paint outright -- the car turns
# grey instead of getting scuffed. MUL darkens it instead.
bm.detail_blend_mode = BaseMaterial3D.BLEND_MODE_MUL
bm.detail_albedo = load(SCRATCH)
bm.detail_uv_layer = BaseMaterial3D.DETAIL_UV_1
skin["scratch"] = true
_skins.append(skin)
surfs.append({"arrays": arr, "base": base, "mat": mat})
pending.append({"mi": mi, "xform": xform, "surfs": surfs}) pending.append({"mi": mi, "xform": xform, "surfs": surfs})
if pending.is_empty() or bounds.size.length() < 0.1: if pending.is_empty() or bounds.size.length() < 0.1:
return false return false
@ -131,6 +175,27 @@ func impact(local_point: Vector3, local_dir: Vector3, strength: float) -> void:
moved = true moved = true
if moved: if moved:
_write() _write()
_wear()
func _wear() -> void:
## Paint damage tracks how bent the car actually is, so the scratches and the
## crumple always agree -- no separate damage counter to drift out of sync.
var total := 0.0
for d in _disp:
total += d.length()
damage = clampf(total / (_cp.size() * MAX_PULL * 0.28), 0.0, 1.0)
for s in _skins:
var m: BaseMaterial3D = s["mat"]
var base: Color = s["albedo"]
# dull and dirty rather than recolour: a smashed shitbox is still its
# own colour, just filthier
m.albedo_color = base.lerp(Color(0.34, 0.31, 0.28), damage * 0.5)
m.roughness = lerpf(s["rough"], minf(s["rough"] + 0.45, 1.0), damage)
# StandardMaterial3D's detail layer has no blend-strength dial without a
# mask texture, so gate it on real damage: light scuffs only dull the
# paint, a properly bent car gets visible scratches over the top
if s.has("scratch"):
m.detail_enabled = damage > 0.35
func _write() -> void: func _write() -> void:
for m in _meshes: for m in _meshes:

View File

@ -15,7 +15,10 @@ static var junction: Dictionary = {}
const LAPS := 3 const LAPS := 3
const CRASH_TIME := 60.0 const CRASH_TIME := 60.0
const RIVAL_COUNT := 3 const RIVAL_COUNT := 3
const TRAFFIC_COUNT := {"cruise": 8, "crash": 12, "race": 4} ## drag and derby are explicitly 0: both fall through to the RacePath traffic
## branch otherwise (every level GLB has a RacePath), littering a drag strip and
## a derby ring with loop traffic that belongs to neither event.
const TRAFFIC_COUNT := {"cruise": 8, "crash": 12, "race": 4, "drag": 0, "derby": 0}
const PARKED_MAX := 50 # PP_ markers filled with shuntable fleet cars const PARKED_MAX := 50 # PP_ markers filled with shuntable fleet cars
## Street furniture that topples. Marker prefix -> size, mass, colour. ## Street furniture that topples. Marker prefix -> size, mass, colour.
## Deliberately physics, not scenery: clipping a lamp post at 120 and watching ## Deliberately physics, not scenery: clipping a lamp post at 120 and watching
@ -28,6 +31,27 @@ const SMASHABLE := {
const JCT_RUN_TIME := 30.0 # crash into something before this or the run ends const JCT_RUN_TIME := 30.0 # crash into something before this or the run ends
const JCT_SETTLE := 9.0 # aftertouch/crashbreaker window after the crash const JCT_SETTLE := 9.0 # aftertouch/crashbreaker window after the crash
const ROAM_RIVALS := 2 # challengers circling the RacePath in cruise const ROAM_RIVALS := 2 # challengers circling the RacePath in cruise
## Pursuit (Simpsons Hit & Run's meter, our code): wreck enough street furniture
## and the law turns up. Heat decays if you behave, so it's a choice not a timer.
const HEAT_PER_SMASH := 0.14
const HEAT_DECAY := 0.055 # per second while not smashing anything
const HEAT_COOLDOWN := 20.0 # grace after an escape before heat can build again
const COPS_MIN := 2
const COPS_MAX := 4
const BUST_RANGE := 7.0 # a cop this close, this long, and you're done
const BUST_TIME := 4.0
const ESCAPE_RANGE := 220.0
const ESCAPE_TIME := 6.0
## Drag (NFSU2's, our code): the whole event is the gearbox. Revs climb, you
## shift in the window, and a blown shift costs you the race in a straight line
## where there's nothing else to get wrong.
const DRAG_GEARS := 5
const SHIFT_LO := 0.74 # revs below this = shifted early, lose drive
const REDLINE := 0.99 # hit it without shifting and the engine bogs
const DRAG_REV_RATE := [0.0, 0.62, 0.50, 0.40, 0.32, 0.26] # per gear, revs/sec
const DERBY_CARS := 5 # opponents in the arena, last one running wins
const DERBY_ARENA := 90.0 # leave the ring this long and you forfeit
const DERBY_OUT := 8.0
const OUTRUN_GAP := 250.0 # escape distance that decides an outrun (NFSU2 rule) const OUTRUN_GAP := 250.0 # escape distance that decides an outrun (NFSU2 rule)
const OUTRUN_TIME := 90.0 const OUTRUN_TIME := 90.0
const OUTRUN_START_D := 12.0 # pull alongside within this, at speed, to trigger const OUTRUN_START_D := 12.0 # pull alongside within this, at speed, to trigger
@ -36,6 +60,27 @@ var jstate := "" # junction: "run" -> "settle" -> "done"
var breaker_used := false var breaker_used := false
var spinners: Array[Node3D] = [] # ferris wheels etc, turned in _process var spinners: Array[Node3D] = [] # ferris wheels etc, turned in _process
var heat := 0.0
var heat_cd := 0.0
var cops: Array[Cop] = []
var bust_t := 0.0
var escape_t := 0.0
var busts := 0
var escapes := 0
var drag_end: Vector3
var drag_len := 0.0
var gear := 1
var revs := 0.0
var bog := 0.0 # seconds of lost drive from a blown shift
var clean_shifts := 0
var blown_shifts := 0
var drag_t := 0.0
var derby: Array[Cop] = [] # arena opponents; Cop AI already hunts a target
var derby_centre: Vector3
var derby_out_t := 0.0
var derby_kills := 0
var outrun_rival: Rival = null var outrun_rival: Rival = null
var outrun_t := 0.0 var outrun_t := 0.0
var outrun_cd := 0.0 # cooldown so a finished outrun doesn't instantly re-arm var outrun_cd := 0.0 # cooldown so a finished outrun doesn't instantly re-arm
@ -55,9 +100,11 @@ var near_cd := {} # traffic -> near-miss cooldown
var speed_label: Label var speed_label: Label
var boost_bar: ProgressBar var boost_bar: ProgressBar
var heat_bar: ProgressBar
var info_label: Label var info_label: Label
var msg_label: Label var msg_label: Label
var msg_timer: SceneTreeTimer var msg_timer: SceneTreeTimer
var _sticky := false # a result screen is up; transient messages must not clobber it
func _ready() -> void: func _ready() -> void:
var level: Node = (load(level_path) as PackedScene).instantiate() var level: Node = (load(level_path) as PackedScene).instantiate()
@ -99,8 +146,21 @@ func _ready() -> void:
if mode == "junction": if mode == "junction":
var sp: Dictionary = junction["spawn"] var sp: Dictionary = junction["spawn"]
var jf := Vector3(sp["dx"], 0, sp["dz"]).normalized() var jf := Vector3(sp["dx"], 0, sp["dz"]).normalized()
car.transform = Transform3D(Basis.looking_at(jf, Vector3.UP), Vector3(sp["x"], 0.7, sp["z"])) car.transform = Transform3D(Basis.looking_at(jf, Vector3.UP),
_clear_spawn(Vector3(sp["x"], 0.7, sp["z"]), jf))
car.hold_wreck = true car.hold_wreck = true
# The marker test lives in the branch condition, not inside it: as an `elif`
# with the test inside, a level without a drag strip (the code-built carpark)
# matched the branch, set no transform, and left the car at the world origin
# half-sunk in the ground slab with no way to end the run.
elif mode == "drag" and _drag_markers(level).size() == 2:
var mk := _drag_markers(level)
drag_end = Vector3(mk[1].global_position.x, 0.0, mk[1].global_position.z)
var from := Vector3(mk[0].global_position.x, 0.7, mk[0].global_position.z)
var dir := (drag_end - Vector3(from.x, 0, from.z)).normalized()
drag_len = Vector3(from.x, 0, from.z).distance_to(drag_end)
car.transform = Transform3D(Basis.looking_at(dir, Vector3.UP),
_clear_spawn(from, dir) - dir.cross(Vector3.UP) * 2.0)
elif mode == "race" and race_path: elif mode == "race" and race_path:
car.transform = _grid_xform(0.0) car.transform = _grid_xform(0.0)
else: else:
@ -134,6 +194,52 @@ func _ready() -> void:
prev_off[r] = off prev_off[r] = off
r.wreck_started.connect(_rival_wrecked.bind(r)) r.wreck_started.connect(_rival_wrecked.bind(r))
rivals.append(r) rivals.append(r)
elif mode == "derby":
# Cop AI is exactly what a derby wants: hunts a target, reverses out of
# wedges. Each one chases a different car so it's a brawl, not a mob.
derby_centre = Vector3(car.global_position.x, 0.0, car.global_position.z)
var pool := Registry.cars().values()
for i in DERBY_CARS:
var d: Cop = (load("res://src/cop.tscn") as PackedScene).instantiate()
d.stats = load(pool[randi() % pool.size()])
d.power_boost = 1.0
# a wreck is an elimination: without this car.gd's 2.2 s _recover
# timer stands them back up and the field never empties
d.hold_wreck = true
add_child(d)
var a := TAU * i / DERBY_CARS
var at := derby_centre + Vector3(cos(a), 0, sin(a)) * 26.0 + Vector3.UP * 0.75
d.global_transform = Transform3D(
Basis.looking_at((derby_centre - at).normalized() * Vector3(1, 0, 1), Vector3.UP), at)
d.wreck_started.connect(_derby_down.bind(d))
derby.append(d)
for i in derby.size():
derby[i].target = car if i % 2 == 0 else derby[(i + 1) % derby.size()]
elif mode == "drag" and drag_len > 0.0:
# one opponent, one straight -- a Path3D of two points is all the AI needs
# Run the rival down its OWN lane. Anchoring the strip on the player put
# the AI's racing line on top of the player and they simply collided.
var fwd := -car.global_basis.z
fwd = Vector3(fwd.x, 0.0, fwd.z).normalized()
var lane := fwd.cross(Vector3.UP).normalized() * 4.0
var strip := Path3D.new()
var sc := Curve3D.new()
sc.add_point(car.global_position + lane)
sc.add_point(drag_end + Vector3.UP * 0.7 + lane)
# a 2-point curve makes rival.gd's fmod() lookahead wrap to the start once
# it nears the end, spinning the car around -- extend past the line instead
sc.add_point(drag_end + Vector3.UP * 0.7 + lane + fwd * 120.0)
strip.curve = sc
add_child(strip)
var pool := Registry.cars().values()
var r: Rival = (load("res://src/rival.tscn") as PackedScene).instantiate()
r.stats = load(pool[randi() % pool.size()])
r.path = strip
add_child(r)
r.global_transform = Transform3D(Basis.looking_at(fwd, Vector3.UP),
car.global_position + lane)
r.progress = 0.0
rivals.append(r)
elif mode == "cruise" and race_path: elif mode == "cruise" and race_path:
# roaming challengers: they lap the circuit until you pick a fight # roaming challengers: they lap the circuit until you pick a fight
var pool := Registry.cars().values() var pool := Registry.cars().values()
@ -163,7 +269,7 @@ func _ready() -> void:
add_child(path) add_child(path)
var L := curve.get_baked_length() var L := curve.get_baked_length()
var headway: float = stream["speed"] * stream["interval"] var headway: float = stream["speed"] * stream["interval"]
var n: int = clampi(int(L / headway), 1, 9) var n: int = clampi(int(L / headway), 1, 14)
for i in n: for i in n:
var t := TrafficCar.new() var t := TrafficCar.new()
t.path = path t.path = path
@ -203,6 +309,16 @@ func _ready() -> void:
boost_bar.size = Vector2(160, 18) boost_bar.size = Vector2(160, 18)
boost_bar.show_percentage = false boost_bar.show_percentage = false
hud.add_child(boost_bar) hud.add_child(boost_bar)
heat_bar = ProgressBar.new()
heat_bar.position = Vector2(24, 96)
heat_bar.size = Vector2(160, 10)
heat_bar.show_percentage = false
heat_bar.max_value = 1.0
heat_bar.visible = false
var fill := StyleBoxFlat.new()
fill.bg_color = Color(0.85, 0.16, 0.12)
heat_bar.add_theme_stylebox_override("fill", fill)
hud.add_child(heat_bar)
info_label = Label.new() info_label = Label.new()
info_label.anchor_left = 1.0 info_label.anchor_left = 1.0
info_label.anchor_right = 1.0 info_label.anchor_right = 1.0
@ -220,6 +336,54 @@ func _ready() -> void:
msg_label.add_theme_font_size_override("font_size", 48) msg_label.add_theme_font_size_override("font_size", 48)
hud.add_child(msg_label) hud.add_child(msg_label)
func _drag_markers(level: Node) -> Array[Node3D]:
var out: Array[Node3D] = []
var s := level.find_child("DragStart", true, false) as Node3D
var e := level.find_child("DragEnd", true, false) as Node3D
if s and e:
out = [s, e]
return out
func _clear_spawn(pos: Vector3, fwd: Vector3) -> Vector3:
## The generator places launch points from street centrelines alone, so one
## can land inside a building or a mall canopy leg -- Albert x Adelaide was
## wedged at its start line, 1 m driven in 25 s. Back off along the approach
## until the car actually fits.
var space := get_world_3d().direct_space_state
var q := PhysicsShapeQueryParameters3D.new()
var box := BoxShape3D.new()
# must clear the ground slab: its top face is y=0 and the spawn sits at 0.7,
# so a 1.4-tall box centred there touches the ground at every candidate and
# the whole search silently fails
box.size = Vector3(2.2, 0.9, 4.8)
q.shape = box
var lift := Vector3(0, 0.25, 0)
var side := Vector3.UP.cross(fwd).normalized()
# nearest-first: try the exact spot, then widening back/side offsets. A clear
# box isn't enough -- Albert x Adelaide fitted fine but faced a wall 2.7 m
# on, so the launch corridor has to be clear too.
var tries: Array[Vector3] = [Vector3.ZERO]
# nearest-first, and allow sliding forward as well as back: Albert x Adelaide's
# generated launch sits inside a CBD building block, where nothing behind it
# is clear either. A shortened run-up beats an unplayable event.
for along in [0.0, -6.0, 6.0, -12.0, 12.0, -20.0, 20.0, -30.0, 30.0,
45.0, 60.0, 75.0, 90.0, 105.0]:
for lat in [0.0, 4.0, -4.0, 8.0, -8.0, 13.0, -13.0]:
tries.append(fwd * along + side * lat)
for off in tries:
var at := pos + off
q.transform = Transform3D(Basis.IDENTITY, at + lift)
if not space.intersect_shape(q, 1).is_empty():
continue
var ray := PhysicsRayQueryParameters3D.create(at + lift, at + lift + fwd * 22.0)
if space.intersect_ray(ray).is_empty():
if off.length() > 0.5:
push_warning("junction spawn blocked; moved %.0f m to clear the run-up"
% off.length())
return at
push_warning("junction spawn has no clear launch corridor")
return pos
func _spawn_smashables(level: Node) -> void: func _spawn_smashables(level: Node) -> void:
## Fill the level's PP_/WB_ markers with shuntable parked cars and wheelie ## Fill the level's PP_/WB_ markers with shuntable parked cars and wheelie
## bins. Real fleet models, asleep until rammed. ## bins. Real fleet models, asleep until rammed.
@ -400,6 +564,12 @@ func _physics_process(delta: float) -> void:
_race_tick() _race_tick()
if mode == "cruise" and race_path: if mode == "cruise" and race_path:
_outrun_tick(delta) _outrun_tick(delta)
if mode == "cruise":
_pursuit_tick(delta)
if mode == "drag" and not over:
_drag_tick(delta)
if mode == "derby" and not over:
_derby_tick(delta)
if mode == "junction" and jstate == "settle": if mode == "junction" and jstate == "settle":
if Input.is_action_just_pressed("boost") and not breaker_used: if Input.is_action_just_pressed("boost") and not breaker_used:
_crashbreaker() _crashbreaker()
@ -412,6 +582,194 @@ func _physics_process(delta: float) -> void:
+ fwd.normalized() * Input.get_axis("brake", "throttle") + fwd.normalized() * Input.get_axis("brake", "throttle")
car.apply_central_force(push * car.mass * 9.0) car.apply_central_force(push * car.mass * 9.0)
func _derby_down(d: Cop) -> void:
if over:
return # no bounties after the result screen is up
if not car.wrecked and car.global_position.distance_to(d.global_position) < 22.0:
derby_kills += 1
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
car.boost = car.boost_max
_msg("WRECKED 'EM! x%d" % derby_kills, 46)
func _derby_tick(delta: float) -> void:
var alive: Array[Cop] = []
for d in derby:
if is_instance_valid(d) and not d.wrecked:
alive.append(d)
# retarget anyone hunting a car that's already out, or they circle a corpse
for d in alive:
if d.target == null or not is_instance_valid(d.target) \
or (d.target is Cop and (d.target as Cop).wrecked):
d.target = car if alive.size() < 2 else alive[randi() % alive.size()]
if d.target == d:
d.target = car
var flat := Vector3(car.global_position.x, 0.0, car.global_position.z)
var from_centre := flat.distance_to(derby_centre)
if from_centre > DERBY_ARENA:
derby_out_t += delta
else:
derby_out_t = maxf(derby_out_t - delta, 0.0)
info_label.text = "DERBY %d left %s" % [
alive.size(),
"GET BACK IN %ds" % ceili(DERBY_OUT - derby_out_t) if derby_out_t > 0.5 else "%d wrecked" % derby_kills]
if car.wrecked:
over = true
_msg("WRECKED\n%d of %d taken out\nR to retry, Esc for menu"
% [derby_kills, DERBY_CARS], 44, true)
elif derby_out_t >= DERBY_OUT:
over = true
_msg("RAN AWAY\nR to retry, Esc for menu", 44, true)
elif alive.is_empty():
over = true
_msg("LAST ONE RUNNING\n%d wrecked\nR to retry, Esc for menu" % derby_kills, 48, true)
func _drag_tick(delta: float) -> void:
if drag_len <= 0.0:
info_label.text = "no drag strip in this level"
return
drag_t += delta
bog = maxf(bog - delta, 0.0)
if gear <= DRAG_GEARS:
revs = minf(revs + DRAG_REV_RATE[gear] * delta * (1.0 if bog == 0.0 else 0.25), 1.0)
if Input.is_action_just_pressed("boost"):
if revs >= SHIFT_LO and revs < REDLINE:
clean_shifts += 1
_msg("CLEAN SHIFT", 34)
else:
blown_shifts += 1
bog = 0.9
_msg("MISSED IT", 34)
gear += 1
revs = 0.28 if gear <= DRAG_GEARS else 1.0
elif revs >= REDLINE:
bog = maxf(bog, 0.35) # sitting on the limiter goes nowhere
# drive comes from the box, not the throttle: clean shifts pay, bogging hurts
var tune := 1.0 + clean_shifts * 0.07 - blown_shifts * 0.05
# clamp the gear term: gear runs to DRAG_GEARS+1 as the "past top" sentinel,
# and un-clamped that paid 6/5 -- more grunt than top gear for shifting into nothing
car.power_scale = (0.35 if bog > 0.0 else tune) \
* (0.75 + 0.25 * mini(gear, DRAG_GEARS) / float(DRAG_GEARS))
var flat := Vector3(car.global_position.x, 0.0, car.global_position.z)
var left := flat.distance_to(drag_end)
var rival_left := INF
if not rivals.is_empty():
var rf := Vector3(rivals[0].global_position.x, 0.0, rivals[0].global_position.z)
rival_left = rf.distance_to(drag_end)
info_label.text = "GEAR %d %s %dm %.1fs" % [
mini(gear, DRAG_GEARS),
("BOGGED" if bog > 0.0 else ("SHIFT!" if revs >= SHIFT_LO else "%d%%" % int(revs * 100))),
int(left), drag_t]
# the rival crossing has to end it too, or losing means the run never stops
if left < 12.0 or rival_left < 12.0:
over = true
car.power_scale = 1.0 # _drag_tick stops on `over`, so reset it here
var won := left <= rival_left
_msg("%s %.2fs\n%d clean / %d blown\nR to retry, Esc for menu"
% ["WON" if won else "LOST", drag_t, clean_shifts, blown_shifts], 44, true)
func add_heat(amount: float) -> void:
if mode != "cruise" or heat_cd > 0.0 or not cops.is_empty():
return
heat = minf(heat + amount, 1.0)
if heat >= 1.0:
_start_pursuit()
func _start_pursuit() -> void:
var pool := Registry.cars().values()
var n := COPS_MIN + (busts + escapes) / 2 # they escalate as the night goes on
for i in mini(n, COPS_MAX):
var c: Cop = (load("res://src/cop.tscn") as PackedScene).instantiate()
c.stats = load(pool[randi() % pool.size()])
c.target = car
c.power_boost = 1.12
c.hold_wreck = true # wrecked cop stays out of the chase
add_child(c)
# Drop them in behind, spread across the road, clear of the player.
# Flattened: multiplying by the full basis while the player is pitched
# (mid-jump, up a kerb) spawns cops in the air or under the road.
var flat_b := Basis.looking_at(
Vector3(-car.global_basis.z.x, 0.0, -car.global_basis.z.z).normalized(), Vector3.UP)
var back := flat_b * Vector3((i - 0.5) * 4.0, 0.0, 22.0 + i * 5.0)
c.global_transform = Transform3D(
Basis.looking_at(-back.normalized(), Vector3.UP), car.global_position + back)
c.wreck_started.connect(_cop_wrecked.bind(c))
cops.append(c)
bust_t = 0.0
escape_t = 0.0
_msg("WANTED!\nlose them", 48)
func _cop_wrecked(c: Cop) -> void:
# taking one out is worth the same as any takedown
if not car.wrecked and car.global_position.distance_to(c.global_position) < 25.0:
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
car.boost = car.boost_max
_msg("COP DOWN!", 48)
func _end_pursuit(escaped: bool) -> void:
for c in cops:
if is_instance_valid(c):
c.queue_free()
cops.clear()
heat = 0.0
heat_cd = HEAT_COOLDOWN
info_label.text = "" # nothing else writes this in cruise, so "WANTED" would stick
if escaped:
escapes += 1
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
car.boost = car.boost_max
_msg("LOST 'EM! x%d" % escapes, 52)
else:
busts += 1
_msg("BUSTED", 52)
func _pursuit_tick(delta: float) -> void:
heat_cd = maxf(heat_cd - delta, 0.0)
if cops.is_empty():
if heat > 0.0:
heat = maxf(heat - HEAT_DECAY * delta, 0.0)
return
cops = cops.filter(func(c): return is_instance_valid(c))
if cops.is_empty():
_end_pursuit(true)
return
var nearest := INF
for c in cops:
if not c.wrecked:
nearest = minf(nearest, car.global_position.distance_to(c.global_position))
if nearest == INF: # every cop wrecked = you won
_end_pursuit(true)
return
if nearest < BUST_RANGE and not car.wrecked:
bust_t += delta
escape_t = 0.0
else:
bust_t = maxf(bust_t - delta * 0.5, 0.0)
if nearest > ESCAPE_RANGE:
escape_t += delta
else:
escape_t = 0.0
info_label.text = "WANTED %dm %s" % [
int(nearest),
("BUSTED IN %ds" % ceili(BUST_TIME - bust_t)) if bust_t > 1.0
else ("LOSING THEM %ds" % ceili(ESCAPE_TIME - escape_t)) if escape_t > 0.5
else "%d cars" % cops.size()]
if car.wrecked and nearest < 40.0:
_end_pursuit(false)
elif bust_t >= BUST_TIME:
_end_pursuit(false)
elif escape_t >= ESCAPE_TIME:
_end_pursuit(true)
func _outrun_tick(delta: float) -> void: func _outrun_tick(delta: float) -> void:
## NFSU2-style impromptu 1v1: pull alongside a roaming rival at speed and it's ## NFSU2-style impromptu 1v1: pull alongside a roaming rival at speed and it's
## on. Straight-line distance decides WHEN it ends (so ducking down a side ## on. Straight-line distance decides WHEN it ends (so ducking down a side
@ -510,6 +868,8 @@ func _nearest_off(pos: Vector3, seed_off: float, L: float) -> float:
func _player_crash(dv: float) -> void: func _player_crash(dv: float) -> void:
if mode in ["crash", "junction"] and not over: if mode in ["crash", "junction"] and not over:
score += minf(dv, 30.0) * 120.0 score += minf(dv, 30.0) * 120.0
if dv > 3.0:
add_heat(HEAT_PER_SMASH * minf(dv / 6.0, 2.0))
if mode == "junction" and jstate == "run" and dv > 5.0: if mode == "junction" and jstate == "run" and dv > 5.0:
jstate = "settle" jstate = "settle"
time_left = JCT_SETTLE time_left = JCT_SETTLE
@ -536,6 +896,7 @@ func _traffic_smashed(impulse: float) -> void:
score += impulse * 400.0 score += impulse * 400.0
_msg("$%d" % int(impulse * 400.0), 40) _msg("$%d" % int(impulse * 400.0), 40)
car.earn_boost(0.2) car.earn_boost(0.2)
add_heat(HEAT_PER_SMASH)
func _crashbreaker() -> void: func _crashbreaker() -> void:
breaker_used = true breaker_used = true
@ -594,6 +955,11 @@ func _jct_tally() -> void:
_msg("DAMAGE: $%d\n%s\nR to retry, Esc for menu" % [int(score), medal], 48, true) _msg("DAMAGE: $%d\n%s\nR to retry, Esc for menu" % [int(score), medal], 48, true)
func _msg(text: String, size: int, sticky := false) -> void: func _msg(text: String, size: int, sticky := false) -> void:
# a sticky message is a result screen; don't let a stray "NEAR MISS" from a
# still-rolling wreck overwrite it a moment later
if _sticky and not sticky:
return
_sticky = sticky
msg_label.text = text msg_label.text = text
msg_label.add_theme_font_size_override("font_size", size) msg_label.add_theme_font_size_override("font_size", size)
if sticky: if sticky:
@ -622,7 +988,16 @@ func _process(delta: float) -> void:
if jstate == "run": if jstate == "run":
_msg("Didn't even crash. Cooked.", 40) _msg("Didn't even crash. Cooked.", 40)
_jct_tally() _jct_tally()
if over and mode in ["crash", "junction"] and Input.is_action_just_pressed("reset"): # drag and derby both tell you "R to retry", so they have to honour it too
if mode == "race" and Input.is_action_just_pressed("reset"):
# car.gd teleports the player home on R; without resyncing the lap
# tracker, _nearest_off's 25 m local window walks the offset around the
# loop and silently books phantom laps
for c in laps:
prev_off[c] = 0.0 if c == car else (c as Rival).progress
laps[car] = 0
if over and mode in ["crash", "junction", "drag", "derby"] \
and Input.is_action_just_pressed("reset"):
get_tree().reload_current_scene() get_tree().reload_current_scene()
return return
for sp in spinners: for sp in spinners:
@ -635,3 +1010,5 @@ func _process(delta: float) -> void:
boost_bar.size.x = 160.0 * car.boost_max boost_bar.size.x = 160.0 * car.boost_max
boost_bar.max_value = 100.0 * car.boost_max boost_bar.max_value = 100.0 * car.boost_max
boost_bar.value = car.boost * 100.0 boost_bar.value = car.boost * 100.0
heat_bar.visible = mode == "cruise" and heat > 0.02 and cops.is_empty()
heat_bar.value = heat

View File

@ -1,7 +1,8 @@
extends Control extends Control
## Menu: pick a car, a level and a mode from whatever exists, hit RACE. ## Menu: pick a car, a level and a mode from whatever exists, hit RACE.
const MODES := [["cruise", "Cruise"], ["crash", "Crash Mode"], ["race", "Race"]] const MODES := [["cruise", "Cruise"], ["crash", "Crash Mode"], ["race", "Race"],
["drag", "Drag"], ["derby", "Derby"]]
@onready var car_list: ItemList = $VBox/HBox/Cars @onready var car_list: ItemList = $VBox/HBox/Cars
@onready var level_list: ItemList = $VBox/HBox/Levels @onready var level_list: ItemList = $VBox/HBox/Levels

View File

@ -10,34 +10,36 @@ func _initialize() -> void:
func _run() -> void: func _run() -> void:
var cars := Registry.cars() var cars := Registry.cars()
var levels := Registry.levels() var levels := Registry.levels()
assert(not cars.is_empty(), "no cars found") _check(not cars.is_empty(), "no cars found")
assert(not levels.is_empty(), "no levels found") _check(not levels.is_empty(), "no levels found")
for p in cars.values(): for p in cars.values():
var s: CarStats = load(p) var s: CarStats = load(p)
assert(s != null and s.engine_power > 0.0, "bad stats: %s" % p) _check(s != null and s.engine_power > 0.0, "bad stats: %s" % p)
if s.model_path != "": if s.model_path != "":
assert(load(s.model_path) is PackedScene, "bad model: %s" % s.model_path) _check(load(s.model_path) is PackedScene, "bad model: %s" % s.model_path)
# datto is procedural (has det_* parts); imported models legitimately have none # datto is procedural (has det_* parts); imported models legitimately have none
Game.car_path = cars.get("datto_120y", cars.values()[0]) Game.car_path = cars.get("datto_120y", cars.values()[0])
# the carpark is the controlled arena; city levels have walls that complicate the ram test # the carpark is the controlled arena; city levels have walls that complicate the ram test
Game.level_path = levels.get("woolies_carpark", levels.values()[0]) Game.level_path = levels.get("woolies_carpark", levels.values()[0])
# cruise: throttle 3s, car must move # cruise: throttle 3s, car must move
print(" .. cruise")
var game: Game = await _boot("cruise") var game: Game = await _boot("cruise")
if game.car.stats.model_path != "": if game.car.stats.model_path != "":
assert(game.car._parts.size() >= 8, "model has no detachable det_* parts") _check(game.car._parts.size() >= 8, "model has no detachable det_* parts")
var start: Vector3 = game.car.global_position var start: Vector3 = game.car.global_position
Input.action_press("throttle") Input.action_press("throttle")
for i in 180: for i in 180:
await physics_frame await physics_frame
var dist := start.distance_to(game.car.global_position) var dist := start.distance_to(game.car.global_position)
assert(dist > 5.0, "car didn't move (%.1f m)" % dist) _check(dist > 5.0, "car didn't move (%.1f m)" % dist)
Input.action_release("throttle") Input.action_release("throttle")
game.free() game.free()
# crash mode: traffic spawned, ram the nearest one, score must rise # crash mode: traffic spawned, ram the nearest one, score must rise
print(" .. crash")
game = await _boot("crash") game = await _boot("crash")
assert(game.traffic.size() == Game.TRAFFIC_COUNT["crash"], "traffic missing") _check(game.traffic.size() == Game.TRAFFIC_COUNT["crash"], "traffic missing")
var victim: TrafficCar = game.traffic[0] var victim: TrafficCar = game.traffic[0]
await physics_frame # traffic takes position on its first physics tick await physics_frame # traffic takes position on its first physics tick
Input.action_press("throttle") Input.action_press("throttle")
@ -54,18 +56,19 @@ func _run() -> void:
if not victim.live: if not victim.live:
break break
Input.action_release("throttle") Input.action_release("throttle")
assert(not victim.live, "rammed traffic never went dynamic") _check(not victim.live, "rammed traffic never went dynamic")
assert(game.score > 0.0, "crash mode scored nothing") _check(game.score > 0.0, "crash mode scored nothing")
var crash_score := game.score var crash_score := game.score
game.free() game.free()
# junction: convoys spawn, driving straight in causes the crash -> settle -> # junction: convoys spawn, driving straight in causes the crash -> settle ->
# crashbreaker -> tally # crashbreaker -> tally
var jcts := Registry.junctions().filter(func(j): return j.level_id == "woolies_carpark") var jcts := Registry.junctions().filter(func(j): return j.level_id == "woolies_carpark")
assert(not jcts.is_empty(), "no woolies junction event") _check(not jcts.is_empty(), "no woolies junction event")
Game.junction = jcts[0].data Game.junction = jcts[0].data
print(" .. junction")
game = await _boot("junction") game = await _boot("junction")
assert(game.traffic.size() >= 8, "junction convoys missing (%d)" % game.traffic.size()) _check(game.traffic.size() >= 8, "junction convoys missing (%d)" % game.traffic.size())
Input.action_press("throttle") Input.action_press("throttle")
for i in 950: for i in 950:
await physics_frame await physics_frame
@ -78,9 +81,9 @@ func _run() -> void:
break break
Input.action_release("throttle") Input.action_release("throttle")
Input.action_release("boost") Input.action_release("boost")
assert(game.jstate != "run", "never crashed into the convoy") _check(game.jstate != "run", "never crashed into the convoy")
assert(game.breaker_used, "crashbreaker never fired") _check(game.breaker_used, "crashbreaker never fired")
assert(game.over and game.score > 0.0, "junction never tallied (score %d)" % int(game.score)) _check(game.over and game.score > 0.0, "junction never tallied (score %d)" % int(game.score))
var jct_score := game.score var jct_score := game.score
Game.junction = {} Game.junction = {}
game.free() game.free()
@ -88,7 +91,7 @@ func _run() -> void:
# outrun: roaming rivals exist in cruise; pulling alongside at speed arms the # outrun: roaming rivals exist in cruise; pulling alongside at speed arms the
# duel, and the win path pays out and clears state # duel, and the win path pays out and clears state
game = await _boot("cruise") game = await _boot("cruise")
assert(game.rivals.size() == Game.ROAM_RIVALS, "no roaming rivals in cruise") _check(game.rivals.size() == Game.ROAM_RIVALS, "no roaming rivals in cruise")
var rr: Rival = game.rivals[0] var rr: Rival = game.rivals[0]
await physics_frame await physics_frame
game.car.global_position = rr.global_position + Vector3(3, 0.3, 0) game.car.global_position = rr.global_position + Vector3(3, 0.3, 0)
@ -97,17 +100,72 @@ func _run() -> void:
await physics_frame await physics_frame
if game.outrun_rival != null: if game.outrun_rival != null:
break break
assert(game.outrun_rival == rr, "outrun didn't arm on proximity") _check(game.outrun_rival == rr, "outrun didn't arm on proximity")
var bm0: float = game.car.boost_max var bm0: float = game.car.boost_max
game._outrun_end(true, "test") game._outrun_end(true, "test")
assert(game.outrun_rival == null and game.outruns_won == 1, "outrun win didn't settle") _check(game.outrun_rival == null and game.outruns_won == 1, "outrun win didn't settle")
assert(game.car.boost_max > bm0, "outrun win paid no boost") _check(game.car.boost_max > bm0, "outrun win paid no boost")
assert(game.outrun_cd > 0.0, "no re-arm cooldown after outrun") _check(game.outrun_cd > 0.0, "no re-arm cooldown after outrun")
game.free()
# pursuit: smashing raises heat, heat summons cops, fleeing loses them
print(" .. pursuit")
game = await _boot("cruise")
for i in 10:
game.add_heat(Game.HEAT_PER_SMASH)
_check(game.cops.size() >= Game.COPS_MIN, "heat didn't summon cops")
game.car.global_position += Vector3(900, 0, 900)
for i in 700:
await physics_frame
if game.cops.is_empty():
break
_check(game.escapes == 1 and game.cops.is_empty(), "escape never resolved")
_check(game.heat_cd > 0.0, "no cooldown after escape")
game.free()
# drag: needs a level with DragStart/DragEnd, so not the code-built carpark
Game.level_path = levels["southbank"]
print(" .. drag")
game = await _boot("drag")
_check(game.drag_len > 100.0, "no drag strip found (%.0f m)" % game.drag_len)
_check(game.rivals.size() == 1, "drag had no opponent")
Input.action_press("throttle")
for i in 10:
await physics_frame
_check(game.revs > 0.0, "revs never climbed")
var g0: int = game.gear
# shift the moment revs enter the window. First gear redlines in ~1.6 s, so
# idling here first pins revs at 1.0 and the shift correctly scores as blown.
var spin := 0
while game.revs < Game.SHIFT_LO and spin < 600: # bounded: never hang the suite
await physics_frame
spin += 1
_check(game.revs >= Game.SHIFT_LO and game.revs < Game.REDLINE,
"revs %.2f not inside the shift window [%.2f, %.2f)" % [game.revs, Game.SHIFT_LO, Game.REDLINE])
Input.action_press("boost")
await physics_frame
Input.action_release("boost")
await physics_frame
_check(game.gear == g0 + 1, "shift didn't change gear")
_check(game.clean_shifts == 1 and game.blown_shifts == 0,
"in-window shift scored as blown (%d/%d)" % [game.clean_shifts, game.blown_shifts])
Input.action_release("throttle")
game.free()
Game.level_path = levels.get("woolies_carpark", levels.values()[0])
# derby: opponents spawn in a ring and each hunts something
print(" .. derby")
game = await _boot("derby")
_check(game.derby.size() == Game.DERBY_CARS, "derby field wrong size")
await physics_frame
for d in game.derby:
_check(d.target != null and d.target != d, "derby car has no/self target")
game.free() game.free()
# race: rivals spawn and make progress along the path # race: rivals spawn and make progress along the path
print(" .. race")
game = await _boot("race") game = await _boot("race")
assert(game.rivals.size() == Game.RIVAL_COUNT, "rivals missing") _check(game.rivals.size() == Game.RIVAL_COUNT, "rivals missing")
var p0: float = game.rivals[0].progress var p0: float = game.rivals[0].progress
for i in 240: for i in 240:
await physics_frame await physics_frame
@ -115,13 +173,23 @@ func _run() -> void:
for r in game.rivals: for r in game.rivals:
if absf(r.progress - p0) > 3.0 or r.linear_velocity.length() > 3.0: if absf(r.progress - p0) > 3.0 or r.linear_velocity.length() > 3.0:
moved = true moved = true
assert(moved, "no rival is driving") _check(moved, "no rival is driving")
game.free() game.free()
print("smoke ok: %d cars, %d levels | cruise %.1f m | crash $%d | junction $%d | outrun arms+pays | race AI driving" % print("smoke ok: %d cars, %d levels | cruise %.1f m | crash $%d | junction $%d | outrun arms+pays | pursuit spawns+escapes | drag shifts | derby %d cars | race AI driving" %
[cars.size(), levels.size(), dist, int(crash_score), int(jct_score)]) [cars.size(), levels.size(), dist, int(crash_score), int(jct_score), Game.DERBY_CARS])
quit(0) quit(0)
func _check(cond: bool, msg: String) -> void:
## NOT assert(): a failed assert in a headless SceneTree script halts the
## script but leaves the tree running, so the process spins forever instead
## of failing. One bad assertion cost 53 minutes of wall clock before this.
if cond:
return
push_error(msg)
print("SMOKE FAIL: %s" % msg)
quit(1)
func _boot(mode: String) -> Game: func _boot(mode: String) -> Game:
Game.mode = mode Game.mode = mode
var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate() var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate()

23
tools/build_all_junctions.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# Regenerate every OSM crash-junction event. The spec strings live here because
# they were previously nowhere -- the original invocations weren't recorded, and
# recovering "which two streets is this event?" meant re-deriving them from the
# generated geometry. Don't let that happen twice.
#
# woolies_carpark/junctions.json is hand-authored (Trolley Run) and not touched.
set -e
cd "$(dirname "$0")/.."
C="${OSM_CACHE:-$HOME/.cache/burnout_osm}"
[ -d "$C" ] || { echo "no OSM cache at $C -- see README"; exit 1; }
while IFS='|' read -r id json lat lon specs; do
[ -z "$id" ] && continue
IFS=';' read -ra SPEC <<< "$specs"
python3 tools/osm_junction.py "$C/$json" "levels/$id/junctions.json" "$lat" "$lon" "${SPEC[@]}"
done <<'EOF'
queen_st_mall|brisbane_cbd.json|-27.4699|153.0251|Albert x Adelaide|Albert Street|Adelaide Street;Edward x Elizabeth|Edward Street|Elizabeth Street
southbank|southbank.json|-27.4775|153.0215|Melbourne x Merivale|Melbourne Street|Merivale Street;Grey x Glenelg|Grey Street|Glenelg Street
albert_parklands|albert_parklands.json|-27.4740|153.0280|Alice x George|Alice Street|George Street;Margaret x Edward|Margaret Street|Edward Street
story_bridge|story_bridge.json|-27.4635|153.0355|Bridge Approach|Bradfield Highway|Kemp Place
kangaroo_point|kangaroo_point.json|-27.4780|153.0345|Cliffs Corner|River Terrace|Main Street
EOF
echo "junctions rebuilt (woolies_carpark left alone -- hand-authored)"

View File

@ -373,7 +373,7 @@ def add_canopy(bm, at, fwd):
d = json.load(open(src)) d = json.load(open(src))
counts = dict.fromkeys( counts = dict.fromkeys(
("bld", "road", "path", "park", "planter", "water", "cliff", "tree", "prop", ("bld", "road", "path", "park", "planter", "water", "cliff", "tree", "prop",
"cross", "signal", "pole", "pp", "sl", "bo", "wb"), 0) "cross", "signal", "pole", "pp", "sl", "bo", "wb", "drag_m"), 0)
GREEN_LEISURE = ("park", "recreation_ground", "pitch", "playground", "golf_course", "nature_reserve") GREEN_LEISURE = ("park", "recreation_ground", "pitch", "playground", "golf_course", "nature_reserve")
GREEN_LANDUSE = ("grass", "recreation_ground", "village_green", "meadow", "forest") GREEN_LANDUSE = ("grass", "recreation_ground", "village_green", "meadow", "forest")
PLANTER_LEISURE = ("garden",) PLANTER_LEISURE = ("garden",)
@ -397,6 +397,7 @@ sig_bm, lens_bm = bmesh.new(), bmesh.new()
pole_bm, wire_bm = bmesh.new(), bmesh.new() pole_bm, wire_bm = bmesh.new(), bmesh.new()
pp_spots, wb_spots = [], [] # (x, y, yaw): parked-car / wheelie-bin markers for game.gd to fill pp_spots, wb_spots = [], [] # (x, y, yaw): parked-car / wheelie-bin markers for game.gd to fill
smash_spots = {"SL": [], "BO": [], "WB": []} # street lamps / bollards / bins off OSM smash_spots = {"SL": [], "BO": [], "WB": []} # street lamps / bollards / bins off OSM
drag_candidates = [] # wide road polylines; the straightest run becomes the drag strip
PP_CAP, WB_CAP = 70, 40 PP_CAP, WB_CAP = 70, 40
SMASH_CAP = {"SL": 90, "BO": 90, "WB": 60} SMASH_CAP = {"SL": 90, "BO": 90, "WB": 60}
PARK_STREETS = ("residential", "tertiary", "secondary", "unclassified") PARK_STREETS = ("residential", "tertiary", "secondary", "unclassified")
@ -651,6 +652,8 @@ for e in d["elements"]:
acc += 28.0 acc += 28.0
acc -= segL acc -= segL
road_ways.append((pts, wide, hw)) road_ways.append((pts, wide, hw))
if hw not in ("service", "busway") and wide >= 7.0:
drag_candidates.append(pts)
# no footpath strip on a pedestrian mall -- the mall paving IS the surface # no footpath strip on a pedestrian mall -- the mall paving IS the surface
if hw != "pedestrian" and wide >= 7.0 and hw not in ("busway",): # dashed centre line if hw != "pedestrian" and wide >= 7.0 and hw not in ("busway",): # dashed centre line
for i in range(len(pts) - 1): for i in range(len(pts) - 1):
@ -879,6 +882,40 @@ for prefix, spots, cap in batches:
bpy.context.collection.objects.link(emp) bpy.context.collection.objects.link(emp)
counts[prefix.lower()] = min(len(spots), cap) counts[prefix.lower()] = min(len(spots), cap)
# Drag strip: the longest genuinely straight run of wide road in the level.
# Emitted as a marker pair so drag mode doesn't need hand-authored coordinates
# per level -- a strip that bends is a strip you crash on rather than race.
best_run = None
for pts in drag_candidates:
i = 0
while i < len(pts) - 1:
ax, az = pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]
aL = math.hypot(ax, az)
if aL < 1.0:
i += 1
continue
ax, az = ax / aL, az / aL
j, run = i + 1, aL
while j < len(pts) - 1:
bx, bz = pts[j + 1][0] - pts[j][0], pts[j + 1][1] - pts[j][1]
bL = math.hypot(bx, bz)
if bL < 1.0 or (bx / bL) * ax + (bz / bL) * az < 0.985: # ~10 deg
break
run += bL
j += 1
if best_run is None or run > best_run[0]:
best_run = (run, pts[i], pts[j])
i = max(j, i + 1)
if best_run and best_run[0] >= 150.0:
run, p0, p1 = best_run
yaw = math.atan2(p1[1] - p0[1], p1[0] - p0[0])
for nm, p in (("DragStart", p0), ("DragEnd", p1)):
emp = bpy.data.objects.new(nm, None)
emp.location = (p[0], p[1], 0.1)
emp.rotation_euler = (0, 0, yaw)
bpy.context.collection.objects.link(emp)
counts["drag_m"] = int(run)
if spawn_arg: if spawn_arg:
slat, slon, sbear = [float(v) for v in spawn_arg.split(",")] slat, slon, sbear = [float(v) for v in spawn_arg.split(",")]
sx, sy = xy(slat, slon) sx, sy = xy(slat, slon)

View File

@ -45,6 +45,7 @@ signage_strip|768|seamless tiling texture, a long horizontal row of colourful fi
billboard_ad|768|single large fictional advertising billboard poster, bold colourful retro soft drink advertisement with invented gibberish brand name lettering and a sunburst, flat frontal view, even lighting|colourful fictional poster advertisement with made up unreadable brand lettering, bold shapes, flat view|0 billboard_ad|768|single large fictional advertising billboard poster, bold colourful retro soft drink advertisement with invented gibberish brand name lettering and a sunburst, flat frontal view, even lighting|colourful fictional poster advertisement with made up unreadable brand lettering, bold shapes, flat view|0
billboard_ad2|768|single large fictional advertising billboard poster, 1980s Australian beer advertisement style, bold blue and gold blocks with invented gibberish brand lettering, flat frontal view, even lighting|fictional poster ad with blue and gold blocks and made up unreadable lettering, flat frontal view|0 billboard_ad2|768|single large fictional advertising billboard poster, 1980s Australian beer advertisement style, bold blue and gold blocks with invented gibberish brand lettering, flat frontal view, even lighting|fictional poster ad with blue and gold blocks and made up unreadable lettering, flat frontal view|0
billboard_ad3|768|single large fictional advertising billboard poster, bright red and cream used car yard sale advertisement with invented gibberish lettering and starburst price tags, flat frontal view, even lighting|fictional red and cream sale poster with made up unreadable lettering and starbursts, flat frontal view|0 billboard_ad3|768|single large fictional advertising billboard poster, bright red and cream used car yard sale advertisement with invented gibberish lettering and starburst price tags, flat frontal view, even lighting|fictional red and cream sale poster with made up unreadable lettering and starbursts, flat frontal view|0
scratches|512|seamless tiling texture of scratched and scuffed dull grey car paint, fine directional scratches, small dents and primer showing through in patches, mid grey, flat even lighting|repeating scuffed scratched grey painted metal panel surface with worn patches, even light|150
EOF EOF
) )

View File

@ -5,14 +5,23 @@ Usage: osm_junction.py <area.json> <out junctions.json> <lat0> <lon0> "Name|Stre
For each spec: junction = shared node of the two streets. Every same-named way For each spec: junction = shared node of the two streets. Every same-named way
passing within RADIUS of it becomes a convoy stream (clipped, plus a reversed passing within RADIUS of it becomes a convoy stream (clipped, plus a reversed
copy lane-offset the other way). Spawn point sits ~120m up Street A aimed at copy lane-offset the other way). Spawn point sits SPAWN_DIST up Street A aimed
the junction. Coordinates are level-local Godot metres (x east, z south). at the junction. Coordinates are level-local Godot metres (x east, z south).
RADIUS is the whole event. Clip too wide and the convoy spreads thin over
hundreds of metres, so you arrive at the intersection through a 20 m gap and
score nothing -- "Margaret x Edward" was unwinnable that way, 27 convoy cars
alive and zero collisions. Keep the streams hugging the junction so the cars
are actually crossing when you get there.
""" """
import json import json
import math import math
import sys import sys
from itertools import zip_longest
RADIUS = 170.0 RADIUS = 95.0 # convoy clip window around the junction
SPAWN_DIST = 120.0 # launch distance up street A
MIN_STREAM = 28.0 # shorter than this and a "stream" is a couple of parked cars
LANE = 2.7 LANE = 2.7
TARGETS = [15000, 35000, 60000] TARGETS = [15000, 35000, 60000]
@ -41,6 +50,45 @@ def offset(pts, d):
outp.append([p[0] + dz / L * d, p[1] - dx / L * d]) outp.append([p[0] + dz / L * d, p[1] - dx / L * d])
return outp return outp
def launch_point(pts, jpt):
"""Walk street A outward from the junction to SPAWN_DIST, aimed back at it.
Taking the farthest clipped point instead (the old way) tied the launch
distance to RADIUS, so tightening the convoy window would have dragged the
start line in on top of the intersection.
"""
i = min(range(len(pts)), key=lambda k: math.hypot(pts[k][0] - jpt[0], pts[k][1] - jpt[1]))
best = None
for step in (1, -1):
run, prev, k, end, hit = 0.0, pts[i], i + step, pts[i], False
while 0 <= k < len(pts):
seg = math.hypot(pts[k][0] - prev[0], pts[k][1] - prev[1])
if run + seg >= SPAWN_DIST and seg > 0.01:
# land exactly on SPAWN_DIST -- OSM segments can be hundreds of
# metres, so stopping at the far vertex overshot by 100m+
f = (SPAWN_DIST - run) / seg
end = [prev[0] + (pts[k][0] - prev[0]) * f,
prev[1] + (pts[k][1] - prev[1]) * f]
run, hit = SPAWN_DIST, True
break
run += seg
prev, end, k = pts[k], pts[k], k + step
if best is None or (hit and not best[3]) or (hit == best[3] and run > best[0]):
best = (run, end, step, hit)
run, end, step, hit = best
if not hit and run > 1.0:
# street ran out -- extend along its last heading so every event
# launches from the same distance regardless of how OSM split the way
j = max(min(i + step, len(pts) - 1), 0)
dx, dz = end[0] - pts[j][0], end[1] - pts[j][1]
L = math.hypot(dx, dz)
if L > 0.01:
grow = SPAWN_DIST - run
end = [end[0] + dx / L * grow, end[1] + dz / L * grow]
dx, dz = jpt[0] - end[0], jpt[1] - end[1]
L = math.hypot(dx, dz) or 1.0
return {"x": end[0], "z": end[1], "dx": dx / L, "dz": dz / L}
events = [] events = []
for spec in sys.argv[5:]: for spec in sys.argv[5:]:
name, a, b = spec.split("|")[:3] name, a, b = spec.split("|")[:3]
@ -50,14 +98,32 @@ for spec in sys.argv[5:]:
if not shared: if not shared:
print("SKIP %s: %s never meets %s" % (name, a, b)) print("SKIP %s: %s never meets %s" % (name, a, b))
continue continue
jn = sorted(shared)[0] # Two named streets often share several nodes (OSM splits ways at every
jpt = None # change of tag), and the lowest node id is arbitrary -- for Albert x
for w in ways[a]: # Adelaide it landed inside a CBD building block with the convoy 60 m away
if jn in w["nodes"]: # and no drivable run-up. Pick the shared node with the most street geometry
g = w["geometry"][w["nodes"].index(jn)] # around it: the busiest crossing is the best crash junction anyway.
jpt = xz(g["lat"], g["lon"]) def node_pt(n):
streams = [] for street in (a, b):
for w in ways[street]:
if n in w["nodes"]:
g = w["geometry"][w["nodes"].index(n)]
return xz(g["lat"], g["lon"])
return None
def busyness(n):
p = node_pt(n)
if p is None:
return -1
return sum(1 for w in ways.get(a, []) + ways.get(b, [])
for g in w["geometry"]
if math.hypot(*[c - d for c, d in zip(xz(g["lat"], g["lon"]), p)]) < RADIUS)
jn = max(sorted(shared), key=busyness)
jpt = node_pt(jn)
by_street = {a: [], b: []}
spawn = None spawn = None
spawn_d = 1e9
for street in (a, b): for street in (a, b):
for w in ways[street]: for w in ways[street]:
pts = [xz(g["lat"], g["lon"]) for g in w["geometry"]] pts = [xz(g["lat"], g["lon"]) for g in w["geometry"]]
@ -66,21 +132,37 @@ for spec in sys.argv[5:]:
continue continue
length = sum(math.hypot(near[i + 1][0] - near[i][0], near[i + 1][1] - near[i][1]) length = sum(math.hypot(near[i + 1][0] - near[i][0], near[i + 1][1] - near[i][1])
for i in range(len(near) - 1)) for i in range(len(near) - 1))
if length < 60: if length < MIN_STREAM:
continue continue
streams.append({"pts": offset(near, LANE), "speed": 12.0, "interval": 2.2}) by_street[street].append({"pts": offset(near, LANE), "speed": 12.0, "interval": 1.5})
streams.append({"pts": offset(list(reversed(near)), LANE), "speed": 11.0, "interval": 2.6}) by_street[street].append({"pts": offset(list(reversed(near)), LANE),
if street == a and spawn is None: "speed": 11.0, "interval": 1.8})
best = max(near, key=lambda p: math.hypot(p[0] - jpt[0], p[1] - jpt[1])) if street == a:
dx, dz = jpt[0] - best[0], jpt[1] - best[1] # OSM splits a street into many ways; only the one that actually
L = math.hypot(dx, dz) or 1.0 # reaches the junction can define the launch, or we start 40 m
spawn = {"x": best[0], "z": best[1], "dx": dx / L, "dz": dz / L} # off the approach and never aim at the intersection
d_near = min(math.hypot(p[0] - jpt[0], p[1] - jpt[1]) for p in pts)
if d_near < spawn_d:
spawn, spawn_d = launch_point(pts, jpt), d_near
# Interleave the two streets before capping. Appending A's streams then B's
# and slicing [:6] could drop street B entirely -- leaving an "intersection"
# with traffic only on the road you launch down, and nothing to hit.
streams = []
for pair in zip_longest(by_street[a], by_street[b]):
for s in pair:
if s is not None:
streams.append(s)
streams = streams[:6]
if not streams or spawn is None: if not streams or spawn is None:
print("SKIP %s: no usable streams" % name) print("SKIP %s: no usable streams" % name)
continue continue
events.append({"name": name, "spawn": spawn, "streams": streams[:6], "targets": TARGETS}) cross = sum(1 for s in streams if s in by_street[b])
print("OK %s: %d streams, spawn %.0fm out" % (name, min(len(streams), 6), events.append({"name": name, "spawn": spawn, "streams": streams, "targets": TARGETS})
math.hypot(spawn["x"] - jpt[0], spawn["z"] - jpt[1]))) print("OK %s: %d streams (%d crossing), spawn %.0fm out"
% (name, len(streams), cross, math.hypot(spawn["x"] - jpt[0], spawn["z"] - jpt[1])))
json.dump(events, open(out, "w"), indent=1) json.dump(events, open(out, "w"), indent=1)
print("wrote %s (%d events)" % (out, len(events))) print("wrote %s (%d events)" % (out, len(events)))
if len(events) < len(sys.argv[5:]):
sys.exit("FAILED: %d of %d specs produced nothing -- refusing to ship a level "
"with missing junctions" % (len(sys.argv[5:]) - len(events), len(sys.argv[5:])))

104
tools/tune_junctions.gd Normal file
View File

@ -0,0 +1,104 @@
extends SceneTree
## Measure what each crash junction is actually worth, then write per-event
## medal targets back into levels/<id>/junctions.json.
##
## Godot --headless --path . --script tools/tune_junctions.gd -- runs=12
##
## Every junction shipped with the same 15/35/60k guess, which is meaningless:
## a six-lane approach with four convoy streams pays several times what a
## carpark aisle does. This drives each event headless with a spread of launch
## speeds and steering biases, takes the damage distribution, and sets
## bronze/silver/gold at the 35th/65th/88th percentile of what was achieved.
##
## Percentiles, not max: gold should be a good run, not a freak one. Runs are
## seeded per (event, attempt) so the numbers reproduce.
const SETTLE_FRAMES := 1500
func _initialize() -> void:
_run()
func _run() -> void:
var runs := 12
for a in OS.get_cmdline_user_args():
if a.begins_with("runs="):
runs = int(a.split("=")[1])
var by_level := {}
for j in Registry.junctions():
by_level.get_or_add(j.level_id, []).append(j)
for level_id in by_level:
var path := "res://levels/%s/junctions.json" % level_id
var events: Array = JSON.parse_string(FileAccess.get_file_as_string(path))
for j in by_level[level_id]:
var scores: Array[float] = []
for attempt in runs:
scores.append(await _play(j.data, attempt))
scores.sort()
var pick := func(p: float) -> int:
var v: float = scores[clampi(int(round(p * (scores.size() - 1))), 0, scores.size() - 1)]
return int(round(v / 500.0)) * 500 # tidy numbers, not $47,213
var targets := [pick.call(0.35), pick.call(0.65), pick.call(0.88)]
# guarantee a real gap between medals even on a flat distribution
targets[0] = maxi(targets[0], 2000)
targets[1] = maxi(targets[1], targets[0] + 2500)
targets[2] = maxi(targets[2], targets[1] + 4000)
var duds := 0
for s in scores:
if s <= 0.0:
duds += 1
if duds * 2 >= scores.size():
push_warning("%s: %d/%d runs scored nothing -- event may be unwinnable"
% [j.name, duds, scores.size()])
print(" !! %s scored nothing in %d/%d runs" % [j.name, duds, scores.size()])
for ev in events:
if ev.get("name") == j.name:
ev["targets"] = targets
print("%-18s %-24s min $%d med $%d max $%d -> %s"
% [level_id, j.name, int(scores[0]), int(scores[scores.size() / 2]),
int(scores[-1]), str(targets)])
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(JSON.stringify(events, " ") + "\n")
f.close()
print("tuned: wrote targets for %d levels" % by_level.size())
quit(0)
func _play(data: Dictionary, attempt: int) -> float:
seed(hash(str(data.get("name", "")) + str(attempt)))
Game.car_path = Registry.cars()["kingswood_hz"]
Game.level_path = Registry.levels()[_level_of(data)]
Game.mode = "junction"
Game.junction = data
var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate()
root.add_child(game)
await process_frame
# vary the launch so the spread reflects skill, not one scripted line
var lean := (attempt % 5 - 2) * 0.16
var boosty := attempt % 3 == 0
Input.action_press("throttle")
if boosty:
Input.action_press("boost")
var fired := false
for i in SETTLE_FRAMES:
await physics_frame
if i < 90:
game.car.steer_in = lean
if game.jstate == "settle" and not fired:
Input.action_release("throttle")
Input.action_press("boost")
fired = true
if game.over:
break
Input.action_release("throttle")
Input.action_release("boost")
var s: float = game.score
game.free()
await process_frame
return s
func _level_of(data: Dictionary) -> String:
for j in Registry.junctions():
if j.data == data:
return j.level_id
return ""

View File

@ -0,0 +1 @@
uid://dkj574xr62aff