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

+59 -23
View File
@@ -16,6 +16,7 @@ const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
const VenueScenario := preload("res://src/core/venue_scenario.gd")
const WorldBuilder := preload("res://src/game/world_builder.gd")
const Materials := preload("res://src/game/materials.gd")
const GuardController := preload("res://src/game/guard_controller.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
@@ -44,24 +45,34 @@ var _last_phase: int = -1
## structure — phase-based lighting). Night phases switch street lamps on.
const PHASE_LIGHTING := {
Rules.Phase.EARLY: {
"sun_energy": 1.2, "sun_angle": -50.0, "sun_color": Color(1.0, 0.98, 0.92),
"sun_energy": 1.05, "sun_angle": -50.0, "sun_color": Color(1.0, 0.97, 0.90),
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
"exposure": 0.88, "glow": 0.35, "fog_color": Color(0.80, 0.85, 0.92),
"fog_density": 0.0028, "fog_energy": 0.75, "contrast": 1.04, "saturation": 1.06,
"ambient_energy": 0.45,
},
Rules.Phase.MIDDAY: {
"sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98),
"sun_energy": 1.3, "sun_angle": -62.0, "sun_color": Color(1.0, 0.99, 0.96),
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
"exposure": 0.85, "glow": 0.3, "fog_color": Color(0.84, 0.88, 0.94),
"fog_density": 0.0022, "fog_energy": 0.8, "contrast": 1.05, "saturation": 1.05,
"ambient_energy": 0.45,
},
Rules.Phase.EVENING: {
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
"exposure": 1.05, "glow": 0.7, "fog_color": Color(0.95, 0.68, 0.48),
"fog_density": 0.0045, "fog_energy": 0.6, "contrast": 1.08, "saturation": 1.12,
},
Rules.Phase.LATE: {
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
"exposure": 1.25, "glow": 0.95, "fog_color": Color(0.10, 0.13, 0.24),
"fog_density": 0.0055, "fog_energy": 0.5, "contrast": 1.1, "saturation": 1.0,
},
}
var hud: Hud = null
@@ -103,12 +114,12 @@ var _venue_index := 0
## RNG for venue door-staff lines so repeated interactions vary.
var _door_rng := RandomNumberGenerator.new()
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
const NEARBY_RADIUS := 12.0
## Aim-free targeting lives on the guard (`GuardController.nearby_candidates`);
## this file only reacts to the signals it emits and logs the selection.
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
## events evict the oldest unfocused NPC so authored moments still spawn.
const MAX_LIVE_NPCS := 16
var _nearby_index := -1
func _ready() -> void:
_door_rng.randomize()
@@ -219,7 +230,6 @@ func _reset_shift_state() -> void:
current_incident_npc_id = ""
targeted_npc = null
_focused_avatar = null
_nearby_index = -1
inspection_open = false
wand_open = false
_wand_cooldown = 0.0
@@ -429,8 +439,9 @@ func _ensure_lighting() -> void:
sky.sky_material = _sky
env.sky = sky
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_energy = 0.5
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
# Tonemap, glow, fog and colour grading live in SurfaceMaterials so the
# world builder and the environment tune-up stay in one place.
Materials.configure_environment(env, PHASE_LIGHTING[Rules.Phase.EARLY])
_env_node.environment = env
scene_root.add_child(_env_node)
@@ -448,6 +459,8 @@ func _apply_phase_lighting() -> void:
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
_sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5))
_sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE)
if _env_node and _env_node.environment:
Materials.configure_environment(_env_node.environment, cfg)
if world_builder:
world_builder.set_night_mode(cfg.get("night", false))
@@ -642,12 +655,14 @@ func _on_guard_target(target: Node3D) -> void:
targeted_npc = target
_set_focused_avatar(target)
_begin_incident(target)
_refresh_nearby_hint()
func _on_guard_clear() -> void:
targeted_npc = null
_set_focused_avatar(null)
hud.set_target_label("")
hud.set_action_hint("")
_refresh_nearby_hint()
func _set_focused_avatar(avatar: Node3D) -> void:
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
@@ -699,6 +714,12 @@ func _unhandled_input(event: InputEvent) -> void:
_handle_key(event)
func _handle_mouse_button(event: InputEventMouseButton) -> void:
if event.is_action_pressed("cycle_target_next"):
_cycle_nearby_target(1)
return
if event.is_action_pressed("cycle_target_prev"):
_cycle_nearby_target(-1)
return
if event.button_index != MOUSE_BUTTON_LEFT:
return
if radial and radial.is_open:
@@ -794,6 +815,10 @@ func _handle_cancel(from_mouse: bool) -> void:
if _close_top_modal():
return
if from_mouse:
# Right click with nothing open drops the current target, so the player can
# break focus without walking or looking away.
if targeted_npc != null:
_clear_focus()
return
if settings_panel and settings_panel.visible:
return
@@ -974,30 +999,41 @@ func _cycle_venue() -> void:
hud.set_wand_icon(false)
hud.set_shift_state(coordinator, selected_scenario)
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
## interact without precise aiming (accessibility).
func _cycle_nearby_target() -> void:
## Aim-free targeting: cycle through NPCs near the guard (Tab, mouse wheel).
## Lets players interact without precise aiming (accessibility) and pins the
## target so the focus cone does not drop someone the player is not looking at.
func _cycle_nearby_target(direction: int = 1) -> void:
if guard == null:
return
var nearby: Array = []
for avatar in npcs:
if not is_instance_valid(avatar):
continue
if (avatar.global_position - guard.global_position).length() <= NEARBY_RADIUS:
nearby.append(avatar)
if nearby.is_empty():
var next_target: Node3D = guard.cycle_target(direction)
if next_target == null:
hud.log(Strings.lookup("hud.no_one_nearby"))
_refresh_nearby_hint()
return
_nearby_index = (_nearby_index + 1) % nearby.size()
var next_target: Node3D = nearby[_nearby_index]
targeted_npc = next_target
_set_focused_avatar(next_target)
_begin_incident(next_target)
guard.pin_target(next_target)
var controller: Object = _targeted_controller()
var who: String = next_target.name
if controller and controller.profile and controller.profile.display_name != "":
who = str(controller.profile.display_name)
hud.log(Strings.fmt("hud.targeting", [who]))
_refresh_nearby_hint()
## Drop the focus and hand selection back to the aim cone (right click).
func _clear_focus() -> void:
if guard:
guard.clear_target()
targeted_npc = null
_set_focused_avatar(null)
if hud:
hud.set_target_label("")
hud.set_action_hint("")
_refresh_nearby_hint()
## How many people the aim-free ring can cycle through right now.
func _refresh_nearby_hint() -> void:
if hud == null or guard == null:
return
hud.set_nearby_hint(guard.nearby_candidates().size())
func _apply_interaction(level: int, action_name: String) -> void:
if targeted_npc == null: