From 8b44d3bdcaaf692952fc68a9239494c6fd56843d Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 16 Apr 2026 15:32:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20menu=20flow=20and=20happiness=20system=20inte?= =?UTF-8?q?gration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/iteration_log.md | 6 + .../scenes/encyclopedia/encyclopedia_panel.gd | 120 ++++++++++++ .../encyclopedia/encyclopedia_panel.tscn | 105 ++++++++++ src/game/engine/scenes/hud/minimap.gd | 23 +++ src/game/engine/scenes/hud/top_bar.gd | 17 +- src/game/engine/scenes/hud/top_bar.tscn | 9 + src/game/engine/scenes/menus/settings.gd | 87 +++++++++ src/game/engine/scenes/menus/settings.tscn | 183 ++++++++++++++++++ .../engine/scenes/tests/menu_flow_proof.gd | 56 ++++++ .../engine/scenes/tests/menu_flow_proof.tscn | 6 + .../engine/src/modules/empire/happiness.gd | 6 + .../src/modules/management/turn_processor.gd | 83 +++++++- 12 files changed, 698 insertions(+), 3 deletions(-) create mode 100644 src/game/engine/scenes/encyclopedia/encyclopedia_panel.gd create mode 100644 src/game/engine/scenes/encyclopedia/encyclopedia_panel.tscn create mode 100644 src/game/engine/scenes/menus/settings.gd create mode 100644 src/game/engine/scenes/menus/settings.tscn create mode 100644 src/game/engine/scenes/tests/menu_flow_proof.gd create mode 100644 src/game/engine/scenes/tests/menu_flow_proof.tscn diff --git a/.project/iteration_log.md b/.project/iteration_log.md index 4ca4d6f0..635f88f0 100644 --- a/.project/iteration_log.md +++ b/.project/iteration_log.md @@ -52,3 +52,9 @@ 2026-04-16 14:47 Task #18 PLAYER GUIDE UPDATE complete: new Personality Axes page at /empire/personality in guide web app. PersonalityAxesPage.tsx renders 6-axis explainer + race strategic axes grid. Pulls from @resources/races/strategic_axes.json (no hardcoded data). Wired through lazy-pages.ts, App.tsx route, nav.tsx sidebar. pnpm typecheck clean. Visual verification blocked by WASM not built on macOS (environment, not code). (guide-dev) 2026-04-16 15:06 BATCH 9 (post ttv-v2 heal_suppress 5): 10 PASS / 4 FAIL. Seeds T145/T182/T157 victory. Median TTV 157 (below 200 target, WORSE than batch 8's 166). victories 3/3=100% (above 80% target). Heal-suppress extension had WRONG direction — longer suppress made cities die faster because damage accumulates. Need to REVERT 5→3 or raise BASE_CITY_HP to push TTV up. Seed 1 has imp=0 (task #29 worker fix didn't cascade here — some seed still lacks worker). Batch baselines: checklist stayed at 10/4 pre→post. 2026-04-16 15:15 BATCH 10 (BASE_CITY_HP 320 + revert suppress 3 + HP 320): 11 PASS / 3 FAIL. Victories 2/3 in range (67%), median TTV 194 (6 below 200 target), combats 221, pop 20, tiles 76, techs 28, loot 1. Fails: TTV, worker/seed, T100-both-players. ttv-v2-dev's math model predicted TTV 160-170, empirical 194 — model under-predicted by 30 turns (field-buildup phase longer than math assumed). Next: ttv-v2-dev applying melee_fraction 0.30 + wall tier 2 for +15-25 turns. Expected batch 11 median TTV ~210-220 → should PASS TTV target. +2026-04-16 15:26 BATCH 11 (melee_frac 0.35 + HP 260 revert): **12 PASS / 2 FAIL — BEST YET**. +- victories 2/3 (67%, IN RANGE) +- **median TTV 280 (IN 200-350 RANGE — FIRST TIME)** +- pop_peak 26, combats 401, techs 38, tiles 74 +- worker improvements min 8 (was 0 — task #29 + #46 fixes cascaded) +Remaining 2 FAILS: loot_dropped 0 (wilds not engaging this batch — variance), both-players-T100 1/3 (persistent structural). Stop criterion needs FULL 14/14 for 2 consecutive batches — we're 2 short. diff --git a/src/game/engine/scenes/encyclopedia/encyclopedia_panel.gd b/src/game/engine/scenes/encyclopedia/encyclopedia_panel.gd new file mode 100644 index 00000000..782d04c9 --- /dev/null +++ b/src/game/engine/scenes/encyclopedia/encyclopedia_panel.gd @@ -0,0 +1,120 @@ +extends Control +## Encyclopedia overlay: searchable reference for units, buildings, techs, spells. +## Read-only display — tabbed entry list + detail pane. + +const CATEGORIES: Array[Dictionary] = [ + {"key": "units", "label": "Units"}, {"key": "buildings", "label": "Buildings"}, + {"key": "techs", "label": "Techs"}, {"key": "spells", "label": "Spells"}, +] +var _current_category: int = 0 +var _search_text: String = "" + +@onready var _tab_bar: TabBar = %TabBar +@onready var _search_field: LineEdit = %SearchField +@onready var _entry_list: ItemList = %EntryList +@onready var _detail_name: Label = %DetailName +@onready var _detail_meta: Label = %DetailMeta +@onready var _detail_body: RichTextLabel = %DetailBody +@onready var _close_button: Button = %CloseButton + + +func _ready() -> void: + for cat: Dictionary in CATEGORIES: + _tab_bar.add_tab(cat.label) + _tab_bar.tab_changed.connect(_on_tab_changed) + _search_field.text_changed.connect(_on_search_changed) + _entry_list.item_selected.connect(_on_entry_selected) + _close_button.pressed.connect(_on_close) + _clear_detail() + _refresh_list() + + +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() + + +func _on_tab_changed(tab: int) -> void: + _current_category = tab + _clear_detail() + _refresh_list() + + +func _on_search_changed(text: String) -> void: + _search_text = text.to_lower().strip_edges() + _refresh_list() + + +func _on_entry_selected(index: int) -> void: + var entry: Dictionary = _entry_list.get_item_metadata(index) + if not entry.is_empty(): + _show_detail(entry) + + +func _refresh_list() -> void: + _entry_list.clear() + var entries: Array = _load_entries(CATEGORIES[_current_category].key) + entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return String(a.get("name", a.get("id", ""))) < String(b.get("name", b.get("id", ""))) + ) + for entry: Dictionary in entries: + var name_str: String = entry.get("name", entry.get("id", "")) + if _search_text != "" and not name_str.to_lower().contains(_search_text): + continue + _entry_list.set_item_metadata(_entry_list.add_item(name_str), entry) + + +func _load_entries(category: String) -> Array: + match category: + "units": return DataLoader.get_all_units() + "buildings": return DataLoader.get_all_buildings() + "techs": return DataLoader.get_all_techs() + "spells": return DataLoader.get_all_spells() + return [] + + +func _show_detail(entry: Dictionary) -> void: + _detail_name.text = entry.get("name", entry.get("id", "")) + var bits: Array[String] = [] + if entry.has("tier"): bits.append("Tier %d" % int(entry.tier)) + var school: String = String(entry.get("school", "")) + if school != "" and school != "": bits.append(school.capitalize()) + var pillar: String = String(entry.get("pillar", "")) + if pillar != "": bits.append(pillar.capitalize()) + if entry.get("cost", null) != null: bits.append("Cost: %s" % str(entry.cost)) + if entry.get("mana_cost", null) != null: bits.append("Mana: %s" % str(entry.mana_cost)) + _detail_meta.text = " | ".join(bits) + + var parts: Array[String] = [] + var desc: String = String(entry.get("description", "")) + if desc != "": parts.append(desc) + var req: String = String(entry.get("tech_required", "")) + if req != "" and req != "": parts.append("[b]Requires:[/b] %s" % req) + var reqs: Array = entry.get("requires", []) + if reqs is Array and not reqs.is_empty(): + parts.append("[b]Requires:[/b] %s" % ", ".join(reqs)) + var unlocks: Dictionary = entry.get("unlocks", {}) + if unlocks is Dictionary and not unlocks.is_empty(): + var unlocked: Array[String] = [] + for key: String in unlocks: + var arr: Array = unlocks[key] + if arr is Array and not arr.is_empty(): + unlocked.append("%s: %s" % [key, ", ".join(arr)]) + if not unlocked.is_empty(): + parts.append("[b]Unlocks:[/b] %s" % "; ".join(unlocked)) + _detail_body.text = "\n\n".join(parts) + + +func _clear_detail() -> void: + _detail_name.text = "Select an entry" + _detail_meta.text = "" + _detail_body.text = "" + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + get_viewport().set_input_as_handled() + _on_close() diff --git a/src/game/engine/scenes/encyclopedia/encyclopedia_panel.tscn b/src/game/engine/scenes/encyclopedia/encyclopedia_panel.tscn new file mode 100644 index 00000000..516700f6 --- /dev/null +++ b/src/game/engine/scenes/encyclopedia/encyclopedia_panel.tscn @@ -0,0 +1,105 @@ +[gd_scene load_steps=2 format=3 uid="uid://cenpdy0encc01"] + +[ext_resource type="Script" path="res://engine/scenes/encyclopedia/encyclopedia_panel.gd" id="1_encyc"] + +[node name="EncyclopediaPanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_encyc") + +[node name="Backdrop" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0, 0, 0, 0.55) + +[node name="PanelContainer" type="PanelContainer" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -440.0 +offset_top = -300.0 +offset_right = 440.0 +offset_bottom = 300.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Margin" type="MarginContainer" parent="PanelContainer"] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 12 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 12 + +[node name="VBox" type="VBoxContainer" parent="PanelContainer/Margin"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="TopBar" type="HBoxContainer" parent="PanelContainer/Margin/VBox"] +layout_mode = 2 + +[node name="Title" type="Label" parent="PanelContainer/Margin/VBox/TopBar"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Encyclopedia" +theme_override_font_sizes/font_size = 20 + +[node name="CloseButton" type="Button" parent="PanelContainer/Margin/VBox/TopBar"] +unique_name_in_owner = true +layout_mode = 2 +text = "Close" + +[node name="TabBar" type="TabBar" parent="PanelContainer/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 + +[node name="SearchField" type="LineEdit" parent="PanelContainer/Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +placeholder_text = "Search..." +clear_button_enabled = true + +[node name="Split" type="HSplitContainer" parent="PanelContainer/Margin/VBox"] +layout_mode = 2 +size_flags_vertical = 3 +split_offset = 300 + +[node name="EntryList" type="ItemList" parent="PanelContainer/Margin/VBox/Split"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 + +[node name="DetailPane" type="VBoxContainer" parent="PanelContainer/Margin/VBox/Split"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 6 + +[node name="DetailName" type="Label" parent="PanelContainer/Margin/VBox/Split/DetailPane"] +unique_name_in_owner = true +layout_mode = 2 +text = "Select an entry" +theme_override_font_sizes/font_size = 18 + +[node name="DetailMeta" type="Label" parent="PanelContainer/Margin/VBox/Split/DetailPane"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_font_sizes/font_size = 12 +theme_override_colors/font_color = Color(0.75, 0.72, 0.65, 1) + +[node name="DetailBody" type="RichTextLabel" parent="PanelContainer/Margin/VBox/Split/DetailPane"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +bbcode_enabled = true +fit_content = true +scroll_active = true diff --git a/src/game/engine/scenes/hud/minimap.gd b/src/game/engine/scenes/hud/minimap.gd index 05e1fbde..40315c35 100644 --- a/src/game/engine/scenes/hud/minimap.gd +++ b/src/game/engine/scenes/hud/minimap.gd @@ -190,6 +190,29 @@ func _draw_fog(game_map: RefCounted, player_index: int) -> void: ) +func _draw_owner_tiles(game_map: RefCounted) -> void: + ## Tint each owned tile with the controlling player's color (subtle overlay). + var player_colors: Dictionary = {} + for p: Variant in GameState.players: + if p is PlayerScript: + player_colors[(p as PlayerScript).index] = (p as PlayerScript).color + for axial: Vector2i in game_map.tiles: + var raw_tile: RefCounted = game_map.get_tile(axial) as RefCounted + if raw_tile == null: + continue + var owner_idx: int = int(raw_tile.get("owner")) + if owner_idx < 0 or not player_colors.has(owner_idx): + continue + var tint: Color = player_colors[owner_idx] + tint.a = OWNER_TINT_ALPHA + var pixel_pos: Vector2 = _world_to_mini( + HexUtilsScript.axial_to_pixel(axial) + Vector2( + HexUtilsScript.HEX_WIDTH * 0.5, HexUtilsScript.HEX_HEIGHT * 0.5 + ) + ) + _overlay_rect.draw_rect(Rect2(pixel_pos - OWNER_TILE_SIZE * 0.5, OWNER_TILE_SIZE), tint) + + func _get_tile_visibility(game_map: RefCounted, axial: Vector2i, player_index: int) -> int: var raw_tile: RefCounted = game_map.get_tile(axial) as RefCounted if raw_tile == null: diff --git a/src/game/engine/scenes/hud/top_bar.gd b/src/game/engine/scenes/hud/top_bar.gd index 14f397d4..eba057a4 100644 --- a/src/game/engine/scenes/hud/top_bar.gd +++ b/src/game/engine/scenes/hud/top_bar.gd @@ -32,6 +32,7 @@ func _ready() -> void: %BugReportButton.pressed.connect(_on_bug_report_pressed) %StatsButton.pressed.connect(_on_stats_pressed) + %EncyclopediaButton.pressed.connect(_on_encyclopedia_pressed) _refresh_all() @@ -74,7 +75,12 @@ func _refresh_all() -> void: func _update_turn() -> void: - %TurnLabel.text = "%s %d" % [ThemeVocabulary.lookup("turn"), GameState.turn_number] + var turn_word: String = ThemeVocabulary.lookup("turn") + var limit: int = int(GameState.game_settings.get("turn_limit", 0)) + if limit > 0: + %TurnLabel.text = "%s %d / %d" % [turn_word, GameState.turn_number, limit] + else: + %TurnLabel.text = "%s %d" % [turn_word, GameState.turn_number] func _update_era() -> void: @@ -158,6 +164,9 @@ func _unhandled_key_input(event: InputEvent) -> void: if key.pressed and not key.echo and key.keycode == KEY_F9: get_viewport().set_input_as_handled() _on_stats_pressed() + elif key.pressed and not key.echo and key.keycode == KEY_F1: + get_viewport().set_input_as_handled() + _on_encyclopedia_pressed() func _format_signed(value: int) -> String: @@ -190,3 +199,9 @@ func _on_bug_report_pressed() -> void: 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/ui/bug_report.tscn") + + +func _on_encyclopedia_pressed() -> void: + 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/encyclopedia/encyclopedia_panel.tscn") diff --git a/src/game/engine/scenes/hud/top_bar.tscn b/src/game/engine/scenes/hud/top_bar.tscn index 35e38e6e..9c6dddba 100644 --- a/src/game/engine/scenes/hud/top_bar.tscn +++ b/src/game/engine/scenes/hud/top_bar.tscn @@ -135,6 +135,15 @@ text = "S" theme_override_font_sizes/font_size = 14 theme_override_colors/font_color = Color(0.5, 0.8, 0.9, 1) +[node name="EncyclopediaButton" type="Button" parent="MarginContainer/HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(32, 32) +tooltip_text = "Encyclopedia (F1)" +text = "?" +theme_override_font_sizes/font_size = 14 +theme_override_colors/font_color = Color(0.85, 0.75, 0.45, 1) + [node name="BugReportButton" type="Button" parent="MarginContainer/HBoxContainer"] unique_name_in_owner = true layout_mode = 2 diff --git a/src/game/engine/scenes/menus/settings.gd b/src/game/engine/scenes/menus/settings.gd new file mode 100644 index 00000000..2ca0ad65 --- /dev/null +++ b/src/game/engine/scenes/menus/settings.gd @@ -0,0 +1,87 @@ +extends Control +## Focused settings screen: volume sliders, resolution, language stub. +## Persists through SettingsManager; resolution applied via DisplayServer. + +const RESOLUTIONS: Array[Vector2i] = [ + Vector2i(1280, 720), + Vector2i(1600, 900), + Vector2i(1920, 1080), + Vector2i(2560, 1440), + Vector2i(3840, 2160), +] + +@onready var _master: HSlider = %MasterSlider +@onready var _master_val: Label = %MasterValue +@onready var _music: HSlider = %MusicSlider +@onready var _music_val: Label = %MusicValue +@onready var _sfx: HSlider = %SfxSlider +@onready var _sfx_val: Label = %SfxValue +@onready var _resolution: OptionButton = %ResolutionOption +@onready var _language: OptionButton = %LanguageOption +@onready var _back: Button = %BackButton + + +func _ready() -> void: + for res in RESOLUTIONS: + _resolution.add_item("%d x %d" % [res.x, res.y]) + _language.add_item("English") + _language.disabled = true + _refresh() + _master.value_changed.connect(_on_master) + _music.value_changed.connect(_on_music) + _sfx.value_changed.connect(_on_sfx) + _resolution.item_selected.connect(_on_resolution) + _back.pressed.connect(_on_back) + _back.grab_focus() + + +func _refresh() -> void: + var m: int = int(SettingsManager.get_setting("audio", "master_volume")) + var mu: int = int(SettingsManager.get_setting("audio", "music_volume")) + var s: int = int(SettingsManager.get_setting("audio", "sfx_volume")) + _master.value = m + _master_val.text = "%d%%" % m + _music.value = mu + _music_val.text = "%d%%" % mu + _sfx.value = s + _sfx_val.text = "%d%%" % s + var current: Vector2i = DisplayServer.window_get_size() + var idx: int = RESOLUTIONS.find(current) + _resolution.select(idx if idx >= 0 else 2) + _language.select(0) + + +func _on_master(v: float) -> void: + var iv: int = int(v) + SettingsManager.set_setting("audio", "master_volume", iv) + _master_val.text = "%d%%" % iv + + +func _on_music(v: float) -> void: + var iv: int = int(v) + SettingsManager.set_setting("audio", "music_volume", iv) + _music_val.text = "%d%%" % iv + + +func _on_sfx(v: float) -> void: + var iv: int = int(v) + SettingsManager.set_setting("audio", "sfx_volume", iv) + _sfx_val.text = "%d%%" % iv + + +func _on_resolution(idx: int) -> void: + if idx < 0 or idx >= RESOLUTIONS.size(): + return + DisplayServer.window_set_size(RESOLUTIONS[idx]) + + +func _on_back() -> void: + var main: Node = get_tree().root.get_node_or_null("Main") + if main != null and main.has_method("change_scene"): + main.change_scene("res://engine/scenes/menus/main_menu.tscn") + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + get_viewport().set_input_as_handled() + _on_back() diff --git a/src/game/engine/scenes/menus/settings.tscn b/src/game/engine/scenes/menus/settings.tscn new file mode 100644 index 00000000..88ef2920 --- /dev/null +++ b/src/game/engine/scenes/menus/settings.tscn @@ -0,0 +1,183 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://engine/scenes/menus/settings.gd" id="1"] + +[node name="Settings" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.055, 0.04, 0.09, 1) + +[node name="Margin" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_constants/margin_left = 240 +theme_override_constants/margin_top = 60 +theme_override_constants/margin_right = 240 +theme_override_constants/margin_bottom = 60 + +[node name="VBox" type="VBoxContainer" parent="Margin"] +layout_mode = 2 +theme_override_constants/separation = 18 + +[node name="Title" type="Label" parent="Margin/VBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 34 +theme_override_colors/font_color = Color(0.95, 0.82, 0.3, 1) +text = "Settings" +horizontal_alignment = 1 + +[node name="Rule" type="ColorRect" parent="Margin/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 1) +color = Color(0.6, 0.45, 0.12, 0.6) + +[node name="MasterRow" type="HBoxContainer" parent="Margin/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +theme_override_constants/separation = 12 + +[node name="MasterLabel" type="Label" parent="Margin/VBox/MasterRow"] +layout_mode = 2 +custom_minimum_size = Vector2(220, 0) +theme_override_font_sizes/font_size = 15 +text = "Master Volume" +vertical_alignment = 1 + +[node name="MasterSlider" type="HSlider" parent="Margin/VBox/MasterRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 4 +min_value = 0.0 +max_value = 100.0 +step = 1.0 +value = 80.0 + +[node name="MasterValue" type="Label" parent="Margin/VBox/MasterRow"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(60, 0) +theme_override_font_sizes/font_size = 15 +text = "80%" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="MusicRow" type="HBoxContainer" parent="Margin/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +theme_override_constants/separation = 12 + +[node name="MusicLabel" type="Label" parent="Margin/VBox/MusicRow"] +layout_mode = 2 +custom_minimum_size = Vector2(220, 0) +theme_override_font_sizes/font_size = 15 +text = "Music Volume" +vertical_alignment = 1 + +[node name="MusicSlider" type="HSlider" parent="Margin/VBox/MusicRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 4 +min_value = 0.0 +max_value = 100.0 +step = 1.0 +value = 70.0 + +[node name="MusicValue" type="Label" parent="Margin/VBox/MusicRow"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(60, 0) +theme_override_font_sizes/font_size = 15 +text = "70%" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="SfxRow" type="HBoxContainer" parent="Margin/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +theme_override_constants/separation = 12 + +[node name="SfxLabel" type="Label" parent="Margin/VBox/SfxRow"] +layout_mode = 2 +custom_minimum_size = Vector2(220, 0) +theme_override_font_sizes/font_size = 15 +text = "SFX Volume" +vertical_alignment = 1 + +[node name="SfxSlider" type="HSlider" parent="Margin/VBox/SfxRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 4 +min_value = 0.0 +max_value = 100.0 +step = 1.0 +value = 70.0 + +[node name="SfxValue" type="Label" parent="Margin/VBox/SfxRow"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(60, 0) +theme_override_font_sizes/font_size = 15 +text = "70%" +horizontal_alignment = 2 +vertical_alignment = 1 + +[node name="ResolutionRow" type="HBoxContainer" parent="Margin/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +theme_override_constants/separation = 12 + +[node name="ResolutionLabel" type="Label" parent="Margin/VBox/ResolutionRow"] +layout_mode = 2 +custom_minimum_size = Vector2(220, 0) +theme_override_font_sizes/font_size = 15 +text = "Resolution" +vertical_alignment = 1 + +[node name="ResolutionOption" type="OptionButton" parent="Margin/VBox/ResolutionRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="LanguageRow" type="HBoxContainer" parent="Margin/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 40) +theme_override_constants/separation = 12 + +[node name="LanguageLabel" type="Label" parent="Margin/VBox/LanguageRow"] +layout_mode = 2 +custom_minimum_size = Vector2(220, 0) +theme_override_font_sizes/font_size = 15 +text = "Language" +vertical_alignment = 1 + +[node name="LanguageOption" type="OptionButton" parent="Margin/VBox/LanguageRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="Spacer" type="Control" parent="Margin/VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="BackButton" type="Button" parent="Margin/VBox"] +unique_name_in_owner = true +layout_mode = 2 +custom_minimum_size = Vector2(200, 48) +size_flags_horizontal = 4 +theme_override_colors/font_color = Color(1.0, 0.92, 0.4, 1) +theme_override_font_sizes/font_size = 17 +text = "Back to Menu" diff --git a/src/game/engine/scenes/tests/menu_flow_proof.gd b/src/game/engine/scenes/tests/menu_flow_proof.gd new file mode 100644 index 00000000..5740399b --- /dev/null +++ b/src/game/engine/scenes/tests/menu_flow_proof.gd @@ -0,0 +1,56 @@ +extends Node +## HOW TO PLAY PROOF: navigates main_menu → how_to_play, captures screenshot +## showing clan personality panel + Back/Guide buttons. + +const MainMenuScene: PackedScene = preload("res://engine/scenes/menus/main_menu.tscn") +const HowToPlayScene: PackedScene = preload("res://engine/scenes/menus/how_to_play.tscn") + +var _captured: bool = false +var _screenshot_name: String = "menu_flow_proof" + + +func _ready() -> void: + RenderingServer.set_default_clear_color(Color(0.06, 0.05, 0.04)) + get_viewport().size = Vector2i(1920, 1080) + DisplayServer.window_set_size(Vector2i(1920, 1080)) + + var env_name: String = OS.get_environment("SCREENSHOT_NAME") + if not env_name.is_empty(): + _screenshot_name = env_name + + DataLoader.load_theme("age-of-dwarves") + await get_tree().process_frame + + var menu: Control = MainMenuScene.instantiate() + add_child(menu) + for _i: int in range(4): + await get_tree().process_frame + print("MainMenu buttons visible: %s" % str(menu.get_node("%HowToPlayButton").text)) + + menu.queue_free() + await get_tree().process_frame + + var htp: Control = HowToPlayScene.instantiate() + add_child(htp) + for _i: int in range(12): + await get_tree().process_frame + print("HowToPlay clans populated: %d" % htp.get_node("%ClansContainer").get_child_count()) + + _capture_and_quit() + + +func _capture_and_quit() -> void: + if _captured: + return + _captured = true + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("user://screenshots")) + var image: Image = get_viewport().get_texture().get_image() + if image == null: + get_tree().quit(1) + return + var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_") + var rel_path: String = "user://screenshots/%s_%s.png" % [_screenshot_name, timestamp] + var abs_path: String = ProjectSettings.globalize_path(rel_path) + if image.save_png(abs_path) == OK: + print("SCREENSHOT_PATH:%s" % abs_path) + get_tree().quit() diff --git a/src/game/engine/scenes/tests/menu_flow_proof.tscn b/src/game/engine/scenes/tests/menu_flow_proof.tscn new file mode 100644 index 00000000..1bbd2b48 --- /dev/null +++ b/src/game/engine/scenes/tests/menu_flow_proof.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bmenuflow0proof"] + +[ext_resource type="Script" path="res://engine/scenes/tests/menu_flow_proof.gd" id="1_script"] + +[node name="MenuFlowProof" type="Node"] +script = ExtResource("1_script") diff --git a/src/game/engine/src/modules/empire/happiness.gd b/src/game/engine/src/modules/empire/happiness.gd index 6bd71ea7..542852e0 100644 --- a/src/game/engine/src/modules/empire/happiness.gd +++ b/src/game/engine/src/modules/empire/happiness.gd @@ -51,6 +51,12 @@ static func process_turn(player: RefCounted, _game_map: RefCounted) -> void: var building_happiness: int = TurnProcessorHelpersScript.sum_building_effects( player, "happiness" ) + # happiness_per_city (wonder effect) — +N happiness per owned city, applied + # once per building that carries the effect (royal_runestone +1/city etc.). + var per_city_bonus: int = TurnProcessorHelpersScript.sum_building_effects( + player, "happiness_per_city" + ) + building_happiness += per_city_bonus * player.cities.size() var ascension_active: bool = bool(player.ascension_active) var growth_tier: String = player.growth_tier if player.growth_tier != "" else "balanced" diff --git a/src/game/engine/src/modules/management/turn_processor.gd b/src/game/engine/src/modules/management/turn_processor.gd index b39275c4..23a904e2 100644 --- a/src/game/engine/src/modules/management/turn_processor.gd +++ b/src/game/engine/src/modules/management/turn_processor.gd @@ -79,7 +79,17 @@ func _process_production(player: RefCounted) -> void: # Player var yields: Dictionary = c.get_yields(tile_json) # Add building production bonuses (forge +2, barracks +1, etc.) var building_prod: int = _sum_city_building_effect(c, "production") - var prod: int = int((yields.get("production", 1) + building_prod) * prod_modifier) + # production_from_hills — +N prod per worked hills tile (first_mineshaft etc.). + var prod_hills: int = _sum_city_building_effect(c, "production_from_hills") + if prod_hills > 0: + for tile_pos: Vector2i in c.get_worked_tiles(): + var tile: Resource = game_map.get_tile(tile_pos) + if tile != null and tile.biome_id == "hills": + building_prod += prod_hills + var prod_pct: float = _sum_city_building_effect_float(c, "production_percent") + var prod: int = int( + (yields.get("production", 1) + building_prod) * (1.0 + prod_pct) * prod_modifier + ) # Capture current item before apply_production pops it on completion. var current: Dictionary = ( c.production_queue.front() as Dictionary @@ -145,7 +155,12 @@ func _process_research(player: RefCounted) -> void: # Player ) var yields: Dictionary = city.get_yields(tile_json) var building_sci: int = _sum_city_building_effect(city as CityScript, "science") - player.research_progress += int((yields.get("science", 0) + building_sci) * sci_modifier) + var sci_pct: float = _sum_city_building_effect_float( + city as CityScript, "science_percent" + ) + player.research_progress += int( + (yields.get("science", 0) + building_sci) * (1.0 + sci_pct) * sci_modifier + ) # Check if researching a spell (not a tech) var spell_data: Dictionary = DataLoader.get_spell(player.researching) @@ -262,12 +277,52 @@ func _sum_city_building_effect_float(city: CityScript, effect_type: String) -> f func _apply_building_bonuses(city: CityScript, building_id: String) -> void: var bdata: Dictionary = DataLoader.get_building(building_id) var effects: Array = bdata.get("effects", []) + var owner_player: RefCounted = GameState.get_player(city.owner) if city.owner >= 0 else null for effect: Dictionary in effects: var etype: String = effect.get("type", "") var value: int = int(effect.get("value", 0)) if etype == "hp_bonus" and value > 0: city.set_max_hp(city.max_hp + value) city.heal(value) + elif etype == "city_hp" and value > 0 and owner_player != null: + # Empire-wide max HP bump from mundane wonder (iron_bulwark +100). + for other_ref: Variant in owner_player.cities: + if other_ref is CityScript: + var other: CityScript = other_ref as CityScript + other.set_max_hp(other.max_hp + value) + other.heal(value) + elif etype == "free_tech" and value > 0 and owner_player != null: + _grant_free_tech(owner_player, value) + elif etype == "free_golden_age_on_build" and value > 0 and owner_player != null: + owner_player.golden_age_active = true + owner_player.golden_age_turns = HappinessScript.GOLDEN_AGE_DURATION + EventBus.golden_age_started.emit(owner_player.index) + + +func _grant_free_tech(player: RefCounted, count: int) -> void: + ## Pick cheapest unresearched techs the player can currently access and + ## grant them instantly. Wonder flavor: "instant shortcut" rather than a + ## free high-tier tech so pacing stays intact. + var researched: Array = player.researched_techs if player.researched_techs != null else [] + var candidates: Array[Dictionary] = [] + for t: Dictionary in DataLoader.get_all_techs(): + var tid: String = str(t.get("id", "")) + if tid == "" or tid in researched: + continue + var prereqs: Array = t.get("prerequisites", []) as Array + var all_met: bool = true + for pr: Variant in prereqs: + if not (str(pr) in researched): + all_met = false + break + if all_met: + candidates.append(t) + candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return int(a.get("cost", 999999)) < int(b.get("cost", 999999)) + ) + for i: int in range(mini(count, candidates.size())): + player.add_tech(str(candidates[i].get("id", ""))) + EventBus.tech_researched.emit(str(candidates[i].get("id", "")), player.index) func _process_city_healing(player: RefCounted) -> void: @@ -369,6 +424,17 @@ func _process_economy(player: RefCounted, game_map: RefCounted) -> void: # Play var yields: Dictionary = c.get_yields(tile_json) var building_gold: int = _sum_city_building_effect(c, "gold") var city_gold: int = int(yields.get("gold", 0)) + building_gold + # gold_per_city_pop (wonder effect) — flat gold per pop point. + var gold_per_pop: int = _sum_city_building_effect(c, "gold_per_city_pop") + if gold_per_pop > 0: + city_gold += gold_per_pop * int(c.population) + # gold_from_mines — +N gold per mine improvement in owned tiles. + var gold_mine: int = _sum_city_building_effect(c, "gold_from_mines") + if gold_mine > 0: + for tile_pos: Vector2i in c.owned_tiles: + var tile: Resource = game_map.get_tile(tile_pos) + if tile != null and tile.improvement == "mine": + city_gold += gold_mine # Apply percentage bonuses (marketplace +25% = 0.25) var gold_pct: float = _sum_city_building_effect_float(c, "gold_percent") if gold_pct > 0.0: @@ -416,7 +482,20 @@ func _process_culture(player: RefCounted, game_map: RefCounted) -> void: continue var c: CityScript = city_ref as CityScript var tile_json: String = BuildableHelperScript.build_tile_yields_json(c, game_map) + var pre_culture: float = c.get_culture_stored() var can_expand: bool = c.process_culture(tile_json) + # Apply culture_percent and border_growth_percent bonuses to the + # culture gained this turn. process_culture already added raw culture; + # we top up the stockpile by (raw_gain * total_pct) so wonders scale. + var cult_pct: float = _sum_city_building_effect_float(c, "culture_percent") + var border_pct: float = _sum_city_building_effect_float(c, "border_growth_percent") + var total_pct: float = cult_pct + border_pct + if total_pct > 0.0: + var post_culture: float = c.get_culture_stored() + var gained: float = post_culture - pre_culture + if gained > 0.0: + c.set_culture_stored(post_culture + gained * total_pct) + can_expand = c.get_can_expand() if not can_expand: continue # Build candidates JSON for Rust border expansion