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()