diff --git a/public/resources/unit_actions/build_improvement.json b/public/resources/unit_actions/build_improvement.json new file mode 100644 index 00000000..3bc1a85f --- /dev/null +++ b/public/resources/unit_actions/build_improvement.json @@ -0,0 +1,23 @@ +{ + "id": "build_improvement", + "name": "Build Improvement", + "description": "Construct a tile improvement on owned territory. AP cost varies by improvement type.", + "ap_cost": "per_improvement", + "ap_cost_value": null, + "eligible_units": ["engineer", "dwarf_engineer", "dwarf_high_engineer", "dwarf_grand_engineer", "dwarf_ascendant_engineer"], + "terrain_restrictions": { + "requires_owned": true, + "blocked_by": ["water", "deep_ocean"] + }, + "outcome": "improvement_built", + "ap_cost_table": { + "farm": 3, + "mine": 4, + "road": 2, + "field_fortification": 2, + "clear_forest": 4, + "clear_rubble": 5, + "repair": 3 + }, + "notes": "ap_cost_table entries are canonical AP costs per improvement. Rust resolver reads this table via cost_for(unit, action). fast_build flag halves cost (rounded up)." +} diff --git a/public/resources/unit_actions/found_city.json b/public/resources/unit_actions/found_city.json new file mode 100644 index 00000000..963561f6 --- /dev/null +++ b/public/resources/unit_actions/found_city.json @@ -0,0 +1,15 @@ +{ + "id": "found_city", + "name": "Found City", + "description": "Establish a new hold on this tile. Consumes the Pioneer's entire AP pool — a one-time, irreversible act.", + "ap_cost": "all", + "ap_cost_value": null, + "eligible_units": ["pioneer", "dwarf_founder"], + "terrain_restrictions": { + "requires_unclaimed": true, + "blocked_by": ["water", "mountain", "deep_ocean"] + }, + "outcome": "city_founded", + "consumes_unit": false, + "notes": "ap_cost 'all' means drain() — current AP is consumed, capacity is retained. Pioneer survives as a citizen." +} diff --git a/public/resources/unit_actions/prepare_land.json b/public/resources/unit_actions/prepare_land.json new file mode 100644 index 00000000..90883ee6 --- /dev/null +++ b/public/resources/unit_actions/prepare_land.json @@ -0,0 +1,17 @@ +{ + "id": "prepare_land", + "name": "Prepare Land", + "description": "Reduce terrain cultural resistance on the current tile, making it a higher-priority expansion target. Effect decays if the Pioneer leaves (unless Boundary Markers tech is researched).", + "ap_cost": 2, + "ap_cost_value": 2, + "eligible_units": ["pioneer"], + "terrain_restrictions": { + "requires_unclaimed": true, + "blocked_by": ["water", "deep_ocean"] + }, + "outcome": "terrain_resistance_reduced", + "resistance_reduction_per_turn": 2, + "decay_on_departure": true, + "decay_override_tech": "boundary_markers", + "notes": "Stackable per-tile. Multiple Prepare Land uses accumulate resistance reduction on the same tile." +} diff --git a/public/resources/units/dwarf_ascendant_engineer.json b/public/resources/units/dwarf_ascendant_engineer.json index eec657f7..5120c486 100644 --- a/public/resources/units/dwarf_ascendant_engineer.json +++ b/public/resources/units/dwarf_ascendant_engineer.json @@ -54,6 +54,7 @@ ] }, "tier": 8, + "action_point_capacity": 16, "great_person_class": "great_engineer", "great_person_gpp_type": "engineering", "great_person_produces": "wonder_hurry", diff --git a/public/resources/units/dwarf_engineer.json b/public/resources/units/dwarf_engineer.json index a233f6a8..32a5804e 100644 --- a/public/resources/units/dwarf_engineer.json +++ b/public/resources/units/dwarf_engineer.json @@ -69,6 +69,7 @@ ] }, "tier": 1, + "action_point_capacity": 6, "great_person_class": "great_engineer", "great_person_gpp_type": "engineering", "great_person_produces": "wonder_hurry", diff --git a/public/resources/units/dwarf_founder.json b/public/resources/units/dwarf_founder.json index 9dd77890..c8d77885 100644 --- a/public/resources/units/dwarf_founder.json +++ b/public/resources/units/dwarf_founder.json @@ -49,6 +49,7 @@ ] }, "tier": 2, + "action_point_capacity": 10, "capturable": true, "ransom_multiplier": 2.0 } diff --git a/public/resources/units/dwarf_grand_engineer.json b/public/resources/units/dwarf_grand_engineer.json index 3cb8b281..f77f3f7e 100644 --- a/public/resources/units/dwarf_grand_engineer.json +++ b/public/resources/units/dwarf_grand_engineer.json @@ -54,6 +54,7 @@ ] }, "tier": 3, + "action_point_capacity": 14, "great_person_class": "great_engineer", "great_person_gpp_type": "engineering", "great_person_produces": "wonder_hurry", diff --git a/public/resources/units/dwarf_high_engineer.json b/public/resources/units/dwarf_high_engineer.json index 054c8a17..b3389161 100644 --- a/public/resources/units/dwarf_high_engineer.json +++ b/public/resources/units/dwarf_high_engineer.json @@ -52,6 +52,7 @@ ] }, "tier": 2, + "action_point_capacity": 10, "great_person_class": "great_engineer", "great_person_gpp_type": "engineering", "great_person_produces": "wonder_hurry", diff --git a/src/game/engine/scenes/statistics/statistics.gd b/src/game/engine/scenes/statistics/statistics.gd new file mode 100644 index 00000000..9fe87e89 --- /dev/null +++ b/src/game/engine/scenes/statistics/statistics.gd @@ -0,0 +1,709 @@ +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. +const RANK_COLORS: Array[Color] = [ + Color(0.3, 0.9, 0.4), + Color(0.9, 0.85, 0.3), + Color(0.9, 0.6, 0.2), + Color(0.9, 0.3, 0.3), +] + +## 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: + _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 = Color(0.0, 0.0, 0.0, 0.72) + 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 = Color(0.09, 0.08, 0.07, 0.97) + style.border_color = Color(0.55, 0.48, 0.32, 0.9) + 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", Color(0.9, 0.84, 0.65)) + 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", Color(0.85, 0.78, 0.58)) + 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", Color(0.85, 0.78, 0.58)) + 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", Color(0.5, 0.5, 0.5)) + 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", Color(0.7, 0.65, 0.5)) + 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", Color(0.4, 0.4, 0.4)) + 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", Color(0.5, 0.5, 0.5)) + 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", Color(0.4, 0.4, 0.4)) + 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", Color(0.4, 0.4, 0.4)) + _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", Color(0.4, 0.4, 0.4)) + _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", Color(0.95, 0.82, 0.3)) + 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", Color(0.3, 0.9, 0.4)) + -1: + trend_lbl.text = ThemeVocabulary.lookup("trend_down") + trend_lbl.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3)) + _: + trend_lbl.text = ThemeVocabulary.lookup("trend_flat") + trend_lbl.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5)) + 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", Color(0.4, 0.4, 0.4)) + _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", Color(0.55, 0.5, 0.38)) + 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() diff --git a/src/game/engine/scenes/ui/ingame_menu.gd b/src/game/engine/scenes/ui/ingame_menu.gd index f360e9ab..87eab37a 100644 --- a/src/game/engine/scenes/ui/ingame_menu.gd +++ b/src/game/engine/scenes/ui/ingame_menu.gd @@ -77,7 +77,7 @@ func _on_stats() -> void: _close() var main: Node = get_tree().root.get_node_or_null("Main") if main != null and main.has_method("push_overlay"): - main.push_overlay("res://engine/scenes/overviews/demographics.tscn") + main.push_overlay("res://engine/scenes/statistics/statistics.tscn") func _on_options() -> void: