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

+123 -33
View File
@@ -5,6 +5,7 @@ extends CharacterBody3D
const NPCController := preload("res://src/core/npc_controller.gd")
const Rules := preload("res://src/core/rules.gd")
const Materials := preload("res://src/game/materials.gd")
var npc: NPCController = null
var spawn_position: Vector3 = Vector3.ZERO
@@ -95,6 +96,7 @@ var _name_tag: Label3D = null
var _photo_prop: MeshInstance3D = null
var _sign_prop: MeshInstance3D = null
var _legs: Array[MeshInstance3D] = []
var _arms: Array[MeshInstance3D] = []
var _theater_t := 0.0
var _photo_prop_base_y := 0.0
var _sign_prop_base_y := 0.0
@@ -102,43 +104,105 @@ const SKIN_TONE := Color(0.88, 0.72, 0.60)
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
## Which small accessory a role wears. This is look-only: the player must still
## read intent from behaviour, never from the archetype name (ADR-0010).
const ACCESSORIES := {
Rules.NpcType.AUTHORIZED_STAFF: "cap",
Rules.NpcType.RESPONDER: "cap",
Rules.NpcType.DELIVERY: "cap",
Rules.NpcType.TOURIST: "backpack",
Rules.NpcType.INFLUENCER: "sunglasses",
Rules.NpcType.TRESPASSER: "hood",
Rules.NpcType.VIOLENT_THREAT: "hood",
Rules.NpcType.MENTAL_CRISIS: "hood",
Rules.NpcType.AUTHORIZED_GUEST: "lanyard",
Rules.NpcType.PERFORMER: "none",
Rules.NpcType.PROTESTER: "none",
Rules.NpcType.INTOXICATED: "none",
Rules.NpcType.CONFUSED_VISITOR: "none",
}
## Clothing palette for one avatar. Pure and seed-driven so crowds read as
## individuals and the result is deterministic (tests rely on that).
static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
var base: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
var frac := float((seed_value * 1103515245 + 12345) % 1000) / 1000.0
var shirt := base.lerp(Color(0.92, 0.92, 0.94), frac * 0.22)
shirt = shirt.darkened(frac * 0.12)
# Jeans through khaki/chino, so not every avatar wears the same trousers.
var pants := Color(0.16, 0.18, 0.23).lerp(Color(0.46, 0.38, 0.28), frac)
var hair_index := (seed_value * 7) % HAIR_COLORS.size()
return {
"shirt": shirt,
"sleeves": shirt.darkened(0.16),
"pants": pants,
"hair": HAIR_COLORS[hair_index],
"skin": SKIN_TONE.darkened(frac * 0.18),
"accessory": ACCESSORIES.get(npc_type, "none"),
"height_scale": 0.92 + 0.16 * frac,
"blend": frac,
}
func _build_body(npc_type: int) -> void:
_rig = Node3D.new()
_rig.name = "Rig"
add_child(_rig)
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
# Per-seed variation: slight height and brightness differences so crowds
# read as individuals (deterministic LCG, no RNG side effects).
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0
var height_scale := 0.92 + 0.16 * frac
_rig.scale.y = height_scale
var shader_delta := 0.12 * frac
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta)
var limbs_color := base_color.darkened(0.25)
# Torso
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
# Legs
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR))
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR))
# Arms
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
var outfit := outfit_for(npc_type, _visual_seed)
var shirt: Color = outfit["shirt"]
var sleeves: Color = outfit["sleeves"]
var pants: Color = outfit["pants"]
var skin: Color = outfit["skin"]
var hair: Color = outfit["hair"]
_rig.scale.y = float(outfit["height_scale"])
var limbs_color := sleeves
# Torso, with a shoulder block and hips so the silhouette is not a tube.
_add_capsule(_rig, 0.30, 0.90, Vector3(0, 1.02, 0), shirt, 0.0, "cloth")
_add_box_mesh(_rig, Vector3(0.58, 0.14, 0.28), Vector3(0, 1.36, 0), shirt, "cloth")
_add_box_mesh(_rig, Vector3(0.44, 0.18, 0.26), Vector3(0, 0.62, 0), pants, "cloth")
# Legs and arms are tracked so the walk cycle can swing them.
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), pants, 0.0, "cloth"))
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), pants, 0.0, "cloth"))
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0, "cloth"))
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0, "cloth"))
# Hands and shoes give the limbs an end point at conversation distance.
_add_sphere(_rig, 0.065, Vector3(-0.47, 0.94, 0.03), skin, "skin")
_add_sphere(_rig, 0.065, Vector3(0.47, 0.94, 0.03), skin, "skin")
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE)
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), skin, 0.0, "skin")
_head = Node3D.new()
_head.name = "Head"
_head.position = Vector3(0, 1.62, 0)
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
var hair_i := (_visual_seed * 7) % HAIR_COLORS.size()
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i])
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
_add_sphere(_head, 0.17, Vector3.ZERO, skin, "skin")
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), hair, "cloth")
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
_rig.add_child(_head)
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
_add_accessory(str(outfit["accessory"]), shirt)
_build_focus_circle()
_add_props(npc_type)
## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
func _add_accessory(kind: String, shirt: Color) -> void:
match kind:
"cap":
_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth")
_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth")
"hood":
_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth")
"backpack":
_add_box_mesh(_rig, Vector3(0.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth")
_add_box_mesh(_rig, Vector3(0.30, 0.06, 0.02), Vector3(0, 1.24, -0.33), Color(0.42, 0.44, 0.5), "cloth")
"sunglasses":
_add_box_mesh(_head, Vector3(0.24, 0.05, 0.03), Vector3(0, 0.04, 0.15), Color(0.06, 0.06, 0.08), "glass")
"lanyard":
_add_box_mesh(_rig, Vector3(0.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal")
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(-0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
_:
pass
func _build_focus_circle() -> void:
_focus_circle = MeshInstance3D.new()
_focus_circle.name = "FocusCircle"
@@ -180,7 +244,8 @@ func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
if _name_tag:
_name_tag.visible = focused
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> MeshInstance3D:
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3,
color: Color, z_rot: float = 0.0, role: String = "cloth") -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := CapsuleMesh.new()
mesh.radius = radius
@@ -188,24 +253,34 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
mi.mesh = mesh
mi.position = pos
mi.rotation_degrees.z = z_rot
mi.material_override = _material(color)
mi.material_override = _material(color, role)
parent.add_child(mi)
return mi
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
role: String = "skin") -> void:
var mi := MeshInstance3D.new()
var mesh := SphereMesh.new()
mesh.radius = radius
mesh.height = radius * 2.0
mi.mesh = mesh
mi.position = pos
mi.material_override = _material(color)
mi.material_override = _material(color, role)
parent.add_child(mi)
func _material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
return mat
func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color,
role: String = "cloth") -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
mi.mesh = mesh
mi.position = pos
mi.material_override = _material(color, role)
parent.add_child(mi)
return mi
func _material(color: Color, role: String = "cloth") -> StandardMaterial3D:
return Materials.flat(color, role)
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
func _add_props(npc_type: int) -> void:
@@ -358,6 +433,11 @@ static func prop_lift_for_pose(pose: String, phase: float) -> float:
return 0.0
return maxf(0.0, sin(phase * 1.7)) * 0.16
## Opposite arm/leg swing for the walk cycle. `side` is 0 or 1; pure so the gait
## can be checked without a scene tree.
static func limb_swing(phase: float, side: int, amplitude: float) -> float:
return sin(phase + float(side) * PI) * amplitude
## Sway amount for a performer playing to the sidewalk.
static func sway_for_pose(pose: String, phase: float) -> float:
if pose != "perform":
@@ -374,6 +454,10 @@ func _animate(delta: float) -> void:
_bob_phase += delta * (6.0 + speed)
_rig.position.y = sin(_bob_phase) * 0.035
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
for i in _legs.size():
_legs[i].rotation.x = limb_swing(_bob_phase, i, 0.5)
for i in _arms.size():
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.38)
elif pose != "idle" or not is_zero_approx(_rig.position.y):
# Idle theater: seated, photographing, filming, or performing.
_theater_t += delta
@@ -393,10 +477,16 @@ func _animate(delta: float) -> void:
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
for arm in _arms:
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14)
else:
_bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
for i in _legs.size():
_legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2)
for arm in _arms:
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.2)
# The focus circle billboards via its material; no per-frame look_at needed.
# Attention cue: head turns toward the guard when awareness was raised.
if _head != null and npc.guard_noticed: