Implement prototype: venue-driven simulation core, scenarios, tests, and playable entrance scene
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:
zwitschi committed 2026-10-31 11:37:54 +01:00
1 parent e23f284ad4
commit 439e0a4281
45 files changed
+4368 -7

No files matched your search

+402
View File
@@ -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)
+83
View File
@@ -0,0 +1,83 @@
class_name GuardController
extends CharacterBody3D
## First-person guard controller: WASD movement, mouse look, interact raycast.
signal picked_target(target: Node)
signal cleared_target()
var move_speed: float = 3.2
var look_sensitivity: float = 0.0022
var is_ready := false
var targeted_npc: Node3D = null
@onready var camera: Camera3D = $Head/Camera3D
@onready var head: Node3D = $Head
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
is_ready = true
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * look_sensitivity)
head.rotate_x(-event.relative.y * look_sensitivity)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _physics_process(_delta: float) -> void:
# Movement relative to the head's yaw only.
var forward := -global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
var right := global_transform.basis.x
right.y = 0.0
right = right.normalized()
var input_dir := Vector2(
Input.get_axis("move_left", "move_right"),
Input.get_axis("move_forward", "move_back")
)
var direction := (forward * input_dir.y + right * input_dir.x).normalized()
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
velocity.y -= 9.8 * _delta
move_and_slide()
_update_target()
func _update_target() -> void:
var space := get_world_3d().direct_space_state
var from := camera.global_position
var to := from + -camera.global_transform.basis.z * 8.0
var query := PhysicsRayQueryParameters3D.create(from, to)
query.collide_with_areas = true
query.collide_with_bodies = true
query.exclude = [get_rid()]
var hit := space.intersect_ray(query)
var new_target: Node3D = null
if hit:
var obj: Object = hit.get("collider")
var parent_avatar: Node3D = _find_avatar(obj)
if parent_avatar != null:
new_target = parent_avatar
if new_target != targeted_npc:
targeted_npc = new_target
if new_target != null:
picked_target.emit(new_target)
else:
cleared_target.emit()
func _find_avatar(obj: Object) -> Node3D:
# Avatars are CharacterBody3D with a collision shape; guard against self and scene junk.
var n := obj as Node
if n == null:
return null
var current: Node = n
while current != null:
if current.has_method("get_npc_controller"):
return current as Node3D
current = current.get_parent()
return null
+11
View File
@@ -0,0 +1,11 @@
extends Node
## Top-level game entry point. Instantiates the GameManager which owns the world,
## guard, simulation coordinator, and UI.
const GameManager := preload("res://src/game/game_manager.gd")
func _ready() -> void:
var manager := GameManager.new()
manager.name = "GameManager"
add_child(manager)
print("[Step Line] game started")
+118
View File
@@ -0,0 +1,118 @@
class_name NpcAvatar
extends CharacterBody3D
## Visual + navigation wrapper around a logic NPCController (ADR-0001).
## Gives the controller a body, color-coded by archetype, and simple movement.
const NPCController := preload("res://src/core/npc_controller.gd")
const Rules := preload("res://src/core/rules.gd")
var npc: NPCController = null
var spawn_position: Vector3 = Vector3.ZERO
var target_position: Vector3 = Vector3.ZERO
var awaiting_interaction := false
var arrived := false
const TYPE_COLORS := {
Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55),
Rules.NpcType.PERFORMER: Color(0.80, 0.55, 0.90),
Rules.NpcType.INFLUENCER: Color(0.95, 0.55, 0.55),
Rules.NpcType.CONFUSED_VISITOR: Color(0.95, 0.85, 0.50),
Rules.NpcType.INTOXICATED: Color(0.60, 0.75, 0.50),
Rules.NpcType.TRESPASSER: Color(0.55, 0.55, 0.75),
Rules.NpcType.VIOLENT_THREAT: Color(0.85, 0.25, 0.25),
Rules.NpcType.MENTAL_CRISIS: Color(0.85, 0.65, 0.45),
Rules.NpcType.AUTHORIZED_STAFF: Color(0.45, 0.70, 0.90),
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),
}
func _init() -> void:
npc = NPCController.new()
func get_npc_controller() -> NPCController:
return npc
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3) -> void:
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
spawn_position = p_spawn
target_position = p_target
position = p_spawn
npc.fsm.state_changed.connect(_on_state_changed)
_build_body(profile.npc_type)
# Normalize intent targets: approach the entrance, stop near the door.
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
shape.radius = 0.35
shape.height = 1.6
collider.shape = shape
collider.position = Vector3(0, 0.8, 0)
add_child(collider)
func _build_body(npc_type: int) -> void:
var body := MeshInstance3D.new()
var capsule := CapsuleMesh.new()
capsule.radius = 0.35
capsule.height = 1.6
body.mesh = capsule
var mat := StandardMaterial3D.new()
mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
body.material_override = mat
add_child(body)
var id_tag := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.12
sphere.height = 0.24
var tag_mat := StandardMaterial3D.new()
tag_mat.albedo_color = Color(1, 1, 1)
id_tag.mesh = sphere
id_tag.material_override = tag_mat
id_tag.position = Vector3(0, 1.05, 0)
id_tag.scale = Vector3(1, 0.4, 1)
add_child(id_tag)
func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null:
return
_advance_fsm()
_move_toward_target(delta)
func _advance_fsm() -> void:
if npc.leave_requested:
var away := global_position + (global_position - spawn_position).normalized() * 40.0
away.y = 0.5
target_position = away
return
if arrived and not awaiting_interaction:
awaiting_interaction = true
func _move_toward_target(delta: float) -> void:
var to_target := target_position - global_position
to_target.y = 0.0
var dist := to_target.length()
if dist < 0.7:
_arrive()
return
var dir := to_target.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()
func _arrive() -> void:
arrived = true
velocity = Vector3.ZERO
if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive":
target_position = Vector3(0, 0.5, 1.2)
if (global_position - Vector3(0, 0.5, 1.2)).length() < 0.8:
awaiting_interaction = true
func _on_state_changed(_node: Object, _from: String, to_state: String) -> void:
match to_state:
"LEAVING":
npc.leave_requested = true
"THREATENING", "FORCING_ENTRY":
# Advance toward the door posture; keep it simple for the prototype.
pass
+168
View File
@@ -0,0 +1,168 @@
class_name Hud
extends CanvasLayer
## Minimal on-screen HUD: top status bar, target info, action hints,
## event log, access inspection panel, and a debug overlay.
const Rules := preload("res://src/core/rules.gd")
var top_label: Label
var time_label: Label
var target_label: Label
var hint_label: Label
var log_label: Label
var debug_label: Label
var inspect_panel: PanelContainer
var inspect_text: Label
var inspect_meta: Label
var _debug_enabled := false
var _log_lines: Array[String] = []
func _init() -> void:
name = "HUD"
layer = 10
var margin := MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_bottom", 12)
add_child(margin)
var vbox := VBoxContainer.new()
margin.add_child(vbox)
var top := HBoxContainer.new()
vbox.add_child(top)
top_label = Label.new()
top_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
top.add_child(top_label)
time_label = Label.new()
top.add_child(time_label)
var spacer := Control.new()
spacer.custom_minimum_size = Vector2(0, 6)
vbox.add_child(spacer)
target_label = Label.new()
target_label.add_theme_font_size_override("font_size", 16)
vbox.add_child(target_label)
hint_label = Label.new()
hint_label.modulate = Color(1, 0.9, 0.6)
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
hint_label.custom_minimum_size = Vector2(0, 40)
vbox.add_child(hint_label)
var spacer2 := Control.new()
spacer2.custom_minimum_size = Vector2(0, 4)
vbox.add_child(spacer2)
log_label = Label.new()
log_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
log_label.custom_minimum_size = Vector2(0, 120)
log_label.add_theme_font_size_override("font_size", 13)
vbox.add_child(log_label)
var bottom_spacer := Control.new()
bottom_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
vbox.add_child(bottom_spacer)
var controls := Label.new()
controls.text = "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · A allow · D deny · F debug"
controls.add_theme_font_size_override("font_size", 12)
controls.modulate = Color(0.8, 0.8, 0.85)
vbox.add_child(controls)
# --- inspect panel (popup center) ---
inspect_panel = PanelContainer.new()
inspect_panel.visible = false
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
inspect_panel.modulate.a = 1.0
var ivbox := VBoxContainer.new()
inspect_panel.add_child(ivbox)
inspect_text = Label.new()
inspect_text.add_theme_font_size_override("font_size", 15)
ivbox.add_child(inspect_text)
inspect_meta = Label.new()
inspect_meta.add_theme_font_size_override("font_size", 12)
inspect_meta.modulate = Color(0.8, 0.85, 1.0)
ivbox.add_child(inspect_meta)
var insp_hint := Label.new()
insp_hint.text = "A = allow entry D = deny entry"
insp_hint.add_theme_font_size_override("font_size", 12)
ivbox.add_child(insp_hint)
add_child(inspect_panel)
# --- debug overlay ---
debug_label = Label.new()
debug_label.visible = false
debug_label.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
debug_label.position = Vector2(16, 0)
debug_label.add_theme_font_size_override("font_size", 11)
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(debug_label)
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
top_label.text = scenario.display_name if scenario else "Step Line"
var mins := int(coordinator.elapsed_s) / 60
var secs := int(coordinator.elapsed_s) % 60
time_label.text = "shift %02d:%02d phase %s\nrep %.0f crowd %.0f%%" % [
mins, secs,
Rules.PHASE_NAMES.get(coordinator.current_phase, "Early"),
coordinator.reputation.rating,
coordinator.crowd.mood * 100.0,
]
func set_target_label(text: String) -> void:
target_label.text = text
func set_action_hint(text: String) -> void:
hint_label.text = text
func log(text: String) -> void:
_log_lines.append(text)
if _log_lines.size() > 8:
_log_lines.pop_front()
log_label.text = "\n".join(_log_lines)
func toggle_debug() -> void:
_debug_enabled = not _debug_enabled
debug_label.visible = _debug_enabled
func refresh_debug(coordinator: Object, npcs: Array) -> void:
if not _debug_enabled:
return
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [
coordinator.elapsed_s, coordinator.duration_s, coordinator.reputation.rating]]
for npc in npcs:
var c: Object = npc.get_npc_controller()
if c == null:
continue
var profile: Object = c.profile
lines.append(" [%s] %s intent=%s state=%s esc=%d" % [
Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received,
])
debug_label.text = "\n".join(lines)
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
var profile: Object = controller.profile
var npc_display: String = (profile.display_name if profile else "Person")
var granted: bool = result.get("granted", false)
inspect_text.text = "Access check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"]
var lines: Array[String] = []
for artifact in artifacts:
lines.append(" • " + artifact.summary())
if result.get("reasons", []).size() > 0:
lines.append("Reasons: " + ", ".join(result.get("reasons", [])))
if result.get("issues", []).size() > 0:
lines.append("Issues: " + "; ".join(result.get("issues", [])))
inspect_meta.text = "\n".join(lines)
inspect_panel.visible = true
func hide_inspection() -> void:
inspect_panel.visible = false
+159
View File
@@ -0,0 +1,159 @@
class_name RadioPanel
extends CanvasLayer
## Radio report form (PLAN.md Level 4 assist / Level 5 emergency).
## Emergency calls use hold-to-activate (press and hold 1.6s) to prevent
## accidental activation.
signal submitted(report: Dictionary, emergency: bool)
signal cancelled()
const Rules := preload("res://src/core/rules.gd")
var panel: PanelContainer
var npc_name_label: Label
var location_edit: LineEdit
var desc_edit: LineEdit
var weapons_check: CheckBox
var danger_option: OptionButton
var action_button: Button
var hold_hint: Label
var status_label: Label
var _emergency := false
var _npc_id := ""
var _holding := false
var _hold_time := 0.0
const HOLD_NEEDED := 1.6
var _truth := {"weapons_seen": false, "danger_level": 0}
func _init() -> void:
name = "RadioPanel"
layer = 20
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.55)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
panel = PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
add_child(panel)
var vbox := VBoxContainer.new()
panel.add_child(vbox)
vbox.add_theme_constant_override("separation", 8)
var title := Label.new()
title.name = "RadioTitle"
title.add_theme_font_size_override("font_size", 18)
vbox.add_child(title)
npc_name_label = Label.new()
vbox.add_child(npc_name_label)
location_edit = LineEdit.new()
location_edit.placeholder_text = "Location (e.g. front steps)"
location_edit.text = "front entrance"
vbox.add_child(location_edit)
desc_edit = LineEdit.new()
desc_edit.placeholder_text = "Describe the person and behavior"
vbox.add_child(desc_edit)
weapons_check = CheckBox.new()
weapons_check.text = "Weapon seen"
vbox.add_child(weapons_check)
var danger_row := HBoxContainer.new()
vbox.add_child(danger_row)
var danger_label := Label.new()
danger_label.text = "Danger level: "
danger_row.add_child(danger_label)
danger_option = OptionButton.new()
for i in 4:
danger_option.add_item("level %d — %s" % [i, _danger_name(i)], i)
danger_option.select(1)
danger_row.add_child(danger_option)
hold_hint = Label.new()
hold_hint.text = ""
hold_hint.modulate = Color(1, 0.85, 0.55)
vbox.add_child(hold_hint)
status_label = Label.new()
status_label.text = ""
vbox.add_child(status_label)
var buttons := HBoxContainer.new()
vbox.add_child(buttons)
action_button = Button.new()
action_button.text = "Send request"
action_button.button_down.connect(func(): _start_hold())
action_button.button_up.connect(func(): _stop_hold(false))
buttons.add_child(action_button)
var cancel_btn := Button.new()
cancel_btn.text = "Cancel"
cancel_btn.pressed.connect(func(): cancelled.emit())
buttons.add_child(cancel_btn)
var _title_label: Label
func open(controller: Object, emergency: bool, truth: Dictionary) -> void:
_emergency = emergency
_truth = truth
_npc_id = controller.npc_id if controller else ""
npc_name_label.text = "Target: %s" % (controller.profile.display_name if controller else "?")
var title: Label = panel.find_child("RadioTitle", true, false)
title.text = "EMERGENCY CALL" if emergency else "Security Assistance"
weapons_check.button_pressed = truth.get("weapons_seen", false)
danger_option.select(truth.get("danger_level", 1))
hold_hint.text = "Hold the button for %.1fs" % HOLD_NEEDED if emergency else ""
status_label.text = ""
action_button.text = "SEND EMERGENCY" if emergency else "Send request"
_holding = false
_hold_time = 0.0
visible = true
func _start_hold() -> void:
_holding = _emergency
_hold_time = 0.0
func _stop_hold(cancelled_early: bool) -> void:
if _holding:
_holding = false
if not cancelled_early and _hold_time >= HOLD_NEEDED:
_submit()
else:
status_label.text = "Call cancelled - must hold the button."
func _process(delta: float) -> void:
if not visible:
return
if _holding:
_hold_time += delta
status_label.text = "Holding... %.0f%%" % (minf(1.0, _hold_time / HOLD_NEEDED) * 100.0)
if _hold_time >= HOLD_NEEDED:
var was := _holding
_holding = false
if was:
_submit()
func _submit() -> void:
if not visible:
return
var report := {
"npc_id": _npc_id,
"location": location_edit.text,
"description": desc_edit.text,
"behavior": desc_edit.text,
"weapons_seen": weapons_check.button_pressed,
"danger_level": danger_option.get_selected_id(),
}
visible = false
submitted.emit(report, _emergency)
func _danger_name(level: int) -> String:
match level:
0: return "none"
1: return "low"
2: return "elevated"
3: return "immediate threat"
_: return "unknown"
+77
View File
@@ -0,0 +1,77 @@
class_name ShiftReview
extends CanvasLayer
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
signal restart_requested()
var report_label: Label
func _init() -> void:
name = "ShiftReview"
layer = 30
var dim := ColorRect.new()
dim.color = Color(0.04, 0.05, 0.08, 0.92)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := VBoxContainer.new()
center.set_anchors_preset(Control.PRESET_CENTER)
center.alignment = BoxContainer.ALIGNMENT_CENTER
center.add_theme_constant_override("separation", 12)
add_child(center)
var title := Label.new()
title.text = "Shift Review"
title.add_theme_font_size_override("font_size", 28)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
center.add_child(title)
report_label = Label.new()
report_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
report_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
report_label.custom_minimum_size = Vector2(720, 0)
center.add_child(report_label)
var restart := Button.new()
restart.text = "Run another shift"
restart.pressed.connect(func(): restart_requested.emit())
restart.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(restart)
var quit_btn := Button.new()
quit_btn.text = "Quit"
quit_btn.pressed.connect(func(): get_tree().quit())
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(quit_btn)
func show_review(review: Dictionary, scenario: Resource) -> void:
var reputation: Dictionary = review.get("reputation", {})
var incidents: Dictionary = review.get("incidents", {})
var crowd: Dictionary = review.get("crowd", {})
var radio: Dictionary = review.get("radio", {})
var incident_list: Array = incidents.get("incidents", [])
var peaceful := 0
for inc in incident_list:
if inc.get("outcome", "") in ["peaceful", "left"]:
peaceful += 1
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)],
"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)],
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
"",
"Highlights:",
]
var mods: Array = reputation.get("modifiers", [])
var shown := 0
for mod in mods:
var delta: float = float(mod.get("delta", 0.0))
lines.append(" %s (%+.0f) %s" % [mod.get("category", ""), delta, mod.get("reason", "")])
shown += 1
if shown >= 12:
break
report_label.text = "\n".join(lines)
visible = true
+110
View File
@@ -0,0 +1,110 @@
class_name WorldBuilder
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
## No art assets required; all geometry is BoxMesh with flat materials.
var root: Node3D = null
func _init(root_node: Node3D) -> void:
root = root_node
func build() -> void:
_build_ground()
_build_building()
_build_steps()
_build_sidewalk()
_build_street()
_build_guard_spawn()
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D:
var mesh := BoxMesh.new()
mesh.size = size
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mesh.material = mat
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.position = pos
mi.cast_shadow = (1 if cast_shadow else 0) as GeometryInstance3D.ShadowCastingSetting
parent.add_child(mi)
return mi
func _build_ground() -> void:
var ground := StaticBody3D.new()
ground.name = "Ground"
_add_box(root, Vector3(160, 0.5, 160), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
var col := BoxShape3D.new()
col.size = Vector3(160, 1, 160)
var shape_owner := ground.create_shape_owner(ground)
ground.shape_owner_add_shape(shape_owner, col)
root.add_child(ground)
func _build_building() -> void:
var building := StaticBody3D.new()
building.name = "Building"
# Main facade
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.35, 0.32, 0.30))
# Entrance recess + door recess frame
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.10, 0.10, 0.10))
# Door
_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))
# 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()
col.size = Vector3(40, 18, 4)
var so := building.create_shape_owner(building)
building.shape_owner_add_shape(so, col)
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -2)))
root.add_child(building)
func _build_steps() -> void:
var steps := StaticBody3D.new()
steps.name = "Steps"
var step_w := 8.0
var tread := 0.6
var height := 0.16
for i in 4:
var y: float = i * height
var z: float = i * tread
_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:
var sidewalk := StaticBody3D.new()
sidewalk.name = "Sidewalk"
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.60, 0.58, 0.56))
var col := BoxShape3D.new()
col.size = Vector3(30, 0.36, 8)
var so := sidewalk.create_shape_owner(sidewalk)
sidewalk.shape_owner_add_shape(so, col)
sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 8)))
root.add_child(sidewalk)
func _build_street() -> void:
var street := StaticBody3D.new()
street.name = "Street"
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.13, 0.13, 0.14))
# Center dashes
for i in range(-40, 41, 4):
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30))
var col := BoxShape3D.new()
col.size = Vector3(120, 0.2, 26)
var so := street.create_shape_owner(street)
street.shape_owner_add_shape(so, col)
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22)))
root.add_child(street)
func _build_guard_spawn() -> void:
var marker := Marker3D.new()
marker.name = "GuardSpawn"
marker.position = Vector3(0, 1.6, -2.5)
root.add_child(marker)