v0.3.0: rendering pass, generated props, selection model and Web build
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Successful in 4m17s

Rendering and presentation
- Shared surface-material module (src/game/materials.gd) with per-surface
  roughness/metallic/uv profiles; ACES tonemapping, glow, fog and phase-driven sky
  energy. WorldBuilder rebuilt on those profiles with facade windows that light up,
  door trim/handles, curb, lane dashes, bollards, bench, bin and planter foliage.
- NPC silhouettes: per-role clothing palettes, caps/hoods/backpacks/sunglasses,
  hands and shoes, arm/leg walk swing.
- Six generated low-poly GLB props (bollards, bench, bin, planter, newspaper box,
  hydrant) built by tools/model_pipeline.py, loaded by WorldBuilder with a
  procedural fallback; ADR-0012.

Selection and movement
- Focus ranking by view angle then distance (NpcFocus.rank_indices); Tab and the
  mouse wheel cycle nearby people initially, right click clears focus with a
  re-acquire grace, HUD shows the candidate count, Shift walks at half speed.
  ADR-0011. Windowed smoke extended to cover wheel cycling and clearing.

QA tooling
- screen_designs.py catalogue grown to 19 screens (8 scene, 11 UI) plus a `list`
  subcommand; draft generation keeps existing AI references unless --force.

Web export
- New Web preset using the non-threaded Godot template (static hosting, no
  COOP/COEP). tools/build_releases.sh exports build/web and packages
  build/step-line-web-<ver>.zip; publish_release.sh uploads it with the desktop
  artifacts. test_web_export.gd covers the preset and release wiring.

Verification
- Headless suite 177 -> 181, Python tooling 53 tests, all eight smokes pass
  (including the windowed focus/wheel smoke). Web bundle serves index.html,
  index.wasm (35 MB) and index.pck (30 MB) with HTTP 200 from a static server.
