tools/fetch_dem.py pulls the open AWS terrarium elevation tiles for each level's bbox (derived from the OSM geometry itself -- the fetch bboxes were never recorded) and writes an f32 heightmap. The builder bilinear-samples it: roads/footpaths/crossings drape (subdivided to ~8 m so long OSM segments stop chording across curved ground), buildings lift rigidly to the lowest terrain under their footprint with a 2 m foundation skirt, trees/props/planters ride their own base heights, and the flat ground slab becomes a real terrain mesh. Bridges get an explicit deck profile (lerp between end heights plus a hump) instead of draping down into the river, the truss follows the deck, and the Bradfield Highway now genuinely flies over Kemp Place -- the game has an underpass. Kangaroo Point's cliffs are an actual 20 m drop, Edward Street climbs to 55 m at Spring Hill. Junction events bake spawn/convoy heights (osm_junction.py grew a pure-python DEM sampler); the game respects marker heights everywhere it used to hardcode ground=0. Bugs the terrain surfaced, all found by measurement: - Long road segments chorded across curved ground: the car visually sank to its windows mid-block. Fixed by subdividing draped ribbons. - The 9 m terrain grid aliases cliff edges and bulged metres ABOVE the finely draped roads: a car spawning there was inside the terrain sheet and got depenetrated through the floor into the void (y = -710). Fixed by giving roads real collision and carving the terrain grid down wherever a draped road sample lands (11k+ carve samples on Kangaroo Point alone). - The junction validator and tuner drove throttle-only, which cross-slope drift turned into 40-86 m misses; they now hold aim like a player would. - Edward x Elizabeth launched down a 17% grade, went airborne at the crest and wedged off-street: unwinnable geometry, not a bug. Swapped for Elizabeth x George on the flat lower CBD. All 9 junctions validate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
9.1 KiB
Python
209 lines
9.1 KiB
Python
#!/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 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 os
|
|
import sys
|
|
from itertools import zip_longest
|
|
|
|
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]
|
|
|
|
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)
|
|
|
|
# terrain heights (tools/fetch_dem.py output), pure-python bilinear -- events
|
|
# bake spawn/convoy heights so the game never has to guess ground level
|
|
from array import array
|
|
DEM = None
|
|
DEM_META = None
|
|
_base = src[:-5] if src.endswith(".json") else src
|
|
if os.path.exists(_base + "_dem.f32"):
|
|
DEM_META = json.load(open(_base + "_dem.json"))
|
|
DEM = array("f")
|
|
with open(_base + "_dem.f32", "rb") as f:
|
|
DEM.fromfile(f, DEM_META["w"] * DEM_META["h"])
|
|
if sys.byteorder == "big":
|
|
DEM.byteswap()
|
|
|
|
def hgt(x, z):
|
|
if DEM is None:
|
|
return 0.0
|
|
lat = LAT0 - z / M_LAT
|
|
lon = LON0 + x / M_LON
|
|
w, h = DEM_META["w"], DEM_META["h"]
|
|
u = (lon - DEM_META["min_lon"]) / (DEM_META["max_lon"] - DEM_META["min_lon"]) * (w - 1)
|
|
v = (DEM_META["max_lat"] - lat) / (DEM_META["max_lat"] - DEM_META["min_lat"]) * (h - 1)
|
|
u = min(max(u, 0.0), w - 1.001)
|
|
v = min(max(v, 0.0), h - 1.001)
|
|
x0, y0 = int(u), int(v)
|
|
fx, fy = u - x0, v - y0
|
|
a = DEM[y0 * w + x0] * (1 - fx) + DEM[y0 * w + x0 + 1] * fx
|
|
b = DEM[(y0 + 1) * w + x0] * (1 - fx) + DEM[(y0 + 1) * w + x0 + 1] * fx
|
|
return a * (1 - fy) + b * fy
|
|
|
|
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
|
|
|
|
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
|
|
dx, dz = dx / L, dz / L
|
|
ey = round(hgt(end[0] - dz * LANE, end[1] + dx * LANE), 2)
|
|
# Sit in a lane, not on the centreline. Convoys are offset +/-LANE either
|
|
# side, so a centreline launch threads straight between them -- Grey x
|
|
# Glenelg passed within 2.9 m of a convoy car and never touched one,
|
|
# scoring nothing in 12 of 12 tuning runs. Same transform as offset().
|
|
# sign matters: offsetting the other way moved the launch AWAY from the
|
|
# convoy lane and widened Grey x Glenelg's closest pass from 2.9 m to 5.5 m
|
|
return {"x": end[0] - dz * LANE, "z": end[1] + dx * LANE, "y": ey, "dx": dx, "dz": dz}
|
|
|
|
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
|
|
# 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"]]
|
|
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 < MIN_STREAM:
|
|
continue
|
|
fwd_pts = [[px, pz, round(hgt(px, pz), 2)] for px, pz in offset(near, LANE)]
|
|
rev_pts = [[px, pz, round(hgt(px, pz), 2)] for px, pz in offset(list(reversed(near)), LANE)]
|
|
by_street[street].append({"pts": fwd_pts, "speed": 12.0, "interval": 1.5})
|
|
by_street[street].append({"pts": rev_pts, "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
|
|
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:])))
|