Two proper venues off the real Ipswich pair, both built in code. Willowbank Dragway (levels/willowbank_dragway): a true 402 m dual-lane strip -- concrete launch pad, FLUX-rubbered lanes, crowd grandstand with canopy, red control tower, distance boards at 60ft/eighth/1000ft, checkered finish under a scoreboard gantry, sand trap and tyre wall, smashable SL_ light towers and a staging paddock of parked fleet cars. Between the lanes: a working christmas tree, six bulbs driven by game.gd through material_override emission. Drag mode grew the event around it. Every drag now stages at the tree (three ambers, green at 2.7 s); rolling >2 m early is a RED LIGHT that bogs your launch. The opposition is a six-rung ladder of preset locals (Nanna's Shopping Run through The Willowbank Widowmaker), each with its own fleet car, power and reaction time. Beating your current rung climbs the ladder and pays an escalating purse ($250-$1600, top dog repeats); crossing at all pays $50; and every car keeps a persistent quarter-mile PB worth $150 when beaten. Losing no longer ends the run early -- you still cross for your ET, with a 10 s grace if you dawdle. Ladder rung and PBs persist in the garage save (schema extended compatibly). Queensland Raceway (levels/qld_raceway): the National Circuit digitised from the official track map and scaled to its exact 3126 m lap. The road is one generated quad strip with trimesh collision; kerbs, gravel traps and tyre stacks derive automatically from centreline curvature, sponsor hoardings space themselves down the straights, and the SF straight gets the checkered line, gantry, pit wall and the mandatory red pit complex. Plus the pond. Race/cruise/rage all run on its RacePath. Trap for the diary: Godot front faces wind CLOCKWISE. The first cut of the road ribbon wound CCW and rendered invisible from above while colliding perfectly -- the smoke suite passed and the screenshot showed four cars gridded on open grass. Three new FLUX textures (crowd, dragstrip, sand; the sand prompt tripped Cloudflare's NSFW filter, of all things, and fell back to local). Smoke suite covers tree staging, the held rival, rung-0 identity, green-bulb emission, red-light innocence, PB/rung persistence and the 3126 m lap. Also swept stale shot_*.png.import debris and ignored the pattern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
374 lines
13 KiB
GDScript
374 lines
13 KiB
GDScript
extends Node3D
|
|
## Queensland Raceway's National Circuit -- "the Paperclip" -- built in code.
|
|
## The centreline is digitised from the official track map (pixel coords) and
|
|
## scaled so the lap is exactly 3126 m, the real National Circuit length.
|
|
## Three tilted parallel straights, two outer hairpins, the inner loop and the
|
|
## T6 fishhook. Corners get kerbs, sand traps and tyre stacks automatically
|
|
## from curvature; straights get billboard hoardings.
|
|
##
|
|
## Race mode picks up the RacePath (same curve) and grids the field on it.
|
|
|
|
const TEX := "res://assets/textures/%s.jpg"
|
|
|
|
const LAP_M := 3126.0
|
|
const ROAD_W := 13.0
|
|
const STEP := 4.0 # road-mesh sampling, metres
|
|
## National Circuit red line off the track map, in image pixels (x, y-down).
|
|
## Drive order: SF heading west -> T1 -> west straight -> T2 hairpin -> top
|
|
## straight east -> east 180 -> middle straight west -> inner loop left ->
|
|
## third straight east -> T6 fishhook -> front straight -> SF.
|
|
const CENTRE_PX := [
|
|
[680, 795], [300, 800], [150, 782], [85, 720], [70, 620], [68, 400],
|
|
[85, 290], [130, 235], [220, 215], [700, 262], [1290, 335],
|
|
[1345, 372], [1352, 420], [1300, 452], [900, 470], [460, 485],
|
|
[400, 505], [375, 545], [385, 590], [430, 622], [520, 640],
|
|
[800, 680], [1080, 718], [1150, 720], [1200, 748], [1195, 782],
|
|
[1140, 795],
|
|
]
|
|
|
|
var curve: Curve3D
|
|
|
|
func _ready() -> void:
|
|
var rng := RandomNumberGenerator.new()
|
|
rng.seed = 3126
|
|
curve = _build_curve()
|
|
var samples := _samples()
|
|
|
|
# grass world sized off the track's bounding box
|
|
var lo := Vector3(INF, 0, INF)
|
|
var hi := Vector3(-INF, 0, -INF)
|
|
for s in samples:
|
|
lo.x = minf(lo.x, s["pos"].x); lo.z = minf(lo.z, s["pos"].z)
|
|
hi.x = maxf(hi.x, s["pos"].x); hi.z = maxf(hi.z, s["pos"].z)
|
|
var mid := (lo + hi) * 0.5
|
|
var span := hi - lo
|
|
_slab(Vector3(mid.x, -0.5, mid.z), Vector3(span.x + 260, 1, span.z + 260), _mat("grass", 18.0))
|
|
|
|
_road(samples)
|
|
var corners := _corners(samples)
|
|
_kerbs(samples)
|
|
for c in corners:
|
|
_corner_dressing(c, samples)
|
|
_hoardings(samples, corners)
|
|
_start_finish(samples)
|
|
_pits(samples)
|
|
|
|
# the pond, as seen from every aerial photo of the place
|
|
var pond := Vector3(lo.x + span.x * 0.25, 0.03, lo.z + span.z * 0.37)
|
|
_slab(pond, Vector3(55, 0.03, 38), _flat(Color(0.13, 0.25, 0.30), 0.15), false)
|
|
|
|
_gums(rng, samples, lo, hi)
|
|
|
|
var path := Path3D.new()
|
|
path.name = "RacePath"
|
|
path.curve = curve
|
|
add_child(path)
|
|
|
|
var spawn := Marker3D.new()
|
|
spawn.name = "Spawn"
|
|
var t0: Dictionary = samples[3]
|
|
spawn.transform = Transform3D(Basis.looking_at(t0["tan"], Vector3.UP),
|
|
t0["pos"] + Vector3.UP * 0.5)
|
|
add_child(spawn)
|
|
|
|
func _build_curve() -> Curve3D:
|
|
# raw pixel loop -> catmull tangents -> measure -> scale to LAP_M exactly
|
|
var raw: Array[Vector3] = []
|
|
for p in CENTRE_PX:
|
|
raw.append(Vector3(p[0], 0, p[1]))
|
|
var c := Curve3D.new()
|
|
var n := raw.size()
|
|
for i in n + 1:
|
|
var p := raw[i % n]
|
|
var t: Vector3 = (raw[(i + 1) % n] - raw[(i - 1 + n) % n]) * 0.2
|
|
c.add_point(p, -t, t)
|
|
var scale := LAP_M / c.get_baked_length()
|
|
var sc := Curve3D.new()
|
|
for i in c.point_count:
|
|
sc.add_point(c.get_point_position(i) * scale,
|
|
c.get_point_in(i) * scale, c.get_point_out(i) * scale)
|
|
return sc
|
|
|
|
func _samples() -> Array:
|
|
## [{pos, tan, side, dist}] every STEP metres. side points to the LEFT of
|
|
## the drive direction.
|
|
var out: Array = []
|
|
var L := curve.get_baked_length()
|
|
var d := 0.0
|
|
while d < L:
|
|
var p := curve.sample_baked(d)
|
|
var q := curve.sample_baked(fmod(d + 2.0, L))
|
|
var tn := (q - p)
|
|
tn.y = 0.0
|
|
tn = tn.normalized() if tn.length_squared() > 0.0001 else Vector3.FORWARD
|
|
out.append({"pos": p, "tan": tn, "side": Vector3.UP.cross(tn).normalized(), "dist": d})
|
|
d += STEP
|
|
return out
|
|
|
|
func _road(samples: Array) -> void:
|
|
## One long quad strip with real collision -- cars live on this surface.
|
|
var st := SurfaceTool.new()
|
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
|
var h := Vector3.UP * 0.05
|
|
var n := samples.size()
|
|
for i in n:
|
|
var a: Dictionary = samples[i]
|
|
var b: Dictionary = samples[(i + 1) % n]
|
|
var al: Vector3 = a["pos"] + a["side"] * ROAD_W * 0.5 + h
|
|
var ar: Vector3 = a["pos"] - a["side"] * ROAD_W * 0.5 + h
|
|
var bl: Vector3 = b["pos"] + b["side"] * ROAD_W * 0.5 + h
|
|
var br: Vector3 = b["pos"] - b["side"] * ROAD_W * 0.5 + h
|
|
var va: float = a["dist"] * 0.14
|
|
var vb: float = (a["dist"] + STEP) * 0.14
|
|
# Godot front faces wind CLOCKWISE seen from the normal side: the first
|
|
# cut of this strip wound CCW and the entire road was culled from above
|
|
for v in [[al, Vector2(0, va)], [br, Vector2(1.8, vb)], [ar, Vector2(1.8, va)],
|
|
[al, Vector2(0, va)], [bl, Vector2(0, vb)], [br, Vector2(1.8, vb)]]:
|
|
st.set_normal(Vector3.UP)
|
|
st.set_uv(v[1])
|
|
st.add_vertex(v[0])
|
|
var mesh := st.commit()
|
|
var m := StandardMaterial3D.new()
|
|
m.albedo_texture = load(TEX % "asphalt")
|
|
m.roughness = 0.92
|
|
mesh.surface_set_material(0, m)
|
|
var body := StaticBody3D.new()
|
|
var mi := MeshInstance3D.new()
|
|
mi.mesh = mesh
|
|
body.add_child(mi)
|
|
var cs := CollisionShape3D.new()
|
|
cs.shape = mesh.create_trimesh_shape()
|
|
body.add_child(cs)
|
|
add_child(body)
|
|
|
|
func _curvature(samples: Array, i: int) -> float:
|
|
## signed heading change per metre; positive = turning left
|
|
var n := samples.size()
|
|
var a: Vector3 = samples[i]["tan"]
|
|
var b: Vector3 = samples[(i + 1) % n]["tan"]
|
|
return a.signed_angle_to(b, Vector3.UP) / STEP
|
|
|
|
func _corners(samples: Array) -> Array:
|
|
## contiguous runs of |curvature| > threshold, min 6 samples (24 m)
|
|
## -> [{from, to, sign}] as sample indices
|
|
var out: Array = []
|
|
var n := samples.size()
|
|
var run_start := -1
|
|
var run_sign := 0.0
|
|
for i in n:
|
|
var k := _curvature(samples, i)
|
|
if absf(k) > 0.010:
|
|
if run_start < 0:
|
|
run_start = i
|
|
run_sign = signf(k)
|
|
else:
|
|
if run_start >= 0 and i - run_start >= 6:
|
|
out.append({"from": run_start, "to": i - 1, "sign": run_sign})
|
|
run_start = -1
|
|
if run_start >= 0 and n - run_start >= 6:
|
|
out.append({"from": run_start, "to": n - 1, "sign": run_sign})
|
|
return out
|
|
|
|
func _kerbs(samples: Array) -> void:
|
|
## red/white kerb blocks along both edges wherever the road actually turns
|
|
var red := _flat(Color(0.78, 0.12, 0.10), 0.7)
|
|
var white := _flat(Color(0.90, 0.90, 0.86), 0.7)
|
|
var n := samples.size()
|
|
for i in n:
|
|
if absf(_curvature(samples, i)) < 0.008 or i % 2 != 0:
|
|
continue
|
|
var s: Dictionary = samples[i]
|
|
for e in [-1.0, 1.0]:
|
|
var at: Vector3 = s["pos"] + s["side"] * e * (ROAD_W * 0.5 + 0.7) + Vector3.UP * 0.045
|
|
var mi := MeshInstance3D.new()
|
|
var bm := BoxMesh.new()
|
|
bm.size = Vector3(1.4, 0.07, STEP * 2.0)
|
|
bm.material = red if (i / 2) % 2 == 0 else white
|
|
mi.mesh = bm
|
|
mi.position = at
|
|
mi.basis = Basis.looking_at(s["tan"], Vector3.UP)
|
|
add_child(mi)
|
|
|
|
func _corner_dressing(c: Dictionary, samples: Array) -> void:
|
|
## outside of every real corner: a sand trap; inside: a tyre stack
|
|
var midi := int((int(c["from"]) + int(c["to"])) / 2.0)
|
|
var s: Dictionary = samples[midi]
|
|
# turning left (+sign) means the outside is the RIGHT edge (-side)
|
|
var out_dir: Vector3 = s["side"] * (-1.0 if float(c["sign"]) > 0.0 else 1.0)
|
|
var span := (int(c["to"]) - int(c["from"])) * STEP
|
|
if span > 40.0: # only proper corners earn a gravel trap
|
|
var at: Vector3 = s["pos"] + out_dir * (ROAD_W * 0.5 + 13.0)
|
|
var mi := MeshInstance3D.new()
|
|
var bm := BoxMesh.new()
|
|
bm.size = Vector3(minf(span * 0.9, 60.0), 0.04, 26.0)
|
|
bm.material = _mat("sand", 5.0)
|
|
mi.mesh = bm
|
|
mi.position = Vector3(at.x, 0.035, at.z)
|
|
mi.basis = Basis.looking_at(s["tan"], Vector3.UP)
|
|
add_child(mi)
|
|
var rubber := _flat(Color(0.07, 0.07, 0.08), 0.85)
|
|
var inn: Vector3 = s["pos"] - out_dir * (ROAD_W * 0.5 + 3.5)
|
|
for j in 5:
|
|
var mi := MeshInstance3D.new()
|
|
var cm := CylinderMesh.new()
|
|
cm.top_radius = 0.58
|
|
cm.bottom_radius = 0.58
|
|
cm.height = 1.05
|
|
cm.radial_segments = 9
|
|
cm.material = rubber
|
|
mi.mesh = cm
|
|
mi.position = inn + s["tan"] * (j - 2.0) * 1.3 + Vector3.UP * 0.53
|
|
add_child(mi)
|
|
|
|
func _hoardings(samples: Array, corners: Array) -> void:
|
|
## sponsor boards down the straights, FLUX gibberish and all
|
|
var ads := ["billboard_ad", "billboard_ad2", "billboard_ad3"]
|
|
var n := samples.size()
|
|
var since := 0.0
|
|
var adi := 0
|
|
for i in n:
|
|
since += STEP
|
|
if since < 130.0 or absf(_curvature(samples, i)) > 0.004:
|
|
continue
|
|
since = 0.0
|
|
var s: Dictionary = samples[i]
|
|
var at: Vector3 = s["pos"] + s["side"] * (ROAD_W * 0.5 + 9.0)
|
|
var post := _flat(Color(0.3, 0.31, 0.33), 0.5)
|
|
for j in [-3.2, 3.2]:
|
|
_boxat(at + s["tan"] * j + Vector3.UP * 0.9, Vector3(0.18, 1.8, 0.18), post, s["tan"])
|
|
_boxat(at + Vector3.UP * 1.9, Vector3(8.0, 1.9, 0.12), _mat(ads[adi % 3], 0.24, 0.4), s["tan"])
|
|
adi += 1
|
|
|
|
func _start_finish(samples: Array) -> void:
|
|
var s: Dictionary = samples[0]
|
|
for row in 2:
|
|
for i in 9:
|
|
var c := Color(0.05, 0.05, 0.06) if (i + row) % 2 == 0 else Color(0.92, 0.92, 0.9)
|
|
var at: Vector3 = s["pos"] + s["side"] * (-6.0 + i * 1.5) + s["tan"] * float(row) * 1.5
|
|
_boxat(Vector3(at.x, 0.06, at.z), Vector3(1.5, 0.02, 1.5), _flat(c, 0.7), s["tan"])
|
|
var steel := _flat(Color(0.25, 0.26, 0.28), 0.5)
|
|
for e in [-1.0, 1.0]:
|
|
_boxat(s["pos"] + s["side"] * e * (ROAD_W * 0.5 + 1.2) + Vector3.UP * 3.5,
|
|
Vector3(0.35, 7.0, 0.35), steel, s["tan"])
|
|
_boxat(s["pos"] + Vector3.UP * 6.6, Vector3(ROAD_W + 4.5, 1.6, 0.3),
|
|
_mat("billboard_ad2", 0.22, 0.4), s["tan"])
|
|
|
|
func _pits(samples: Array) -> void:
|
|
## the red pit complex on the outside of the front straight: garage row,
|
|
## pit wall, paddock awnings, a couple of transporters
|
|
var s: Dictionary = samples[mini(20, samples.size() - 1)] # ~80 m up-track of SF
|
|
var out_dir: Vector3 = -s["side"] # outside of the circuit on the front straight
|
|
var red := _flat(Color(0.72, 0.10, 0.08), 0.55)
|
|
var base: Vector3 = s["pos"] + out_dir * (ROAD_W * 0.5 + 22.0)
|
|
_boxat(base + Vector3.UP * 4.0, Vector3(14, 8.0, 95), red, s["tan"], true)
|
|
_boxat(base + Vector3.UP * 8.2, Vector3(15, 0.4, 98), _flat(Color(0.16, 0.17, 0.19), 0.6), s["tan"])
|
|
_boxat(base - out_dir * 7.1 + Vector3.UP * 5.9, Vector3(0.2, 2.2, 88),
|
|
_mat("facade_glass", 0.2, 0.25), s["tan"])
|
|
_boxat(base - out_dir * 7.1 + Vector3.UP * 2.0, Vector3(0.25, 3.4, 88),
|
|
_mat("shopfront", 0.2, 0.4), s["tan"])
|
|
# pit wall between track and pit lane
|
|
_boxat(s["pos"] + out_dir * (ROAD_W * 0.5 + 2.5) + Vector3.UP * 0.45,
|
|
Vector3(0.4, 0.9, 130), _mat("concrete_ground", 3.0), s["tan"], true)
|
|
# paddock: white awnings + transporters behind the garages
|
|
var wt := _mat("panel_white", 0.5, 0.5)
|
|
for j in 4:
|
|
_boxat(base + out_dir * 16.0 + s["tan"] * (-33.0 + j * 22.0) + Vector3.UP * 2.9,
|
|
Vector3(9, 0.2, 7), wt, s["tan"])
|
|
for j in 2:
|
|
_boxat(base + out_dir * 27.0 + s["tan"] * (-12.0 + j * 26.0) + Vector3.UP * 1.9,
|
|
Vector3(2.5, 3.8, 13.5), _flat(Color(0.82, 0.83, 0.86), 0.4), s["tan"], true)
|
|
|
|
func _gums(rng: RandomNumberGenerator, samples: Array, lo: Vector3, hi: Vector3) -> void:
|
|
## eucalypt scatter anywhere that's 25 m clear of the racing line
|
|
var pts: Array[Vector3] = []
|
|
for i in range(0, samples.size(), 3):
|
|
pts.append(samples[i]["pos"])
|
|
var placed := 0
|
|
var tries := 0
|
|
while placed < 40 and tries < 400:
|
|
tries += 1
|
|
var at := Vector3(rng.randf_range(lo.x - 90, hi.x + 90), 0, rng.randf_range(lo.z - 90, hi.z + 90))
|
|
var ok := true
|
|
for p in pts:
|
|
if Vector2(at.x - p.x, at.z - p.z).length_squared() < 625.0:
|
|
ok = false
|
|
break
|
|
if not ok:
|
|
continue
|
|
placed += 1
|
|
var trunk := MeshInstance3D.new()
|
|
var cm := CylinderMesh.new()
|
|
cm.top_radius = 0.14
|
|
cm.bottom_radius = 0.24
|
|
cm.height = rng.randf_range(5.0, 8.5)
|
|
cm.radial_segments = 7
|
|
cm.material = _mat("bark", 1.4)
|
|
trunk.mesh = cm
|
|
trunk.position = at + Vector3(0, cm.height * 0.5, 0)
|
|
add_child(trunk)
|
|
var crown := MeshInstance3D.new()
|
|
var sm := SphereMesh.new()
|
|
sm.radius = rng.randf_range(2.4, 3.6)
|
|
sm.height = sm.radius * 1.6
|
|
sm.radial_segments = 10
|
|
sm.rings = 6
|
|
sm.material = _mat("foliage", 0.55)
|
|
crown.mesh = sm
|
|
crown.position = at + Vector3(0, cm.height + sm.radius * 0.4, 0)
|
|
add_child(crown)
|
|
|
|
func _boxat(pos: Vector3, size: Vector3, mat: Material, along: Vector3, collide := false) -> void:
|
|
var mi := MeshInstance3D.new()
|
|
var bm := BoxMesh.new()
|
|
bm.size = size
|
|
bm.material = mat
|
|
mi.mesh = bm
|
|
if collide:
|
|
var body := StaticBody3D.new()
|
|
var cs := CollisionShape3D.new()
|
|
var bs := BoxShape3D.new()
|
|
bs.size = size
|
|
cs.shape = bs
|
|
body.add_child(cs)
|
|
body.add_child(mi)
|
|
body.position = pos
|
|
body.basis = Basis.looking_at(along, Vector3.UP)
|
|
add_child(body)
|
|
else:
|
|
mi.position = pos
|
|
mi.basis = Basis.looking_at(along, Vector3.UP)
|
|
add_child(mi)
|
|
|
|
func _mat(tex: String, scale: float, rough := 0.9) -> StandardMaterial3D:
|
|
var m := StandardMaterial3D.new()
|
|
m.albedo_texture = load(TEX % tex)
|
|
m.uv1_scale = Vector3(scale, scale, scale)
|
|
m.uv1_triplanar = true
|
|
m.roughness = rough
|
|
m.metallic = 0.0
|
|
return m
|
|
|
|
func _flat(color: Color, rough := 0.85) -> StandardMaterial3D:
|
|
var m := StandardMaterial3D.new()
|
|
m.albedo_color = color
|
|
m.roughness = rough
|
|
m.metallic = 0.0
|
|
return m
|
|
|
|
func _slab(pos: Vector3, size: Vector3, mat: Material, collide := true) -> void:
|
|
var body := StaticBody3D.new()
|
|
var mi := MeshInstance3D.new()
|
|
var bm := BoxMesh.new()
|
|
bm.size = size
|
|
bm.material = mat
|
|
mi.mesh = bm
|
|
body.add_child(mi)
|
|
if collide:
|
|
var shape := CollisionShape3D.new()
|
|
var bs := BoxShape3D.new()
|
|
bs.size = size
|
|
shape.shape = bs
|
|
body.add_child(shape)
|
|
body.position = pos
|
|
add_child(body)
|