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,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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user