Verbs (PLAN levels 1-3) - Eye contact: own Level 1 radial slice, logged as "eye contact". - Recognition is a player decision: "Recognize" sets NPCController.player_recognized, writes access evidence, and GameManager only then appends a RECOGNITION artifact. It is no longer implied by NPC type. - Body camera: real flag (off by default) driving the REC overlay; B toggles it and a formal warning arms it automatically, writing a body_camera incident event. - Blocking stations the guard 0.9 m from the person toward the entrance and turns to face them, instead of only emitting the escalation level. - Request reason: Level 3 ask answered from the NPC's seeded dialogue_lines. - Radial grows to 12 slices (174 px outer radius, centered labels). Incident recording fix - IncidentRecorder.start_incident() was never called, so add_event / add_access_evidence / add_escalation silently no-opped and the shift review always reported zero incidents. Incidents now open on focus (guarded by has_open_incident), verbs call _ensure_incident, and ShiftCoordinator closes leftovers as "unresolved" before building the review. Copy and docs - 9 new string keys (eye contact, body camera, recognition, reason, station). - README controls, 26-step smoke checklist, test count, status lists. - TODO section moved to DONE; MEMORY records the verb design and the recorder bug. Verification: headless 98/98, smoke_plan_verbs.gd + smoke_session_shell.gd + windowed smoke_focus_radial.gd all pass. Version bumped to 0.2.3.
471 lines
17 KiB
GDScript
471 lines
17 KiB
GDScript
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")
|
|
const Strings := preload("res://src/core/strings.gd")
|
|
const T_VIGNETTE := preload("res://assets/textures/ai/game_ready/vignette.png")
|
|
const T_ICON_FLASH := preload("res://assets/textures/ai/game_ready/icons/flashlight_on.png")
|
|
const T_ICON_DOOR := preload("res://assets/textures/ai/game_ready/icons/door_locked.png")
|
|
const T_ICON_WAND := preload("res://assets/textures/ai/game_ready/icons/wand.png")
|
|
const T_ICON_REC := preload("res://assets/textures/ai/game_ready/icons/rec_dot.png")
|
|
const T_CARD_ID := preload("res://assets/textures/ai/game_ready/cards/id_card.png")
|
|
const T_CARD_WRIST := preload("res://assets/textures/ai/game_ready/cards/wristband.png")
|
|
const T_CARD_TICKET := preload("res://assets/textures/ai/game_ready/cards/ticket.png")
|
|
const T_CARD_CRED := preload("res://assets/textures/ai/game_ready/cards/credential.png")
|
|
const T_CARD_GUEST := preload("res://assets/textures/ai/game_ready/cards/guest_list.png")
|
|
const CARD_TEXTURES := {
|
|
Rules.AccessKind.ID: T_CARD_ID,
|
|
Rules.AccessKind.WRISTBAND: T_CARD_WRIST,
|
|
Rules.AccessKind.TICKET: T_CARD_TICKET,
|
|
Rules.AccessKind.CREDENTIAL: T_CARD_CRED,
|
|
Rules.AccessKind.GUEST_LIST: T_CARD_GUEST,
|
|
}
|
|
|
|
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 radio_cooldown_label: Label
|
|
var mode_label: Label
|
|
var vignette_rect: TextureRect
|
|
var cam_clock: Label
|
|
var icon_row: HBoxContainer
|
|
var flashlight_icon: TextureRect
|
|
var door_icon: TextureRect
|
|
var wand_icon: TextureRect
|
|
var wand_panel: PanelContainer
|
|
var wand_text: Label
|
|
var wand_meta: Label
|
|
var escalation_label: Label
|
|
|
|
var _debug_enabled := false
|
|
var _log_lines: Array[String] = []
|
|
var _cam_overlay: Control = null
|
|
var _body_camera_on := false
|
|
|
|
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 = Strings.lookup("hud.controls")
|
|
controls.add_theme_font_size_override("font_size", 12)
|
|
controls.modulate = Color(0.8, 0.8, 0.85)
|
|
vbox.add_child(controls)
|
|
|
|
# --- emergency radio cooldown indicator ---
|
|
radio_cooldown_label = Label.new()
|
|
radio_cooldown_label.text = ""
|
|
radio_cooldown_label.add_theme_font_size_override("font_size", 13)
|
|
radio_cooldown_label.modulate = Color(1, 0.55, 0.45)
|
|
vbox.add_child(radio_cooldown_label)
|
|
|
|
# --- door / flashlight state ---
|
|
mode_label = Label.new()
|
|
mode_label.text = "Door: open · Light: off"
|
|
mode_label.add_theme_font_size_override("font_size", 13)
|
|
mode_label.modulate = Color(0.85, 0.85, 0.9)
|
|
vbox.add_child(mode_label)
|
|
|
|
# --- escalation level indicator (color-coded) ---
|
|
escalation_label = Label.new()
|
|
escalation_label.text = "Escalation: Observation"
|
|
escalation_label.add_theme_font_size_override("font_size", 13)
|
|
escalation_label.modulate = Color(0.6, 0.9, 0.6)
|
|
vbox.add_child(escalation_label)
|
|
|
|
# --- escalation vignette (screen-space tint) ---
|
|
vignette_rect = TextureRect.new()
|
|
vignette_rect.texture = T_VIGNETTE
|
|
vignette_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
vignette_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
vignette_rect.modulate = Color(1, 1, 1, 0)
|
|
vignette_rect.show_behind_parent = false
|
|
add_child(vignette_rect)
|
|
|
|
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
|
|
# Toggled by the body-camera flag (PLAN Level 2); off until the guard activates
|
|
# it or issues a formal warning.
|
|
_cam_overlay = Control.new()
|
|
_cam_overlay.name = "BodyCameraOverlay"
|
|
_cam_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
_cam_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_cam_overlay.visible = false
|
|
add_child(_cam_overlay)
|
|
var cam_box := VBoxContainer.new()
|
|
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
|
cam_box.position = Vector2(-180, 50)
|
|
cam_box.alignment = BoxContainer.ALIGNMENT_END
|
|
_cam_overlay.add_child(cam_box)
|
|
var rec_row := HBoxContainer.new()
|
|
rec_row.alignment = BoxContainer.ALIGNMENT_END
|
|
cam_box.add_child(rec_row)
|
|
var rec_icon := TextureRect.new()
|
|
rec_icon.texture = T_ICON_REC
|
|
rec_icon.custom_minimum_size = Vector2(22, 22)
|
|
rec_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
rec_row.add_child(rec_icon)
|
|
var rec_label := Label.new()
|
|
rec_label.text = "REC"
|
|
rec_label.add_theme_font_size_override("font_size", 15)
|
|
rec_label.modulate = Color(0.95, 0.35, 0.35)
|
|
rec_row.add_child(rec_label)
|
|
cam_clock = Label.new()
|
|
cam_clock.text = "SHIFT 00:00:00"
|
|
cam_clock.add_theme_font_size_override("font_size", 12)
|
|
cam_clock.modulate = Color(0.95, 0.95, 0.95)
|
|
cam_clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
cam_box.add_child(cam_clock)
|
|
# corner brackets
|
|
var bracket_color := Color(0.9, 0.9, 0.92, 0.55)
|
|
_add_corner_brackets(bracket_color)
|
|
|
|
# --- mode icons next to the door/light label ---
|
|
icon_row = HBoxContainer.new()
|
|
vbox.add_child(icon_row)
|
|
flashlight_icon = _make_icon(T_ICON_FLASH)
|
|
flashlight_icon.visible = false
|
|
icon_row.add_child(flashlight_icon)
|
|
door_icon = _make_icon(T_ICON_DOOR)
|
|
door_icon.visible = false
|
|
icon_row.add_child(door_icon)
|
|
wand_icon = _make_icon(T_ICON_WAND)
|
|
wand_icon.visible = false
|
|
icon_row.add_child(wand_icon)
|
|
|
|
# --- inspect panel (popup center) ---
|
|
var inspect_center := CenterContainer.new()
|
|
inspect_center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
inspect_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
add_child(inspect_center)
|
|
inspect_panel = PanelContainer.new()
|
|
inspect_panel.visible = false
|
|
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)
|
|
inspect_center.add_child(inspect_panel)
|
|
|
|
# --- wand screening panel (popup center) ---
|
|
var wand_center := CenterContainer.new()
|
|
wand_center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
wand_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
add_child(wand_center)
|
|
wand_panel = PanelContainer.new()
|
|
wand_panel.visible = false
|
|
var wvbox := VBoxContainer.new()
|
|
wand_panel.add_child(wvbox)
|
|
wand_text = Label.new()
|
|
wand_text.add_theme_font_size_override("font_size", 15)
|
|
wvbox.add_child(wand_text)
|
|
wand_meta = Label.new()
|
|
wand_meta.add_theme_font_size_override("font_size", 12)
|
|
wand_meta.modulate = Color(0.8, 0.85, 1.0)
|
|
wvbox.add_child(wand_meta)
|
|
var wand_hint := Label.new()
|
|
wand_hint.text = "A = admit D = refuse entry"
|
|
wand_hint.add_theme_font_size_override("font_size", 12)
|
|
wvbox.add_child(wand_hint)
|
|
wand_center.add_child(wand_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,
|
|
]
|
|
if cam_clock:
|
|
var total := int(coordinator.elapsed_s)
|
|
cam_clock.text = "SHIFT %02d:%02d:%02d" % [total / 3600, (total / 60) % 60, total % 60]
|
|
_update_radio_cooldown(coordinator)
|
|
|
|
func _update_radio_cooldown(coordinator: Object) -> void:
|
|
var remaining: float = coordinator.radio.emergency_cooldown_remaining
|
|
if remaining > 0.0:
|
|
radio_cooldown_label.text = Strings.fmt("hud.cooldown", [remaining])
|
|
else:
|
|
radio_cooldown_label.text = ""
|
|
|
|
func set_target_label(text: String) -> void:
|
|
target_label.text = text
|
|
|
|
var _flashlight := false
|
|
var _door_locked := false
|
|
|
|
func set_door(locked: bool) -> void:
|
|
_door_locked = locked
|
|
_refresh_mode_label()
|
|
|
|
func set_flashlight(on: bool) -> void:
|
|
_flashlight = on
|
|
_refresh_mode_label()
|
|
|
|
func _refresh_mode_label() -> void:
|
|
mode_label.text = "Door: %s · Light: %s" % ["LOCKED" if _door_locked else "open", "on" if _flashlight else "off"]
|
|
if icon_row:
|
|
flashlight_icon.visible = _flashlight
|
|
door_icon.visible = _door_locked
|
|
|
|
func set_wand_icon(visible: bool) -> void:
|
|
if wand_icon:
|
|
wand_icon.visible = visible
|
|
|
|
## Show or hide the body-camera overlay (REC + clock + brackets). The flag is a
|
|
## record of the interaction (PLAN Level 2), not a permanent HUD fixture.
|
|
func set_body_camera(on: bool) -> void:
|
|
_body_camera_on = on
|
|
if _cam_overlay:
|
|
_cam_overlay.visible = on
|
|
|
|
func is_body_camera_on() -> bool:
|
|
return _body_camera_on
|
|
|
|
## Scale the whole HUD text block (accessibility settings).
|
|
func set_font_scale(scale: float) -> void:
|
|
var clamped := clampf(scale, 0.75, 1.75)
|
|
if top_label:
|
|
top_label.scale = Vector2(clamped, clamped)
|
|
if log_label:
|
|
log_label.scale = Vector2(clamped, clamped)
|
|
if target_label:
|
|
target_label.scale = Vector2(clamped, clamped)
|
|
if hint_label:
|
|
hint_label.scale = Vector2(clamped, clamped)
|
|
|
|
func _make_icon(texture: Texture2D) -> TextureRect:
|
|
var tr := TextureRect.new()
|
|
tr.texture = texture
|
|
tr.custom_minimum_size = Vector2(20, 20)
|
|
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
tr.modulate = Color(1, 1, 1, 0.9)
|
|
return tr
|
|
|
|
func _add_corner_brackets(color: Color) -> void:
|
|
const LEN := 34.0
|
|
const TH := 3.0
|
|
const OFF := 10.0
|
|
var viewport := get_viewport()
|
|
var vp: Vector2 = viewport.get_visible_rect().size if viewport else Vector2(1280, 720)
|
|
var corners := [
|
|
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
|
|
]
|
|
var parent: Node = _cam_overlay if _cam_overlay != null else self
|
|
for i in 4:
|
|
var c: Vector2 = corners[i]
|
|
var flip_x: bool = (i % 2) == 1
|
|
var flip_y: bool = i >= 2
|
|
var h := ColorRect.new()
|
|
h.color = color
|
|
h.size = Vector2(LEN, TH)
|
|
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
|
|
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
parent.add_child(h)
|
|
var v := ColorRect.new()
|
|
v.color = color
|
|
v.size = Vector2(TH, LEN)
|
|
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
|
|
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
parent.add_child(v)
|
|
|
|
func set_action_hint(text: String) -> void:
|
|
hint_label.text = text
|
|
|
|
|
|
|
|
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
|
|
## Green → yellow → orange → red as the escalation level rises.
|
|
func set_escalation(level: int) -> void:
|
|
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
|
|
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
|
|
var tint := Color(0.6, 0.9, 0.6)
|
|
match level:
|
|
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
|
|
tint = Color(0.6, 0.9, 0.6)
|
|
Rules.Level.FORMAL_WARNING:
|
|
tint = Color(0.95, 0.9, 0.4)
|
|
Rules.Level.BLOCKING:
|
|
tint = Color(1.0, 0.65, 0.3)
|
|
Rules.Level.RADIO_ASSIST, Rules.Level.EMERGENCY:
|
|
tint = Color(1.0, 0.35, 0.3)
|
|
escalation_label.modulate = tint
|
|
# Screen-space vignette follows the escalation color.
|
|
if vignette_rect:
|
|
vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0)
|
|
|
|
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"]
|
|
_clear_card_row()
|
|
var card_row := HBoxContainer.new()
|
|
card_row.name = "INSPECT_CARD_ROW"
|
|
card_row.add_theme_constant_override("separation", 6)
|
|
var added_card := false
|
|
for artifact in artifacts:
|
|
var tex: Texture2D = CARD_TEXTURES.get(artifact.kind, null) as Texture2D
|
|
if tex == null:
|
|
continue
|
|
var card := TextureRect.new()
|
|
card.texture = tex
|
|
card.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
card.custom_minimum_size = Vector2(92, 58)
|
|
card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
card.tooltip_text = artifact.summary()
|
|
if artifact.attributes.size() > 0:
|
|
card.tooltip_text += "\n" + str(artifact.attributes)
|
|
card_row.add_child(card)
|
|
added_card = true
|
|
if added_card:
|
|
var content := inspect_panel.get_child(0) as VBoxContainer
|
|
content.add_child(card_row)
|
|
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:
|
|
_clear_card_row()
|
|
inspect_panel.visible = false
|
|
|
|
## Remove previously added artifact cards so reopening rebuilds them.
|
|
func _clear_card_row() -> void:
|
|
if inspect_panel == null:
|
|
return
|
|
var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control
|
|
if row:
|
|
row.get_parent().remove_child(row)
|
|
row.queue_free()
|
|
|
|
func show_wand(controller: Object, result: Dictionary) -> void:
|
|
var profile: Object = controller.profile
|
|
var npc_display: String = (profile.display_name if profile else "Person")
|
|
var alert: bool = result.get("alert", false)
|
|
wand_text.text = "Wand check — %s\nAlert: %s" % [npc_display, "YES" if alert else "none"]
|
|
var lines: Array[String] = []
|
|
if alert:
|
|
var item: String = result.get("item", "")
|
|
if result.get("prohibited", false):
|
|
lines.append("The wand alerts on a suspicious item (%s)." % item)
|
|
lines.append("Ask the guest to identify it before deciding.")
|
|
else:
|
|
lines.append("The wand alerts on a harmless item (%s)." % item)
|
|
lines.append("Likely a false alarm — keys, belt, or phone.")
|
|
else:
|
|
lines.append("No alert. The guest appears clear.")
|
|
wand_meta.text = "\n".join(lines)
|
|
wand_panel.visible = true
|
|
|
|
func hide_wand() -> void:
|
|
wand_panel.visible = false
|