Move guard look handling to the _input stage, recapture the cursor on window-focus return and when clicking back into gameplay, and make HUD controls mouse-transparent. Add regression tests for yaw/pitch and HUD mouse filters. Verified under Xvfb with real relative mouse motion. Headless 237/237, Python 94/94, movement smoke 60 fps.
648 lines
24 KiB
GDScript
648 lines
24 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 UiArt := preload("res://src/game/ui/art.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 nearby_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 precision_label: Label
|
|
var _target_chip: PanelContainer = null
|
|
var _escalation_segments: Array[ColorRect] = []
|
|
var _controls_label: Label
|
|
|
|
var _debug_enabled := false
|
|
var _log_lines: Array[String] = []
|
|
var _cam_overlay: Control = null
|
|
var _wand_art_layer: Control = null
|
|
var _wand_art := UiArt.new()
|
|
var _inspect_art_layer: Control = null
|
|
var _inspect_art := UiArt.new()
|
|
var _body_camera_on := false
|
|
var _font_scale := 1.0
|
|
var _base_font_sizes: Dictionary = {}
|
|
|
|
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 name sits in a small boxed chip just below the crosshair, matching
|
|
# the targeting mockup rather than pushing the whole HUD column down.
|
|
_target_chip = PanelContainer.new()
|
|
_target_chip.name = "TargetChip"
|
|
_target_chip.set_anchors_preset(Control.PRESET_CENTER)
|
|
_target_chip.offset_left = -150
|
|
_target_chip.offset_right = 150
|
|
_target_chip.offset_top = 38
|
|
_target_chip.offset_bottom = 78
|
|
_target_chip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_target_chip.visible = false
|
|
var chip_style := StyleBoxFlat.new()
|
|
chip_style.bg_color = Color(0.06, 0.075, 0.09, 0.82)
|
|
chip_style.border_color = Color(0.82, 0.84, 0.86, 0.3)
|
|
chip_style.border_width_left = 1
|
|
chip_style.border_width_top = 1
|
|
chip_style.border_width_right = 1
|
|
chip_style.border_width_bottom = 1
|
|
chip_style.corner_radius_top_left = 8
|
|
chip_style.corner_radius_top_right = 8
|
|
chip_style.corner_radius_bottom_left = 8
|
|
chip_style.corner_radius_bottom_right = 8
|
|
chip_style.content_margin_left = 12
|
|
chip_style.content_margin_right = 12
|
|
chip_style.content_margin_top = 6
|
|
chip_style.content_margin_bottom = 6
|
|
_target_chip.add_theme_stylebox_override("panel", chip_style)
|
|
add_child(_target_chip)
|
|
target_label = Label.new()
|
|
target_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
target_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
target_label.add_theme_font_size_override("font_size", 17)
|
|
_target_chip.add_child(target_label)
|
|
|
|
nearby_label = Label.new()
|
|
nearby_label.add_theme_font_size_override("font_size", 12)
|
|
nearby_label.modulate = Color(0.92, 0.95, 1.0)
|
|
# A shadow keeps the hint readable over the bright sidewalk and the sky.
|
|
nearby_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.7))
|
|
nearby_label.add_theme_constant_override("shadow_offset_x", 1)
|
|
nearby_label.add_theme_constant_override("shadow_offset_y", 1)
|
|
vbox.add_child(nearby_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)
|
|
controls.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
vbox.add_child(controls)
|
|
_controls_label = 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)
|
|
precision_label = Label.new()
|
|
precision_label.text = Strings.lookup("hud.precision")
|
|
precision_label.add_theme_font_size_override("font_size", 12)
|
|
precision_label.modulate = Color(0.55, 0.85, 1.0)
|
|
precision_label.visible = false
|
|
vbox.add_child(precision_label)
|
|
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)
|
|
var esc_row := HBoxContainer.new()
|
|
esc_row.add_theme_constant_override("separation", 4)
|
|
vbox.add_child(esc_row)
|
|
for i in 5:
|
|
var segment := ColorRect.new()
|
|
segment.custom_minimum_size = Vector2(26, 7)
|
|
segment.color = Color(0.2, 0.22, 0.26, 0.8)
|
|
esc_row.add_child(segment)
|
|
_escalation_segments.append(segment)
|
|
|
|
# --- 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)
|
|
# Leave the top-right shift/rep/crowd block clear; the REC row sits below it.
|
|
cam_box.position = Vector2(-200, 92)
|
|
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) ---
|
|
_inspect_art_layer = Control.new()
|
|
_inspect_art_layer.name = "InspectArt"
|
|
_inspect_art_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
_inspect_art_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_inspect_art_layer.visible = false
|
|
add_child(_inspect_art_layer)
|
|
if not _inspect_art.add_screen_backdrop(_inspect_art_layer, "access_inspection",
|
|
Rect2(0.220, 0.026, 0.474, 0.935)):
|
|
var inspect_dim := ColorRect.new()
|
|
inspect_dim.color = Color(0, 0, 0, 0.55)
|
|
inspect_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
inspect_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_inspect_art_layer.add_child(inspect_dim)
|
|
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
|
|
inspect_panel.custom_minimum_size = Vector2(520, 0)
|
|
var inspect_style := StyleBoxFlat.new()
|
|
inspect_style.bg_color = Color(0, 0, 0, 0)
|
|
inspect_style.content_margin_left = 28
|
|
inspect_style.content_margin_right = 28
|
|
inspect_style.content_margin_top = 20
|
|
inspect_style.content_margin_bottom = 20
|
|
inspect_panel.add_theme_stylebox_override("panel", inspect_style)
|
|
var ivbox := VBoxContainer.new()
|
|
inspect_panel.add_child(ivbox)
|
|
inspect_text = Label.new()
|
|
inspect_text.add_theme_font_size_override("font_size", 15)
|
|
inspect_text.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
ivbox.add_child(inspect_text)
|
|
inspect_meta = Label.new()
|
|
inspect_meta.add_theme_font_size_override("font_size", 12)
|
|
inspect_meta.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
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)
|
|
insp_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
ivbox.add_child(insp_hint)
|
|
inspect_center.add_child(inspect_panel)
|
|
|
|
# --- wand screening panel (popup center) ---
|
|
_wand_art_layer = Control.new()
|
|
_wand_art_layer.name = "WandArt"
|
|
_wand_art_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
_wand_art_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_wand_art_layer.visible = false
|
|
add_child(_wand_art_layer)
|
|
if not _wand_art.add_screen_backdrop(_wand_art_layer, "wand_screening",
|
|
Rect2(0.236, 0.094, 0.453, 0.884)):
|
|
var wand_dim := ColorRect.new()
|
|
wand_dim.color = Color(0, 0, 0, 0.55)
|
|
wand_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
wand_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_wand_art_layer.add_child(wand_dim)
|
|
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
|
|
wand_panel.custom_minimum_size = Vector2(440, 0)
|
|
var wand_style := StyleBoxFlat.new()
|
|
wand_style.bg_color = Color(0, 0, 0, 0)
|
|
wand_style.content_margin_left = 26
|
|
wand_style.content_margin_right = 26
|
|
wand_style.content_margin_top = 18
|
|
wand_style.content_margin_bottom = 18
|
|
wand_panel.add_theme_stylebox_override("panel", wand_style)
|
|
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)
|
|
_ignore_mouse(self)
|
|
|
|
## HUD controls are presentation-only. Leaving the full-screen containers on the
|
|
## default MOUSE_FILTER_STOP can swallow mouse motion/buttons before the guard's
|
|
## captured-look input sees them. Make every non-gameplay HUD control inert to
|
|
## the mouse; modal menus are separate CanvasLayers and keep their own filters.
|
|
func _ignore_mouse(node: Node) -> void:
|
|
for child in node.get_children():
|
|
if child is Control:
|
|
(child as Control).mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_ignore_mouse(child)
|
|
|
|
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
|
|
top_label.text = scenario.display_name if scenario else "Gig 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
|
|
if _target_chip:
|
|
_target_chip.visible = text.strip_edges() != ""
|
|
|
|
## How many people the aim-free ring (Tab / mouse wheel) can cycle through.
|
|
func set_nearby_hint(count: int) -> void:
|
|
if nearby_label == null:
|
|
return
|
|
nearby_label.text = Strings.fmt("hud.nearby", [count]) if count > 0 else ""
|
|
|
|
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).
|
|
## Resize HUD text by changing font sizes (not Control.scale, which overflows
|
|
## anchored labels and breaks the right-aligned layout).
|
|
func set_font_scale(scale: float) -> void:
|
|
var clamped := clampf(scale, 0.75, 1.75)
|
|
_font_scale = clamped
|
|
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
|
|
nearby_label, time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
|
|
for label in scaled:
|
|
if label == null:
|
|
continue
|
|
if not _base_font_sizes.has(label):
|
|
_base_font_sizes[label] = label.get_theme_font_size("font_size")
|
|
var base: int = int(_base_font_sizes[label])
|
|
label.add_theme_font_size_override("font_size", maxi(8, int(round(float(base) * 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
|
|
|
|
## Precision-walk state chip next to the door/light indicators.
|
|
func set_precision(active: bool) -> void:
|
|
if precision_label:
|
|
precision_label.visible = active
|
|
|
|
## Swap the control legend for the device the player is actually using.
|
|
func set_input_device(device: String) -> void:
|
|
if _controls_label == null:
|
|
return
|
|
if device == "gamepad":
|
|
_controls_label.text = Strings.lookup("hud.controls_gamepad")
|
|
else:
|
|
_controls_label.text = Strings.lookup("hud.controls")
|
|
|
|
|
|
|
|
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
|
|
## Green → yellow → orange → red as the escalation level rises.
|
|
## Grayscale-safe escalation meter: filled/empty blocks plus the level number, so
|
|
## the cue does not rely on hue alone (accessibility).
|
|
static func escalation_meter(level: int) -> String:
|
|
var clamped := clampi(level, 0, 5)
|
|
var filled := ""
|
|
for i in 5:
|
|
filled += "#" if i < clamped else "-"
|
|
return "[%s] L%d/5" % [filled, clamped]
|
|
|
|
func set_escalation(level: int) -> void:
|
|
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
|
|
escalation_label.text = "%s %s" % [Strings.fmt("hud.escalation_prefix", [name]), escalation_meter(level)]
|
|
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
|
|
for i in _escalation_segments.size():
|
|
_escalation_segments[i].color = tint if i < clampi(level, 0, 5) else Color(0.2, 0.22, 0.26, 0.8)
|
|
# 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)
|
|
|
|
## Dev helper: drop queued log lines so a staged screenshot shows only the
|
|
## lines that belong to the moment.
|
|
func clear_log() -> void:
|
|
_log_lines.clear()
|
|
log_label.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 INSPECTION\n\nAccess 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.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
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
|
|
if _inspect_art_layer:
|
|
_inspect_art_layer.visible = true
|
|
|
|
func hide_inspection() -> void:
|
|
_clear_card_row()
|
|
inspect_panel.visible = false
|
|
if _inspect_art_layer:
|
|
_inspect_art_layer.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
|
|
if _wand_art_layer:
|
|
_wand_art_layer.visible = true
|
|
|
|
func hide_wand() -> void:
|
|
wand_panel.visible = false
|
|
if _wand_art_layer:
|
|
_wand_art_layer.visible = false
|