Smashable street furniture; fix levels losing every texture on rebuild

Street lamps, bollards and bins are no longer baked scenery. The builder emits
SL_/BO_/WB_ markers off the OSM nodes and game.gd spawns them as RigidBodies
with a low centre of mass, so clipping a lamp at 100 topples it instead of
driving through a painted pole. ~206 physics bodies per level (58 parked cars,
60 lamps, 88 bollards/bins), all asleep until hit. Verified by ramming one:
23.6 deg tilt, launched at 18 m/s.

Fixes a self-inflicted trap from the last commit: level_*.jpg are Godot's
extraction of the GLB's embedded textures, NOT Blender output. build_all
cleared them to avoid orphans but left level.glb.import, so Godot's cache
decided nothing had changed and never re-extracted -- queen_st_mall shipped
with 0 of its 31 textures, loading fine but rendering untextured. The .import
now goes with them.

Also confirmed the Wheel of Brisbane pivot really does turn (0.086 rad over
120 frames, 0.000 m centre drift) -- it was previously untested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-28 18:53:05 +10:00
parent 1db71420a3
commit e9717326f4
16 changed files with 183 additions and 66 deletions

View File

@ -104,11 +104,14 @@ whole footway network — so most of the detail is real Brisbane, not invented:
- **Trees** are a trunk plus three offset welded spheres (figs) or radiating
fronds (palms), scattered along kerbs on top of the ones OSM maps — Brisbane
has far more street trees than are tagged.
- **Smashables**: the builder drops `PP_`/`WB_` marker empties (kerbside
parking + carpark rows from `amenity=parking`, wheelie bins on footpaths);
game.gd fills them at load with shuntable RigidBody fleet cars and council
bins (asleep until rammed, skipped in junction mode to keep the puzzles
deterministic).
- **Smashables**: the builder drops marker empties and game.gd fills them at
load with RigidBodies — `PP_` kerbside/carpark parking spots become shuntable
fleet cars, and `SL_`/`BO_`/`WB_` (OSM street lamps, bollards, bins) become
topple-able street furniture with a low centre of mass so a clipped lamp post
goes over rather than helicoptering. All asleep until rammed, and skipped
entirely in junction mode to keep those puzzles deterministic. Lamps and
bollards are deliberately *not* baked into the level mesh — a static pole you
drive through is worse than no pole.
- **Brisbane colour**: red busway asphalt, jacaranda-purple canopy on ~1 in 5
street trees, Queenslander weatherboard + corrugated iron on `building=house`
footprints, fictional shop-fascia signs above awnings and rooftop billboards

Binary file not shown.

View File

@ -3,7 +3,7 @@
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://2sfe3g28na0f"
uid="uid://lekue6r6vs5u"
path="res://.godot/imported/level.glb-4aa597fe23a02438c7c91aac8329adb8.scn"
[deps]
@ -42,4 +42,4 @@ materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=0
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -3,7 +3,7 @@
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dgc141hgvu6r0"
uid="uid://cik0c7knt33ep"
path="res://.godot/imported/level.glb-30461b4810ef38297351fb89cbe9aa18.scn"
[deps]
@ -42,4 +42,4 @@ materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=0
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -3,7 +3,7 @@
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://c674y4fs837pi"
uid="uid://bo4jjuhgers8e"
path="res://.godot/imported/level.glb-f093d0a503a60c4e982eefdc88ad879e.scn"
[deps]
@ -42,4 +42,4 @@ materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=0
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -3,7 +3,7 @@
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bbivnp8wb7dw6"
uid="uid://ce6dmcne00017"
path="res://.godot/imported/level.glb-17af9cdeb05fd15a816097b7ae0fb96c.scn"
[deps]
@ -42,4 +42,4 @@ materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=0
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -3,7 +3,7 @@
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bggawagdgkqmy"
uid="uid://dah3x674j2af"
path="res://.godot/imported/level.glb-045fc5cf8c0e529ecdc2678ee7acde2a.scn"
[deps]
@ -42,4 +42,4 @@ materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=0
gltf/texture_map_mode=1

