- Link every modal's enabled controls into a wrapping UiFocus ring and give each screen a default focus target. - Add gamepad A/B bindings for ui_accept/ui_cancel; include Start menu footer actions and Continue in its focus ring. - Skip hidden/disabled controls, close focus traps via per-modal cancel behavior, and return review to the menu on cancel. - Record the focus audit and add regression coverage.
251 lines
8.7 KiB
GDScript
251 lines
8.7 KiB
GDScript
class_name EventTimeline
|
|
extends CanvasLayer
|
|
## Body-camera timeline for the shift review (PLAN.md: Evidence and Body Camera).
|
|
## First version is a text timeline of the structured incident events ADR-0005
|
|
## already stores: step through them with the arrow keys or the buttons. No live
|
|
## 3D replay.
|
|
|
|
signal closed()
|
|
|
|
const Strings := preload("res://src/core/strings.gd")
|
|
const UiArt := preload("res://src/game/ui/art.gd")
|
|
const UiStyles := preload("res://src/game/ui/ui_styles.gd")
|
|
const UiFocus := preload("res://src/game/ui/ui_focus.gd")
|
|
|
|
var events: Array[Dictionary] = []
|
|
var index := 0
|
|
var _list_label: Label = null
|
|
var _detail_label: Label = null
|
|
var _counter_label: Label = null
|
|
var _prev_button: Button = null
|
|
var _next_button: Button = null
|
|
var _close_button: Button = null
|
|
var _root: Control = null
|
|
|
|
func _init() -> void:
|
|
name = "EventTimeline"
|
|
layer = 32
|
|
_build()
|
|
hide()
|
|
|
|
func _build() -> void:
|
|
_root = Control.new()
|
|
_root.name = "TimelineRoot"
|
|
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
_root.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
add_child(_root)
|
|
|
|
var panel_rect := Rect2(0.293, 0.0, 0.345, 1.0)
|
|
var art := UiArt.new()
|
|
if not art.add_screen_backdrop(_root, "body_camera_timeline", panel_rect):
|
|
var dim := ColorRect.new()
|
|
dim.color = Color(0.02, 0.03, 0.05, 0.9)
|
|
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
_root.add_child(dim)
|
|
|
|
var column_host := MarginContainer.new()
|
|
column_host.set_anchor(SIDE_LEFT, panel_rect.position.x)
|
|
column_host.set_anchor(SIDE_TOP, panel_rect.position.y)
|
|
column_host.set_anchor(SIDE_RIGHT, panel_rect.position.x + panel_rect.size.x)
|
|
column_host.set_anchor(SIDE_BOTTOM, panel_rect.position.y + panel_rect.size.y)
|
|
column_host.add_theme_constant_override("margin_left", 16)
|
|
column_host.add_theme_constant_override("margin_right", 16)
|
|
column_host.add_theme_constant_override("margin_top", 18)
|
|
column_host.add_theme_constant_override("margin_bottom", 18)
|
|
_root.add_child(column_host)
|
|
|
|
var column := VBoxContainer.new()
|
|
column.add_theme_constant_override("separation", 8)
|
|
column_host.add_child(column)
|
|
|
|
var title := Label.new()
|
|
title.text = Strings.lookup("timeline.title")
|
|
title.add_theme_font_size_override("font_size", 20)
|
|
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
column.add_child(title)
|
|
|
|
_counter_label = Label.new()
|
|
_counter_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
_counter_label.add_theme_color_override("font_color", Color(0.72, 0.78, 0.9))
|
|
column.add_child(_counter_label)
|
|
|
|
_detail_label = Label.new()
|
|
_detail_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_detail_label.custom_minimum_size = Vector2(0, 44)
|
|
_detail_label.add_theme_font_size_override("font_size", 15)
|
|
column.add_child(_detail_label)
|
|
|
|
var list_scroll := ScrollContainer.new()
|
|
list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
list_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
column.add_child(list_scroll)
|
|
_list_label = Label.new()
|
|
_list_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
_list_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_list_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_list_label.add_theme_font_size_override("font_size", 13)
|
|
_list_label.add_theme_color_override("font_color", Color(0.8, 0.84, 0.9))
|
|
list_scroll.add_child(_list_label)
|
|
|
|
var row := HBoxContainer.new()
|
|
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
row.add_theme_constant_override("separation", 10)
|
|
column.add_child(row)
|
|
|
|
_prev_button = Button.new()
|
|
_prev_button.name = "PrevButton"
|
|
_prev_button.text = Strings.lookup("timeline.prev")
|
|
_prev_button.pressed.connect(func(): step(-1))
|
|
UiStyles.apply_button(_prev_button, 15, true)
|
|
row.add_child(_prev_button)
|
|
|
|
_next_button = Button.new()
|
|
_next_button.name = "NextButton"
|
|
_next_button.text = Strings.lookup("timeline.next")
|
|
_next_button.pressed.connect(func(): step(1))
|
|
UiStyles.apply_button(_next_button, 15, true)
|
|
row.add_child(_next_button)
|
|
|
|
_close_button = Button.new()
|
|
_close_button.name = "CloseButton"
|
|
_close_button.text = Strings.lookup("timeline.close")
|
|
_close_button.pressed.connect(_on_close)
|
|
UiStyles.apply_button(_close_button, 15, true)
|
|
row.add_child(_close_button)
|
|
|
|
var hint := Label.new()
|
|
hint.text = Strings.lookup("timeline.hint")
|
|
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
|
|
column.add_child(hint)
|
|
|
|
## Flatten every incident's event stream into one time-ordered list.
|
|
func flatten(review: Dictionary) -> Array[Dictionary]:
|
|
var out: Array[Dictionary] = []
|
|
var incidents: Dictionary = review.get("incidents", {})
|
|
var list: Array = incidents.get("incidents", [])
|
|
for incident in list:
|
|
var incident_id := str(incident.get("id", ""))
|
|
var npc_id := str(incident.get("npc_id", ""))
|
|
for event in incident.get("events", []):
|
|
var row: Dictionary = (event as Dictionary).duplicate()
|
|
row["incident_id"] = incident_id
|
|
row["npc_id"] = npc_id
|
|
out.append(row)
|
|
out.sort_custom(func(a, b): return int(a.get("at_ms", 0)) < int(b.get("at_ms", 0)))
|
|
return out
|
|
|
|
func show_timeline(review: Dictionary) -> void:
|
|
events = flatten(review)
|
|
index = 0
|
|
visible = true
|
|
_root.visible = true
|
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
|
_refresh()
|
|
var first := UiFocus.first_enabled([_prev_button, _next_button, _close_button])
|
|
if first:
|
|
first.grab_focus()
|
|
|
|
func step(direction: int) -> void:
|
|
if events.is_empty():
|
|
return
|
|
index = clampi(index + direction, 0, events.size() - 1)
|
|
_refresh()
|
|
|
|
func close() -> void:
|
|
visible = false
|
|
_root.visible = false
|
|
closed.emit()
|
|
|
|
func _on_close() -> void:
|
|
close()
|
|
|
|
func _refresh() -> void:
|
|
var has_events := not events.is_empty()
|
|
if _prev_button:
|
|
_prev_button.disabled = not has_events or index <= 0
|
|
if _next_button:
|
|
_next_button.disabled = not has_events or index >= events.size() - 1
|
|
UiFocus.link([_prev_button, _next_button, _close_button])
|
|
# A disabled control cannot keep focus; move to the other end of the row.
|
|
if _prev_button and _prev_button.disabled and _prev_button.has_focus() and _close_button:
|
|
_close_button.grab_focus()
|
|
if _next_button and _next_button.disabled and _next_button.has_focus() and _close_button:
|
|
_close_button.grab_focus()
|
|
if not has_events:
|
|
_counter_label.text = Strings.lookup("timeline.empty")
|
|
_detail_label.text = ""
|
|
_list_label.text = ""
|
|
return
|
|
var event: Dictionary = events[index]
|
|
_counter_label.text = Strings.fmt("timeline.position", [index + 1, events.size()])
|
|
_detail_label.text = describe(event)
|
|
var lines: Array[String] = []
|
|
var from := maxi(0, index - 6)
|
|
var to := mini(events.size(), from + 13)
|
|
for i in range(from, to):
|
|
var marker := ">" if i == index else " "
|
|
lines.append("%s %s" % [marker, describe(events[i])])
|
|
_list_label.text = "\n".join(lines)
|
|
|
|
## One-line summary of a recorded event. Public so it is headless-testable.
|
|
func describe(event: Dictionary) -> String:
|
|
var type := str(event.get("type", "event"))
|
|
var npc := str(event.get("npc_id", ""))
|
|
var stamp := _stamp(int(event.get("at_ms", 0)))
|
|
var detail := ""
|
|
match type:
|
|
"player_action":
|
|
detail = "%s (L%d)" % [str(event.get("action", "")), int(event.get("level", 0))]
|
|
"access_check":
|
|
var evidence: Dictionary = event.get("evidence", {})
|
|
detail = "decided %s (verified %s)" % [
|
|
"allow" if bool(evidence.get("decided", false)) else "deny",
|
|
"yes" if bool(evidence.get("verified_granted", false)) else "no",
|
|
]
|
|
"recognition_decision":
|
|
detail = "recognized"
|
|
"reason_requested":
|
|
var reply := str(event.get("reply", ""))
|
|
detail = "reason: \"%s\"" % (reply if reply != "" else "no answer")
|
|
"body_camera":
|
|
detail = "body camera %s (%s)" % [
|
|
"on" if bool(event.get("on", false)) else "off",
|
|
str(event.get("reason", "")),
|
|
]
|
|
"responder_action":
|
|
detail = "%s response: %s" % [str(event.get("call_type", "assist")), str(event.get("outcome", ""))]
|
|
"evacuation":
|
|
detail = "evacuated"
|
|
"investigation_opened":
|
|
detail = "investigation: %s" % str(event.get("reason", ""))
|
|
_:
|
|
detail = type
|
|
return "%s %s %s" % [stamp, npc, detail]
|
|
|
|
func _stamp(at_ms: int) -> String:
|
|
if at_ms <= 0:
|
|
return "--:--"
|
|
var total := at_ms / 1000
|
|
return "%02d:%02d" % [total / 60, total % 60]
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if not visible:
|
|
return
|
|
if event.is_action_pressed("ui_cancel"):
|
|
close()
|
|
get_viewport().set_input_as_handled()
|
|
return
|
|
if event is InputEventKey and event.pressed and not event.echo:
|
|
match event.keycode:
|
|
KEY_LEFT, KEY_A:
|
|
step(-1)
|
|
get_viewport().set_input_as_handled()
|
|
KEY_RIGHT, KEY_D:
|
|
step(1)
|
|
get_viewport().set_input_as_handled()
|