Implement TODO backlog: LATE phase fix, protester, CALL_SOMEONE, door lock, flashlight, persistence, navmesh, JSON localization, and release tooling
CI / headless-tests (push) Successful in 55s

- Fix LATE shift phase (start 700s) so the violent-threat event is reachable
- Add PROTESTER archetype (rules, profile, colors, events in public venues)
- Add CALL_SOMEONE reaction (FSM -> ARGUING, influencer/trespasser/protester weights)
- Door lock: DoorState core class, L binding, red/green visual indicator,
  locked doors thwart FORCE_ENTRY
- Flashlight: G binding, SpotLight3D, perception radius bonus at evening/late
- Persistence: save shift results via Save autoload, history + best score on
  review screen, round-trip test
- Navigation: flat NavigationRegion3D navmesh, NavigationAgent3D path following
  with direct-movement fallback; steps made visual-only so NPCs reach entrance
- Localization: strings moved to content/strings.en.json (loader keeps
  lookup/fmt signatures; coverage test over all keys)
- Distribution: export_presets.cfg (Linux/Windows/macOS), tools/build_releases.sh,
  docs/RELEASING.md, prominent README Downloads section
- Remove stale signal_bus.gd (invalid static-signal experiment)
- Tests: 53 passing across 9 suites
This commit is contained in:
zwitschi committed 2026-10-31 12:51:13 +01:00
1 parent c808165302
commit 1e972a7490
27 files changed
+1044 -340

No files matched your search

+58 -6
View File
@@ -7,6 +7,7 @@ const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd")
const DoorState := preload("res://src/core/door_state.gd")
const WandScreener := preload("res://src/core/wand_screener.gd")
const RadioSystem := preload("res://src/core/radio_system.gd")
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
@@ -21,6 +22,9 @@ const Hud := preload("res://src/game/ui/hud.gd")
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
var world_builder: WorldBuilder = null
var door := DoorState.new()
var flashlight_on := false
var coordinator: ShiftCoordinator = null
var guard: GuardController = null
var hud: Hud = null
@@ -55,7 +59,8 @@ func _ready() -> void:
scene_root.name = "SceneRoot"
add_child(scene_root)
WorldBuilder.new(scene_root).build()
world_builder = WorldBuilder.new(scene_root)
world_builder.build()
_ensure_lighting()
guard = _build_guard()
@@ -86,6 +91,15 @@ func _build_guard() -> GuardController:
camera.name = "Camera3D"
camera.current = true
head.add_child(camera)
var flashlight := SpotLight3D.new()
flashlight.name = "Flashlight"
flashlight.visible = false
flashlight.position = Vector3(0, -0.15, -0.4)
flashlight.rotation_degrees = Vector3(-10, 0, 0)
flashlight.spot_range = 18.0
flashlight.spot_angle = 28.0
flashlight.light_energy = 2.5
head.add_child(flashlight)
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
shape.radius = 0.4
@@ -202,7 +216,10 @@ func _responder_profile() -> NpcProfile:
return p
func _on_shift_ended(review: Dictionary) -> void:
shift_review_panel.show_review(review, selected_scenario)
# Persist the finished shift locally (Save autoload, user:// JSON).
if Save:
Save.save_shift_result(review)
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
func _on_guard_target(target: Node3D) -> void:
targeted_npc = target
@@ -259,6 +276,10 @@ func _handle_key(keycode: Key) -> void:
_cycle_nearby_target()
KEY_F:
hud.toggle_debug()
KEY_G:
_toggle_flashlight()
KEY_L:
_toggle_door_lock()
_:
_handle_action_key(keycode)
@@ -291,6 +312,25 @@ func _handle_action_key(keycode: Key) -> void:
_:
pass
## Toggle the guard's flashlight. In evening/late phases it widens NPC
## perception radius (PLAN.md: Level 3 — flashlight at night).
func _toggle_flashlight() -> void:
flashlight_on = not flashlight_on
var lamp: SpotLight3D = guard.get_node_or_null("Head/Flashlight") as SpotLight3D
if lamp:
lamp.visible = flashlight_on
hud.set_flashlight(flashlight_on)
hud.log(Strings.lookup("flashlight.on") if flashlight_on else Strings.lookup("flashlight.off"))
## Lock or unlock the restricted door (PLAN.md: Level 3). Locked doors thwart
## FORCE_ENTRY attempts during the reaction flow.
func _toggle_door_lock() -> void:
door.toggle()
if world_builder:
world_builder.set_door_locked(door.locked)
hud.set_door(door.locked)
hud.log(Strings.lookup("door.locked") if door.locked else Strings.lookup("door.unlocked"))
## Cycle the dialogue tone (Professional → Friendly → Firm → Suspicious).
func _cycle_tone() -> void:
var idx := TONE_ORDER.find(selected_tone)
@@ -368,11 +408,20 @@ func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
hud.log(Strings.fmt("react.threaten", [name]))
coordinator.crowd.apply_event("aggressive_behavior")
Rules.Reaction.FORCE_ENTRY:
hud.log(Strings.fmt("react.force_entry", [name]))
coordinator.crowd.apply_event("emergency_responder")
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt")
var force_result: Dictionary = door.attempt_force()
if force_result.get("succeeded", false):
hud.log(Strings.fmt("react.force_entry", [name]))
coordinator.crowd.apply_event("emergency_responder")
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt")
else:
hud.log(Strings.fmt("door.thwarted", [name]))
coordinator.crowd.apply_event("aggressive_behavior")
coordinator.reputation.apply("prop", -2.0, "forcing attempt (locked door)")
Rules.Reaction.RETURN_LATER:
hud.log(Strings.fmt("react.return_later", [name]))
Rules.Reaction.CALL_SOMEONE:
hud.log(Strings.fmt("react.call_someone", [name]))
coordinator.crowd.apply_event("aggressive_behavior")
_:
hud.log(Strings.lookup("react.none"))
@@ -576,6 +625,9 @@ func _perception_pass() -> void:
if guard == null:
return
var guard_pos: Vector3 = guard.global_position
var lit_bonus := 0.0
if flashlight_on and coordinator.current_phase >= Rules.Phase.EVENING:
lit_bonus = 4.0
for avatar in npcs:
if not is_instance_valid(avatar):
continue
@@ -584,7 +636,7 @@ func _perception_pass() -> void:
continue
var distance: float = (avatar.global_position - guard_pos).length()
var already_noticed: bool = controller.guard_noticed
var perception: Dictionary = controller.perceive_guard(distance)
var perception: Dictionary = controller.perceive_guard(distance, lit_bonus)
if perception.get("noticed", false) and not already_noticed:
# First-time notice: log a subtle reaction.
var name: String = (controller.profile.display_name if controller.profile else "Person")