Crash Mode junctions: scripted convoys, Aftertouch, Crashbreaker, medals

9 junction events across 6 levels, auto-generated from real OSM intersections
(tools/osm_junction.py): each crossing street's ways become lane-offset convoy
streams, deterministic speeds/intervals/models -- learnable puzzles, not dice.
Menu lists CRASH JCT entries; crash -> 9s settle with camera-relative
aftertouch on the wreck and a one-shot crashbreaker shockwave (blast() on
traffic, radial impulse on debris, orange boom). Damage tally vs
bronze/silver/gold targets. Fixed zero-normal UV projection that striped
roads. Smoke test drives a full junction run to tally (k, gold).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-28 00:14:33 +10:00
parent 7fd11f3806
commit 565caa6f85
26 changed files with 2126 additions and 14 deletions

View File

@ -14,6 +14,11 @@ Controls: WASD/arrows drive, Shift drift, Space boost, R reset (or retry after C
- **Cruise** — free roam with traffic.
- **Crash Mode** — 60 seconds, rack up damage dollars ramming traffic and parked cars.
- **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/<id>/junctions.json`; generate more from real intersections with
`tools/osm_junction.py <area.json> <out.json> <lat0> <lon0> "Name|Street A|Street B"`.
- **Race** — 3 laps vs 3 rubber-banded rivals on the level's `RacePath`.
The boost loop is Burnout 3's: earn boost by drifting, near-missing traffic, and smashing

View File

@ -0,0 +1,426 @@
[
{
"name": "Alice x George",
"spawn": {
"x": -143.83199552181551,
"z": 142.01018819980536,
"dx": 0.7668348571083106,
"dz": -0.6418444530598335
},
"streams": [
{
"pts": [
[
-71.62300756706763,
85.0918821008208
],
[
-72.10279819575109,
85.49061753866586
],
[
-82.55345192912239,
94.80588029839768
],
[
-104.44928325520195,
113.36472803055571
],
[
-114.55295338424757,
122.93815788447793
],
[
-118.63551709634598,
126.33214688818488
],
[
-124.17972594270637,
130.60060399325408
],
[
-132.4126578466066,
136.84137198262476
],
[
-135.42619497476394,
139.13115862719584
],
[
-137.0173915026139,
140.32992242527928
],
[
-142.22691046279974,
144.18129435873087
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
-145.4370805808313,
139.83908204087984
],
[
-140.2367304705485,
135.99450357474848
],
[
-138.6871885076824,
134.82698377267795
],
[
-135.67603860636424,
132.53900681717874
],
[
-127.45466133363814,
126.30702760692736
],
[
-121.99640893818653,
122.1055163120108
],
[
-118.19749682812089,
118.9535245154313
],
[
-108.01476176313997,
109.30919316947609
],
[
-86.07852578965569,
90.71517690194068
],
[
-75.68968831102165,
81.45400766143295
],
[
-75.08305996358739,
80.94603629889595
]
],
"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
},
{
"pts": [
[
-111.32018174165945,
35.21033597859207
],
[
-78.48079808538708,
73.20344791473178
],
[
-76.9072614072839,
75.01987789888024
],
[
-71.34412554104603,
81.2149947472113
]
],
"speed": 11.0,
"interval": 2.6
}
],
"targets": [
15000,
35000,
60000
]
},
{
"name": "Margaret x Edward",
"spawn": {
"x": 88.92833122889411,
"z": -205.50177900009658,
"dx": 0.7566215209215891,
"dz": -0.6538530982401947
},
"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": [
[
185.8338152967176,
-292.8126451679154
],
[
193.65593395098287,
-299.88251164434115
],
[
211.37786867540518,
-315.2307257048046
],
[
216.02310568686988,
-319.25459820267537
],
[
237.6429961947087,
-338.58229333857753
],
[
239.66099718191472,
-341.2883463335457
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
243.95347049022743,
-338.01196526635846
],
[
241.32949950132615,
-334.63644826175323
],
[
219.61114951437548,
-315.21901379742513
],
[
214.91318335480702,
-311.1488694949895
],
[
197.21785955435436,
-295.8238559560563
],
[
189.45480420951895,
-288.8065948320647
]
],
"speed": 11.0,
"interval": 2.6
},
{
"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
],
[
229.982416914263,
-237.5875502401286
],
[
190.83429465784906,
-282.80981346079136
],
[
185.5730615349928,
-289.07758916339617
],
[
180.09260788812207,
-295.6548470145767
],
[
177.29669340310514,
-298.8501898379666
],
[
175.7564478621494,
-300.60775216234316
],
[
118.90617949034443,
-365.5157484793863
],
[
112.7841869787125,
-372.58003543563154
]
],
"speed": 11.0,
"interval": 2.6
}
],
"targets": [
15000,
35000,
60000
]
}
]

Binary file not shown.

View File

@ -0,0 +1,134 @@
[
{
"name": "Cliffs Corner",
"spawn": {
"x": 49.98333461330541,
"z": -68.64433920000972,
"dx": 0.4680499904857942,
"dz": -0.8837019895905226
},
"streams": [
{
"pts": [
[
47.4831595229508,
-69.66371377177926
],
[
63.94149741985269,
-110.02953643297833
],
[
78.3750416498091,
-148.8340213803462
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
83.43625143590378,
-146.95142862004474
],
[
68.97210013309922,
-108.06662116698321
],
[
52.48350970366002,
-67.62496462824019
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
114.66387685267605,
-57.53027727981913
],
[
109.86666098023801,
-74.50891216449172
],
[
107.12471250932794,
-155.39569742995997
],
[
106.88904877360271,
-160.41551590552262
],
[
106.3952482221942,
-170.89775425802756
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
111.78926635550437,
-171.15185734246612
],
[
112.28308355725834,
-160.66926529510476
],
[
112.52147634417389,
-155.58262017041363
],
[
115.2509302403802,
-74.92079143598734
],
[
119.8558354049959,
-59.01471872056136
]
],
"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
}
],
"targets": [
15000,
35000,
60000
]
}
]

Binary file not shown.

View File

@ -0,0 +1,538 @@
[
{
"name": "Albert x Adelaide",
"spawn": {
"x": -141.55578237343948,
"z": -187.08015060009473,
"dx": 0.735035843445057,
"dz": 0.6780282507764803
},
"streams": [
{
"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.33652985317063,
-167.58066870505155
],
[
-136.23707672722756,
-177.3382472125045
],
[
-138.0495681108421,
-179.37835778679877
],
[
-139.33017336122177,
-180.76880262140824
],
[
-143.5234010906565,
-185.23123832968892
]
],
"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
},
{
"pts": [
[
-86.70325250389301,
-94.08771386994844
],
[
-77.60701968874399,
-101.92094169090129
],
[
-72.06522503228508,
-106.94786296530025
],
[
-68.79209061830386,
-109.50838129624599
],
[
-62.692661313561906,
-114.75385541597699
],
[
-56.45081914916183,
-120.13849830634709
],
[
-53.14418135326541,
-122.9809049049611
],
[
-38.75486932340002,
-135.5677577748611
],
[
-8.138880164463979,
-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
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
60.765864254843116,
-217.9578738114873
],
[
35.77719298273772,
-194.91415523271732
],
[
31.239945374075152,
-190.73303753393867
],
[
30.11741737461444,
-189.76849583719522
],
[
29.172798227938216,
-188.97034350113577
],
[
25.522915182059183,
-185.76304781436195
],
[
11.118032425570394,
-172.8441212543805
],
[
-4.543064055336218,
-159.02564328250827
],
[
-35.16387720923361,
-131.5347966250365
],
[
-49.59536988091847,
-118.91077749494814
],
[
-52.92601130212681,
-116.04756569366813
],
[
-59.168575286057155,
-110.66230098388816
],
[
-65.33626025215173,
-105.35901550369637
],
[
-68.5423745958537,
-102.85524463438601
],
[
-74.0429581637501,
-97.86416150877923
],
[
-83.17948939242227,
-89.9958813300908
]
],
"speed": 11.0,
"interval": 2.6
}
],
"targets": [
15000,
35000,
60000
]
},
{
"name": "Edward x Elizabeth",
"spawn": {
"x": 329.71079592281694,
"z": -5.595044399780328,
"dx": -0.6532357656212038,
"dz": -0.7571545644802516
},
"streams": [
{
"pts": [
[
258.4805413787254,
-92.28866542876725
],
[
262.2737122591504,
-88.11954201221562
],
[
266.8273793037693,
-83.1100839184909
],
[
270.87333374760516,
-78.65784777138674
],
[
298.1107780903454,
-47.27921270488467
],
[
323.17112576090676,
-16.89985163934848
],
[
325.81187978583756,
-13.69226732374769
],
[
331.6793038120916,
-7.443009954076085
]
],
"speed": 12.0,
"interval": 2.2
},
{
"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
],
[
200.9901724381037,
-45.56696118289086
],
[
206.29822476676847,
-50.115127103271014
],
[
208.01277161716473,
-51.59585061763732
],
[
243.56749157860247,
-82.55471207644285
],
[
246.1313684904348,
-84.84463727526342
],
[
254.68470539632878,
-92.48524564052305
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
258.28215988994066,
-88.4580479589594
],
[
249.72869976275985,
-80.81732952477397
],
[
247.11707918748664,
-78.48526152300741
],
[
211.55809324014456,
-47.52268298180012
],
[
209.81572383420533,
-46.017908496633005
],
[
204.51598169911696,
-41.47689161686228
],
[
188.3547169687786,
-27.51764724920578
],
[
158.5007797295594,
-1.5251294005982539
],
[
152.49584713714452,
3.737997039525666
],
[
146.0412138327473,
9.337403004260793
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
299.8317240905086,
-131.8999654994903
],
[
302.3544939757109,
-134.11003589468572
],
[
328.3524909104502,
-156.3039359135965
],
[
335.8998393149425,
-162.7566931458765
],
[
376.4710012181815,
-198.14867992704066
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
380.02079919024186,
-194.079412872944
],
[
339.4433211293332,
-158.68192485417183
],
[
331.85925322106283,
-152.19752408611663
],
[
305.86522961245754,
-130.0070205048928
],
[
303.390286276,
-127.8383604999869
]
],
"speed": 11.0,
"interval": 2.6
}
],
"targets": [
15000,
35000,
60000
]
}
]

Binary file not shown.

View File

@ -0,0 +1,346 @@
[
{
"name": "Melbourne x Merivale",
"spawn": {
"x": -590.6178507503562,
"z": -217.37742659981197,
"dx": 0.8530074497663933,
"dz": -0.5218987360044418
},
"streams": [
{
"pts": [
[
-592.80332396911,
-218.96289397888663
],
[
-586.6662380952372,
-227.08658029182723
],
[
-522.2006803362468,
-263.3161280710875
],
[
-513.2984743659174,
-268.5646201058227
],
[
-505.76645618454586,
-272.8477143314658
],
[
-497.90899041561073,
-277.2629244179048
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
-495.26368453007734,
-272.55523318206934
],
[
-503.1094123444767,
-268.14663806831595
],
[
-510.58925218160783,
-263.8934194939837
],
[
-519.5432562746829,
-258.6152667290545
],
[
-583.8043810107387,
-222.50730370835848
],
[
-588.4323775316024,
-215.7919592207373
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
-497.7097209821479,
-277.3642792991366
],
[
-489.2653413897242,
-281.21700470668776
],
[
-470.16395422466655,
-292.9741741951012
],
[
-453.3973200149641,
-303.3696498434199
],
[
-438.7171383481014,
-313.0237718470059
],
[
-428.17568968279613,
-319.6952218182481
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
-425.28794348242786,
-315.13222698127333
],
[
-435.78284848324455,
-308.49056735250105
],
[
-450.4943046193153,
-298.8163541564887
],
[
-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
]
],
"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
}
],
"targets": [
15000,
35000,
60000
]
},
{
"name": "Grey x Glenelg",
"spawn": {
"x": -132.09447432867904,
"z": -92.67206939992113,
"dx": 0.5885516813877528,
"dz": 0.8084595959821672
},
"streams": [
{
"pts": [
[
-66.8941396578206,
18.021454924938546
],
[
-68.93721227192415,
14.729250272207434
],
[
-74.03417645108553,
5.788135121643277
],
[
-78.66584427851572,
-1.692678191469608
],
[
-82.8231789453831,
-8.34834069992463
],
[
-134.38713557935665,
-91.24598932165014
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
-129.80181307800143,
-94.09814947819211
],
[
-78.23825562104584,
-11.201142499900957
],
[
-74.07981104388904,
-4.543695408341438
],
[
-69.38832494823436,
3.0356700785490762
],
[
-64.27327518677029,
12.007542928328153
],
[
-62.30660151137585,
15.172859875635417
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
-62.42866919418658,
-1.5633764655737972
],
[
-56.22534149367183,
7.176315693408943
],
[
-31.591830618167315,
46.425585467880346
],
[
5.553177653998906,
108.18727469018124
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
0.9256242241840709,
110.9703933104001
],
[
-36.19855979655569,
49.24303933209625
],
[
-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
]
],
"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
}
],
"targets": [
15000,
35000,
60000
]
}
]

Binary file not shown.

View File

@ -0,0 +1,198 @@
[
{
"name": "Bridge Approach",
"spawn": {
"x": 8.830464544568363,
"z": 869.4323045999147,
"dx": 0.07444215026982055,
"dz": -0.9972253337451898
},
"streams": [
{
"pts": [
[
6.138933818706997,
869.2186170903494
],
[
7.71576357448385,
849.3684911078449
],
[
10.638374835867648,
826.3950177708408
],
[
13.290793665081416,
807.4161707785927
],
[
16.464164221809273,
786.8204240256596
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
21.80118214360166,
787.6427619747212
],
[
18.633212388594718,
808.2026580211177
],
[
15.991303480558578,
827.1064634292346
],
[
13.086203238107553,
849.932740892406
],
[
11.521995270429729,
869.64599210948
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
18.99248078399424,
762.39669386784
],
[
25.743285889885765,
718.3587501785464
],
[
28.769387854660618,
692.1866319902829
],
[
30.0890967826306,
666.9831668179552
],
[
31.42169559470248,
651.6049662957291
],
[
31.81270185839052,
638.4108005958807
],
[
32.07909068323288,
624.4813765582306
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
37.478103203918025,
624.5846422421965
],
[
37.211108262175465,
638.5419810043787
],
[
36.81191635813593,
651.9298049040424
],
[
35.47759634851261,
667.3354059817195
],
[
34.150141407857646,
692.6421440096167
],
[
31.09171746204034,
719.1032498212462
],
[
24.33013387059421,
763.2148989320567
]
],
"speed": 11.0,
"interval": 2.6
},
{
"pts": [
[
46.61372413580953,
629.091352780875
],
[
46.0791068558773,
644.9124964101077
],
[
45.148393336775854,
661.3968152993656
],
[
43.42646160590967,
688.423534995915
],
[
41.167633319989406,
712.1819019166054
],
[
39.13812925040989,
728.9192605875077
]
],
"speed": 12.0,
"interval": 2.2
},
{
"pts": [
[
33.77740686081252,
728.2691410126066
],
[
35.797668406451436,
711.6131516837517
],
[
38.04296969005352,
688.0016170039884
],
[
39.75839999553985,
661.068224700852
],
[
40.68465217733579,
644.6678359900116
],
[
41.21680242495871,
628.9090452194655
]
],
"speed": 11.0,
"interval": 2.6
}
],
"targets": [
15000,
35000,
60000
]
}
]

Binary file not shown.

View File

@ -0,0 +1,13 @@
[
{
"name": "Trolley Run",
"spawn": {"x": 33.0, "z": 52.0, "dx": 0.0, "dz": -1.0},
"streams": [
{"pts": [[-70.0, 15.0], [70.0, 15.0]], "speed": 11.0, "interval": 2.0},
{"pts": [[70.0, 12.0], [-70.0, 12.0]], "speed": 12.0, "interval": 2.4},
{"pts": [[-70.0, -21.0], [70.0, -21.0]], "speed": 10.0, "interval": 2.2},
{"pts": [[70.0, -24.0], [-70.0, -24.0]], "speed": 12.0, "interval": 2.8}
],
"targets": [8000, 20000, 40000]
}
]

40
shot_bridge.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bqaxeko1tl5w7"
path="res://.godot/imported/shot_bridge.png-2373c808c2e6eab2488c5d984769ec6f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_bridge.png"
dest_files=["res://.godot/imported/shot_bridge.png-2373c808c2e6eab2488c5d984769ec6f.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

40
shot_jct.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dkm1p5n1xyg7t"
path="res://.godot/imported/shot_jct.png-d1005de9ee52815e0d48b6f3b6f72efa.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_jct.png"
dest_files=["res://.godot/imported/shot_jct.png-d1005de9ee52815e0d48b6f3b6f72efa.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

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b4qj6hifty7co"
path="res://.godot/imported/shot_jct_woolies.png-8055f5e30848dcfa5c2e67aa3a1cbdb1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_jct_woolies.png"
dest_files=["res://.godot/imported/shot_jct_woolies.png-8055f5e30848dcfa5c2e67aa3a1cbdb1.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

40
shot_southbank.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dq74to3ccwpby"
path="res://.godot/imported/shot_southbank.png-c868fc85f6d129b498f68423d5fba80c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_southbank.png"
dest_files=["res://.godot/imported/shot_southbank.png-c868fc85f6d129b498f68423d5fba80c.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

@ -29,6 +29,7 @@ var steer_in := 0.0
var drift_in := false
var boost_in := false
var power_scale := 1.0 # rubber-band hook, set by game.gd
var hold_wreck := false # junction mode: stay wrecked (aftertouch phase)
var steer := 0.0
var boost := 0.0
@ -140,6 +141,8 @@ func _impact(jolt: Vector3) -> void:
get_tree().create_timer(2.2, true, false, true).timeout.connect(_recover)
func _recover() -> void:
if hold_wreck:
return # junction mode: the wreck is the gameplay now
wrecked = false
if is_player:
boost = boost_max # Burnout rule: recovery refills the meter

View File

@ -9,11 +9,17 @@ extends Node3D
static var car_path := ""
static var level_path := ""
static var mode := "cruise"
static var junction: Dictionary = {}
const LAPS := 3
const CRASH_TIME := 60.0
const RIVAL_COUNT := 3
const TRAFFIC_COUNT := {"cruise": 6, "crash": 10, "race": 4}
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
var jstate := "" # junction: "run" -> "settle" -> "done"
var breaker_used := false
var car: Car
var cam: Camera3D
@ -57,7 +63,12 @@ func _ready() -> void:
car.stats = load(car_path)
# place BEFORE add_child: teleporting a continuous_cd body after it enters
# the physics world sweeps the jump as motion and hits everything en route
if mode == "race" and race_path:
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.hold_wreck = true
elif mode == "race" and race_path:
car.transform = _grid_xform(0.0)
else:
var spawn := level.find_child("Spawn", true, false)
@ -92,7 +103,31 @@ func _ready() -> void:
rivals.append(r)
car.spawn_xform = car.global_transform
if race_path:
if mode == "junction":
jstate = "run"
time_left = JCT_RUN_TIME
var midx := 0
for stream in junction["streams"]:
var path := Path3D.new()
var curve := Curve3D.new()
for p in stream["pts"]:
curve.add_point(Vector3(p[0], 0, p[1]))
path.curve = curve
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)
for i in n:
var t := TrafficCar.new()
t.path = path
t.offset = fposmod(i * headway, L)
t.speed = stream["speed"]
t.model_idx = midx
midx += 1
add_child(t)
t.smashed.connect(_traffic_smashed)
traffic.append(t)
elif race_path:
var n: int = TRAFFIC_COUNT.get(mode, 4)
for i in n:
var t := TrafficCar.new()
@ -169,6 +204,17 @@ func _physics_process(delta: float) -> void:
_msg("NEAR MISS", 32)
if mode == "race" and race_path and not over:
_race_tick()
if mode == "junction" and jstate == "settle":
if Input.is_action_just_pressed("boost") and not breaker_used:
_crashbreaker()
if car.wrecked: # aftertouch: nudge the tumbling wreck, camera-relative
var right := cam.global_basis.x
right.y = 0
var fwd := -cam.global_basis.z
fwd.y = 0
var push := right.normalized() * Input.get_axis("steer_left", "steer_right") \
+ fwd.normalized() * Input.get_axis("brake", "throttle")
car.apply_central_force(push * car.mass * 9.0)
func _race_tick() -> void:
var L := race_path.curve.get_baked_length()
@ -206,8 +252,13 @@ func _nearest_off(pos: Vector3, seed_off: float, L: float) -> float:
return best
func _player_crash(dv: float) -> void:
if mode == "crash" and not over:
if mode in ["crash", "junction"] and not over:
score += minf(dv, 30.0) * 120.0
if mode == "junction" and jstate == "run" and dv > 5.0:
jstate = "settle"
time_left = JCT_SETTLE
_msg("CRASH!\narrows = aftertouch space = crashbreaker", 36)
return
if dv > 6.0:
_msg("SMASH", 40)
@ -225,11 +276,67 @@ func _rival_wrecked(r: Rival) -> void:
_msg("TAKEDOWN!", 56)
func _traffic_smashed(impulse: float) -> void:
if mode == "crash" and not over:
if mode in ["crash", "junction"] and not over:
score += impulse * 400.0
_msg("$%d" % int(impulse * 400.0), 40)
car.earn_boost(0.2)
func _crashbreaker() -> void:
breaker_used = true
score += 2000.0
var origin := car.global_position
var q := PhysicsShapeQueryParameters3D.new()
var sphere := SphereShape3D.new()
sphere.radius = 14.0
q.shape = sphere
q.transform = Transform3D(Basis.IDENTITY, origin)
var hits := get_world_3d().direct_space_state.intersect_shape(q, 64)
for h in hits:
var b: Object = h.collider
if b == car or not b is RigidBody3D:
continue
var dir: Vector3 = (b as RigidBody3D).global_position - origin
var dist := dir.length()
dir = (dir / maxf(dist, 0.5) + Vector3.UP * 0.6).normalized()
var strength := 16.0 / (1.0 + dist * 0.25)
if b is TrafficCar:
(b as TrafficCar).blast(dir * strength)
score += 500.0
else:
(b as RigidBody3D).apply_central_impulse(dir * strength * (b as RigidBody3D).mass)
car.apply_central_impulse(Vector3.UP * car.mass * 7.0)
_msg("CRASHBREAKER!", 52)
var boom := MeshInstance3D.new()
var sm := SphereMesh.new()
sm.radius = 1.0
sm.height = 2.0
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.5, 0.1, 0.7)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.emission_enabled = true
mat.emission = Color(1.0, 0.4, 0.05)
sm.material = mat
boom.mesh = sm
add_child(boom)
boom.global_position = origin
var tw := create_tween()
tw.tween_property(boom, "scale", Vector3.ONE * 14.0, 0.45)
tw.parallel().tween_property(boom, "transparency", 1.0, 0.45)
tw.tween_callback(boom.queue_free)
func _jct_tally() -> void:
jstate = "done"
over = true
var t: Array = junction.get("targets", [15000, 35000, 60000])
var medal := "NO MEDAL, MATE"
if score >= float(t[2]):
medal = "GOLD!"
elif score >= float(t[1]):
medal = "SILVER"
elif score >= float(t[0]):
medal = "BRONZE"
_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:
msg_label.text = text
msg_label.add_theme_font_size_override("font_size", size)
@ -252,7 +359,14 @@ func _process(delta: float) -> void:
if time_left <= 0.0:
over = true
_msg("DAMAGE: $%d\nR to retry, Esc for menu" % int(score), 48, true)
if over and mode == "crash" and Input.is_action_just_pressed("reset"):
if mode == "junction" and not over:
time_left -= delta
info_label.text = "$%d %d s" % [int(score), ceili(time_left)]
if time_left <= 0.0:
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"):
get_tree().reload_current_scene()
return
var target := car.global_position + car.global_basis * Vector3(0, 2.4, 6.0)

View File

@ -9,6 +9,7 @@ const MODES := [["cruise", "Cruise"], ["crash", "Crash Mode"], ["race", "Race"]]
var car_paths: Array = []
var level_paths: Array = []
var junction_data: Array = []
func _ready() -> void:
var cars := Registry.cars()
@ -20,6 +21,11 @@ func _ready() -> void:
for id in levels:
level_list.add_item(String(id).capitalize())
level_paths.append(levels[id])
junction_data.append({})
for j in Registry.junctions():
level_list.add_item("CRASH JCT: %s (%s)" % [j.name, String(j.level_id).capitalize()])
level_paths.append(j.level_path)
junction_data.append(j.data)
for m in MODES:
mode_list.add_item(m[1])
if not car_paths.is_empty():
@ -33,6 +39,9 @@ func _start() -> void:
if car_list.get_selected_items().is_empty() or level_list.get_selected_items().is_empty():
return
Game.car_path = car_paths[car_list.get_selected_items()[0]]
Game.level_path = level_paths[level_list.get_selected_items()[0]]
Game.mode = MODES[mode_list.get_selected_items()[0]][0]
var li: int = level_list.get_selected_items()[0]
Game.level_path = level_paths[li]
Game.junction = junction_data[li]
# picking a CRASH JCT entry forces junction mode, whatever the mode column says
Game.mode = "junction" if not Game.junction.is_empty() else MODES[mode_list.get_selected_items()[0]][0]
get_tree().change_scene_to_file("res://src/game.tscn")

View File

@ -19,3 +19,17 @@ static func levels() -> Dictionary:
out[dir] = p
break
return out
static func junctions() -> Array:
## crash-junction events: [{level_id, level_path, name, data}]
var out: Array = []
var lvls := levels()
for id in lvls:
var jp := "res://levels/%s/junctions.json" % id
if not FileAccess.file_exists(jp):
continue
var parsed = JSON.parse_string(FileAccess.get_file_as_string(jp))
if parsed is Array:
for ev in parsed:
out.append({"level_id": id, "level_path": lvls[id], "name": ev.get("name", "?"), "data": ev})
return out

View File

@ -14,14 +14,18 @@ var live := true
static var _model_pool: Array[String] = []
static var _pool_built := false
static func pick_model() -> String:
var model_idx := -1 # >= 0: deterministic pick (junction convoys are learnable puzzles)
static func pick_model(idx := -1) -> String:
if not _pool_built:
_pool_built = true
for p in Registry.cars().values():
var s: CarStats = load(p)
if s.model_path != "" and ResourceLoader.exists(s.model_path):
_model_pool.append(s.model_path)
return _model_pool.pick_random() if not _model_pool.is_empty() else ""
if _model_pool.is_empty():
return ""
return _model_pool[idx % _model_pool.size()] if idx >= 0 else _model_pool.pick_random()
func _init() -> void:
mass = 900.0
@ -35,19 +39,22 @@ func _init() -> void:
box.size = Vector3(1.7, 1.2, 4.0)
cs.shape = box
add_child(cs)
var mp := pick_model()
body_entered.connect(_hit)
func _ready() -> void:
# visuals here, not _init, so model_idx set by the spawner is respected
var mp := pick_model(model_idx)
if mp != "":
add_child((load(mp) as PackedScene).instantiate())
else:
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = box.size
bm.size = Vector3(1.7, 1.2, 4.0)
var mat := StandardMaterial3D.new()
mat.albedo_color = Color.from_hsv(randf(), 0.4, 0.8)
bm.material = mat
mi.mesh = bm
add_child(mi)
body_entered.connect(_hit)
func _physics_process(delta: float) -> void:
if not live or path == null:
@ -58,6 +65,14 @@ func _physics_process(delta: float) -> void:
var ahead := path.to_global(path.curve.sample_baked(fmod(offset + 2.0, L))) + Vector3.UP * 0.75
look_at_from_position(pos, ahead, Vector3.UP)
func blast(impulse: Vector3) -> void:
## Crashbreaker shockwave: go dynamic and fly, no contact needed
if live:
live = false
freeze = false
smashed.emit(clampf(impulse.length(), 3.0, 30.0))
apply_central_impulse(impulse * mass)
func _hit(body: Node) -> void:
if not live or not body is RigidBody3D:
return

View File

@ -17,6 +17,11 @@ func _run() -> void:
var levels := Registry.levels()
Game.level_path = levels.get(opts.get("level", ""), levels.values()[0])
Game.mode = opts.get("mode", "cruise")
Game.junction = {}
if opts.has("jct"): # jct=<index> within the chosen level's junction events
var jcts := Registry.junctions().filter(func(j): return j.level_path == Game.level_path)
Game.junction = jcts[int(opts["jct"])].data
Game.mode = "junction"
var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate()
root.add_child(game)
await process_frame

View File

@ -59,6 +59,32 @@ func _run() -> void:
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")
Game.junction = jcts[0].data
game = await _boot("junction")
assert(game.traffic.size() >= 8, "junction convoys missing (%d)" % game.traffic.size())
Input.action_press("throttle")
for i in 950:
await physics_frame
if game.jstate == "settle" and not game.breaker_used:
Input.action_release("throttle")
Input.action_press("boost")
elif game.breaker_used:
Input.action_release("boost")
if game.over:
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))
var jct_score := game.score
Game.junction = {}
game.free()
# race: rivals spawn and make progress along the path
game = await _boot("race")
assert(game.rivals.size() == Game.RIVAL_COUNT, "rivals missing")
@ -72,8 +98,8 @@ func _run() -> void:
assert(moved, "no rival is driving")
game.free()
print("smoke ok: %d cars, %d levels | cruise %.1f m | crash $%d | race AI driving" %
[cars.size(), levels.size(), dist, int(crash_score)])
print("smoke ok: %d cars, %d levels | cruise %.1f m | crash $%d | junction $%d | race AI driving" %
[cars.size(), levels.size(), dist, int(crash_score), int(jct_score)])
quit(0)
func _boot(mode: String) -> Game:

19
tools/build_all_levels.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# Rebuild every OSM level. Area JSONs are cached in $OSM_CACHE (defaults to the
# session scratchpad); refetch via the README curl + osm_xml_to_json.py if absent.
set -e
cd "$(dirname "$0")/.."
B=/Applications/Blender.app/Contents/MacOS/Blender
C="${OSM_CACHE:-/private/tmp/claude-501/-Users-johnking-Documents-BurnoutInfinityShitbox/09d758a4-f01e-48b8-b6a7-542f1069a0ff/scratchpad}"
while IFS='|' read -r id json lat lon sway loop bridge; do
ARGS=$(python3 tools/osm_level_args.py "$C/$json" "$sway" "$loop") || { echo "ARGS FAILED $id"; continue; }
SPAWN=$(echo "$ARGS" | head -1)
RP=$(echo "$ARGS" | tail -1)
"$B" -b -P tools/build_level_osm.py -- "$C/$json" "$id" "$lat" "$lon" "$SPAWN" "$RP" "$bridge" 2>&1 | grep -E '^(level built|bridge truss)'
done <<'EOF'
queen_st_mall|brisbane_cbd.json|-27.4699|153.0251|Queen Street Mall|Adelaide Street,Edward Street,Elizabeth Street,George Street|-
southbank|southbank.json|-27.4775|153.0215|Little Stanley Street|trace:Grey Street|-
albert_parklands|albert_parklands.json|-27.4740|153.0280|Albert Street|Alice Street,George Street,Margaret Street,Edward Street|-
story_bridge|story_bridge.json|-27.4635|153.0355|Bradfield Highway|trace:Bradfield Highway|Bradfield Highway
kangaroo_point|kangaroo_point.json|-27.4780|153.0345|River Terrace|trace:River Terrace|-
EOF

View File

@ -71,6 +71,7 @@ M_LEAF = flat_material("leaf", (0.18, 0.42, 0.16, 1))
M_TRUNK = flat_material("trunk", (0.28, 0.20, 0.12, 1))
def uv_project(bm, scale):
bm.normal_update() # fresh faces have zero normals -> wrong projection axis
uv = bm.loops.layers.uv.new("UVMap")
for f in bm.faces:
n = f.normal

86
tools/osm_junction.py Normal file
View File

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Generate crash-junction events from real OSM intersections.
Usage: osm_junction.py <area.json> <out junctions.json> <lat0> <lon0> "Name|Street A|Street B" ...
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).
"""
import json
import math
import sys
RADIUS = 170.0
LANE = 2.7
TARGETS = [15000, 35000, 60000]
src, out = sys.argv[1], sys.argv[2]
LAT0, LON0 = float(sys.argv[3]), float(sys.argv[4])
M_LAT = 110574.0
M_LON = 111320.0 * math.cos(math.radians(LAT0))
def xz(lat, lon):
return ((lon - LON0) * M_LON, -(lat - LAT0) * M_LAT)
data = json.load(open(src))
ways = {}
for e in data["elements"]:
t = e.get("tags", {})
if e["type"] == "way" and t.get("highway") and t.get("name") and e.get("geometry"):
ways.setdefault(t["name"], []).append(e)
def offset(pts, d):
outp = []
for i, p in enumerate(pts):
a = pts[max(i - 1, 0)]
b = pts[min(i + 1, len(pts) - 1)]
dx, dz = b[0] - a[0], b[1] - a[1]
L = math.hypot(dx, dz) or 1.0
outp.append([p[0] + dz / L * d, p[1] - dx / L * d])
return outp
events = []
for spec in sys.argv[5:]:
name, a, b = spec.split("|")[:3]
na = {n for w in ways.get(a, []) for n in w["nodes"]}
nb = {n for w in ways.get(b, []) for n in w["nodes"]}
shared = na & nb
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 = []
spawn = None
for street in (a, b):
for w in ways[street]:
pts = [xz(g["lat"], g["lon"]) for g in w["geometry"]]
near = [p for p in pts if math.hypot(p[0] - jpt[0], p[1] - jpt[1]) < RADIUS]
if len(near) < 2:
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:
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}
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])))
json.dump(events, open(out, "w"), indent=1)
print("wrote %s (%d events)" % (out, len(events)))