40
shot_sb_bridge.png.import Normal file
View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dpv2rx6laf2w3"
path="res://.godot/imported/shot_sb_bridge.png-3a49e069a8658d202658083a9b3802d5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://shot_sb_bridge.png"
dest_files=["res://.godot/imported/shot_sb_bridge.png-3a49e069a8658d202658083a9b3802d5.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_smash.png.import Normal file
View File

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

@ -17,7 +17,14 @@ const CRASH_TIME := 60.0
const RIVAL_COUNT := 3
const TRAFFIC_COUNT := {"cruise": 8, "crash": 12, "race": 4}
const PARKED_MAX := 50 # PP_ markers filled with shuntable fleet cars
const BINS_MAX := 40 # WB_ markers -> wheelie bins (light, they FLY)
## Street furniture that topples. Marker prefix -> size, mass, colour.
## Deliberately physics, not scenery: clipping a lamp post at 120 and watching
## it go over is the whole point of the genre.
const SMASHABLE := {
"WB": {"size": Vector3(0.55, 1.0, 0.6), "mass": 35.0, "col": Color(0.14, 0.30, 0.13), "max": 45},
"BO": {"size": Vector3(0.22, 0.95, 0.22), "mass": 45.0, "col": Color(0.20, 0.21, 0.23), "max": 60},
"SL": {"size": Vector3(0.26, 7.4, 0.26), "mass": 190.0, "col": Color(0.24, 0.25, 0.27), "max": 60},
}
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
@ -242,32 +249,62 @@ func _spawn_smashables(level: Node) -> void:
Basis(Vector3.UP, t.basis.get_euler().y), Vector3(t.origin.x, 0.75, t.origin.z))
b.sleeping = true
n += 1
var bin_mesh := BoxMesh.new()
bin_mesh.size = Vector3(0.55, 1.0, 0.6)
var bin_mat := StandardMaterial3D.new()
bin_mat.albedo_color = Color(0.14, 0.30, 0.13) # council green
bin_mat.roughness = 0.85
bin_mesh.material = bin_mat
n = 0
for m in level.find_children("WB_*", "Node3D", true, false):
if n >= BINS_MAX:
break
var b := RigidBody3D.new()
b.mass = 35.0
var cs := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = bin_mesh.size
cs.shape = bs
b.add_child(cs)
var mi := MeshInstance3D.new()
mi.mesh = bin_mesh
b.add_child(mi)
add_child(b)
var t: Transform3D = (m as Node3D).global_transform
b.global_transform = Transform3D(
Basis(Vector3.UP, t.basis.get_euler().y), Vector3(t.origin.x, 0.55, t.origin.z))
b.sleeping = true
n += 1
for prefix in SMASHABLE:
var spec: Dictionary = SMASHABLE[prefix]
var size: Vector3 = spec["size"]
var mesh := BoxMesh.new()
mesh.size = size
var mat := StandardMaterial3D.new()
mat.albedo_color = spec["col"]
mat.roughness = 0.8
mat.metallic = 0.3
mesh.material = mat
n = 0
for m in level.find_children("%s_*" % prefix, "Node3D", true, false):
if n >= int(spec["max"]):
break
var b := RigidBody3D.new()
b.mass = spec["mass"]
# low centre of mass so a clipped lamp post topples rather than
# helicoptering off into the skybox
b.center_of_mass_mode = RigidBody3D.CENTER_OF_MASS_MODE_CUSTOM
b.center_of_mass = Vector3(0, -size.y * 0.3, 0)
var cs := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = size
cs.shape = bs
b.add_child(cs)
var mi := MeshInstance3D.new()
mi.mesh = mesh
b.add_child(mi)
if prefix == "SL":
# arm + lantern, visual only -- collision stays the plain pole box
var arm := MeshInstance3D.new()
var am := BoxMesh.new()
am.size = Vector3(1.5, 0.12, 0.12)
am.material = mat
arm.mesh = am
arm.position = Vector3(0.75, size.y * 0.5 - 0.25, 0)
b.add_child(arm)
var head := MeshInstance3D.new()
var hm := BoxMesh.new()
hm.size = Vector3(0.8, 0.14, 0.4)
var hmat := StandardMaterial3D.new()
hmat.albedo_color = Color(0.86, 0.84, 0.72)
hmat.emission_enabled = true
hmat.emission = Color(0.9, 0.85, 0.6)
hmat.emission_energy_multiplier = 0.35
hm.material = hmat
head.mesh = hm
head.position = Vector3(1.45, size.y * 0.5 - 0.36, 0)
b.add_child(head)
add_child(b)
var t: Transform3D = (m as Node3D).global_transform
b.global_transform = Transform3D(
Basis(Vector3.UP, t.basis.get_euler().y),
Vector3(t.origin.x, size.y * 0.5 + 0.02, t.origin.z))
b.sleeping = true
n += 1
func _build_environment() -> void:
## Brisbane midday: hard high sun, deep blue zenith, everything a bit blown out.

