Replace the generic boulevard facade with a stylized 6840 Hollywood Blvd block: six Ionic columns, eight stone steps, Masonic frieze/medallions, event banners, side doors, El Capitan Theatre and retail neighbours, Ovation Hollywood arcade, Walk of Fame and four-lane street markings. Add the Day and Night Hollywood venue-menu rows. Night uses a phase-time offset so a full-length shift stays in LATE while the clock starts at zero; late routine traffic keeps the street populated and the offset round-trips through checkpoint restore. Retune local-light falloff to linear attenuation, add a cool north sky fill, and use the smooth limestone facade texture. Refresh the staged gallery and map references; add geometry-budget, menu, night-offset and late-traffic tests.
267 lines
12 KiB
GDScript
267 lines
12 KiB
GDScript
extends RefCounted
|
|
## Headless tests for the generated prop models (tools/model_pipeline.py) and
|
|
## their placement in the procedural world.
|
|
|
|
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
|
const MODEL_DIR := "res://assets/models/generated/"
|
|
|
|
func test_prop_glb_files_exist() -> bool:
|
|
var missing: Array[String] = []
|
|
for prop in WorldBuilder.MODEL_PROPS:
|
|
if not FileAccess.file_exists(MODEL_DIR + str(prop) + ".glb"):
|
|
missing.append(str(prop))
|
|
return TestUtils.check(missing.is_empty(), "all prop models built (missing: %s)" % str(missing))
|
|
|
|
func test_prop_glb_files_are_imported_scenes() -> bool:
|
|
## `load()` only returns a PackedScene once Godot has imported the `.glb`;
|
|
## a null here means the checkout is missing the import or the model is invalid.
|
|
var problems: Array[String] = []
|
|
for prop in WorldBuilder.MODEL_PROPS:
|
|
var path := MODEL_DIR + str(prop) + ".glb"
|
|
var packed := ResourceLoader.load(path) as PackedScene
|
|
if packed == null:
|
|
problems.append("%s not imported as a scene" % prop)
|
|
elif not FileAccess.file_exists(path + ".import"):
|
|
problems.append("%s has no .import file" % prop)
|
|
return TestUtils.check(problems.is_empty(), "prop models import as scenes (%s)" % str(problems))
|
|
|
|
func test_prop_scenes_instantiate_with_meshes() -> bool:
|
|
var problems: Array[String] = []
|
|
for prop in WorldBuilder.MODEL_PROPS:
|
|
var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene
|
|
if packed == null:
|
|
problems.append("%s: no scene" % prop)
|
|
continue
|
|
var instance := packed.instantiate()
|
|
var meshes := instance.find_children("*", "MeshInstance3D", true, false)
|
|
if meshes.is_empty():
|
|
problems.append("%s: no mesh instances" % prop)
|
|
instance.free()
|
|
return TestUtils.check(problems.is_empty(), "every prop scene has geometry (%s)" % str(problems))
|
|
|
|
func test_prop_scenes_share_one_atlas_material() -> bool:
|
|
## All parts of a prop share one material backed by the colour atlas, so a
|
|
## prop is a single draw material instead of one per part.
|
|
var problems: Array[String] = []
|
|
for prop in WorldBuilder.MODEL_PROPS:
|
|
var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene
|
|
if packed == null:
|
|
problems.append("%s: no scene" % prop)
|
|
continue
|
|
var instance := packed.instantiate()
|
|
var meshes := instance.find_children("*", "MeshInstance3D", true, false)
|
|
var materials := {}
|
|
var surfaces := 0
|
|
for node in meshes:
|
|
var mesh: Mesh = (node as MeshInstance3D).mesh
|
|
if mesh == null:
|
|
continue
|
|
for index in mesh.get_surface_count():
|
|
surfaces += 1
|
|
var material := mesh.surface_get_material(index)
|
|
if material != null:
|
|
materials[material.get_instance_id()] = true
|
|
if surfaces == 0:
|
|
problems.append("%s: no surfaces" % prop)
|
|
elif materials.size() != 1:
|
|
problems.append("%s: %d materials (expected 1)" % [prop, materials.size()])
|
|
instance.free()
|
|
return TestUtils.check(problems.is_empty(), "one atlas material per prop (%s)" % str(problems))
|
|
|
|
func test_prop_instances_share_the_imported_material() -> bool:
|
|
## Instancing a prop must not duplicate its atlas material; every copy uses
|
|
## the same resource, so many props stay cheap to draw.
|
|
var problems: Array[String] = []
|
|
for prop in WorldBuilder.MODEL_PROPS:
|
|
var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene
|
|
if packed == null:
|
|
problems.append("%s: no scene" % prop)
|
|
continue
|
|
var first := packed.instantiate()
|
|
var second := packed.instantiate()
|
|
var a := _first_material(first)
|
|
var b := _first_material(second)
|
|
if a == null or b == null:
|
|
problems.append("%s: missing material" % prop)
|
|
elif a.get_instance_id() != b.get_instance_id():
|
|
problems.append("%s: material is duplicated per instance" % prop)
|
|
first.free()
|
|
second.free()
|
|
return TestUtils.check(problems.is_empty(), "prop instances share materials (%s)" % str(problems))
|
|
|
|
func _first_material(instance: Node) -> Material:
|
|
for node in instance.find_children("*", "MeshInstance3D", true, false):
|
|
var mesh: Mesh = (node as MeshInstance3D).mesh
|
|
if mesh != null and mesh.get_surface_count() > 0:
|
|
return mesh.surface_get_material(0)
|
|
return null
|
|
|
|
func test_world_places_generated_props() -> bool:
|
|
var root := Node3D.new()
|
|
var builder := WorldBuilder.new(root)
|
|
builder.build()
|
|
var detail := root.get_node_or_null("StreetDetail")
|
|
var a := TestUtils.check(detail != null, "StreetDetail exists")
|
|
var placed: int = detail.get_child_count() if detail else 0
|
|
var b := TestUtils.check(placed >= 10, "street furniture placed (%d)" % placed)
|
|
var c := TestUtils.check(builder.loaded_models.size() >= WorldBuilder.MODEL_PROPS.size(),
|
|
"generated models loaded instead of fallbacks")
|
|
var d := TestUtils.check(builder.fallback_props.is_empty(),
|
|
"no procedural fallback used (fallbacks: %s)" % str(builder.fallback_props))
|
|
root.free()
|
|
return a and b and c and d
|
|
|
|
func test_prop_placements_are_valid() -> bool:
|
|
var problems := WorldBuilder.placement_problems()
|
|
return TestUtils.check(problems.is_empty(),
|
|
"prop placements keep the door, steps and approach lane clear (%s)" % str(problems))
|
|
|
|
func test_placement_validator_catches_problems() -> bool:
|
|
var bad: Array = WorldBuilder.PROP_PLACEMENTS.duplicate(true)
|
|
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
|
|
bad.append({"zone": "test", "prop": "bench", "x": 0.0, "z": 2.0})
|
|
var problems := WorldBuilder.placement_problems(bad)
|
|
var a := TestUtils.check(not problems.is_empty(), "validator rejects bad placements")
|
|
var joined := " ".join(problems)
|
|
var b := TestUtils.check(joined.contains("steps and door"),
|
|
"validator reports the door/steps exclusion")
|
|
var c := TestUtils.check(joined.contains("duplicate"), "validator reports a duplicate")
|
|
return a and b and c
|
|
|
|
func test_prop_variants_are_deterministic() -> bool:
|
|
var first := WorldBuilder.variant_for(3, 180.0)
|
|
var second := WorldBuilder.variant_for(3, 180.0)
|
|
var other := WorldBuilder.variant_for(4, 180.0)
|
|
var a := TestUtils.check(is_equal_approx(first["rotation"], second["rotation"])
|
|
and is_equal_approx(first["scale"], second["scale"]),
|
|
"the same placement always gets the same variant")
|
|
var b := TestUtils.check(not is_equal_approx(first["rotation"], other["rotation"]),
|
|
"different placements get different rotations")
|
|
var c := TestUtils.check(first["scale"] >= 0.95 and first["scale"] <= 1.05,
|
|
"variant scale stays subtle")
|
|
var d := TestUtils.check(absf(first["rotation"] - 180.0) <= 2.01,
|
|
"variant rotation stays near the authored angle")
|
|
return a and b and c and d
|
|
|
|
func test_every_prop_has_a_collider() -> bool:
|
|
var root := Node3D.new()
|
|
var builder := WorldBuilder.new(root)
|
|
builder.build()
|
|
var detail := root.get_node_or_null("StreetDetail")
|
|
var problems: Array[String] = []
|
|
if detail == null:
|
|
problems.append("no StreetDetail")
|
|
else:
|
|
for holder in detail.get_children():
|
|
var body := holder.get_node_or_null("Collision")
|
|
if body == null or not (body is StaticBody3D):
|
|
problems.append("%s has no StaticBody3D" % holder.name)
|
|
continue
|
|
var collider := body.get_node_or_null("Shape") as CollisionShape3D
|
|
if collider == null or collider.shape == null:
|
|
problems.append("%s has no collision shape" % holder.name)
|
|
continue
|
|
var size: Vector3
|
|
if collider.shape is BoxShape3D:
|
|
size = (collider.shape as BoxShape3D).size
|
|
elif collider.shape is CylinderShape3D:
|
|
var cylinder := collider.shape as CylinderShape3D
|
|
size = Vector3(cylinder.radius * 2.0, cylinder.height, cylinder.radius * 2.0)
|
|
else:
|
|
problems.append("%s uses an unexpected shape" % holder.name)
|
|
continue
|
|
if size.x <= 0.05 or size.y <= 0.05 or size.z <= 0.05:
|
|
problems.append("%s has a degenerate collider" % holder.name)
|
|
if collider.position.y + size.y * 0.5 <= 0.05:
|
|
problems.append("%s collider sits below the ground" % holder.name)
|
|
if holder.get_node_or_null("Avoidance") == null:
|
|
problems.append("%s has no navigation obstacle" % holder.name)
|
|
root.free()
|
|
return TestUtils.check(problems.is_empty(), "every prop has a static collider (%s)" % str(problems))
|
|
|
|
func test_fallback_props_get_the_same_collider() -> bool:
|
|
## A missing `.glb` must not silently remove collision: fallback primitives go
|
|
## through the same bounds fit as the imported models.
|
|
var root := Node3D.new()
|
|
var builder := WorldBuilder.new(root)
|
|
var holder := Node3D.new()
|
|
var fallback := builder._fallback_prop("bench")
|
|
holder.add_child(fallback)
|
|
builder._add_prop_collision(holder, "bench", fallback)
|
|
var body := holder.get_node_or_null("Collision") as StaticBody3D
|
|
var a := TestUtils.check(body != null, "fallback bench gets a static body")
|
|
var collider := body.get_node_or_null("Shape") as CollisionShape3D if body else null
|
|
var b := TestUtils.check(collider != null and collider.shape is BoxShape3D,
|
|
"fallback bench gets a box shape")
|
|
var width: float = (collider.shape as BoxShape3D).size.x if b else 0.0
|
|
var c := TestUtils.check(width > 1.0, "fallback collider follows the bench width (%.2f)" % width)
|
|
var d := TestUtils.check(holder.get_node_or_null("Avoidance") is NavigationObstacle3D,
|
|
"fallback bench publishes a navigation obstacle")
|
|
holder.free()
|
|
root.free()
|
|
return a and b and c and d
|
|
|
|
func test_world_loads_the_blender_library_props() -> bool:
|
|
var root := Node3D.new()
|
|
var builder := WorldBuilder.new(root)
|
|
builder.build()
|
|
var a := TestUtils.check(builder.fallback_props.is_empty(),
|
|
"no primitive prop fallback (fallbacks: %s)" % str(builder.fallback_props))
|
|
var b := TestUtils.check(builder.library_props.has("street_lamp")
|
|
and builder.library_props.has("bollard")
|
|
and builder.library_props.has("palm_tree"),
|
|
"replacement and new props come from the library (%d)" % builder.library_props.size())
|
|
var c := TestUtils.check(root.get_node_or_null("NeighbourBlocks/ElCapitanTheatre") != null
|
|
and root.get_node_or_null("OvationHollywood/OvationSign") != null
|
|
and root.get_node_or_null("WalkOfFame") != null
|
|
and root.get_node_or_null("Building/FriezeInscription") != null,
|
|
"the landmark neighbours, opposite block, Walk of Fame and frieze are placed")
|
|
var d := TestUtils.check(not builder._lamp_bulb_mats.is_empty()
|
|
and not builder._marquee_emissive.is_empty(),
|
|
"library lamps/marquee expose emissive materials")
|
|
builder.set_night_mode(true)
|
|
var lit := (builder._lamp_bulb_mats[0].emission_energy_multiplier > 0.0
|
|
and builder._marquee_emissive[0].emission_energy_multiplier > 0.0)
|
|
builder.set_night_mode(false)
|
|
var dark := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.0
|
|
var e := TestUtils.check(lit and dark, "night mode drives library emissives")
|
|
var ao_ok := false
|
|
var detail := root.get_node_or_null("StreetDetail")
|
|
if detail != null:
|
|
for child in detail.get_children():
|
|
if str(child.name).begins_with("Prop_bench"):
|
|
for mi in (child as Node3D).find_children("*", "MeshInstance3D", true, false):
|
|
for index in (mi as MeshInstance3D).mesh.get_surface_count():
|
|
var mat := (mi as MeshInstance3D).get_surface_override_material(index) as StandardMaterial3D
|
|
if mat != null and mat.ao_enabled and mat.ao_texture != null:
|
|
ao_ok = true
|
|
var ao := TestUtils.check(ao_ok, "library prop materials carry the baked AO map")
|
|
var triangles := builder.library_triangle_count()
|
|
var f := TestUtils.check(triangles > 0
|
|
and triangles <= WorldBuilder.LIBRARY_TRIANGLE_BUDGET,
|
|
"library prop triangles stay inside the budget (%d)" % triangles)
|
|
root.free()
|
|
return a and b and c and d and e and f and ao
|
|
|
|
func test_world_facade_windows_light_at_night() -> bool:
|
|
var root := Node3D.new()
|
|
var builder := WorldBuilder.new(root)
|
|
builder.build()
|
|
var a := TestUtils.check(builder._window_mats.size() >= 3, "door transom panes built")
|
|
builder.set_night_mode(true)
|
|
var lit: bool = builder._window_mats[0].emission_energy_multiplier > 0.0
|
|
builder.set_night_mode(false)
|
|
var dark: bool = is_zero_approx(builder._window_mats[0].emission_energy_multiplier)
|
|
root.free()
|
|
return a and TestUtils.check(lit, "windows light up at night") and TestUtils.check(dark, "windows are dark by day")
|
|
|
|
func test_fallback_prop_is_usable() -> bool:
|
|
var root := Node3D.new()
|
|
var builder := WorldBuilder.new(root)
|
|
var fallback := builder._fallback_prop("bench")
|
|
var meshes := fallback.find_children("*", "MeshInstance3D", true, false)
|
|
var ok := TestUtils.check(meshes.size() >= 3, "fallback bench has geometry")
|
|
fallback.free()
|
|
root.free()
|
|
return ok
|