Implement NPC perception, returning trespassers, responder dispatch, wand screening, and accessibility polish
CI / headless-tests (push) Successful in 2m11s
CI / headless-tests (push) Successful in 2m11s
This commit is contained in:
1 parent
439e0a4281
commit
81b5d5ce5a
22 files changed
+879
-73
No files matched your search
+219
-27
@@ -4,8 +4,10 @@ extends Node
|
||||
## and routes player input into the interaction and access flows.
|
||||
|
||||
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 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")
|
||||
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
||||
@@ -13,6 +15,8 @@ const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||
const WorldBuilder := preload("res://src/game/world_builder.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")
|
||||
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
|
||||
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")
|
||||
@@ -22,7 +26,9 @@ var guard: GuardController = null
|
||||
var hud: Hud = null
|
||||
var radio_panel: RadioPanel = null
|
||||
var shift_review_panel: ShiftReview = null
|
||||
var audio: AudioFeedback = null
|
||||
var verifier := AccessVerifier.new()
|
||||
var wand_screener := WandScreener.new()
|
||||
|
||||
var active_scenario_id := "hollywood_building"
|
||||
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing
|
||||
@@ -31,10 +37,19 @@ var npcs: Array = []
|
||||
var npc_counter := 0
|
||||
var targeted_npc: Object = null
|
||||
var inspection_open := false
|
||||
var wand_open := false
|
||||
var current_incident_npc_id := ""
|
||||
var current_incident_active := false
|
||||
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
|
||||
|
||||
## Dialogue tone picker (PLAN.md: Level 1 tones). Cycled with T.
|
||||
var selected_tone: int = Rules.Tone.PROFESSIONAL
|
||||
const TONE_ORDER := [Rules.Tone.PROFESSIONAL, Rules.Tone.FRIENDLY, Rules.Tone.FIRM, Rules.Tone.SUSPICIOUS]
|
||||
|
||||
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
|
||||
const NEARBY_RADIUS := 12.0
|
||||
var _nearby_index := -1
|
||||
|
||||
func _ready() -> void:
|
||||
scene_root = Node3D.new()
|
||||
scene_root.name = "SceneRoot"
|
||||
@@ -54,6 +69,7 @@ func _ready() -> void:
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||
coordinator.shift_ended.connect(_on_shift_ended)
|
||||
coordinator.radio.responder_arrived.connect(_on_responder_arrived)
|
||||
|
||||
_build_ui()
|
||||
coordinator.start()
|
||||
@@ -111,6 +127,8 @@ var inspector_current_events: Array = []
|
||||
func _build_ui() -> void:
|
||||
hud = Hud.new()
|
||||
add_child(hud)
|
||||
audio = AudioFeedback.new()
|
||||
add_child(audio)
|
||||
radio_panel = RadioPanel.new()
|
||||
add_child(radio_panel)
|
||||
radio_panel.hide()
|
||||
@@ -143,11 +161,46 @@ func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
|
||||
var spawn_side: float = 1.0 if npc_counter % 2 == 0 else -1.0
|
||||
var spawn_x := spawn_side * (4.0 + float((npc_counter * 13) % 18))
|
||||
var spawn_pos := Vector3(spawn_x, 0.5, 6.0 + float((npc_counter * 7) % 16))
|
||||
var target_pos := Vector3(0.0, 0.5, 1.2)
|
||||
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos)
|
||||
# Per-intent waypoint (photo spots, queue, stage door, sidewalk exit) instead
|
||||
# of every NPC walking to the entrance (TODO: Core Simulation — NPC Perception).
|
||||
var target_pos: Vector3 = selected_scenario.waypoint_for_intent(intent)
|
||||
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos, door_pos)
|
||||
scene_root.add_child(avatar)
|
||||
npcs.append(avatar)
|
||||
|
||||
## Spawn a dispatched responder avatar when a radio call's arrival delay elapses
|
||||
## (TODO: Escalation and Radio — Responder Dispatch). The responder walks to the
|
||||
## entrance and resolves the incident with command/restraint/evacuation/arrest.
|
||||
func _on_responder_arrived(call: Dictionary) -> void:
|
||||
var outcome: String = RadioSystem.responder_outcome(call)
|
||||
var responder_profile := _responder_profile()
|
||||
npc_counter += 1
|
||||
var avatar := NpcAvatar.new()
|
||||
avatar.name = "Responder_%d" % npc_counter
|
||||
# Responder approaches from the street, toward the entrance.
|
||||
var spawn_pos := Vector3(0.0, 0.5, 14.0)
|
||||
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
|
||||
avatar.setup(responder_profile, "respond", npc_counter * 991 + 17, spawn_pos, door_pos, door_pos)
|
||||
scene_root.add_child(avatar)
|
||||
npcs.append(avatar)
|
||||
hud.log(Strings.fmt("responder.arrived", [outcome]))
|
||||
coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), {
|
||||
"type": "responder_action",
|
||||
"outcome": outcome,
|
||||
"call_type": call.get("type", "assist"),
|
||||
})
|
||||
coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism")
|
||||
|
||||
func _responder_profile() -> NpcProfile:
|
||||
var p := NpcProfile.new()
|
||||
p.id = "responder"
|
||||
p.display_name = "Responder"
|
||||
p.npc_type = Rules.NpcType.RESPONDER
|
||||
p.temperament = 0.1
|
||||
p.base_compliance = 1.0
|
||||
return p
|
||||
|
||||
func _on_shift_ended(review: Dictionary) -> void:
|
||||
shift_review_panel.show_review(review, selected_scenario)
|
||||
|
||||
@@ -196,7 +249,14 @@ func _handle_key(keycode: Key) -> void:
|
||||
_toggle_inspection()
|
||||
KEY_Q:
|
||||
if targeted_npc != null and not inspection_open:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "polite direction")
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "polite direction")
|
||||
KEY_W:
|
||||
if targeted_npc != null and not inspection_open:
|
||||
_toggle_wand_screening()
|
||||
KEY_T:
|
||||
_cycle_tone()
|
||||
KEY_TAB:
|
||||
_cycle_nearby_target()
|
||||
KEY_F:
|
||||
hud.toggle_debug()
|
||||
_:
|
||||
@@ -205,15 +265,15 @@ func _handle_key(keycode: Key) -> void:
|
||||
func _handle_action_key(keycode: Key) -> void:
|
||||
match keycode:
|
||||
KEY_1:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "raise hand")
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "raise hand")
|
||||
KEY_2:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "point")
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "point")
|
||||
KEY_3:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "head shake")
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "head shake")
|
||||
KEY_4:
|
||||
_apply_interaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL, "formal warning")
|
||||
_apply_interaction(Rules.Level.FORMAL_WARNING, selected_tone, "formal warning")
|
||||
KEY_5:
|
||||
_apply_interaction(Rules.Level.BLOCKING, Rules.Tone.PROFESSIONAL, "blocking")
|
||||
_apply_interaction(Rules.Level.BLOCKING, selected_tone, "blocking")
|
||||
KEY_6:
|
||||
_open_radio(false)
|
||||
KEY_R:
|
||||
@@ -221,15 +281,47 @@ func _handle_action_key(keycode: Key) -> void:
|
||||
KEY_A:
|
||||
if inspection_open:
|
||||
decide_access(true)
|
||||
elif wand_open:
|
||||
decide_wand(true)
|
||||
KEY_D:
|
||||
if inspection_open:
|
||||
decide_access(false)
|
||||
elif wand_open:
|
||||
decide_wand(false)
|
||||
_:
|
||||
pass
|
||||
|
||||
## Cycle the dialogue tone (Professional → Friendly → Firm → Suspicious).
|
||||
func _cycle_tone() -> void:
|
||||
var idx := TONE_ORDER.find(selected_tone)
|
||||
idx = (idx + 1) % TONE_ORDER.size()
|
||||
selected_tone = TONE_ORDER[idx]
|
||||
hud.set_tone(Rules.TONE_NAMES.get(selected_tone, "?"))
|
||||
hud.log("Tone: %s" % Rules.TONE_NAMES.get(selected_tone, "?"))
|
||||
|
||||
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
|
||||
## interact without precise aiming (accessibility).
|
||||
func _cycle_nearby_target() -> 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():
|
||||
hud.log(Strings.lookup("hud.no_one_nearby"))
|
||||
return
|
||||
_nearby_index = (_nearby_index + 1) % nearby.size()
|
||||
var next_target: Node3D = nearby[_nearby_index]
|
||||
targeted_npc = next_target
|
||||
_begin_incident(next_target)
|
||||
hud.log(Strings.fmt("hud.targeting", [next_target.name]))
|
||||
|
||||
func _apply_interaction(level: int, tone: int, action_name: String) -> void:
|
||||
if targeted_npc == null:
|
||||
hud.log("Aim at someone first.")
|
||||
hud.log(Strings.lookup("hud.aim_first"))
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null or coordinator == null:
|
||||
@@ -245,6 +337,9 @@ func _apply_interaction(level: int, tone: int, action_name: String) -> void:
|
||||
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
||||
if level >= Rules.Level.FORMAL_WARNING:
|
||||
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
|
||||
hud.set_escalation(level)
|
||||
if audio:
|
||||
audio.play_escalation(level)
|
||||
_react_to_outcome(controller, outcome)
|
||||
|
||||
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
|
||||
@@ -252,38 +347,38 @@ func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
|
||||
var name: String = (controller.profile.display_name if controller.profile else "Person")
|
||||
match reaction:
|
||||
Rules.Reaction.COMPLY:
|
||||
hud.log("%s complies and steps back." % name)
|
||||
hud.log(Strings.fmt("react.comply", [name]))
|
||||
coordinator.crowd.apply_event("de_escalation")
|
||||
coordinator.reputation.apply("peaceful", 2.0, "resolved peacefully")
|
||||
_close_incident("peaceful")
|
||||
Rules.Reaction.LEAVE:
|
||||
hud.log("%s turns and leaves." % name)
|
||||
hud.log(Strings.fmt("react.leave", [name]))
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
_close_incident("left")
|
||||
Rules.Reaction.ARGUE:
|
||||
hud.log("%s argues with you." % name)
|
||||
hud.log(Strings.fmt("react.argue", [name]))
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.RECORD:
|
||||
hud.log("%s starts filming you." % name)
|
||||
hud.log(Strings.fmt("react.record", [name]))
|
||||
coordinator.reputation.apply("complaint", -3.0, "filmed interaction")
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.IGNORE:
|
||||
hud.log("%s ignores you." % name)
|
||||
hud.log(Strings.fmt("react.ignore", [name]))
|
||||
Rules.Reaction.THREATEN:
|
||||
hud.log("%s is threatening you. Emergency radio (R) is justified now." % name)
|
||||
hud.log(Strings.fmt("react.threaten", [name]))
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.FORCE_ENTRY:
|
||||
hud.log("%s is forcing the door!" % name)
|
||||
hud.log(Strings.fmt("react.force_entry", [name]))
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt")
|
||||
Rules.Reaction.RETURN_LATER:
|
||||
hud.log("%s leaves but may come back." % name)
|
||||
hud.log(Strings.fmt("react.return_later", [name]))
|
||||
_:
|
||||
hud.log("No reaction (yet).")
|
||||
hud.log(Strings.lookup("react.none"))
|
||||
|
||||
func _open_radio(emergency: bool) -> void:
|
||||
if targeted_npc == null:
|
||||
hud.log("Address someone first before calling.")
|
||||
hud.log(Strings.lookup("radio.address_first"))
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
@@ -295,19 +390,21 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
hud.set_action_hint("")
|
||||
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
|
||||
if not result.get("ok", false):
|
||||
hud.log("Radio blocked: %s" % result.get("reason", "unknown"))
|
||||
hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")]))
|
||||
return
|
||||
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
|
||||
if hits > 0:
|
||||
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
|
||||
hud.log("Dispatch flags inaccuracies in your report (-%d)." % (4 * hits))
|
||||
hud.log(Strings.fmt("radio.inaccurate", [4 * hits]))
|
||||
else:
|
||||
hud.log("Dispatch received your report. Assistance en route.")
|
||||
hud.log(Strings.lookup("radio.received"))
|
||||
if emergency:
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
hud.log("Emergency responder dispatched.")
|
||||
hud.log(Strings.lookup("radio.emergency_dispatched"))
|
||||
else:
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
if audio:
|
||||
audio.play_radio_response()
|
||||
|
||||
# ----------------------------------------------------------------- access --
|
||||
|
||||
@@ -324,12 +421,76 @@ func _toggle_inspection() -> void:
|
||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
||||
hud.show_inspection(controller, artifacts, result)
|
||||
|
||||
# ------------------------------------------------------------- wand screening --
|
||||
|
||||
## Handheld wand inspection (The Joke Shop). Only available when the venue
|
||||
## carries a wand. The wand reports an alert; the player interprets it.
|
||||
func _toggle_wand_screening() -> void:
|
||||
if wand_open:
|
||||
hud.hide_wand()
|
||||
wand_open = false
|
||||
return
|
||||
if not selected_scenario.equipment.has("wand"):
|
||||
hud.log(Strings.lookup("hud.no_wand"))
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
return
|
||||
var profile: Object = controller.profile
|
||||
var items: Array = profile.carried_items if profile else []
|
||||
var result: Dictionary = wand_screener.screen(items)
|
||||
wand_open = true
|
||||
hud.show_wand(controller, result)
|
||||
|
||||
## Resolve a wand screening: the player decides whether to admit the guest.
|
||||
func decide_wand(allowed: bool) -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
hud.hide_wand()
|
||||
wand_open = false
|
||||
return
|
||||
var profile: Object = controller.profile
|
||||
var items: Array = profile.carried_items if profile else []
|
||||
var result: Dictionary = wand_screener.screen(items)
|
||||
var decision: Dictionary = wand_screener.decide(result, allowed)
|
||||
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
||||
"type": "wand_screening",
|
||||
"decided": allowed,
|
||||
"prohibited": decision.get("prohibited", false),
|
||||
"false_alarm": decision.get("false_alarm", false),
|
||||
"item": decision.get("item", ""),
|
||||
"correct": decision.get("correct", false),
|
||||
})
|
||||
var name: String = (profile.display_name if profile else "Person")
|
||||
if decision.get("prohibited", false) and allowed:
|
||||
hud.log(Strings.fmt("wand.admitted_prohibited", [name]))
|
||||
coordinator.reputation.apply("security_failure", -5.0, "admitted prohibited item")
|
||||
elif decision.get("false_alarm", false) and not allowed:
|
||||
hud.log(Strings.fmt("wand.denied_false_alarm", [name]))
|
||||
coordinator.reputation.apply("complaint", -2.0, "false-alarm refusal")
|
||||
elif allowed:
|
||||
hud.log(Strings.fmt("wand.cleared", [name]))
|
||||
coordinator.reputation.apply("access_judgment", 1.0, "correct wand admission")
|
||||
else:
|
||||
hud.log(Strings.fmt("wand.refused", [name]))
|
||||
coordinator.reputation.apply("access_judgment", 1.0, "correct wand refusal")
|
||||
# Mark the NPC as screened so the screening-before-entry flow is satisfied.
|
||||
controller.screened = true
|
||||
hud.hide_wand()
|
||||
wand_open = false
|
||||
|
||||
func decide_access(allowed: bool) -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
return
|
||||
# Screening-before-entry: at wand venues, a guest must be screened first.
|
||||
if allowed and selected_scenario.access_policy.wand_screening_required and not controller.screened:
|
||||
hud.log(Strings.lookup("hud.screen_first"))
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
return
|
||||
var artifacts := _artifacts_for(controller)
|
||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
||||
var granted: bool = result.get("granted", false)
|
||||
@@ -341,17 +502,23 @@ func decide_access(allowed: bool) -> void:
|
||||
})
|
||||
if allowed == granted:
|
||||
if allowed:
|
||||
hud.log("Entry granted: %s" % ("; ".join(result.get("reasons", [])) if result.get("reasons", []) else "policy satisfied"))
|
||||
hud.log(Strings.fmt("access.granted", ["; ".join(result.get("reasons", [])) if result.get("reasons", []) else Strings.lookup("access.policy_satisfied")]))
|
||||
coordinator.reputation.apply("access_judgment", 2.0, "correct admission")
|
||||
if audio:
|
||||
audio.play_confirm()
|
||||
else:
|
||||
hud.log("Entry denied: %s" % "; ".join(result.get("issues", [])))
|
||||
hud.log(Strings.fmt("access.denied", ["; ".join(result.get("issues", []))]))
|
||||
coordinator.reputation.apply("access_judgment", 1.0, "correct refusal")
|
||||
if audio:
|
||||
audio.play_deny()
|
||||
else:
|
||||
if allowed:
|
||||
hud.log("You granted entry despite verification failure!")
|
||||
hud.log(Strings.lookup("access.wrong_grant"))
|
||||
else:
|
||||
hud.log("You denied entry despite valid verification!")
|
||||
hud.log(Strings.lookup("access.wrong_deny"))
|
||||
coordinator.reputation.apply("access_judgment", -6.0, "wrong access decision")
|
||||
if audio:
|
||||
audio.play_deny()
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
|
||||
@@ -400,3 +567,28 @@ func _physics_process(delta: float) -> void:
|
||||
return
|
||||
coordinator.tick(delta)
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
_perception_pass()
|
||||
|
||||
## Perception pass: NPCs notice the guard's proximity (PLAN.md: Level 0
|
||||
## Observation). Feeds each NPC's distance to the guard into its controller
|
||||
## and logs a one-time reaction when an NPC first notices the guard.
|
||||
func _perception_pass() -> void:
|
||||
if guard == null:
|
||||
return
|
||||
var guard_pos: Vector3 = guard.global_position
|
||||
for avatar in npcs:
|
||||
if not is_instance_valid(avatar):
|
||||
continue
|
||||
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
|
||||
if controller == null:
|
||||
continue
|
||||
var distance: float = (avatar.global_position - guard_pos).length()
|
||||
var already_noticed: bool = controller.guard_noticed
|
||||
var perception: Dictionary = controller.perceive_guard(distance)
|
||||
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")
|
||||
if perception.get("band", "") == "near":
|
||||
hud.log(Strings.fmt("perception.near", [name]))
|
||||
else:
|
||||
hud.log(Strings.fmt("perception.aware", [name]))
|
||||
Reference in new issue
Block a user