View File

@ -10,9 +10,12 @@ while IFS='|' read -r id json lat lon sway loop bridge canopy; 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)
# the glTF export drops textures next to the .glb; clear them first or
# renamed/dropped materials leave orphans behind forever
rm -f "levels/$id"/level_*.jpg "levels/$id"/level_*.jpg.import
# level_*.jpg are Godot's extraction of the GLB's embedded textures, not
# Blender's output. Clear them so renamed/dropped materials don't leave
# orphans -- but the .glb.import MUST go too, or Godot's cache decides
# nothing changed and never re-extracts, leaving a level with no textures
# at all (loads, renders untextured, spams "Resource file not found").
rm -f "levels/$id"/level_*.jpg "levels/$id"/level_*.jpg.import "levels/$id"/level.glb.import
"$B" -b -P tools/build_level_osm.py -- "$C/$json" "$id" "$lat" "$lon" "$SPAWN" "$RP" "$bridge" "$canopy" 2>&1 \
| grep -E '^(level built|bridge truss|canopies built)'
done <<'EOF'

View File

@ -289,14 +289,6 @@ def add_tree(fol, jac, frond, trunk, x, y, rng):
(r * rng.uniform(0.75, 1.05), r * rng.uniform(0.75, 1.05), r * rng.uniform(0.55, 0.75)),
subdiv=2)
def add_lamp(bm, x, y, rng):
h = 7.5
cone(bm, (x, y, h / 2), 0.13, 0.09, h, 6)
arm = Vector((math.cos(rng.random() * math.tau), math.sin(rng.random() * math.tau), 0)) * 1.5
beam(bm, Vector((x, y, h - 0.2)), Vector((x, y, h - 0.2)) + arm, 0.11, 0.11)
slab(bm, [(x + arm.x - 0.4, y + arm.y - 0.25, h - 0.35), (x + arm.x + 0.4, y + arm.y - 0.25, h - 0.35),
(x + arm.x + 0.4, y + arm.y + 0.25, h - 0.35), (x + arm.x - 0.4, y + arm.y + 0.25, h - 0.35)], 0.12)
def add_bench(bm, x, y, rng):
a = rng.random() * math.tau
d = Vector((math.cos(a), math.sin(a), 0))
@ -308,12 +300,6 @@ def add_bench(bm, x, y, rng):
slab(bm, [(a0 + p * 0.28).to_tuple(), (a0 - p * 0.28).to_tuple(),
(a0 - p * 0.28 + d * 1.9).to_tuple(), (a0 + p * 0.28 + d * 1.9).to_tuple()], 0.08)
def add_bin(bm, x, y, rng):
cone(bm, (x, y, 0.45), 0.30, 0.27, 0.9, 8)
def add_bollard(bm, x, y, rng):
cone(bm, (x, y, 0.45), 0.10, 0.09, 0.9, 6)
def add_signal(bm, lens, x, y, rng):
"""Aussie traffic signal: pole, lantern head, mast arm with a second head.
Lenses go in their own bmesh so they can be emissive."""
@ -387,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"), 0)
"cross", "signal", "pole", "pp", "sl", "bo", "wb"), 0)
GREEN_LEISURE = ("park", "recreation_ground", "pitch", "playground", "golf_course", "nature_reserve")
GREEN_LANDUSE = ("grass", "recreation_ground", "village_green", "meadow", "forest")
PLANTER_LEISURE = ("garden",)
@ -395,8 +381,11 @@ SKIP_HW = ("steps", "corridor", "platform", "elevator", "proposed", "constructio
ROAD_W = {"motorway": 11.0, "trunk": 10.0, "primary": 9.0, "secondary": 8.0,
"tertiary": 7.0, "pedestrian": 16.0, "residential": 6.5, "busway": 7.0}
PATH_W = {"footway": 2.6, "path": 2.2, "cycleway": 2.4, "pedestrian": 4.0}
PROPS = {"tree": None, "street_lamp": add_lamp, "bench": add_bench,
"waste_basket": add_bin, "bollard": add_bollard, "bus_stop": None}
# Baked decorative props. Lamps, bollards and bins are deliberately absent:
# they're the things you clip at speed, so they become markers that game.gd
# fills with topple-able RigidBodies instead of static scenery.
PROPS = {"bench": add_bench}
SMASHABLE = {"street_lamp": "SL", "bollard": "BO", "waste_basket": "WB"}
fol_bm, frond_bm, trunk_bm = bmesh.new(), bmesh.new(), bmesh.new()
prop_bm, glass_bm = bmesh.new(), bmesh.new()
@ -407,7 +396,9 @@ bill_bm = [bmesh.new(), bmesh.new(), bmesh.new()] # one per ad design
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
PP_CAP, WB_CAP = 70, 40
SMASH_CAP = {"SL": 90, "BO": 90, "WB": 60}
PARK_STREETS = ("residential", "tertiary", "secondary", "unclassified")
tree_sites = [] # (x, y, seed) -- mapped trees first, then scattered
road_ways = [] # (pts, width) for street-tree scattering
@ -428,7 +419,9 @@ for e in d["elements"]:
elif kind == "traffic_signals":
add_signal(sig_bm, lens_bm, px, py, rng)
counts["signal"] += 1
elif kind in PROPS and PROPS[kind]:
elif kind in SMASHABLE:
smash_spots[SMASHABLE[kind]].append((px, py, rng.random() * math.tau))
elif kind in PROPS:
PROPS[kind](prop_bm, px, py, rng)
counts["prop"] += 1
continue
@ -876,14 +869,15 @@ add_obj("ground-convcol", bm, [M_GROUND], 18.0)
# parked-car / wheelie-bin markers -> empties; game.gd fills them with
# shuntable RigidBodies at load (a static GLB can't carry physics bodies)
random.Random(1982).shuffle(pp_spots)
for prefix, spots, cap in (("PP", pp_spots, PP_CAP), ("WB", wb_spots, WB_CAP)):
smash_spots["WB"] += wb_spots # OSM street bins join the procedural wheelie bins
batches = [("PP", pp_spots, PP_CAP)] + [(k, v, SMASH_CAP[k]) for k, v in smash_spots.items()]
for prefix, spots, cap in batches:
for i, (mx, my, yaw) in enumerate(spots[:cap]):
emp = bpy.data.objects.new("%s_%03d" % (prefix, i), None)
emp.location = (mx, my, 0.05)
emp.rotation_euler = (0, 0, yaw)
bpy.context.collection.objects.link(emp)
counts["pp"] = min(len(pp_spots), PP_CAP)
counts["wb"] = min(len(wb_spots), WB_CAP)
counts[prefix.lower()] = min(len(spots), cap)
if spawn_arg:
slat, slon, sbear = [float(v) for v in spawn_arg.split(",")]