Add body-camera timeline to the shift review
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m38s

- New src/game/ui/event_timeline.gd flattens every recorded incident event
  (ADR-0005) into one time-ordered list and shows time, person, and a one-line
  summary. Step with Left/Right or the buttons; Esc returns to the review. No live
  3D replay in this first version.
- Shift review gains a "Body camera timeline" button that hands the review to the
  timeline; the review dim is hidden while it is open (stacked overlays made the
  screen nearly black) and restored on close.
- ShiftCoordinator records started_at_ms so the review can stamp events as mm:ss.
- 8 new string keys for the timeline UI and the review button.

Tests/docs
- Headless suite 113 -> 120 (test_event_timeline.gd), new smoke_timeline.gd that
  opens the timeline from the review, steps through every event, and closes back.
- README, TODO section moved to DONE, MEMORY notes. Version 0.2.6.
This commit is contained in:
zwitschi committed 2026-10-31 21:02:25 +01:00
1 parent 921bab796e
commit 1b976bf351
14 files changed
+614 -71

No files matched your search

+31
View File
@@ -27,6 +27,7 @@ const ShiftReview := preload("res://src/game/ui/shift_review.gd")
const StartMenu := preload("res://src/game/ui/start_menu.gd")
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
var world_builder: WorldBuilder = null
var door := DoorState.new()
@@ -70,6 +71,8 @@ var shift_review_panel: ShiftReview = null
var start_menu: StartMenu = null
var pause_overlay: PauseOverlay = null
var settings_panel: SettingsPanel = null
var event_timeline: EventTimeline = null
var _timeline_from_review := false
var _shift_running := false
var player_settings: Dictionary = {}
var _focused_avatar: Node3D = null
@@ -325,6 +328,7 @@ func _build_ui() -> void:
add_child(shift_review_panel)
shift_review_panel.hide()
shift_review_panel.restart_requested.connect(_on_review_restart)
shift_review_panel.timeline_requested.connect(_open_timeline)
shift_review_panel.visible = false
pause_overlay = PauseOverlay.new()
@@ -340,6 +344,11 @@ func _build_ui() -> void:
settings_panel.hide()
settings_panel.applied.connect(_on_settings_applied)
event_timeline = EventTimeline.new()
add_child(event_timeline)
event_timeline.hide()
event_timeline.closed.connect(_on_timeline_closed)
start_menu = StartMenu.new()
add_child(start_menu)
start_menu.hide()
@@ -347,6 +356,23 @@ func _build_ui() -> void:
start_menu.settings_requested.connect(_open_settings)
start_menu.quit_requested.connect(func(): get_tree().quit())
## Body-camera timeline from the review screen (ADR-0005 events, no 3D replay).
func _open_timeline(review: Dictionary) -> void:
if event_timeline == null:
return
# Hide the review dim so the timeline is not stacked under two overlays.
_timeline_from_review = shift_review_panel != null and shift_review_panel.visible
if shift_review_panel:
shift_review_panel.visible = false
event_timeline.show_timeline(review)
_refresh_freeze()
func _on_timeline_closed() -> void:
if _timeline_from_review and shift_review_panel:
shift_review_panel.visible = true
_timeline_from_review = false
_refresh_freeze()
## "Run another shift" on the review screen returns to the venue menu.
func _on_review_restart() -> void:
_show_start_menu()
@@ -778,6 +804,9 @@ func _close_top_modal() -> bool:
hud.set_action_hint("")
_refresh_freeze()
return true
if event_timeline and event_timeline.visible:
event_timeline.close()
return true
return false
func _open_radial() -> void:
@@ -857,6 +886,8 @@ func _refresh_freeze() -> void:
freeze = true
if settings_panel and settings_panel.visible:
freeze = true
if event_timeline and event_timeline.visible:
freeze = true
if not _shift_running:
freeze = true
if guard:
+211
View File
@@ -0,0 +1,211 @@
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")
var events: Array[Dictionary] = []
var index := 0
var _list_label: Label = null
var _detail_label: Label = null
var _counter_label: Label = 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 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 center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(760, 0)
column.add_theme_constant_override("separation", 8)
center.add_child(column)
var title := Label.new()
title.text = Strings.lookup("timeline.title")
title.add_theme_font_size_override("font_size", 26)
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(760, 48)
_detail_label.add_theme_font_size_override("font_size", 18)
column.add_child(_detail_label)
_list_label = Label.new()
_list_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
_list_label.autowrap_mode = TextServer.AUTOWRAP_OFF
_list_label.custom_minimum_size = Vector2(760, 0)
_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))
column.add_child(_list_label)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 10)
column.add_child(row)
var prev := Button.new()
prev.name = "PrevButton"
prev.text = Strings.lookup("timeline.prev")
prev.pressed.connect(func(): step(-1))
row.add_child(prev)
var next := Button.new()
next.name = "NextButton"
next.text = Strings.lookup("timeline.next")
next.pressed.connect(func(): step(1))
row.add_child(next)
var close := Button.new()
close.name = "CloseButton"
close.text = Strings.lookup("timeline.close")
close.pressed.connect(_on_close)
row.add_child(close)
var hint := Label.new()
hint.text = Strings.lookup("timeline.hint")
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()
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:
if events.is_empty():
_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 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()
KEY_ESCAPE:
close()
get_viewport().set_input_as_handled()
+12
View File
@@ -5,6 +5,10 @@ extends CanvasLayer
const Strings := preload("res://src/core/strings.gd")
signal restart_requested()
signal timeline_requested(review: Dictionary)
## Kept so the timeline button can hand the full review back to the game manager.
var _review: Dictionary = {}
var report_label: Label
@@ -34,6 +38,13 @@ func _init() -> void:
report_label.custom_minimum_size = Vector2(720, 0)
center.add_child(report_label)
var timeline := Button.new()
timeline.name = "TimelineButton"
timeline.text = Strings.lookup("review.timeline_button")
timeline.pressed.connect(func(): timeline_requested.emit(_review))
timeline.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(timeline)
var restart := Button.new()
restart.text = "Run another shift"
restart.pressed.connect(func(): restart_requested.emit())
@@ -47,6 +58,7 @@ func _init() -> void:
center.add_child(quit_btn)
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
_review = review
var reputation: Dictionary = review.get("reputation", {})
var incidents: Dictionary = review.get("incidents", {})
var crowd: Dictionary = review.get("crowd", {})