magicciv/src/game/engine/scenes/statistics/statistics.gd
autocommit 164fed22ae refactor(statistics): ♻️ Replace hardcoded rank badge colors with semantic design tokens for consistency
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-04 20:09:12 -07:00

712 lines
25 KiB
GDScript

class_name StatisticsModal
extends Control
## Five-tab statistics modal (p2-47).
##
## Tab 0 — Demographics: sortable single-turn rankings table (extracted from
## the former standalone demographics.gd widget).
## Tab 1 — Graphs: multi-line chart with Y-axis metric selector.
## Tab 2 — Rankings: leaderboard for the selected metric + trend arrows.
## Tab 3 — Replay: embeds replay_viewer.tscn scoped to the current game.
## Tab 4 — Histories: per-clan chronicle; requires GdGameHistory bridge
## (mc-replay/api-gdext, p3-05). Shows a pending-bridge notice until wired.
##
## Entry points: F9 key OR an "info" HUD button → world_map.gd →
## main.push_overlay("res://engine/scenes/statistics/statistics.tscn").
##
## Last-viewed tab persisted in SettingsManager section "ui", key
## "last_statistics_tab" so the modal reopens where the player left off.
##
## Rail-1: no simulation logic here. Data comes from StatsTracker autoload
## (existing Demographics/Graphs data path) and, once wired, GdGameHistory.
## Rail-2: metric label strings come from ThemeVocabulary.
const ReplayViewerScene: PackedScene = preload(
"res://engine/scenes/menus/replay_viewer.tscn"
)
const TAB_DEMOGRAPHICS: int = 0
const TAB_GRAPHS: int = 1
const TAB_RANKINGS: int = 2
const TAB_REPLAY: int = 3
const TAB_HISTORIES: int = 4
## Rank-badge colours, highest → lowest. Routed through semantic design tokens
## (good → warning → diplomacy → bad); populated in _ready() because token lookups
## are autoload calls and cannot appear in a const initializer.
var _rank_colors: Array[Color] = []
## Active tab index.
var _current_tab: int = 0
## Active metric index (shared by Graphs + Rankings tabs).
var _metric_idx: int = 0
## Replay viewer child (instantiated once, kept hidden when not on Replay tab).
var _replay_viewer: Control = null
## Container nodes for each tab content area (set in _build_*).
var _tab_bar: TabBar = null
var _tab_panels: Array[Control] = []
## Demographics tab widgets.
var _demo_list: VBoxContainer = null
## Graphs tab widgets.
var _graph_area: Control = null
var _graph_metric_label: Label = null
## Rankings tab widgets.
var _rank_list: VBoxContainer = null
var _rank_metric_label: Label = null
## Histories tab widgets.
var _hist_notice: Label = null
## Close button.
var _close_button: Button = null
func _ready() -> void:
_rank_colors = [
ThemeAssets.color("semantic.positive"),
ThemeAssets.color("semantic.warning"),
ThemeAssets.color("semantic.diplomacy"),
ThemeAssets.color("semantic.negative"),
]
_build_modal()
_restore_last_tab()
_refresh_current_tab()
## ── Layout ───────────────────────────────────────────────────────────────────
func _build_modal() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
## Darkened backdrop.
var backdrop: ColorRect = ColorRect.new()
backdrop.name = "Backdrop"
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
backdrop.color = ThemeAssets.color("background.overlay")
backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(backdrop)
## Centred panel.
var panel: PanelContainer = PanelContainer.new()
panel.name = "Panel"
panel.anchor_left = 0.05
panel.anchor_right = 0.95
panel.anchor_top = 0.04
panel.anchor_bottom = 0.96
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = ThemeAssets.color("background.panel")
style.border_color = ThemeAssets.color("border.panel")
style.set_border_width_all(2)
style.set_corner_radius_all(6)
style.content_margin_left = 12.0
style.content_margin_right = 12.0
style.content_margin_top = 10.0
style.content_margin_bottom = 10.0
panel.add_theme_stylebox_override("panel", style)
add_child(panel)
var vbox: VBoxContainer = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 8)
panel.add_child(vbox)
## Title row.
var title_row: HBoxContainer = HBoxContainer.new()
title_row.add_theme_constant_override("separation", 8)
vbox.add_child(title_row)
var title_lbl: Label = Label.new()
title_lbl.name = "TitleLabel"
title_lbl.text = ThemeVocabulary.lookup("statistics_title")
title_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title_lbl.add_theme_font_size_override("font_size", 20)
title_lbl.add_theme_color_override("font_color", ThemeAssets.color("text.title"))
title_row.add_child(title_lbl)
_close_button = Button.new()
_close_button.name = "CloseButton"
_close_button.text = ThemeVocabulary.lookup("close")
_close_button.custom_minimum_size = Vector2(80, 32)
_close_button.pressed.connect(_on_close)
title_row.add_child(_close_button)
## Tab bar.
_tab_bar = TabBar.new()
_tab_bar.name = "TabBar"
_tab_bar.add_tab(ThemeVocabulary.lookup("statistics_tab_demographics"))
_tab_bar.add_tab(ThemeVocabulary.lookup("statistics_tab_graphs"))
_tab_bar.add_tab(ThemeVocabulary.lookup("statistics_tab_rankings"))
_tab_bar.add_tab(ThemeVocabulary.lookup("statistics_tab_replay"))
_tab_bar.add_tab(ThemeVocabulary.lookup("statistics_tab_histories"))
_tab_bar.tab_changed.connect(_on_tab_changed)
vbox.add_child(_tab_bar)
## Content area — one Control per tab, swapped visible.
var content_area: Control = Control.new()
content_area.name = "ContentArea"
content_area.size_flags_vertical = Control.SIZE_EXPAND_FILL
content_area.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_child(content_area)
_build_demographics_tab(content_area)
_build_graphs_tab(content_area)
_build_rankings_tab(content_area)
_build_replay_tab(content_area)
_build_histories_tab(content_area)
func _make_tab_panel(parent: Control, tab_index: int) -> Control:
var panel: Control = Control.new()
panel.name = "TabPanel%d" % tab_index
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.visible = false
parent.add_child(panel)
_tab_panels.append(panel)
return panel
func _build_demographics_tab(parent: Control) -> void:
var panel: Control = _make_tab_panel(parent, TAB_DEMOGRAPHICS)
var scroll: ScrollContainer = ScrollContainer.new()
scroll.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_child(scroll)
var vbox: VBoxContainer = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 4)
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(vbox)
## Column headers.
var header_row: HBoxContainer = HBoxContainer.new()
vbox.add_child(header_row)
_add_header_label(header_row, ThemeVocabulary.lookup("demographics_col_category"), 160)
_add_header_label(header_row, ThemeVocabulary.lookup("demographics_col_you"), 100, true)
_add_header_label(header_row, ThemeVocabulary.lookup("demographics_col_rank"), 60, true)
_add_header_label(header_row, ThemeVocabulary.lookup("demographics_col_best"), 100, true)
_add_header_label(header_row, ThemeVocabulary.lookup("demographics_col_average"), 100, true)
var sep: HSeparator = HSeparator.new()
vbox.add_child(sep)
_demo_list = VBoxContainer.new()
_demo_list.name = "DemoList"
_demo_list.add_theme_constant_override("separation", 2)
vbox.add_child(_demo_list)
func _build_graphs_tab(parent: Control) -> void:
var panel: Control = _make_tab_panel(parent, TAB_GRAPHS)
var vbox: VBoxContainer = VBoxContainer.new()
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", 8)
panel.add_child(vbox)
var ctrl_row: HBoxContainer = HBoxContainer.new()
ctrl_row.add_theme_constant_override("separation", 8)
vbox.add_child(ctrl_row)
var prev_btn: Button = Button.new()
prev_btn.text = ThemeVocabulary.lookup("arrow_prev")
prev_btn.pressed.connect(_on_metric_prev)
ctrl_row.add_child(prev_btn)
_graph_metric_label = Label.new()
_graph_metric_label.name = "GraphMetricLabel"
_graph_metric_label.custom_minimum_size.x = 200
_graph_metric_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_graph_metric_label.add_theme_font_size_override("font_size", 15)
_graph_metric_label.add_theme_color_override("font_color", ThemeAssets.color("text.secondary"))
ctrl_row.add_child(_graph_metric_label)
var next_btn: Button = Button.new()
next_btn.text = ThemeVocabulary.lookup("arrow_next")
next_btn.pressed.connect(_on_metric_next)
ctrl_row.add_child(next_btn)
_graph_area = Control.new()
_graph_area.name = "GraphArea"
_graph_area.size_flags_vertical = Control.SIZE_EXPAND_FILL
_graph_area.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_graph_area.custom_minimum_size = Vector2(0, 200)
vbox.add_child(_graph_area)
func _build_rankings_tab(parent: Control) -> void:
var panel: Control = _make_tab_panel(parent, TAB_RANKINGS)
var vbox: VBoxContainer = VBoxContainer.new()
vbox.set_anchors_preset(Control.PRESET_FULL_RECT)
vbox.add_theme_constant_override("separation", 8)
panel.add_child(vbox)
var ctrl_row: HBoxContainer = HBoxContainer.new()
ctrl_row.add_theme_constant_override("separation", 8)
vbox.add_child(ctrl_row)
var prev_btn: Button = Button.new()
prev_btn.text = ThemeVocabulary.lookup("arrow_prev")
prev_btn.pressed.connect(_on_metric_prev)
ctrl_row.add_child(prev_btn)
_rank_metric_label = Label.new()
_rank_metric_label.name = "RankMetricLabel"
_rank_metric_label.custom_minimum_size.x = 200
_rank_metric_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_rank_metric_label.add_theme_font_size_override("font_size", 15)
_rank_metric_label.add_theme_color_override("font_color", ThemeAssets.color("text.secondary"))
ctrl_row.add_child(_rank_metric_label)
var next_btn: Button = Button.new()
next_btn.text = ThemeVocabulary.lookup("arrow_next")
next_btn.pressed.connect(_on_metric_next)
ctrl_row.add_child(next_btn)
var scroll: ScrollContainer = ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
vbox.add_child(scroll)
_rank_list = VBoxContainer.new()
_rank_list.name = "RankList"
_rank_list.add_theme_constant_override("separation", 4)
_rank_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(_rank_list)
func _build_replay_tab(parent: Control) -> void:
var panel: Control = _make_tab_panel(parent, TAB_REPLAY)
## Embed the existing replay_viewer.tscn. Its Back button is suppressed
## when embedded inside this modal — close goes through _on_close instead.
_replay_viewer = ReplayViewerScene.instantiate() as Control
_replay_viewer.name = "ReplayViewer"
_replay_viewer.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_child(_replay_viewer)
func _build_histories_tab(parent: Control) -> void:
var panel: Control = _make_tab_panel(parent, TAB_HISTORIES)
_hist_notice = Label.new()
_hist_notice.name = "HistoriesNotice"
_hist_notice.set_anchors_preset(Control.PRESET_FULL_RECT)
_hist_notice.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_hist_notice.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_hist_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_hist_notice.text = ThemeVocabulary.lookup("statistics_histories_pending")
_hist_notice.add_theme_font_size_override("font_size", 15)
_hist_notice.add_theme_color_override("font_color", ThemeAssets.color("text.muted"))
panel.add_child(_hist_notice)
## ── Tab switching ─────────────────────────────────────────────────────────────
func _on_tab_changed(tab: int) -> void:
_current_tab = tab
_persist_tab(tab)
_show_tab(tab)
_refresh_current_tab()
func _show_tab(tab: int) -> void:
for i: int in _tab_panels.size():
_tab_panels[i].visible = (i == tab)
func _restore_last_tab() -> void:
var saved: int = 0
if Engine.has_singleton("SettingsManager"):
saved = int(SettingsManager.get_setting("ui", "last_statistics_tab"))
elif _has_settings_manager_node():
saved = int(SettingsManager.get_setting("ui", "last_statistics_tab"))
_current_tab = clampi(saved, 0, 4)
_tab_bar.current_tab = _current_tab
_show_tab(_current_tab)
func _persist_tab(tab: int) -> void:
if Engine.has_singleton("SettingsManager") or _has_settings_manager_node():
SettingsManager.set_setting("ui", "last_statistics_tab", tab)
func _has_settings_manager_node() -> bool:
var tree: SceneTree = Engine.get_main_loop() as SceneTree
if tree == null or tree.root == null:
return false
return tree.root.has_node("SettingsManager")
## ── Refresh routing ──────────────────────────────────────────────────────────
func _refresh_current_tab() -> void:
match _current_tab:
TAB_DEMOGRAPHICS:
_refresh_demographics()
TAB_GRAPHS:
_refresh_graph()
TAB_RANKINGS:
_refresh_rankings()
TAB_REPLAY, TAB_HISTORIES:
pass ## Static content — no dynamic refresh needed.
## ── Demographics tab ─────────────────────────────────────────────────────────
func _refresh_demographics() -> void:
for child: Node in _demo_list.get_children():
child.queue_free()
var current_idx: int = GameState.current_player_index
for cat: String in StatsTracker.CATEGORIES:
var label_text: String = StatsTracker.category_labels.get(cat, cat) as String
var rankings: Array = StatsTracker.get_rankings(cat)
if rankings.is_empty():
_add_demo_row(label_text, "--", 0, "--", "--")
continue
var my_val: int = 0
var my_rank: int = 0
var best_val: int = int(rankings[0]["value"])
var total: int = 0
for entry: Dictionary in rankings:
total += int(entry["value"])
if int(entry["index"]) == current_idx:
my_val = int(entry["value"])
my_rank = int(entry["rank"])
var avg: float = float(total) / maxf(float(rankings.size()), 1.0)
_add_demo_row(
label_text,
_format_val(my_val),
my_rank,
_format_val(best_val),
_format_val(int(avg)),
)
func _add_demo_row(
category: String, value: String, rank: int,
best: String, average: String
) -> void:
var row: HBoxContainer = HBoxContainer.new()
row.custom_minimum_size.y = 32
var cat_lbl: Label = Label.new()
cat_lbl.text = category
cat_lbl.custom_minimum_size.x = 160
cat_lbl.size_flags_vertical = Control.SIZE_SHRINK_CENTER
cat_lbl.add_theme_font_size_override("font_size", 13)
cat_lbl.add_theme_color_override("font_color", ThemeAssets.color("text.secondary"))
row.add_child(cat_lbl)
var val_lbl: Label = Label.new()
val_lbl.text = value
val_lbl.custom_minimum_size.x = 100
val_lbl.size_flags_vertical = Control.SIZE_SHRINK_CENTER
val_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
val_lbl.add_theme_font_size_override("font_size", 14)
row.add_child(val_lbl)
var rank_lbl: Label = Label.new()
rank_lbl.custom_minimum_size.x = 60
rank_lbl.size_flags_vertical = Control.SIZE_SHRINK_CENTER
rank_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
rank_lbl.add_theme_font_size_override("font_size", 14)
if rank > 0:
rank_lbl.text = "#%d" % rank
var ci: int = mini(rank - 1, _rank_colors.size() - 1)
rank_lbl.add_theme_color_override("font_color", _rank_colors[ci])
else:
rank_lbl.text = "--"
rank_lbl.add_theme_color_override("font_color", ThemeAssets.color("text.disabled"))
row.add_child(rank_lbl)
var best_lbl: Label = Label.new()
best_lbl.text = best
best_lbl.custom_minimum_size.x = 100
best_lbl.size_flags_vertical = Control.SIZE_SHRINK_CENTER
best_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
best_lbl.add_theme_font_size_override("font_size", 13)
best_lbl.add_theme_color_override("font_color", ThemeAssets.color("text.muted"))
row.add_child(best_lbl)
var avg_lbl: Label = Label.new()
avg_lbl.text = average
avg_lbl.custom_minimum_size.x = 100
avg_lbl.size_flags_vertical = Control.SIZE_SHRINK_CENTER
avg_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
avg_lbl.add_theme_font_size_override("font_size", 13)
avg_lbl.add_theme_color_override("font_color", ThemeAssets.color("text.disabled"))
row.add_child(avg_lbl)
_demo_list.add_child(row)
## ── Graphs tab ───────────────────────────────────────────────────────────────
func _refresh_graph() -> void:
var cat: String = StatsTracker.CATEGORIES[_metric_idx]
_graph_metric_label.text = StatsTracker.category_labels.get(cat, cat) as String
for child: Node in _graph_area.get_children():
child.queue_free()
var hist: Array = StatsTracker.get_history()
if hist.is_empty():
_add_graph_placeholder(ThemeVocabulary.lookup("no_history"))
return
var min_val: float = INF
var max_val: float = -INF
var max_turn: int = 1
for snap: Dictionary in hist:
var turn: int = snap.get("turn", 1) as int
if turn > max_turn:
max_turn = turn
for pd: Dictionary in snap.get("players", []):
var v: float = float(pd.get(cat, 0))
min_val = minf(min_val, v)
max_val = maxf(max_val, v)
if min_val == INF:
min_val = 0.0
if max_val <= min_val:
max_val = min_val + 1.0
var area_size: Vector2 = _graph_area.size
if area_size.x < 10.0 or area_size.y < 10.0:
area_size = Vector2(600.0, 280.0)
var pad: float = 24.0
var plot_w: float = area_size.x - pad * 2.0
var plot_h: float = area_size.y - pad * 2.0
for p: Variant in GameState.players:
if p == null:
continue
var series: Array = StatsTracker.get_player_series(int(p.get("index")), cat)
if series.size() < 2:
continue
var line: Line2D = Line2D.new()
line.width = 2.0
line.default_color = p.get("color") as Color if p.get("color") is Color else Color.WHITE
line.antialiased = true
for pt: Dictionary in series:
var t: float = float(pt.get("turn", 1))
var v: float = float(pt.get("value", 0))
var x: float = pad + (t / maxf(float(max_turn), 1.0)) * plot_w
var y: float = pad + (1.0 - (v - min_val) / (max_val - min_val)) * plot_h
line.add_point(Vector2(x, y))
_graph_area.add_child(line)
_add_axis_label(Vector2(pad, pad - 14.0), _format_val(int(max_val)))
_add_axis_label(
Vector2(pad, area_size.y - pad + 2.0),
_format_val(int(min_val))
)
_add_axis_label(
Vector2(area_size.x - pad - 36.0, area_size.y - pad + 2.0),
"T%d" % max_turn
)
func _add_axis_label(pos: Vector2, text: String) -> void:
var lbl: Label = Label.new()
lbl.text = text
lbl.position = pos
lbl.add_theme_font_size_override("font_size", 10)
lbl.add_theme_color_override("font_color", ThemeAssets.color("text.disabled"))
_graph_area.add_child(lbl)
func _add_graph_placeholder(text: String) -> void:
var lbl: Label = Label.new()
lbl.text = text
lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.add_theme_font_size_override("font_size", 16)
lbl.add_theme_color_override("font_color", ThemeAssets.color("text.disabled"))
_graph_area.add_child(lbl)
## ── Rankings tab ─────────────────────────────────────────────────────────────
func _refresh_rankings() -> void:
var cat: String = StatsTracker.CATEGORIES[_metric_idx]
_rank_metric_label.text = StatsTracker.category_labels.get(cat, cat) as String
for child: Node in _rank_list.get_children():
child.queue_free()
var rankings: Array = StatsTracker.get_rankings(cat)
if rankings.is_empty():
_add_rank_placeholder(ThemeVocabulary.lookup("no_data"))
return
## Previous-turn data for trend arrows.
var hist: Array = StatsTracker.get_history()
var prev_snap: Dictionary = {}
if hist.size() >= 2:
prev_snap = hist[hist.size() - 2]
for entry: Dictionary in rankings:
var idx: int = entry.get("index", -1) as int
var rank: int = entry.get("rank", 0) as int
var value: int = int(entry.get("value", 0))
var player: RefCounted = GameState.get_player(idx) as RefCounted
var player_name: String = "Player %d" % idx
if player != null:
player_name = str(player.get("player_name"))
## Trend: compare to previous turn snapshot value for the same player/cat.
var trend: int = 0
for pd: Dictionary in prev_snap.get("players", []):
if int(pd.get("index", -1)) == idx:
var prev_val: int = int(pd.get(cat, 0))
if value > prev_val:
trend = 1
elif value < prev_val:
trend = -1
break
var clan_color: Color = Color.GRAY
if player != null and player.get("color") is Color:
clan_color = player.get("color") as Color
_add_rank_row(rank, player_name, clan_color, _format_val(value), trend)
func _add_rank_row(
rank: int, name: String, clan_color: Color,
value: String, trend: int
) -> void:
var row: HBoxContainer = HBoxContainer.new()
row.custom_minimum_size.y = 36
row.add_theme_constant_override("separation", 8)
var rank_lbl: Label = Label.new()
rank_lbl.text = "#%d" % rank
rank_lbl.custom_minimum_size.x = 44
rank_lbl.size_flags_vertical = Control.SIZE_SHRINK_CENTER
rank_lbl.add_theme_font_size_override("font_size", 16)
var ci: int = mini(rank - 1, _rank_colors.size() - 1)
rank_lbl.add_theme_color_override("font_color", _rank_colors[ci])
row.add_child(rank_lbl)
var dot: ColorRect = ColorRect.new()
dot.custom_minimum_size = Vector2(14, 14)
dot.size_flags_vertical = Control.SIZE_SHRINK_CENTER
dot.color = clan_color
row.add_child(dot)
var name_lbl: Label = Label.new()
name_lbl.text = name
name_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_lbl.size_flags_vertical = Control.SIZE_SHRINK_CENTER
name_lbl.add_theme_font_size_override("font_size", 15)
row.add_child(name_lbl)
var val_lbl: Label = Label.new()
val_lbl.text = value
val_lbl.custom_minimum_size.x = 80
val_lbl.size_flags_vertical = Control.SIZE_SHRINK_CENTER
val_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
val_lbl.add_theme_font_size_override("font_size", 15)
val_lbl.add_theme_color_override("font_color", ThemeAssets.color("accent.goldResource"))
row.add_child(val_lbl)
var trend_lbl: Label = Label.new()
trend_lbl.custom_minimum_size.x = 24
trend_lbl.size_flags_vertical = Control.SIZE_SHRINK_CENTER
trend_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
trend_lbl.add_theme_font_size_override("font_size", 15)
match trend:
1:
trend_lbl.text = ThemeVocabulary.lookup("trend_up")
trend_lbl.add_theme_color_override("font_color", ThemeAssets.color("semantic.positive"))
-1:
trend_lbl.text = ThemeVocabulary.lookup("trend_down")
trend_lbl.add_theme_color_override("font_color", ThemeAssets.color("semantic.negative"))
_:
trend_lbl.text = ThemeVocabulary.lookup("trend_flat")
trend_lbl.add_theme_color_override("font_color", ThemeAssets.color("text.muted"))
row.add_child(trend_lbl)
_rank_list.add_child(row)
func _add_rank_placeholder(text: String) -> void:
var lbl: Label = Label.new()
lbl.text = text
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.add_theme_font_size_override("font_size", 15)
lbl.add_theme_color_override("font_color", ThemeAssets.color("text.disabled"))
_rank_list.add_child(lbl)
## ── Metric cycling (Graphs + Rankings share _metric_idx) ─────────────────────
func _on_metric_prev() -> void:
var count: int = StatsTracker.CATEGORIES.size()
_metric_idx = (_metric_idx - 1 + count) % count
if _current_tab == TAB_GRAPHS:
_refresh_graph()
elif _current_tab == TAB_RANKINGS:
_refresh_rankings()
func _on_metric_next() -> void:
var count: int = StatsTracker.CATEGORIES.size()
_metric_idx = (_metric_idx + 1) % count
if _current_tab == TAB_GRAPHS:
_refresh_graph()
elif _current_tab == TAB_RANKINGS:
_refresh_rankings()
## ── Helpers ──────────────────────────────────────────────────────────────────
func _add_header_label(
parent: HBoxContainer, text: String, min_w: int,
right_align: bool = false
) -> void:
var lbl: Label = Label.new()
lbl.text = text
lbl.custom_minimum_size.x = min_w
lbl.add_theme_font_size_override("font_size", 12)
lbl.add_theme_color_override("font_color", ThemeAssets.color("text.muted"))
if right_align:
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
parent.add_child(lbl)
func _format_val(value: int) -> String:
if value >= 10000:
return "%.1fK" % (float(value) / 1000.0)
return str(value)
## ── Input / close ────────────────────────────────────────────────────────────
func _input(event: InputEvent) -> void:
if not event is InputEventKey:
return
var key: InputEventKey = event as InputEventKey
if key.pressed and (key.keycode == KEY_ESCAPE or key.keycode == KEY_F9):
get_viewport().set_input_as_handled()
_on_close()
func _on_close() -> void:
var main: Node = get_tree().root.get_node_or_null("Main")
if main != null and main.has_method("pop_overlay"):
main.pop_overlay()
else:
queue_free()