v0.3.0: rendering pass, generated props, selection model and Web build
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:
1 parent
f763cff744
commit
afa1d6beec
48 files changed
+2602
-147
No files matched your search
@@ -93,8 +93,53 @@ func _advance() -> void:
|
||||
if state_frames >= 5:
|
||||
if gm.radial.is_open:
|
||||
_fail("action wheel did not close on cancel")
|
||||
state = 4
|
||||
state_frames = 0
|
||||
4:
|
||||
# Aim-free selection (ADR-0011): the cycled target stays pinned, the
|
||||
# HUD reports how many people can be cycled, and the mouse wheel is
|
||||
# bound to the same ring.
|
||||
if state_frames >= 5:
|
||||
if gm.targeted_npc == null:
|
||||
_fail("target lost after the wheel closed")
|
||||
if not gm.guard.target_pinned:
|
||||
_fail("cycled target was not pinned")
|
||||
if gm.hud.nearby_label.text == "":
|
||||
_fail("nearby hint empty while people are in range")
|
||||
_wheel(true)
|
||||
state = 5
|
||||
state_frames = 0
|
||||
5:
|
||||
if state_frames >= 5:
|
||||
if gm.targeted_npc == null:
|
||||
_fail("mouse wheel dropped the target")
|
||||
elif not gm.guard.target_pinned:
|
||||
_fail("mouse wheel did not pin the cycled target")
|
||||
print("smoke: wheel cycled to %s" % gm.targeted_npc.name)
|
||||
# Right click with nothing open returns to cone focus.
|
||||
gm.call("_handle_cancel", true)
|
||||
state = 6
|
||||
state_frames = 0
|
||||
6:
|
||||
if state_frames >= 5:
|
||||
if gm.targeted_npc != null:
|
||||
_fail("right click did not clear the target")
|
||||
if gm.guard.target_pinned:
|
||||
_fail("clearing the target left it pinned")
|
||||
_finish()
|
||||
|
||||
## Synthesised mouse-wheel event, fed through the real handler so the input-map
|
||||
## binding is exercised as well as the cycling logic.
|
||||
func _wheel(down: bool) -> void:
|
||||
var ev := InputEventMouseButton.new()
|
||||
ev.button_index = MOUSE_BUTTON_WHEEL_DOWN if down else MOUSE_BUTTON_WHEEL_UP
|
||||
ev.pressed = true
|
||||
ev.position = Vector2(640, 360)
|
||||
ev.global_position = ev.position
|
||||
if not ev.is_action_pressed("cycle_target_next"):
|
||||
_fail("cycle_target_next is not bound to the mouse wheel")
|
||||
gm.call("_handle_mouse_button", ev)
|
||||
|
||||
func _stage_target() -> void:
|
||||
if stage_npc == null or not is_instance_valid(stage_npc):
|
||||
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
|
||||
@@ -127,7 +172,7 @@ func _fail(message: String) -> void:
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: focus circle + action wheel opened and cancelled")
|
||||
print("SMOKE OK: focus circle + action wheel + aim-free cycling")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in new issue
Block a user