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
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:
1 parent
c808165302
commit
1e972a7490
27 files changed
+1044
-340
No files matched your search
@@ -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")
|
||||
|
||||
@@ -12,6 +12,10 @@ var target_position: Vector3 = Vector3.ZERO
|
||||
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
|
||||
var awaiting_interaction := false
|
||||
var arrived := false
|
||||
var agent: NavigationAgent3D = null
|
||||
## Set once the nav agent proves it has a path; direct movement is the fallback.
|
||||
var _nav_usable := false
|
||||
var _nav_try_frames := 0
|
||||
|
||||
const TYPE_COLORS := {
|
||||
Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55),
|
||||
@@ -26,6 +30,7 @@ const TYPE_COLORS := {
|
||||
Rules.NpcType.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65),
|
||||
Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40),
|
||||
Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85),
|
||||
Rules.NpcType.PROTESTER: Color(0.90, 0.40, 0.20),
|
||||
}
|
||||
|
||||
func _init() -> void:
|
||||
@@ -42,6 +47,15 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
||||
position = p_spawn
|
||||
npc.fsm.state_changed.connect(_on_state_changed)
|
||||
_build_body(profile.npc_type)
|
||||
_nav_usable = false
|
||||
_nav_try_frames = 0
|
||||
agent = NavigationAgent3D.new()
|
||||
agent.name = "NavAgent"
|
||||
agent.path_desired_distance = 0.4
|
||||
agent.target_desired_distance = 4.0
|
||||
agent.radius = 0.35
|
||||
agent.height = 1.2
|
||||
add_child(agent)
|
||||
|
||||
# Normalize intent targets: approach the entrance, stop near the door.
|
||||
var collider := CollisionShape3D.new()
|
||||
@@ -96,6 +110,40 @@ func _advance_fsm(delta: float) -> void:
|
||||
awaiting_interaction = true
|
||||
|
||||
func _move_toward_target(delta: float) -> void:
|
||||
if agent == null:
|
||||
_move_direct(delta)
|
||||
return
|
||||
agent.target_position = target_position
|
||||
if agent.is_navigation_finished():
|
||||
if (global_position - target_position).length() < 3.0:
|
||||
_arrive()
|
||||
else:
|
||||
# Path finished but we are far away: navigation is unusable, fall back.
|
||||
_move_direct(delta)
|
||||
return
|
||||
# Learn whether the navmesh actually serves this spot.
|
||||
if not _nav_usable:
|
||||
_nav_try_frames += 1
|
||||
if agent.is_target_reachable():
|
||||
_nav_usable = true
|
||||
elif _nav_try_frames > 10:
|
||||
_move_direct(delta)
|
||||
return
|
||||
var next := agent.get_next_path_position()
|
||||
var dir := (next - global_position)
|
||||
dir.y = 0.0
|
||||
if dir.length() < 0.2:
|
||||
velocity = Vector3.ZERO
|
||||
return
|
||||
dir = dir.normalized()
|
||||
velocity.x = dir.x * 1.3
|
||||
velocity.z = dir.z * 1.3
|
||||
velocity.y -= 9.8 * delta
|
||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||
move_and_slide()
|
||||
|
||||
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
|
||||
func _move_direct(delta: float) -> void:
|
||||
var to_target := target_position - global_position
|
||||
to_target.y = 0.0
|
||||
var dist := to_target.length()
|
||||
|
||||
@@ -16,6 +16,7 @@ var inspect_panel: PanelContainer
|
||||
var inspect_text: Label
|
||||
var inspect_meta: Label
|
||||
var radio_cooldown_label: Label
|
||||
var mode_label: Label
|
||||
var wand_panel: PanelContainer
|
||||
var wand_text: Label
|
||||
var wand_meta: Label
|
||||
@@ -91,6 +92,13 @@ func _init() -> void:
|
||||
radio_cooldown_label.modulate = Color(1, 0.55, 0.45)
|
||||
vbox.add_child(radio_cooldown_label)
|
||||
|
||||
# --- door / flashlight state ---
|
||||
mode_label = Label.new()
|
||||
mode_label.text = "Door: open · Light: off"
|
||||
mode_label.add_theme_font_size_override("font_size", 13)
|
||||
mode_label.modulate = Color(0.85, 0.85, 0.9)
|
||||
vbox.add_child(mode_label)
|
||||
|
||||
# --- dialogue tone indicator ---
|
||||
tone_label = Label.new()
|
||||
tone_label.text = "Tone: Professional"
|
||||
@@ -175,6 +183,20 @@ func _update_radio_cooldown(coordinator: Object) -> void:
|
||||
func set_target_label(text: String) -> void:
|
||||
target_label.text = text
|
||||
|
||||
var _flashlight := false
|
||||
var _door_locked := false
|
||||
|
||||
func set_door(locked: bool) -> void:
|
||||
_door_locked = locked
|
||||
_refresh_mode_label()
|
||||
|
||||
func set_flashlight(on: bool) -> void:
|
||||
_flashlight = on
|
||||
_refresh_mode_label()
|
||||
|
||||
func _refresh_mode_label() -> void:
|
||||
mode_label.text = "Door: %s · Light: %s" % ["LOCKED" if _door_locked else "open", "on" if _flashlight else "off"]
|
||||
|
||||
func set_action_hint(text: String) -> void:
|
||||
hint_label.text = text
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func _init() -> void:
|
||||
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
center.add_child(quit_btn)
|
||||
|
||||
func show_review(review: Dictionary, scenario: Resource) -> void:
|
||||
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
|
||||
var reputation: Dictionary = review.get("reputation", {})
|
||||
var incidents: Dictionary = review.get("incidents", {})
|
||||
var crowd: Dictionary = review.get("crowd", {})
|
||||
@@ -55,9 +55,25 @@ func show_review(review: Dictionary, scenario: Resource) -> void:
|
||||
if inc.get("outcome", "") in ["peaceful", "left"]:
|
||||
peaceful += 1
|
||||
|
||||
# Persisted-shift history (Save autoload): record count + best rating.
|
||||
var history_line := ""
|
||||
if history.size() > 0:
|
||||
var best := -1.0
|
||||
var past_grades: Array[String] = []
|
||||
for entry in history:
|
||||
var rep: Dictionary = entry.get("reputation", {})
|
||||
var rating_v: float = float(rep.get("rating", 0.0))
|
||||
best = maxf(best, rating_v)
|
||||
var g: String = rep.get("grade", "?")
|
||||
if not past_grades.has(g):
|
||||
past_grades.append(g)
|
||||
history_line = "Shifts on record: %d · best score: %.0f · grades seen: %s" % [
|
||||
history.size(), best, ", ".join(past_grades)]
|
||||
|
||||
var lines: Array[String] = [
|
||||
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")),
|
||||
"Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
|
||||
history_line,
|
||||
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
|
||||
"NPCs encountered: %d" % review.get("spawned_count", 0),
|
||||
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
|
||||
|
||||
@@ -4,6 +4,9 @@ class_name WorldBuilder
|
||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||
|
||||
var root: Node3D = null
|
||||
## Door lock indicator mesh near the handle; toggled by GameManager.
|
||||
var door_indicator: MeshInstance3D = null
|
||||
var _door_indicator_mat: StandardMaterial3D = null
|
||||
|
||||
func _init(root_node: Node3D) -> void:
|
||||
root = root_node
|
||||
@@ -15,6 +18,7 @@ func build() -> void:
|
||||
_build_sidewalk()
|
||||
_build_street()
|
||||
_build_guard_spawn()
|
||||
_build_navmesh()
|
||||
|
||||
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D:
|
||||
var mesh := BoxMesh.new()
|
||||
@@ -50,6 +54,9 @@ func _build_building() -> void:
|
||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.16, 0.15, 0.13))
|
||||
# Door window
|
||||
_add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.5, 0.7, 0.9, 1.0))
|
||||
# Door lock indicator (green = open, red = locked), placed beside the handle
|
||||
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3))
|
||||
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
||||
# Awning sign bar
|
||||
_add_box(building, Vector3(8, 0.5, 0.6), Vector3(0, 7.0, -3.6), Color(0.45, 0.44, 0.44))
|
||||
var col := BoxShape3D.new()
|
||||
@@ -60,7 +67,10 @@ func _build_building() -> void:
|
||||
root.add_child(building)
|
||||
|
||||
func _build_steps() -> void:
|
||||
var steps := StaticBody3D.new()
|
||||
# Steps are visual only (no collider) so NPCs and the guard can walk up the
|
||||
# entrance on the flat navigation mesh; the ground and building provide the
|
||||
# actual collision boundaries.
|
||||
var steps := Node3D.new()
|
||||
steps.name = "Steps"
|
||||
var step_w := 8.0
|
||||
var tread := 0.6
|
||||
@@ -71,11 +81,6 @@ func _build_steps() -> void:
|
||||
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
||||
# Landing at the top
|
||||
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(step_w, 0.65, tread * 4 + 3)
|
||||
var so := steps.create_shape_owner(steps)
|
||||
steps.shape_owner_add_shape(so, col)
|
||||
steps.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0.32, 0.8)))
|
||||
root.add_child(steps)
|
||||
|
||||
func _build_sidewalk() -> void:
|
||||
@@ -103,8 +108,44 @@ func _build_street() -> void:
|
||||
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22)))
|
||||
root.add_child(street)
|
||||
|
||||
func _build_navmesh() -> void:
|
||||
## Flat navigation mesh over the walkable floor: landing strip, sidewalk,
|
||||
## and street front. Hand-authored polygons; no runtime bake needed.
|
||||
var region := NavigationRegion3D.new()
|
||||
region.name = "NavRegion"
|
||||
var mesh := NavigationMesh.new()
|
||||
mesh.agent_radius = 0.4
|
||||
mesh.agent_max_climb = 0.8
|
||||
mesh.agent_max_slope = 45.0
|
||||
mesh.cell_size = 0.25
|
||||
var verts := PackedVector3Array([
|
||||
Vector3(-18, 0.0, -0.6), Vector3(18, 0.0, -0.6),
|
||||
Vector3(18, 0.0, 20.0), Vector3(-18, 0.0, 20.0),
|
||||
])
|
||||
mesh.set_vertices(verts)
|
||||
mesh.add_polygon(PackedInt32Array([0, 1, 2, 3]))
|
||||
region.navigation_mesh = mesh
|
||||
root.add_child(region)
|
||||
|
||||
func _build_guard_spawn() -> void:
|
||||
var marker := Marker3D.new()
|
||||
marker.name = "GuardSpawn"
|
||||
marker.position = Vector3(0, 1.6, -2.5)
|
||||
root.add_child(marker)
|
||||
root.add_child(marker)
|
||||
|
||||
func _lock_indicator_material(color: Color) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.emission_enabled = true
|
||||
mat.emission = color
|
||||
mat.emission_energy_multiplier = 1.5
|
||||
return mat
|
||||
|
||||
## Mirror the door lock state visually (green open, red locked).
|
||||
func set_door_locked(locked: bool) -> void:
|
||||
if door_indicator == null:
|
||||
return
|
||||
var color := Color(0.9, 0.15, 0.15) if locked else Color(0.1, 0.9, 0.3)
|
||||
if door_indicator.material_override is StandardMaterial3D:
|
||||
(door_indicator.material_override as StandardMaterial3D).albedo_color = color
|
||||
(door_indicator.material_override as StandardMaterial3D).emission = color
|
||||
Reference in new issue
Block a user