Implement prototype: venue-driven simulation core, scenarios, tests, and playable entrance scene
CI / headless-tests (push) Canceled after 49s
CI / headless-tests (push) Canceled after 49s
- Godot 4.3 project scaffold with first-person guard, procedural world, interaction HUD, access inspection, radio forms, debug overlay, shift review - Simulation core: ShiftCoordinator, VenueScenario, EventScheduler, NPCController/FSM, AccessVerifier, EscalationManager, RadioSystem, IncidentRecorder, ReputationEvaluator, CrowdSystem, EventBus - Three authored scenarios (Hollywood Blvd building, The Joke Shop, TV studio) - Custom headless test runner (25 tests) replacing editor-dependent GUT, plus GitHub Actions CI - Documentation workflow live: README, PLAN (tech stack), TODO/DONE, MEMORY
This commit is contained in:
1 parent
e23f284ad4
commit
439e0a4281
45 files changed
+4368
-7
No files matched your search
@@ -0,0 +1,402 @@
|
||||
class_name GameManager
|
||||
extends Node
|
||||
## Wireframe: connects a scenario to a ShiftCoordinator, spawns NPC avatars,
|
||||
## and routes player input into the interaction and access flows.
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.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")
|
||||
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 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 coordinator: ShiftCoordinator = null
|
||||
var guard: GuardController = null
|
||||
var hud: Hud = null
|
||||
var radio_panel: RadioPanel = null
|
||||
var shift_review_panel: ShiftReview = null
|
||||
var verifier := AccessVerifier.new()
|
||||
|
||||
var active_scenario_id := "hollywood_building"
|
||||
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing
|
||||
var scene_root: Node3D = null
|
||||
var npcs: Array = []
|
||||
var npc_counter := 0
|
||||
var targeted_npc: Object = null
|
||||
var inspection_open := false
|
||||
var current_incident_npc_id := ""
|
||||
var current_incident_active := false
|
||||
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
|
||||
|
||||
func _ready() -> void:
|
||||
scene_root = Node3D.new()
|
||||
scene_root.name = "SceneRoot"
|
||||
add_child(scene_root)
|
||||
|
||||
WorldBuilder.new(scene_root).build()
|
||||
_ensure_lighting()
|
||||
|
||||
guard = _build_guard()
|
||||
scene_root.add_child(guard)
|
||||
guard.global_position = Vector3(0, 1.6, -2.5)
|
||||
guard.picked_target.connect(_on_guard_target)
|
||||
guard.cleared_target.connect(_on_guard_clear)
|
||||
|
||||
selected_scenario = _build_scenario(active_scenario_id)
|
||||
coordinator = ShiftCoordinator.new()
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||
coordinator.shift_ended.connect(_on_shift_ended)
|
||||
|
||||
_build_ui()
|
||||
coordinator.start()
|
||||
|
||||
var selected_scenario: VenueScenario = null
|
||||
|
||||
func _build_guard() -> GuardController:
|
||||
var g := GuardController.new()
|
||||
g.name = "Guard"
|
||||
var head := Node3D.new()
|
||||
head.name = "Head"
|
||||
g.add_child(head)
|
||||
var camera := Camera3D.new()
|
||||
camera.name = "Camera3D"
|
||||
camera.current = true
|
||||
head.add_child(camera)
|
||||
var collider := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
shape.radius = 0.4
|
||||
shape.height = 1.7
|
||||
collider.shape = shape
|
||||
collider.position = Vector3(0, 0.85, 0)
|
||||
g.add_child(collider)
|
||||
return g
|
||||
|
||||
func _build_scenario(id: String) -> VenueScenario:
|
||||
match id:
|
||||
"joke_shop":
|
||||
return ScenarioFactory.joke_shop()
|
||||
"tv_studio":
|
||||
return ScenarioFactory.tv_studio()
|
||||
_:
|
||||
return ScenarioFactory.hollywood_building()
|
||||
|
||||
func switch_scenario(id: String) -> void:
|
||||
active_scenario_id = id
|
||||
selected_scenario = _build_scenario(id)
|
||||
if coordinator:
|
||||
restart_shift()
|
||||
|
||||
func restart_shift() -> void:
|
||||
shift_review_panel.hide()
|
||||
for n in npcs:
|
||||
n.queue_free()
|
||||
npcs.clear()
|
||||
npc_counter = 0
|
||||
inspector_current_events = []
|
||||
current_incident_active = false
|
||||
current_incident_npc_id = ""
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.start()
|
||||
|
||||
var inspector_current_events: Array = []
|
||||
|
||||
func _build_ui() -> void:
|
||||
hud = Hud.new()
|
||||
add_child(hud)
|
||||
radio_panel = RadioPanel.new()
|
||||
add_child(radio_panel)
|
||||
radio_panel.hide()
|
||||
radio_panel.submitted.connect(_on_radio_submitted)
|
||||
radio_panel.cancelled.connect(func():
|
||||
radio_panel.hide()
|
||||
hud.set_action_hint(""))
|
||||
shift_review_panel = ShiftReview.new()
|
||||
add_child(shift_review_panel)
|
||||
shift_review_panel.hide()
|
||||
shift_review_panel.restart_requested.connect(restart_shift)
|
||||
|
||||
func _ensure_lighting() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-50, -30, 0)
|
||||
sun.light_energy = 1.2
|
||||
scene_root.add_child(sun)
|
||||
var env_node := WorldEnvironment.new()
|
||||
var env := Environment.new()
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.7, 0.7, 0.75)
|
||||
env.ambient_light_energy = 0.5
|
||||
env_node.environment = env
|
||||
scene_root.add_child(env_node)
|
||||
|
||||
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
|
||||
npc_counter += 1
|
||||
var avatar := NpcAvatar.new()
|
||||
avatar.name = "Npc_%d" % npc_counter
|
||||
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)
|
||||
scene_root.add_child(avatar)
|
||||
npcs.append(avatar)
|
||||
|
||||
func _on_shift_ended(review: Dictionary) -> void:
|
||||
shift_review_panel.show_review(review, selected_scenario)
|
||||
|
||||
func _on_guard_target(target: Node3D) -> void:
|
||||
targeted_npc = target
|
||||
_begin_incident(target)
|
||||
|
||||
func _on_guard_clear() -> void:
|
||||
targeted_npc = null
|
||||
hud.set_target_label("")
|
||||
hud.set_action_hint("")
|
||||
|
||||
func _begin_incident(avatar: Node3D) -> void:
|
||||
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
|
||||
if controller == null:
|
||||
return
|
||||
current_incident_npc_id = controller.npc_id
|
||||
current_incident_active = true
|
||||
current_npc_truth = _read_npc_truth(controller)
|
||||
var profile: Object = controller.profile
|
||||
var label := "[%s] %s — %s" % [
|
||||
Rules.NPC_TYPE_NAMES.get((profile.npc_type if profile else -1), "?"),
|
||||
profile.display_name if profile else "?",
|
||||
controller.intent,
|
||||
]
|
||||
hud.set_target_label(label)
|
||||
var hint := ""
|
||||
if profile and profile.npc_type == Rules.NpcType.VIOLENT_THREAT:
|
||||
hint = "High risk: threaten/force-entry likely. Emergency radio (R) is justified."
|
||||
elif profile and profile.npc_type == Rules.NpcType.MENTAL_CRISIS:
|
||||
hint = "Requires patience. Avoid aggressive confrontation."
|
||||
elif profile and profile.npc_type == Rules.NpcType.AUTHORIZED_GUEST:
|
||||
hint = "Likely authorized. Press E to verify access."
|
||||
hud.set_action_hint(hint)
|
||||
|
||||
# ------------------------------------------------------------------ input ---
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
_handle_key(event.keycode)
|
||||
|
||||
func _handle_key(keycode: Key) -> void:
|
||||
match keycode:
|
||||
KEY_E:
|
||||
if targeted_npc != null and not inspection_open:
|
||||
_toggle_inspection()
|
||||
KEY_Q:
|
||||
if targeted_npc != null and not inspection_open:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "polite direction")
|
||||
KEY_F:
|
||||
hud.toggle_debug()
|
||||
_:
|
||||
_handle_action_key(keycode)
|
||||
|
||||
func _handle_action_key(keycode: Key) -> void:
|
||||
match keycode:
|
||||
KEY_1:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "raise hand")
|
||||
KEY_2:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "point")
|
||||
KEY_3:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "head shake")
|
||||
KEY_4:
|
||||
_apply_interaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL, "formal warning")
|
||||
KEY_5:
|
||||
_apply_interaction(Rules.Level.BLOCKING, Rules.Tone.PROFESSIONAL, "blocking")
|
||||
KEY_6:
|
||||
_open_radio(false)
|
||||
KEY_R:
|
||||
_open_radio(true)
|
||||
KEY_A:
|
||||
if inspection_open:
|
||||
decide_access(true)
|
||||
KEY_D:
|
||||
if inspection_open:
|
||||
decide_access(false)
|
||||
_:
|
||||
pass
|
||||
|
||||
func _apply_interaction(level: int, tone: int, action_name: String) -> void:
|
||||
if targeted_npc == null:
|
||||
hud.log("Aim at someone first.")
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null or coordinator == null:
|
||||
return
|
||||
var outcome: Dictionary = controller.apply_player_action(level, tone)
|
||||
var npc_id: String = controller.npc_id
|
||||
coordinator.recorder.add_event(npc_id, {
|
||||
"type": "player_action",
|
||||
"action": action_name,
|
||||
"level": level,
|
||||
"tone": Rules.TONE_NAMES.get(tone, "?"),
|
||||
})
|
||||
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])
|
||||
_react_to_outcome(controller, outcome)
|
||||
|
||||
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
|
||||
var reaction: int = outcome.get("reaction", -1)
|
||||
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)
|
||||
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)
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
_close_incident("left")
|
||||
Rules.Reaction.ARGUE:
|
||||
hud.log("%s argues with you." % name)
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.RECORD:
|
||||
hud.log("%s starts filming you." % name)
|
||||
coordinator.reputation.apply("complaint", -3.0, "filmed interaction")
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.IGNORE:
|
||||
hud.log("%s ignores you." % name)
|
||||
Rules.Reaction.THREATEN:
|
||||
hud.log("%s is threatening you. Emergency radio (R) is justified now." % name)
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.FORCE_ENTRY:
|
||||
hud.log("%s is forcing the door!" % 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("No reaction (yet).")
|
||||
|
||||
func _open_radio(emergency: bool) -> void:
|
||||
if targeted_npc == null:
|
||||
hud.log("Address someone first before calling.")
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
return
|
||||
radio_panel.open(controller, emergency, current_npc_truth)
|
||||
hud.set_action_hint("Fill in the radio report on screen.")
|
||||
|
||||
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"))
|
||||
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))
|
||||
else:
|
||||
hud.log("Dispatch received your report. Assistance en route.")
|
||||
if emergency:
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
hud.log("Emergency responder dispatched.")
|
||||
else:
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
|
||||
# ----------------------------------------------------------------- access --
|
||||
|
||||
func _toggle_inspection() -> void:
|
||||
if inspection_open:
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
return
|
||||
inspection_open = true
|
||||
var artifacts := _artifacts_for(controller)
|
||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
||||
hud.show_inspection(controller, artifacts, result)
|
||||
|
||||
func decide_access(allowed: bool) -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
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)
|
||||
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
||||
"decided": allowed,
|
||||
"verified_granted": granted,
|
||||
"issues": result.get("issues", []),
|
||||
"checked": result.get("checked_artifacts", []),
|
||||
})
|
||||
if allowed == granted:
|
||||
if allowed:
|
||||
hud.log("Entry granted: %s" % ("; ".join(result.get("reasons", [])) if result.get("reasons", []) else "policy satisfied"))
|
||||
coordinator.reputation.apply("access_judgment", 2.0, "correct admission")
|
||||
else:
|
||||
hud.log("Entry denied: %s" % "; ".join(result.get("issues", [])))
|
||||
coordinator.reputation.apply("access_judgment", 1.0, "correct refusal")
|
||||
else:
|
||||
if allowed:
|
||||
hud.log("You granted entry despite verification failure!")
|
||||
else:
|
||||
hud.log("You denied entry despite valid verification!")
|
||||
coordinator.reputation.apply("access_judgment", -6.0, "wrong access decision")
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
|
||||
func _artifacts_for(controller: Object) -> Array:
|
||||
var profile: Object = controller.profile
|
||||
var npc_type: int = profile.npc_type if profile else -1
|
||||
var out: Array = []
|
||||
match npc_type:
|
||||
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
|
||||
Rules.NpcType.DELIVERY:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
|
||||
Rules.NpcType.TRESPASSER:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {"color": "red", "date": "2024-01-01"}))
|
||||
Rules.NpcType.TOURIST:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
||||
Rules.NpcType.CONFUSED_VISITOR:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, {"color": "gold"}))
|
||||
_:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
||||
return out
|
||||
|
||||
func _read_npc_truth(controller: Object) -> Dictionary:
|
||||
var profile: Object = controller.profile
|
||||
var npc_type: int = profile.npc_type if profile else -1
|
||||
return {
|
||||
"weapons_seen": npc_type == Rules.NpcType.VIOLENT_THREAT,
|
||||
"danger_level": 3 if npc_type == Rules.NpcType.VIOLENT_THREAT else 1,
|
||||
}
|
||||
|
||||
func _targeted_controller() -> Object:
|
||||
if targeted_npc == null:
|
||||
return null
|
||||
if targeted_npc.has_method("get_npc_controller"):
|
||||
return targeted_npc.get_npc_controller()
|
||||
return null
|
||||
|
||||
func _close_incident(outcome: String) -> void:
|
||||
if current_incident_active and not current_incident_npc_id.is_empty():
|
||||
coordinator.recorder.close_incident(current_incident_npc_id, outcome)
|
||||
current_incident_active = false
|
||||
current_incident_npc_id = ""
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if coordinator == null or not coordinator.running:
|
||||
return
|
||||
coordinator.tick(delta)
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
Reference in new issue
Block a user