diff --git a/README.md b/README.md index 847d71c..8c4444c 100644 --- a/README.md +++ b/README.md @@ -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 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 - `levels//junctions.json`; generate more from real intersections with - `tools/osm_junction.py "Name|Street A|Street B"`. + `levels//junctions.json`, regenerated in one go by + [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`. +- **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 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. -## Crash deformation +## Crash deformation and damage [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 @@ -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; 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 A car is a folder: `cars//stats.tres` + optional `car.glb`. Models are built diff --git a/assets/textures/scratches.jpg b/assets/textures/scratches.jpg new file mode 100644 index 0000000..161d25f Binary files /dev/null and b/assets/textures/scratches.jpg differ diff --git a/assets/textures/scratches.jpg.import b/assets/textures/scratches.jpg.import new file mode 100644 index 0000000..0f2b79c --- /dev/null +++ b/assets/textures/scratches.jpg.import @@ -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 diff --git a/levels/albert_parklands/junctions.json b/levels/albert_parklands/junctions.json index 4e88bdf..3fbe57e 100644 --- a/levels/albert_parklands/junctions.json +++ b/levels/albert_parklands/junctions.json @@ -2,10 +2,10 @@ { "name": "Alice x George", "spawn": { - "x": -143.83199552181551, - "z": 142.01018819980536, - "dx": 0.7668348571083106, - "dz": -0.6418444530598335 + "x": -165.3192678228276, + "z": 159.9955423589618, + "dx": 0.7668331983120437, + "dz": -0.6418464348787193 }, "streams": [ { @@ -56,7 +56,29 @@ ] ], "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": [ @@ -106,65 +128,7 @@ ] ], "speed": 11.0, - "interval": 2.6 - }, - { - "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 + "interval": 1.8 }, { "pts": [ @@ -186,7 +150,147 @@ ] ], "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": [ @@ -198,64 +302,12 @@ { "name": "Margaret x Edward", "spawn": { - "x": 88.92833122889411, - "z": -205.50177900009658, - "dx": 0.7566215209215891, - "dz": -0.6538530982401947 + "x": 276.7158840463856, + "z": -371.11587218595514, + "dx": -0.7427057931255263, + "dz": 0.6696178797327494 }, "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": [ [ @@ -284,7 +336,41 @@ ] ], "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": [ @@ -314,71 +400,13 @@ ] ], "speed": 11.0, - "interval": 2.6 + "interval": 1.8 }, { "pts": [ [ - 116.8650308963045, - -376.11651856442666 - ], - [ - 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 + 240.4674984004257, + -225.74901403845038 ], [ 229.982416914263, @@ -401,20 +429,12 @@ -298.8501898379666 ], [ - 175.7564478621494, - -300.60775216234316 - ], - [ - 118.90617949034443, - -365.5157484793863 - ], - [ - 112.7841869787125, - -372.58003543563154 + 175.7569318305437, + -300.6071997580377 ] ], "speed": 11.0, - "interval": 2.6 + "interval": 1.8 } ], "targets": [ diff --git a/levels/albert_parklands/level.glb b/levels/albert_parklands/level.glb index dce32ba..305a5ab 100644 Binary files a/levels/albert_parklands/level.glb and b/levels/albert_parklands/level.glb differ diff --git a/levels/albert_parklands/level.glb.import b/levels/albert_parklands/level.glb.import index f8f759b..cc34824 100644 --- a/levels/albert_parklands/level.glb.import +++ b/levels/albert_parklands/level.glb.import @@ -3,7 +3,7 @@ importer="scene" importer_version=1 type="PackedScene" -uid="uid://lekue6r6vs5u" +uid="uid://dm4gmb5igf0n5" path="res://.godot/imported/level.glb-4aa597fe23a02438c7c91aac8329adb8.scn" [deps] diff --git a/levels/kangaroo_point/junctions.json b/levels/kangaroo_point/junctions.json index 7e9c35b..59c8ec7 100644 --- a/levels/kangaroo_point/junctions.json +++ b/levels/kangaroo_point/junctions.json @@ -2,21 +2,17 @@ { "name": "Cliffs Corner", "spawn": { - "x": 49.98333461330541, - "z": -68.64433920000972, - "dx": 0.4680499904857942, - "dz": -0.8837019895905226 + "x": 19.82910866163153, + "z": -91.88863639399733, + "dx": 0.7116289476302671, + "dz": -0.7025555073406219 }, "streams": [ { "pts": [ [ - 47.4831595229508, - -69.66371377177926 - ], - [ - 63.94149741985269, - -110.02953643297833 + 63.92619388342862, + -109.98937518013152 ], [ 78.3750416498091, @@ -24,7 +20,21 @@ ] ], "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": [ @@ -33,96 +43,102 @@ -146.95142862004474 ], [ - 68.97210013309922, - -108.06662116698321 - ], - [ - 52.48350970366002, - -67.62496462824019 + 68.98740366952329, + -108.10678241983003 ] ], "speed": 11.0, - "interval": 2.6 + "interval": 1.8 }, { "pts": [ [ - 114.66387685267605, - -57.53027727981913 + 110.54673313420321, + -214.02759389225906 ], [ - 109.86666098023801, - -74.50891216449172 + 111.53435089017266, + -179.83811309223347 + ] + ], + "speed": 11.0, + "interval": 1.8 + }, + { + "pts": [ + [ + 110.03793689684629, + -168.49583563849748 ], [ - 107.12471250932794, - -155.39569742995997 + 105.07701815835642, + -166.64105720795808 ], [ - 106.88904877360271, - -160.41551590552262 + 102.76621908994164, + -165.6886852292976 ], [ - 106.3952482221942, - -170.89775425802756 + 100.0562323833477, + -163.76233037299872 + ], + [ + 96.9873892973125, + -160.78656603278262 + ], + [ + 94.50499850150788, + -158.03413164414619 + ], + [ + 91.4342291831665, + -153.71805517412884 + ], + [ + 82.70848350683406, + -145.88280488205245 ] ], "speed": 12.0, - "interval": 2.2 + "interval": 1.5 }, { "pts": [ [ - 111.78926635550437, - -171.15185734246612 + 79.10280957887883, + -149.90264511833848 ], [ - 112.28308355725834, - -160.66926529510476 + 87.56161285248508, + -157.4814104264721 ], [ - 112.52147634417389, - -155.58262017041363 + 90.25853122720883, + -161.369924756482 ], [ - 115.2509302403802, - -74.92079143598734 + 93.10927630970947, + -164.5442567671812 ], [ - 119.8558354049959, - -59.01471872056136 + 96.57846276532892, + -167.89332522702423 + ], + [ + 100.07071556655998, + -170.36781557108517 + ], + [ + 103.11280473039093, + -171.67115319207213 + ], + [ + 108.14657768085227, + -173.5537759619962 ] ], "speed": 11.0, - "interval": 2.6 - }, - { - "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 + "interval": 1.8 } ], "targets": [ diff --git a/levels/kangaroo_point/level.glb b/levels/kangaroo_point/level.glb index eef9030..0637267 100644 Binary files a/levels/kangaroo_point/level.glb and b/levels/kangaroo_point/level.glb differ diff --git a/levels/kangaroo_point/level.glb.import b/levels/kangaroo_point/level.glb.import index 789ebee..5647f63 100644 --- a/levels/kangaroo_point/level.glb.import +++ b/levels/kangaroo_point/level.glb.import @@ -3,7 +3,7 @@ importer="scene" importer_version=1 type="PackedScene" -uid="uid://cik0c7knt33ep" +uid="uid://dik381ld32een" path="res://.godot/imported/level.glb-30461b4810ef38297351fb89cbe9aa18.scn" [deps] diff --git a/levels/queen_st_mall/junctions.json b/levels/queen_st_mall/junctions.json index 41af2ce..af4d21f 100644 --- a/levels/queen_st_mall/junctions.json +++ b/levels/queen_st_mall/junctions.json @@ -2,155 +2,33 @@ { "name": "Albert x Adelaide", "spawn": { - "x": -141.55578237343948, - "z": -187.08015060009473, - "dx": 0.735035843445057, - "dz": 0.6780282507764803 + "x": 18.671705882497893, + "z": -22.919507778110138, + "dx": -0.6633868531476461, + "dz": -0.7482766086621067 }, "streams": [ { "pts": [ [ - -63.049013950748005, - -111.03404120330288 + -58.84614846630014, + -114.42417420924829 ], [ - -67.67311197746825, - -116.88556835863362 + -53.880474905008, + -108.39185473942744 ], [ - -69.33640432781439, - -119.01651536179543 + -51.24051503498836, + -105.52578654474546 ], [ - -72.74965354025018, - -122.55658461736515 - ], - [ - -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 + -20.352984240690695, + -70.76962163514554 ] ], "speed": 12.0, - "interval": 2.2 - }, - { - "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 + "interval": 1.5 }, { "pts": [ @@ -191,66 +69,40 @@ -163.05430391717795 ], [ - 7.529561567214451, - -176.87932594543773 - ], - [ - 21.92572970063743, - -189.79048578560977 - ], - [ - 25.62426293364586, - -193.04071169887433 - ], - [ - 26.615516895233576, - -193.87905456229723 - ], - [ - 27.606646263868104, - -194.72792646553222 - ], - [ - 32.11664235322457, - -198.8840883667601 - ], - [ - 57.105103286489076, - -221.92761298838238 + 7.537426213222782, + -176.88629254786764 ] ], "speed": 12.0, - "interval": 2.2 + "interval": 1.5 }, { "pts": [ [ - 60.765864254843116, - -217.9578738114873 + -24.38938905950134, + -67.18250076477023 ], [ - 35.77719298273772, - -194.91415523271732 + -55.272013374374126, + -101.93315225504524 ], [ - 31.239945374075152, - -190.73303753393867 + -57.98533525129133, + -104.88327646076375 ], [ - 30.11741737461444, - -189.76849583719522 - ], + -63.01508813331892, + -110.99198219061685 + ] + ], + "speed": 11.0, + "interval": 1.8 + }, + { + "pts": [ [ - 29.172798227938216, - -188.97034350113577 - ], - [ - 25.522915182059183, - -185.76304781436195 - ], - [ - 11.118032425570394, - -172.8441212543805 + 11.110167779562063, + -172.83715465195058 ], [ -4.543064055336218, @@ -290,7 +142,75 @@ ] ], "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": [ @@ -302,105 +222,39 @@ { "name": "Edward x Elizabeth", "spawn": { - "x": 329.71079592281694, - "z": -5.595044399780328, - "dx": -0.6532357656212038, - "dz": -0.7571545644802516 + "x": 173.87950117253465, + "z": -177.50987452854054, + "dx": 0.688388502507091, + "dz": 0.7253421741606128 }, "streams": [ { "pts": [ [ - 258.4805413787254, - -92.28866542876725 + 228.7426016851313, + -123.80938841141814 ], [ - 262.2737122591504, - -88.11954201221562 + 250.48785179439338, + -101.12418691157934 ], [ - 266.8273793037693, - -83.1100839184909 + 253.19184475779528, + -98.13216139604938 ], [ - 270.87333374760516, - -78.65784777138674 - ], - [ - 298.1107780903454, - -47.27921270488467 - ], - [ - 323.17112576090676, - -16.89985163934848 - ], - [ - 325.81187978583756, - -13.69226732374769 - ], - [ - 331.6793038120916, - -7.443009954076085 + 258.48528328991137, + -92.28343983096748 ] ], "speed": 12.0, - "interval": 2.2 + "interval": 1.5 }, { "pts": [ [ - 327.7422880335423, - -3.7470788454845705 - ], - [ - 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 + 184.8196819904455, + -31.59973983603264 ], [ 200.9901724381037, @@ -428,7 +282,29 @@ ] ], "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": [ @@ -457,76 +333,60 @@ -41.47689161686228 ], [ - 188.3547169687786, - -27.51764724920578 - ], - [ - 158.5007797295594, - -1.5251294005982539 - ], - [ - 152.49584713714452, - 3.737997039525666 - ], - [ - 146.0412138327473, - 9.337403004260793 + 188.34948976520272, + -27.51312056415722 ] ], "speed": 11.0, - "interval": 2.6 + "interval": 1.8 }, { "pts": [ [ - 299.8317240905086, - -131.8999654994903 + 258.4805413787254, + -92.28866542876725 ], [ - 302.3544939757109, - -134.11003589468572 + 262.2737122591504, + -88.11954201221562 ], [ - 328.3524909104502, - -156.3039359135965 + 266.8273793037693, + -83.1100839184909 ], [ - 335.8998393149425, - -162.7566931458765 + 270.87333374760516, + -78.65784777138674 ], [ - 376.4710012181815, - -198.14867992704066 + 298.08928934155523, + -47.30427629341134 ] ], "speed": 12.0, - "interval": 2.2 + "interval": 1.5 }, { "pts": [ [ - 380.02079919024186, - -194.079412872944 + 254.70925209424783, + -92.50690682734246 ], [ - 339.4433211293332, - -158.68192485417183 + 259.70303711688206, + -96.8600914480509 ], [ - 331.85925322106283, - -152.19752408611663 + 263.66545095997486, + -100.3447248693 ], [ - 305.86522961245754, - -130.0070205048928 - ], - [ - 303.390286276, - -127.8383604999869 + 288.61557906385735, + -122.1176793727558 ] ], - "speed": 11.0, - "interval": 2.6 + "speed": 12.0, + "interval": 1.5 } ], "targets": [ diff --git a/levels/queen_st_mall/level.glb b/levels/queen_st_mall/level.glb index 1ae809e..07c7e83 100644 Binary files a/levels/queen_st_mall/level.glb and b/levels/queen_st_mall/level.glb differ diff --git a/levels/queen_st_mall/level.glb.import b/levels/queen_st_mall/level.glb.import index 517edae..ee4dd34 100644 --- a/levels/queen_st_mall/level.glb.import +++ b/levels/queen_st_mall/level.glb.import @@ -3,7 +3,7 @@ importer="scene" importer_version=1 type="PackedScene" -uid="uid://bo4jjuhgers8e" +uid="uid://djsujf76tbo3k" path="res://.godot/imported/level.glb-f093d0a503a60c4e982eefdc88ad879e.scn" [deps] diff --git a/levels/southbank/junctions.json b/levels/southbank/junctions.json index 3bbeb59..4da7554 100644 --- a/levels/southbank/junctions.json +++ b/levels/southbank/junctions.json @@ -2,25 +2,17 @@ { "name": "Melbourne x Merivale", "spawn": { - "x": -590.6178507503562, - "z": -217.37742659981197, - "dx": 0.8530074497663933, - "dz": -0.5218987360044418 + "x": -598.2786454342968, + "z": -212.65545062278278, + "dx": 0.8528773292725991, + "dz": -0.5221113494388324 }, "streams": [ { "pts": [ [ - -592.80332396911, - -218.96289397888663 - ], - [ - -586.6662380952372, - -227.08658029182723 - ], - [ - -522.2006803362468, - -263.3161280710875 + -522.2431469994345, + -263.2916099068527 ], [ -513.2984743659174, @@ -36,7 +28,37 @@ ] ], "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": [ @@ -53,20 +75,42 @@ -263.8934194939837 ], [ - -519.5432562746829, - -258.6152667290545 - ], - [ - -583.8043810107387, - -222.50730370835848 - ], - [ - -588.4323775316024, - -215.7919592207373 + -519.5007896114952, + -258.6397848932893 ] ], "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": [ @@ -96,89 +140,29 @@ ] ], "speed": 12.0, - "interval": 2.2 + "interval": 1.5 }, { "pts": [ [ - -425.28794348242786, - -315.13222698127333 + -490.9127030755138, + -271.2416907037699 ], [ - -435.78284848324455, - -308.49056735250105 + -487.07948135301893, + -265.2321814476218 ], [ - -450.4943046193153, - -298.8163541564887 + -460.79611403958603, + -221.7057389459239 ], [ - -467.3265789435252, - -288.3796882049204 - ], - [ - -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 + -449.78766148749827, + -203.62165684766288 ] ], "speed": 12.0, - "interval": 2.2 - }, - { - "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 + "interval": 1.5 } ], "targets": [ @@ -190,10 +174,10 @@ { "name": "Grey x Glenelg", "spawn": { - "x": -132.09447432867904, - "z": -92.67206939992113, - "dx": 0.5885516813877528, - "dz": 0.8084595959821672 + "x": -135.06485953132193, + "z": -97.50127625771249, + "dx": 0.5279471843098005, + "dz": 0.8492772047920241 }, "streams": [ { @@ -215,26 +199,52 @@ -1.692678191469608 ], [ - -82.8231789453831, - -8.34834069992463 - ], - [ - -134.38713557935665, - -91.24598932165014 + -82.82069253186671, + -8.344352365976752 ] ], "speed": 12.0, - "interval": 2.2 + "interval": 1.5 }, { "pts": [ [ - -129.80181307800143, - -94.09814947819211 + -33.05020815528413, + -14.758620186231095 ], [ - -78.23825562104584, - -11.201142499900957 + -35.06751528318885, + -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, @@ -254,7 +264,41 @@ ] ], "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": [ @@ -267,74 +311,30 @@ 7.176315693408943 ], [ - -31.591830618167315, - 46.425585467880346 - ], - [ - 5.553177653998906, - 108.18727469018124 + -31.608294574960304, + 46.399012606383145 ] ], "speed": 12.0, - "interval": 2.2 + "interval": 1.5 }, { "pts": [ [ - 0.9256242241840709, - 110.9703933104001 + -105.44415325837058, + 17.29338138921854 ], [ - -36.19855979655569, - 49.24303933209625 + -124.85549758561747, + 29.02803771609806 ], [ - -60.768388814750516, - 10.095343106510734 - ], - [ - -66.8313293113839, - 1.5633764655737972 - ] - ], - "speed": 11.0, - "interval": 2.6 - }, - { - "pts": [ - [ - -146.4043520315188, - -133.05664071966743 - ], - [ - -108.51077823239306, - -74.87735033479748 - ], - [ - -83.23180011197921, - -33.360028872910426 + -145.9651989732041, + 41.680234308480806 ] ], "speed": 12.0, - "interval": 2.2 - }, - { - "pts": [ - [ - -87.84412394798038, - -30.551743126791347 - ], - [ - -113.07214707658274, - -71.98703646477304 - ], - [ - -150.92919892918795, - -130.10947927979782 - ] - ], - "speed": 11.0, - "interval": 2.6 + "interval": 1.5 } ], "targets": [ diff --git a/levels/southbank/level.glb b/levels/southbank/level.glb index 2158e2e..c838ef8 100644 Binary files a/levels/southbank/level.glb and b/levels/southbank/level.glb differ diff --git a/levels/southbank/level.glb.import b/levels/southbank/level.glb.import index 8da64ad..75a1581 100644 --- a/levels/southbank/level.glb.import +++ b/levels/southbank/level.glb.import @@ -3,7 +3,7 @@ importer="scene" importer_version=1 type="PackedScene" -uid="uid://ce6dmcne00017" +uid="uid://dw52qeb8csi30" path="res://.godot/imported/level.glb-17af9cdeb05fd15a816097b7ae0fb96c.scn" [deps] diff --git a/levels/story_bridge/junctions.json b/levels/story_bridge/junctions.json index c7bea8e..b8919e3 100644 --- a/levels/story_bridge/junctions.json +++ b/levels/story_bridge/junctions.json @@ -2,191 +2,179 @@ { "name": "Bridge Approach", "spawn": { - "x": 8.830464544568363, - "z": 869.4323045999147, - "dx": 0.07444215026982055, - "dz": -0.9972253337451898 + "x": 32.83372074498513, + "z": -118.28842427027045, + "dx": -0.11409585864571734, + "dz": -0.9934697454074263 }, "streams": [ { "pts": [ [ - 6.138933818706997, - 869.2186170903494 + 21.82446567810751, + -237.81387509878263 ], [ - 7.71576357448385, - 849.3684911078449 + 23.145720027383163, + -226.45520770652828 ], [ - 10.638374835867648, - 826.3950177708408 + 24.788209612143817, + -213.84815371354867 ], [ - 13.290793665081416, - 807.4161707785927 - ], - [ - 16.464164221809273, - 786.8204240256596 + 26.145569174952232, + -200.29741976993301 ] ], "speed": 12.0, - "interval": 2.2 + "interval": 1.5 }, { "pts": [ [ - 21.80118214360166, - 787.6427619747212 + 2.199941588527042, + -240.32717119002737 ], [ - 18.633212388594718, - 808.2026580211177 + -2.5248172223755736, + -243.91950536774928 ], [ - 15.991303480558578, - 827.1064634292346 + -6.325349530807717, + -247.60965512377 ], [ - 13.086203238107553, - 849.932740892406 + -15.183983237537262, + -255.2613583327868 ], [ - 11.521995270429729, - 869.64599210948 - ] - ], - "speed": 11.0, - "interval": 2.6 - }, - { - "pts": [ - [ - 18.99248078399424, - 762.39669386784 + -19.822722086570074, + -258.6729952966697 ], [ - 25.743285889885765, - 718.3587501785464 + -24.279515332983824, + -261.70335578638884 ], [ - 28.769387854660618, - 692.1866319902829 - ], - [ - 30.0890967826306, - 666.9831668179552 - ], - [ - 31.42169559470248, - 651.6049662957291 - ], - [ - 31.81270185839052, - 638.4108005958807 - ], - [ - 32.07909068323288, - 624.4813765582306 + -29.011437974400625, + -263.89076106496833 ] ], "speed": 12.0, - "interval": 2.2 + "interval": 1.5 }, { "pts": [ [ - 37.478103203918025, - 624.5846422421965 + 20.772447151780117, + -199.75931223039953 ], [ - 37.211108262175465, - 638.5419810043787 + 19.423377981138138, + -213.23286388675078 ], [ - 36.81191635813593, - 651.9298049040424 + 17.78654515972297, + -225.7924522932427 ], [ - 35.47759634851261, - 667.3354059817195 - ], - [ - 34.150141407857646, - 692.6421440096167 - ], - [ - 31.09171746204034, - 719.1032498212462 - ], - [ - 24.33013387059421, - 763.2148989320567 + 16.46063564037367, + -237.18991410136036 ] ], "speed": 11.0, - "interval": 2.6 + "interval": 1.8 }, { "pts": [ [ - 46.61372413580953, - 629.091352780875 + -26.737042902514784, + -268.7884265350654 ], [ - 46.0791068558773, - 644.9124964101077 + -21.61124337794858, + -266.3980682135746 ], [ - 45.148393336775854, - 661.3968152993656 + -16.704188313209045, + -263.0814811029762 ], [ - 43.42646160590967, - 688.423534995915 + -11.761774350471743, + -259.43849686691397 ], [ - 41.167633319989406, - 712.1819019166054 + -2.7224195143774796, + -251.63195487598963 ], [ - 39.13812925040989, - 728.9192605875077 + 0.9839307874462617, + -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, - "interval": 2.2 + "interval": 1.5 }, { "pts": [ [ - 33.77740686081252, - 728.2691410126066 + 28.875188928696993, + -229.49242546244793 ], [ - 35.797668406451436, - 711.6131516837517 + 28.72548025209394, + -214.05366159277472 ], [ - 38.04296969005352, - 688.0016170039884 + 28.150874632761848, + -209.45956029627345 ], [ - 39.75839999553985, - 661.068224700852 + 27.279147353059784, + -204.7812989955361 ], [ - 40.68465217733579, - 644.6678359900116 - ], - [ - 41.21680242495871, - 628.9090452194655 + 26.094879529030933, + -199.44340263065618 ] ], - "speed": 11.0, - "interval": 2.6 + "speed": 12.0, + "interval": 1.5 } ], "targets": [ diff --git a/levels/story_bridge/level.glb b/levels/story_bridge/level.glb index 677d61e..4fb3a09 100644 Binary files a/levels/story_bridge/level.glb and b/levels/story_bridge/level.glb differ diff --git a/levels/story_bridge/level.glb.import b/levels/story_bridge/level.glb.import index 895b6fe..4ec43b3 100644 --- a/levels/story_bridge/level.glb.import +++ b/levels/story_bridge/level.glb.import @@ -3,7 +3,7 @@ importer="scene" importer_version=1 type="PackedScene" -uid="uid://dah3x674j2af" +uid="uid://bjd3utm2spgs0" path="res://.godot/imported/level.glb-045fc5cf8c0e529ecdc2678ee7acde2a.scn" [deps] diff --git a/src/cop.gd b/src/cop.gd new file mode 100644 index 0000000..e4b35df --- /dev/null +++ b/src/cop.gd @@ -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 diff --git a/src/cop.gd.uid b/src/cop.gd.uid new file mode 100644 index 0000000..a64d5be --- /dev/null +++ b/src/cop.gd.uid @@ -0,0 +1 @@ +uid://bbwscf3nqqpe5 diff --git a/src/cop.tscn b/src/cop.tscn new file mode 100644 index 0000000..09b44b3 --- /dev/null +++ b/src/cop.tscn @@ -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 diff --git a/src/deform.gd b/src/deform.gd index ed6b9d0..79c3966 100644 --- a/src/deform.gd +++ b/src/deform.gd @@ -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 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 _cp := PackedVector3Array() var _disp := PackedVector3Array() 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: ## Snapshot every mesh under `root`, then build the control hull around them. var pending: Array = [] var bounds := AABB() 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): var mi := n as MeshInstance3D var am := mi.mesh as ArrayMesh @@ -52,8 +72,32 @@ func setup(root: Node3D) -> bool: first = false else: bounds = bounds.expand(base[i]) - surfs.append({"arrays": arr, "base": base, - "mat": am.surface_get_material(s)}) + # the GLB's materials are shared too -- copy them or denting one + # 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}) if pending.is_empty() or bounds.size.length() < 0.1: return false @@ -131,6 +175,27 @@ func impact(local_point: Vector3, local_dir: Vector3, strength: float) -> void: moved = true if moved: _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: for m in _meshes: diff --git a/src/game.gd b/src/game.gd index d88b41e..13438e8 100644 --- a/src/game.gd +++ b/src/game.gd @@ -15,7 +15,10 @@ static var junction: Dictionary = {} const LAPS := 3 const CRASH_TIME := 60.0 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 ## Street furniture that topples. Marker prefix -> size, mass, colour. ## 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_SETTLE := 9.0 # aftertouch/crashbreaker window after the crash 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_TIME := 90.0 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 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_t := 0.0 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 boost_bar: ProgressBar +var heat_bar: ProgressBar var info_label: Label var msg_label: Label var msg_timer: SceneTreeTimer +var _sticky := false # a result screen is up; transient messages must not clobber it func _ready() -> void: var level: Node = (load(level_path) as PackedScene).instantiate() @@ -99,8 +146,21 @@ func _ready() -> void: if mode == "junction": var sp: Dictionary = junction["spawn"] 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 + # 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: car.transform = _grid_xform(0.0) else: @@ -134,6 +194,52 @@ func _ready() -> void: prev_off[r] = off r.wreck_started.connect(_rival_wrecked.bind(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: # roaming challengers: they lap the circuit until you pick a fight var pool := Registry.cars().values() @@ -163,7 +269,7 @@ func _ready() -> void: add_child(path) var L := curve.get_baked_length() 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: var t := TrafficCar.new() t.path = path @@ -203,6 +309,16 @@ func _ready() -> void: boost_bar.size = Vector2(160, 18) boost_bar.show_percentage = false 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.anchor_left = 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) 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: ## Fill the level's PP_/WB_ markers with shuntable parked cars and wheelie ## bins. Real fleet models, asleep until rammed. @@ -400,6 +564,12 @@ func _physics_process(delta: float) -> void: _race_tick() if mode == "cruise" and race_path: _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 Input.is_action_just_pressed("boost") and not breaker_used: _crashbreaker() @@ -412,6 +582,194 @@ func _physics_process(delta: float) -> void: + fwd.normalized() * Input.get_axis("brake", "throttle") 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: ## 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 @@ -510,6 +868,8 @@ func _nearest_off(pos: Vector3, seed_off: float, L: float) -> float: func _player_crash(dv: float) -> void: if mode in ["crash", "junction"] and not over: 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: jstate = "settle" time_left = JCT_SETTLE @@ -536,6 +896,7 @@ func _traffic_smashed(impulse: float) -> void: score += impulse * 400.0 _msg("$%d" % int(impulse * 400.0), 40) car.earn_boost(0.2) + add_heat(HEAT_PER_SMASH) func _crashbreaker() -> void: 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) 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.add_theme_font_size_override("font_size", size) if sticky: @@ -622,7 +988,16 @@ func _process(delta: float) -> void: if jstate == "run": _msg("Didn't even crash. Cooked.", 40) _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() return for sp in spinners: @@ -635,3 +1010,5 @@ func _process(delta: float) -> void: boost_bar.size.x = 160.0 * car.boost_max boost_bar.max_value = 100.0 * car.boost_max boost_bar.value = car.boost * 100.0 + heat_bar.visible = mode == "cruise" and heat > 0.02 and cops.is_empty() + heat_bar.value = heat diff --git a/src/main.gd b/src/main.gd index cd17192..3c464da 100644 --- a/src/main.gd +++ b/src/main.gd @@ -1,7 +1,8 @@ extends Control ## 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 level_list: ItemList = $VBox/HBox/Levels diff --git a/tests/smoke.gd b/tests/smoke.gd index e656703..9c6717a 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -10,34 +10,36 @@ func _initialize() -> void: func _run() -> void: var cars := Registry.cars() var levels := Registry.levels() - assert(not cars.is_empty(), "no cars found") - assert(not levels.is_empty(), "no levels found") + _check(not cars.is_empty(), "no cars found") + _check(not levels.is_empty(), "no levels found") for p in cars.values(): 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 != "": - 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 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 Game.level_path = levels.get("woolies_carpark", levels.values()[0]) # cruise: throttle 3s, car must move + print(" .. cruise") var game: Game = await _boot("cruise") 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 Input.action_press("throttle") for i in 180: await physics_frame 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") game.free() # crash mode: traffic spawned, ram the nearest one, score must rise + print(" .. 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] await physics_frame # traffic takes position on its first physics tick Input.action_press("throttle") @@ -54,18 +56,19 @@ func _run() -> void: if not victim.live: break Input.action_release("throttle") - assert(not victim.live, "rammed traffic never went dynamic") - assert(game.score > 0.0, "crash mode scored nothing") + _check(not victim.live, "rammed traffic never went dynamic") + _check(game.score > 0.0, "crash mode scored nothing") var crash_score := game.score game.free() # junction: convoys spawn, driving straight in causes the crash -> settle -> # crashbreaker -> tally 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 + print(" .. 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") for i in 950: await physics_frame @@ -78,9 +81,9 @@ func _run() -> void: break Input.action_release("throttle") Input.action_release("boost") - assert(game.jstate != "run", "never crashed into the convoy") - assert(game.breaker_used, "crashbreaker never fired") - assert(game.over and game.score > 0.0, "junction never tallied (score %d)" % int(game.score)) + _check(game.jstate != "run", "never crashed into the convoy") + _check(game.breaker_used, "crashbreaker never fired") + _check(game.over and game.score > 0.0, "junction never tallied (score %d)" % int(game.score)) var jct_score := game.score Game.junction = {} game.free() @@ -88,7 +91,7 @@ func _run() -> void: # outrun: roaming rivals exist in cruise; pulling alongside at speed arms the # duel, and the win path pays out and clears state 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] await physics_frame game.car.global_position = rr.global_position + Vector3(3, 0.3, 0) @@ -97,17 +100,72 @@ func _run() -> void: await physics_frame if game.outrun_rival != null: 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 game._outrun_end(true, "test") - assert(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") - assert(game.outrun_cd > 0.0, "no re-arm cooldown after outrun") + _check(game.outrun_rival == null and game.outruns_won == 1, "outrun win didn't settle") + _check(game.car.boost_max > bm0, "outrun win paid no boost") + _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() # race: rivals spawn and make progress along the path + print(" .. 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 for i in 240: await physics_frame @@ -115,13 +173,23 @@ func _run() -> void: for r in game.rivals: if absf(r.progress - p0) > 3.0 or r.linear_velocity.length() > 3.0: moved = true - assert(moved, "no rival is driving") + _check(moved, "no rival is driving") game.free() - print("smoke ok: %d cars, %d levels | cruise %.1f m | crash $%d | junction $%d | outrun arms+pays | race AI driving" % - [cars.size(), levels.size(), dist, int(crash_score), int(jct_score)]) + 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), Game.DERBY_CARS]) 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: Game.mode = mode var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate() diff --git a/tools/build_all_junctions.sh b/tools/build_all_junctions.sh new file mode 100755 index 0000000..d08707e --- /dev/null +++ b/tools/build_all_junctions.sh @@ -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)" diff --git a/tools/build_level_osm.py b/tools/build_level_osm.py index f7fc29c..2554abf 100644 --- a/tools/build_level_osm.py +++ b/tools/build_level_osm.py @@ -373,7 +373,7 @@ def add_canopy(bm, at, fwd): d = json.load(open(src)) counts = dict.fromkeys( ("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_LANDUSE = ("grass", "recreation_ground", "village_green", "meadow", "forest") PLANTER_LEISURE = ("garden",) @@ -397,6 +397,7 @@ sig_bm, lens_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 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 SMASH_CAP = {"SL": 90, "BO": 90, "WB": 60} PARK_STREETS = ("residential", "tertiary", "secondary", "unclassified") @@ -651,6 +652,8 @@ for e in d["elements"]: acc += 28.0 acc -= segL 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 if hw != "pedestrian" and wide >= 7.0 and hw not in ("busway",): # dashed centre line for i in range(len(pts) - 1): @@ -879,6 +882,40 @@ for prefix, spots, cap in batches: bpy.context.collection.objects.link(emp) 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: slat, slon, sbear = [float(v) for v in spawn_arg.split(",")] sx, sy = xy(slat, slon) diff --git a/tools/gen_textures.sh b/tools/gen_textures.sh index c066803..d815ec1 100755 --- a/tools/gen_textures.sh +++ b/tools/gen_textures.sh @@ -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_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 +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 ) diff --git a/tools/osm_junction.py b/tools/osm_junction.py index e26aa8a..b83982d 100644 --- a/tools/osm_junction.py +++ b/tools/osm_junction.py @@ -5,14 +5,23 @@ Usage: osm_junction.py "Name|Stre 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 -copy lane-offset the other way). Spawn point sits ~120m up Street A aimed at -the junction. Coordinates are level-local Godot metres (x east, z south). +copy lane-offset the other way). Spawn point sits SPAWN_DIST up Street A aimed +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 math 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 TARGETS = [15000, 35000, 60000] @@ -41,6 +50,45 @@ def offset(pts, d): outp.append([p[0] + dz / L * d, p[1] - dx / L * d]) 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 = [] for spec in sys.argv[5:]: name, a, b = spec.split("|")[:3] @@ -50,14 +98,32 @@ for spec in sys.argv[5:]: if not shared: print("SKIP %s: %s never meets %s" % (name, a, b)) continue - jn = sorted(shared)[0] - jpt = None - for w in ways[a]: - if jn in w["nodes"]: - g = w["geometry"][w["nodes"].index(jn)] - jpt = xz(g["lat"], g["lon"]) - streams = [] + # Two named streets often share several nodes (OSM splits ways at every + # change of tag), and the lowest node id is arbitrary -- for Albert x + # Adelaide it landed inside a CBD building block with the convoy 60 m away + # and no drivable run-up. Pick the shared node with the most street geometry + # around it: the busiest crossing is the best crash junction anyway. + def node_pt(n): + 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_d = 1e9 for street in (a, b): for w in ways[street]: pts = [xz(g["lat"], g["lon"]) for g in w["geometry"]] @@ -66,21 +132,37 @@ for spec in sys.argv[5:]: continue 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)) - if length < 60: + if length < MIN_STREAM: continue - streams.append({"pts": offset(near, LANE), "speed": 12.0, "interval": 2.2}) - streams.append({"pts": offset(list(reversed(near)), LANE), "speed": 11.0, "interval": 2.6}) - if street == a and spawn is None: - best = max(near, key=lambda p: math.hypot(p[0] - jpt[0], p[1] - jpt[1])) - dx, dz = jpt[0] - best[0], jpt[1] - best[1] - L = math.hypot(dx, dz) or 1.0 - spawn = {"x": best[0], "z": best[1], "dx": dx / L, "dz": dz / L} + by_street[street].append({"pts": offset(near, LANE), "speed": 12.0, "interval": 1.5}) + by_street[street].append({"pts": offset(list(reversed(near)), LANE), + "speed": 11.0, "interval": 1.8}) + if street == a: + # OSM splits a street into many ways; only the one that actually + # reaches the junction can define the launch, or we start 40 m + # 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: print("SKIP %s: no usable streams" % name) continue - events.append({"name": name, "spawn": spawn, "streams": streams[:6], "targets": TARGETS}) - print("OK %s: %d streams, spawn %.0fm out" % (name, min(len(streams), 6), - math.hypot(spawn["x"] - jpt[0], spawn["z"] - jpt[1]))) + cross = sum(1 for s in streams if s in by_street[b]) + events.append({"name": name, "spawn": spawn, "streams": streams, "targets": TARGETS}) + 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) 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:]))) diff --git a/tools/tune_junctions.gd b/tools/tune_junctions.gd new file mode 100644 index 0000000..f7fbc43 --- /dev/null +++ b/tools/tune_junctions.gd @@ -0,0 +1,104 @@ +extends SceneTree +## Measure what each crash junction is actually worth, then write per-event +## medal targets back into levels//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 "" diff --git a/tools/tune_junctions.gd.uid b/tools/tune_junctions.gd.uid new file mode 100644 index 0000000..09b24b7 --- /dev/null +++ b/tools/tune_junctions.gd.uid @@ -0,0 +1 @@ +uid://dkj574xr62aff