- Version 0.3.0; README, PLAN, RELEASING, TODO/DONE and MEMORY updated.
This commit is contained in:
zwitschi committed 2026-10-31 23:07:41 +01:00
1 parent f763cff744
commit afa1d6beec
48 files changed
+2602 -147

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
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_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_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() >= 8, "window 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
+77
View File
@@ -0,0 +1,77 @@
extends RefCounted
## Headless tests for the shared surface/environment recipes (ADR-0012).
const Materials := preload("res://src/game/materials.gd")
const GameManager := preload("res://src/game/game_manager.gd")
const Rules := preload("res://src/core/rules.gd")
func test_every_profile_has_physical_values() -> bool:
var ok := true
for role in ["brick", "concrete", "asphalt", "awning", "stone", "metal",
"painted_metal", "wood", "cloth", "skin", "glass", "rubber"]:
var profile: Dictionary = Materials.profile(role)
var roughness: float = float(profile.get("roughness", -1.0))
var metallic: float = float(profile.get("metallic", -1.0))
if roughness < 0.0 or roughness > 1.0:
ok = false
if metallic < 0.0 or metallic > 1.0:
ok = false
return TestUtils.check(ok, "all roles have roughness/metallic in 0..1")
func test_textured_recipes_keep_their_texture_and_profile() -> bool:
var brick := Materials.brick()
var concrete := Materials.concrete()
var asphalt := Materials.asphalt()
var awning := Materials.awning()
var a := TestUtils.check(brick.albedo_texture != null, "brick keeps its texture")
var b := TestUtils.check(concrete.uv1_scale.x > 1.0, "concrete tiles across the sidewalk")
var c := TestUtils.check(asphalt.roughness < brick.roughness, "asphalt is smoother than brick")
var d := TestUtils.check(awning.albedo_texture != null, "awning keeps its texture")
var e := TestUtils.check(Materials.flat(Color.RED, "metal").metallic > 0.5, "metal role is metallic")
return a and b and c and d and e
func test_glass_and_emissive_recipes() -> bool:
var glass := Materials.glass()
var pane := Materials.window_pane()
var bar := Materials.emissive(Color(1.0, 0.35, 0.75), 2.5)
var a := TestUtils.check(glass.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA,
"glass is alpha blended")
var b := TestUtils.check(glass.roughness < 0.2, "glass is smooth")
var c := TestUtils.check(pane.emission_enabled, "window panes can light up")
var d := TestUtils.check(is_equal_approx(pane.emission_energy_multiplier, 0.0),
"window panes start dark")
var e := TestUtils.check(is_equal_approx(bar.emission_energy_multiplier, 2.5),
"emissive energy is applied")
return a and b and c and d and e
func test_environment_tuning_applies_phase_values() -> bool:
var env := Environment.new()
var late: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.LATE]
Materials.configure_environment(env, late)
var a := TestUtils.check(env.tonemap_mode == Environment.TONE_MAPPER_ACES,
"ACES tonemapping")
var b := TestUtils.check(env.glow_enabled, "glow enabled for the marquee")
var c := TestUtils.check(env.fog_enabled, "fog enabled for distance haze")
var d := TestUtils.check(env.fog_density > 0.0, "fog has a density")
var e := TestUtils.check(env.adjustment_enabled, "colour grading enabled")
var f := TestUtils.check(
env.fog_light_color.is_equal_approx(late["fog_color"]),
"phase fog colour is used")
return a and b and c and d and e and f
func test_every_phase_has_environment_keys() -> bool:
var ok := true
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
"contrast", "saturation"]:
if not cfg.has(key):
ok = false
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")
func test_night_phases_are_night() -> bool:
var evening: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.EVENING]
var late: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.LATE]
var early: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.EARLY]
return TestUtils.check(bool(evening["night"]) and bool(late["night"]) and not bool(early["night"]),
"night flag matches the phase")
+88
View File
@@ -0,0 +1,88 @@
extends RefCounted
## Headless tests for focus ranking, aim-free cycling helpers, and the NPC
## outfit/gait helpers added in the control re-evaluation (ADR-0011).
const NpcFocus := preload("res://src/game/npc_focus.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const Rules := preload("res://src/core/rules.gd")
func test_rank_orders_by_angle_then_distance() -> bool:
var origin := Vector3.ZERO
var forward := Vector3(0, 0, -1)
# Index 2 is dead ahead and near, index 0 dead ahead but far, index 1 off to
# the side. Ranking must be angle first, then distance.
var heads := [Vector3(0, 0, -9), Vector3(0.9, 0, -6), Vector3(0, 0, -5)]
var ranked: Array = NpcFocus.rank_indices(origin, forward, heads)
var a := TestUtils.check_eq(ranked, [2, 0, 1], "angle first, then distance")
var b := TestUtils.check_eq(NpcFocus.pick_index(origin, forward, heads), 2,
"pick_index returns the best-ranked head")
return a and b
func test_rank_excludes_out_of_cone_and_range() -> bool:
var origin := Vector3.ZERO
var forward := Vector3(0, 0, -1)
var heads := [
Vector3(0, 0, 40), # too far
Vector3(8, 0, -5), # too wide
Vector3(0, 0, 6), # behind the camera
Vector3(0.2, 0, -7), # valid
]
var ranked: Array = NpcFocus.rank_indices(origin, forward, heads)
return TestUtils.check_eq(ranked, [3], "only the in-cone head is ranked")
func test_rank_is_empty_when_nobody_is_in_the_cone() -> bool:
var ranked: Array = NpcFocus.rank_indices(
Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)])
var picked := NpcFocus.pick_index(Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)])
return TestUtils.check(ranked.is_empty() and picked == -1, "empty ranking and -1 pick")
func test_limb_swing_is_opposite_per_side() -> bool:
var left := NpcAvatar.limb_swing(PI / 2.0, 0, 1.0)
var right := NpcAvatar.limb_swing(PI / 2.0, 1, 1.0)
var still := NpcAvatar.limb_swing(0.0, 0, 1.0)
var a := TestUtils.check(absf(left - 1.0) < 0.001, "left limb swings to +amplitude")
var b := TestUtils.check(absf(right + 1.0) < 0.001, "right limb swings the other way")
var c := TestUtils.check(absf(still) < 0.001, "phase 0 is the neutral pose")
return a and b and c
func test_outfit_is_deterministic_per_seed() -> bool:
var first: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991)
var second: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991)
var a := TestUtils.check(first["shirt"] == second["shirt"], "same seed, same shirt")
var b := TestUtils.check(first["pants"] == second["pants"], "same seed, same trousers")
var c := TestUtils.check(first["accessory"] == second["accessory"], "same seed, same accessory")
return a and b and c
func test_outfit_varies_across_seeds() -> bool:
var shirts := {}
var pants := {}
for seed_value in [1, 2, 3, 4, 5, 6, 7, 8]:
var outfit: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 977)
shirts[outfit["shirt"]] = true
pants[outfit["pants"]] = true
var a := TestUtils.check(shirts.size() > 1, "shirt colour varies between people")
var b := TestUtils.check(pants.size() > 1, "trouser colour varies between people")
return a and b
func test_accessories_follow_the_role() -> bool:
var a := TestUtils.check_eq(
NpcAvatar.outfit_for(Rules.NpcType.RESPONDER, 5)["accessory"], "cap",
"responders wear a cap")
var b := TestUtils.check_eq(
NpcAvatar.outfit_for(Rules.NpcType.TRESPASSER, 5)["accessory"], "hood",
"trespassers wear a hood")
var c := TestUtils.check_eq(
NpcAvatar.outfit_for(Rules.NpcType.AUTHORIZED_GUEST, 5)["accessory"], "lanyard",
"guests wear a lanyard")
var d := TestUtils.check_eq(
NpcAvatar.outfit_for(Rules.NpcType.INTOXICATED, 5)["accessory"], "none",
"roles without an accessory say none")
return a and b and c and d
func test_height_scale_stays_human() -> bool:
var ok := true
for seed_value in range(1, 40):
var scale_y: float = float(NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 131)["height_scale"])
if scale_y < 0.9 or scale_y > 1.1:
ok = false
return TestUtils.check(ok, "avatar height scale stays between 0.9 and 1.1")
+65
View File
@@ -0,0 +1,65 @@
extends RefCounted
## Web export target: the preset exists, uses the non-threaded template (so it
## runs on plain static hosting without COOP/COEP headers), and the release
## scripts build and publish it.
func _preset() -> String:
## Return the `[preset.N]` block whose name is Web plus its `.options` block.
var lines := FileAccess.get_file_as_string("res://export_presets.cfg").split("\n")
var sections := {}
var current := ""
var web_id := ""
for line in lines:
if line.begins_with("[") and line.ends_with("]"):
current = line
sections[current] = PackedStringArray()
continue
if sections.has(current):
# PackedStringArray is a value type: mutate a local and write it back.
var block: PackedStringArray = sections[current]
block.append(line)
sections[current] = block
if line == 'name="Web"':
web_id = current.trim_prefix("[").trim_suffix("]")
if web_id == "":
return ""
var out: PackedStringArray = []
out.append("[%s]" % web_id)
out.append_array(sections.get("[%s]" % web_id, PackedStringArray()))
var options := "[%s.options]" % web_id
if sections.has(options):
out.append(options)
out.append_array(sections[options])
return "\n".join(out)
func test_web_preset_exists() -> bool:
var preset := _preset()
var a := TestUtils.check(preset != "", "a Web export preset exists")
var b := TestUtils.check(preset.contains('platform="Web"'), "preset targets the Web platform")
var c := TestUtils.check(preset.contains('export_path="build/web/index.html"'), "preset writes build/web/index.html")
return a and b and c
func test_web_preset_is_static_hosting_friendly() -> bool:
var preset := _preset()
return TestUtils.check(preset.contains("variant/thread_support=false"),
"threads are disabled so no COOP/COEP headers are required")
func test_release_scripts_build_the_web_zip() -> bool:
var build := FileAccess.get_file_as_string("res://tools/build_releases.sh")
var a := TestUtils.check(build.contains('--export-release "Web"'), "build script exports Web")
var b := TestUtils.check(build.contains("step-line-web-${VERSION}.zip"), "build script names the web zip")
var publish := FileAccess.get_file_as_string("res://tools/publish_release.sh")
var c := TestUtils.check(publish.contains("step-line-web-$VERSION.zip"), "publish script uploads the web zip")
return a and b and c
func test_web_build_files_present_when_exported() -> bool:
## Only asserts shape if a web build exists in build/web (created by the
## release script); missing build output is not a test failure by itself.
var index := "res://build/web/index.html"
if not FileAccess.file_exists(index):
return true
var html := FileAccess.get_file_as_string(index)
var a := TestUtils.check(html.contains("index.js"), "index.html loads index.js")
var b := TestUtils.check(FileAccess.file_exists("res://build/web/index.wasm"), "wasm payload exists")
var c := TestUtils.check(FileAccess.file_exists("res://build/web/index.pck"), "pck payload exists")
return a and b and c