diff --git a/mcp_home_after_race_gate.png b/mcp_home_after_race_gate.png new file mode 100644 index 00000000..790ef294 Binary files /dev/null and b/mcp_home_after_race_gate.png differ diff --git a/mcp_prod_build_home.png b/mcp_prod_build_home.png new file mode 100644 index 00000000..de120d0c Binary files /dev/null and b/mcp_prod_build_home.png differ diff --git a/public/games/age-of-dwarves/data/homepage-features.json b/public/games/age-of-dwarves/data/homepage-features.json index 8e0782a7..22925fdc 100644 --- a/public/games/age-of-dwarves/data/homepage-features.json +++ b/public/games/age-of-dwarves/data/homepage-features.json @@ -13,6 +13,11 @@ { "title": "16 Asymmetric Races", "desc": "From High Elf arcane masters to Orc conquest hordes, each race offers unique units, buildings, heritage techs, and a fundamentally different approach to empire-building.", + "min_episode": 2 + }, + { + "title": "Five Rival Dwarf Clans", + "desc": "Iron Legion, Forge-Wrights, Deep Delvers, Gold Hall, Stonekeepers — five AI personalities with distinct build priorities, clan politics, and grudges that make every game feel different from the inside of the mountain out.", "min_episode": 1 }, { diff --git a/public/games/age-of-dwarves/vocabulary.json b/public/games/age-of-dwarves/vocabulary.json index ca73982c..1b5ff877 100644 --- a/public/games/age-of-dwarves/vocabulary.json +++ b/public/games/age-of-dwarves/vocabulary.json @@ -99,6 +99,25 @@ "tooltip_skip_turn": "Skip — end this unit's turn without action", "tooltip_found_city": "Found City — consume the founder to settle this tile", "tooltip_build_improvement": "Build Improvement — commit a worker to a tile project (B)", + "move": "Move", + "tooltip_move": "Move — preview a path, right-click to confirm (M)", + "tooltip_move_no_movement": "No movement remaining this turn", + "tooltip_move_no_unit": "Select a unit first", + "tooltip_fortify_no_movement": "Already used this turn", + "tooltip_fortify_civilian": "Civilians cannot fortify", + "tooltip_skip_turn_no_movement": "Already used this turn", + "tooltip_found_city_no_movement": "No movement remaining this turn", + "tooltip_found_city_unavailable": "This unit cannot found a city", + "tooltip_build_improvement_no_movement": "No movement remaining this turn", + "tooltip_build_improvement_unavailable": "This unit cannot build improvements", + "tutorial_button": "Tutorial", + "tooltip_tutorial_button": "Open the first-run tutorial (available turns 1–5)", + "fmt_path_preview_turns": "%d turns", + "village_discovered_title": "Village Discovered", + "fmt_village_reward_gold": "+%d Gold — %s discovered", + "fmt_village_reward_with_unit": "+%d Gold and a %s — %s discovered", + "fmt_village_reward_with_tech": "+%d Gold and %s research — %s discovered", + "village_default_name": "Ancient Vault", "ruin_site": "Tribal Village", "threat_site": "Lair", "independent_settlement": "Freepeople Haven", diff --git a/src/game/engine/scenes/hud/minimap.gd b/src/game/engine/scenes/hud/minimap.gd index 40315c35..53a86398 100644 --- a/src/game/engine/scenes/hud/minimap.gd +++ b/src/game/engine/scenes/hud/minimap.gd @@ -35,12 +35,23 @@ const UNIT_DOT_RADIUS: float = 3.0 const OWNER_TINT_ALPHA: float = 0.38 const OWNER_TILE_SIZE: Vector2 = Vector2(3.0, 3.0) +## p1-18 ping state. `_ping_axial` stores the most recent ping target; +## `_ping_remaining` ticks down each overlay redraw frame so the pulse +## fades naturally without a dedicated tween. +const PING_DURATION_SEC: float = 1.6 +const PING_RING_COLOR: Color = Color(1.0, 0.92, 0.45, 1.0) +const PING_RING_BASE_RADIUS: float = 4.0 +const PING_RING_GROWTH: float = 8.0 +const PING_RING_WIDTH: float = 2.0 + var _map_pixel_size: Vector2 = Vector2.ZERO var _minimap_scale: Vector2 = Vector2.ONE var _cached_image: Image = null var _texture: ImageTexture = null var _dirty: bool = true var _camera: Camera2D = null +var _ping_axial: Vector2i = Vector2i(-9999, -9999) +var _ping_started_at: float = -1.0 @onready var _map_rect: TextureRect = %MapRect @onready var _overlay_rect: Control = %OverlayRect @@ -155,6 +166,15 @@ func _get_tile_terrain(game_map: RefCounted, axial: Vector2i) -> String: return str(raw_tile.get("terrain_id")) +func pulse_at(axial: Vector2i) -> void: + ## p1-18: highlight an axial hex on the minimap with a brief expanding + ## ring. The pulse fades over PING_DURATION_SEC; the existing 0.2 s + ## redraw timer drives the animation tick. + _ping_axial = axial + _ping_started_at = Time.get_ticks_msec() / 1000.0 + _overlay_rect.queue_redraw() + + func _draw_overlay() -> void: var game_map: RefCounted = GameState.get_game_map() as RefCounted if game_map == null: @@ -172,6 +192,26 @@ func _draw_overlay() -> void: _draw_unit_dots(player) _draw_viewport_rect() + _draw_ping() + + +func _draw_ping() -> void: + if _ping_started_at < 0.0: + return + var elapsed: float = Time.get_ticks_msec() / 1000.0 - _ping_started_at + if elapsed >= PING_DURATION_SEC: + _ping_started_at = -1.0 + return + var t: float = clampf(elapsed / PING_DURATION_SEC, 0.0, 1.0) + var radius: float = PING_RING_BASE_RADIUS + PING_RING_GROWTH * t + var color: Color = PING_RING_COLOR + color.a = 1.0 - t + var pixel_pos: Vector2 = _world_to_mini( + HexUtilsScript.axial_to_pixel(_ping_axial) + Vector2( + HexUtilsScript.HEX_WIDTH * 0.5, HexUtilsScript.HEX_HEIGHT * 0.5 + ) + ) + _overlay_rect.draw_arc(pixel_pos, radius, 0.0, TAU, 24, color, PING_RING_WIDTH) func _draw_fog(game_map: RefCounted, player_index: int) -> void: diff --git a/src/game/engine/scenes/hud/tutorial_overlay.gd b/src/game/engine/scenes/hud/tutorial_overlay.gd index 6f203aed..de1be0cd 100644 --- a/src/game/engine/scenes/hud/tutorial_overlay.gd +++ b/src/game/engine/scenes/hud/tutorial_overlay.gd @@ -81,7 +81,9 @@ func _build_ui() -> void: dim.color = Color(0.0, 0.0, 0.0, 0.55) dim.anchor_right = 1.0 dim.anchor_bottom = 1.0 - dim.mouse_filter = Control.MOUSE_FILTER_STOP + ## p0-33: must PASS so the dim overlay does not eat clicks meant for the + ## world map below. Tutorial steps observe live game events, not clicks. + dim.mouse_filter = Control.MOUSE_FILTER_PASS add_child(dim) _root_panel = PanelContainer.new() diff --git a/src/game/engine/scenes/hud/unit_panel.gd b/src/game/engine/scenes/hud/unit_panel.gd index 141fd338..9fc713af 100644 --- a/src/game/engine/scenes/hud/unit_panel.gd +++ b/src/game/engine/scenes/hud/unit_panel.gd @@ -1,10 +1,25 @@ extends PanelContainer ## Bottom-left panel showing selected unit info: name, stats, HP bar, movement, type. ## Shown when a unit is selected, hidden when deselected. -## Found City button visible only for Founder units. +## +## p0-33/p0-35: emits signals for every action button (Move/Fortify/Skip/ +## FoundCity/BuildImprovement). The world_map controller wires the signals +## to the actual handlers — the panel itself never mutates the simulation +## directly. Disabled buttons keep a tooltip explaining WHY they cannot fire. const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +## Action signals consumed by world_map.gd. Disconnected from the slim HUD +## panel that p0-33 retired. +signal move_pressed +signal fortify_pressed +signal skip_pressed +signal found_city_pressed +signal build_improvement_pressed + +## Disabled-button outline color (matches the action-required tutorial badge). +const DISABLED_OUTLINE_COLOR: Color = Color(0.95, 0.35, 0.35, 0.85) + ## The currently selected unit (UnitScript or Dictionary). Typed as RefCounted ## because both unit entity types are RefCounted subclasses at the autoload boundary. var _selected_unit: RefCounted = null @@ -16,33 +31,40 @@ var _selected_unit: RefCounted = null @onready var _hp_label: Label = %HPLabel @onready var _hp_bar: ProgressBar = %HPBar @onready var _movement_label: Label = %MovementLabel +@onready var _move_button: Button = %MoveButton @onready var _fortify_button: Button = %FortifyButton @onready var _skip_button: Button = %SkipButton @onready var _found_city_button: Button = %FoundCityButton +@onready var _build_improvement_button: Button = %BuildImprovementButton func _ready() -> void: visible = false mouse_filter = Control.MOUSE_FILTER_STOP + _move_button.text = ThemeVocabulary.lookup("move") _fortify_button.text = ThemeVocabulary.lookup("fortify") _skip_button.text = ThemeVocabulary.lookup("skip_turn") _found_city_button.text = ThemeVocabulary.lookup("found_city") + _build_improvement_button.text = ThemeVocabulary.lookup("build_improvement") - _apply_tooltips() + _apply_static_tooltips() EventBus.unit_selected.connect(_on_unit_selected) EventBus.unit_deselected.connect(_on_unit_deselected) EventBus.unit_moved.connect(_on_unit_moved) - _fortify_button.pressed.connect(_on_fortify_pressed) - _skip_button.pressed.connect(_on_skip_pressed) - _found_city_button.pressed.connect(_on_found_city_pressed) + _move_button.pressed.connect(func() -> void: move_pressed.emit()) + _fortify_button.pressed.connect(func() -> void: fortify_pressed.emit()) + _skip_button.pressed.connect(func() -> void: skip_pressed.emit()) + _found_city_button.pressed.connect(func() -> void: found_city_pressed.emit()) + _build_improvement_button.pressed.connect(func() -> void: build_improvement_pressed.emit()) -## Resolve tooltip_text for every stats row + action button through -## ThemeVocabulary so theme packs can localize hover copy. -func _apply_tooltips() -> void: +## Resolve tooltip_text for stats rows through ThemeVocabulary so theme packs +## can localize hover copy. Action-button tooltips depend on per-unit state +## and are recomputed in `_refresh_action_buttons`. +func _apply_static_tooltips() -> void: _attack_label.tooltip_text = ThemeVocabulary.lookup("tooltip_attack") _attack_label.mouse_filter = Control.MOUSE_FILTER_STOP _defense_label.tooltip_text = ThemeVocabulary.lookup("tooltip_defense") @@ -52,9 +74,6 @@ func _apply_tooltips() -> void: _hp_bar.tooltip_text = ThemeVocabulary.lookup("tooltip_hit_points") _movement_label.tooltip_text = ThemeVocabulary.lookup("tooltip_movement") _movement_label.mouse_filter = Control.MOUSE_FILTER_STOP - _fortify_button.tooltip_text = ThemeVocabulary.lookup("tooltip_fortify") - _skip_button.tooltip_text = ThemeVocabulary.lookup("tooltip_skip_turn") - _found_city_button.tooltip_text = ThemeVocabulary.lookup("tooltip_found_city") func _on_unit_selected(unit: Variant) -> void: @@ -98,14 +117,49 @@ func _refresh_display() -> void: ] _update_hp_bar(hp, max_hp) + _refresh_action_buttons(move_remaining) _refresh_items() + +func _refresh_action_buttons(move_remaining: int) -> void: + ## p0-33/p0-35: each action button shows tooltip + red-outline when disabled. var is_civilian: bool = _is_civilian(_selected_unit) var is_founder: bool = _is_founder(_selected_unit) - _fortify_button.disabled = is_civilian - _skip_button.disabled = move_remaining <= 0 + var can_build_improvements: bool = _can_build_improvements(_selected_unit) + + _apply_button_state(_move_button, move_remaining > 0, + "tooltip_move", "tooltip_move_no_movement") + _apply_button_state(_fortify_button, move_remaining > 0 and not is_civilian, + "tooltip_fortify", + "tooltip_fortify_civilian" if is_civilian else "tooltip_fortify_no_movement") + _apply_button_state(_skip_button, move_remaining > 0, + "tooltip_skip_turn", "tooltip_skip_turn_no_movement") + _found_city_button.visible = is_founder - _found_city_button.disabled = move_remaining <= 0 + if is_founder: + _apply_button_state(_found_city_button, move_remaining > 0, + "tooltip_found_city", "tooltip_found_city_no_movement") + + _build_improvement_button.visible = can_build_improvements + if can_build_improvements: + _apply_button_state(_build_improvement_button, move_remaining > 0, + "tooltip_build_improvement", "tooltip_build_improvement_no_movement") + + +## Apply enabled/disabled visual + tooltip swap for a single action button. +func _apply_button_state( + btn: Button, enabled: bool, tooltip_key_active: String, tooltip_key_disabled: String +) -> void: + btn.disabled = not enabled + btn.tooltip_text = ThemeVocabulary.lookup( + tooltip_key_active if enabled else tooltip_key_disabled + ) + if enabled: + btn.remove_theme_color_override("font_outline_color") + btn.remove_theme_constant_override("outline_size") + else: + btn.add_theme_color_override("font_outline_color", DISABLED_OUTLINE_COLOR) + btn.add_theme_constant_override("outline_size", 2) func _update_hp_bar(hp: int, max_hp: int) -> void: @@ -123,22 +177,6 @@ func _update_hp_bar(hp: int, max_hp: int) -> void: _hp_bar.modulate = Color(0.9, 0.25, 0.25) -func _on_fortify_pressed() -> void: - if _selected_unit == null: - return - if _selected_unit is UnitScript: - (_selected_unit as UnitScript).fortify() - _refresh_display() - - -func _on_skip_pressed() -> void: - if _selected_unit == null: - return - if _selected_unit is UnitScript: - (_selected_unit as UnitScript).movement_remaining = 0 - _refresh_display() - - func _refresh_items() -> void: ## Dynamically add/remove item rows below the movement label. var vbox: VBoxContainer = _unit_name.get_parent() as VBoxContainer @@ -252,13 +290,6 @@ func _on_item_slot_pressed(item_id: String) -> void: return -func _on_found_city_pressed() -> void: - if _selected_unit == null: - return - EventBus.tile_clicked.emit(_get_position(_selected_unit)) - EventBus.overlay_opened.emit("found_city") - - # -- Unit property access -- @@ -321,12 +352,20 @@ func _is_civilian(unit: RefCounted) -> bool: func _is_founder(unit: RefCounted) -> bool: if unit is UnitScript: - return (unit as UnitScript).has_keyword("found_city") + return (unit as UnitScript).can_found_city var data: Dictionary = DataLoader.get_unit(str(unit.get("type_id"))) var keywords: Array = data.get("keywords", []) return "found_city" in keywords +func _can_build_improvements(unit: RefCounted) -> bool: + if unit is UnitScript: + return (unit as UnitScript).can_build_improvements + var data: Dictionary = DataLoader.get_unit(str(unit.get("type_id"))) + var keywords: Array = data.get("keywords", []) + return "build_improvement" in keywords or "worker" in keywords + + func _get_position(unit: RefCounted) -> Vector2i: if unit is UnitScript: return (unit as UnitScript).position diff --git a/src/game/engine/scenes/hud/unit_panel.tscn b/src/game/engine/scenes/hud/unit_panel.tscn index 2e5a68d3..1cb13785 100644 --- a/src/game/engine/scenes/hud/unit_panel.tscn +++ b/src/game/engine/scenes/hud/unit_panel.tscn @@ -7,8 +7,8 @@ anchors_preset = 2 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 8.0 -offset_top = -252.0 -offset_right = 240.0 +offset_top = -300.0 +offset_right = 260.0 grow_vertical = 0 script = ExtResource("1") @@ -78,6 +78,10 @@ layout_mode = 2 layout_mode = 2 theme_override_constants/separation = 6 +[node name="MoveButton" type="Button" parent="MarginContainer/VBoxContainer/ButtonRow"] +unique_name_in_owner = true +layout_mode = 2 + [node name="FortifyButton" type="Button" parent="MarginContainer/VBoxContainer/ButtonRow"] unique_name_in_owner = true layout_mode = 2 @@ -90,3 +94,8 @@ layout_mode = 2 unique_name_in_owner = true layout_mode = 2 visible = false + +[node name="BuildImprovementButton" type="Button" parent="MarginContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +visible = false diff --git a/src/game/engine/scenes/hud/world_map_hud.gd b/src/game/engine/scenes/hud/world_map_hud.gd index e8fcdac4..6917f27b 100644 --- a/src/game/engine/scenes/hud/world_map_hud.gd +++ b/src/game/engine/scenes/hud/world_map_hud.gd @@ -1,13 +1,28 @@ class_name WorldMapHud extends CanvasLayer ## HUD overlay for the world map. Displays turn counter, resource labels, -## unit panel, and action buttons. Reads display text from ThemeVocabulary. +## top-bar action buttons, and an event-toast surface. Reads display text +## from ThemeVocabulary. +## +## p0-33: the slim programmatic unit panel was retired in favor of +## `unit_panel.tscn` (instanced under the WorldMap scene). This HUD now +## owns only the top bar + prologue banner + transient notifications. signal end_turn_pressed -signal found_city_pressed -signal build_improvement_pressed signal tech_tree_pressed signal chronicle_pressed +## p1-19: emitted when the player clicks the Tutorial button. world_map.gd +## listens, instantiates the tutorial overlay, and hides this button. +signal tutorial_requested + +## p1-19: turns 1-5 are the on-ramp window during which the Tutorial button +## remains available. After turn 5 (or once the player has completed the +## tutorial in any prior session) the button is hidden permanently. +const TUTORIAL_BUTTON_LAST_TURN: int = 5 + +## p1-18: notification toast lifetime + max simultaneous entries. +const NOTIFICATION_LIFETIME_SEC: float = 4.0 +const NOTIFICATION_MAX_VISIBLE: int = 4 var _turn_label: Label = null var _gold_label: Label = null @@ -15,22 +30,21 @@ var _science_label: Label = null var _happiness_label: Label = null var _tech_button: Button = null var _chronicle_button: Button = null +var _tutorial_button: Button = null var _end_turn_button: Button = null -var _unit_panel: PanelContainer = null -var _unit_name_label: Label = null -var _unit_stats_label: Label = null -var _found_city_button: Button = null -var _build_improvement_button: Button = null ## p0-34 prologue banner. Shown on turn -1 (wanderers gather) and turn 0 ## (convergence) — hidden once the Dwarf Tribe resolves into a city. var _prologue_banner: PanelContainer = null var _prologue_banner_label: Label = null +## p1-18 notification stack; right-aligned vertical column above the unit panel. +var _notification_box: VBoxContainer = null +var _tutorial_button_dismissed: bool = false func _ready() -> void: _build_top_bar() - _build_unit_panel() _build_prologue_banner() + _build_notification_box() func _build_top_bar() -> void: @@ -69,6 +83,17 @@ func _build_top_bar() -> void: spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL top_bar.add_child(spacer) + _tutorial_button = Button.new() + _tutorial_button.name = "TutorialButton" + _tutorial_button.text = ThemeVocabulary.lookup("tutorial_button") + _tutorial_button.tooltip_text = ThemeVocabulary.lookup("tooltip_tutorial_button") + _tutorial_button.custom_minimum_size = Vector2(100, 36) + _tutorial_button.pressed.connect(_on_tutorial_button_pressed) + if _tutorial_already_completed(): + _tutorial_button.visible = false + _tutorial_button_dismissed = true + top_bar.add_child(_tutorial_button) + _tech_button = Button.new() _tech_button.name = "TechButton" _tech_button.text = ThemeVocabulary.lookup("tech_tree") @@ -94,49 +119,6 @@ func _build_top_bar() -> void: top_bar.add_child(_end_turn_button) -func _build_unit_panel() -> void: - _unit_panel = PanelContainer.new() - _unit_panel.name = "UnitPanel" - _unit_panel.anchor_top = 1.0 - _unit_panel.anchor_bottom = 1.0 - _unit_panel.anchor_right = 0.3 - _unit_panel.offset_top = -120.0 - _unit_panel.visible = false - add_child(_unit_panel) - - var vbox: VBoxContainer = VBoxContainer.new() - vbox.add_theme_constant_override("separation", 4) - _unit_panel.add_child(vbox) - - _unit_name_label = Label.new() - _unit_name_label.name = "UnitName" - _unit_name_label.add_theme_font_size_override("font_size", 16) - vbox.add_child(_unit_name_label) - - _unit_stats_label = Label.new() - _unit_stats_label.name = "UnitStats" - _unit_stats_label.add_theme_font_size_override("font_size", 14) - vbox.add_child(_unit_stats_label) - - _found_city_button = Button.new() - _found_city_button.name = "FoundCityButton" - _found_city_button.text = ThemeVocabulary.lookup("found_city") - _found_city_button.tooltip_text = ThemeVocabulary.lookup("tooltip_found_city") - _found_city_button.custom_minimum_size = Vector2(100, 32) - _found_city_button.visible = false - _found_city_button.pressed.connect(_on_found_city_button_pressed) - vbox.add_child(_found_city_button) - - _build_improvement_button = Button.new() - _build_improvement_button.name = "BuildImprovementButton" - _build_improvement_button.text = ThemeVocabulary.lookup("build_improvement") - _build_improvement_button.tooltip_text = ThemeVocabulary.lookup("tooltip_build_improvement") - _build_improvement_button.custom_minimum_size = Vector2(140, 32) - _build_improvement_button.visible = false - _build_improvement_button.pressed.connect(_on_build_improvement_button_pressed) - vbox.add_child(_build_improvement_button) - - func _build_prologue_banner() -> void: _prologue_banner = PanelContainer.new() _prologue_banner.name = "PrologueBanner" @@ -157,9 +139,23 @@ func _build_prologue_banner() -> void: _prologue_banner.add_child(_prologue_banner_label) +func _build_notification_box() -> void: + ## p1-18: right-side notification stack — a column of fading toast labels. + _notification_box = VBoxContainer.new() + _notification_box.name = "NotificationBox" + _notification_box.anchor_right = 1.0 + _notification_box.offset_left = -360.0 + _notification_box.offset_right = -16.0 + _notification_box.offset_top = 60.0 + _notification_box.offset_bottom = 220.0 + _notification_box.alignment = BoxContainer.ALIGNMENT_END + _notification_box.add_theme_constant_override("separation", 6) + _notification_box.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_notification_box) + + ## p0-34 banner controller. `state` is the GdPrologue enum int: -## 0 = TurnMinusOne, 1 = TurnZero, 2 = Normal. Hides the unit panel while -## the banner is up since no unit is selectable during the prologue. +## 0 = TurnMinusOne, 1 = TurnZero, 2 = Normal. func set_prologue_banner(state: int) -> void: if _prologue_banner == null: return @@ -167,17 +163,65 @@ func set_prologue_banner(state: int) -> void: 0: _prologue_banner_label.text = ThemeVocabulary.lookup("prologue_banner_turn_minus_one") _prologue_banner.visible = true - hide_unit_panel() 1: _prologue_banner_label.text = ThemeVocabulary.lookup("prologue_banner_turn_zero") _prologue_banner.visible = true - hide_unit_panel() _: _prologue_banner.visible = false func update_turn(turn: int) -> void: _turn_label.text = _format_turn_text(turn) + _update_tutorial_button_visibility(turn) + + +## p1-18 toast helper. Adds a transient label to the notification stack; +## auto-removes after NOTIFICATION_LIFETIME_SEC. Older entries are evicted +## once NOTIFICATION_MAX_VISIBLE is exceeded so the column never bloats. +func show_notification(text: String) -> void: + if _notification_box == null or text.is_empty(): + return + var panel: PanelContainer = PanelContainer.new() + panel.set_meta("notification_toast", true) + panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = Color(0.08, 0.06, 0.04, 0.92) + style.border_color = Color(0.85, 0.7, 0.3, 0.85) + style.set_border_width_all(2) + style.set_corner_radius_all(4) + style.content_margin_left = 12.0 + style.content_margin_right = 12.0 + style.content_margin_top = 6.0 + style.content_margin_bottom = 6.0 + panel.add_theme_stylebox_override("panel", style) + + var label: Label = Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 14) + label.add_theme_color_override("font_color", Color(1.0, 0.94, 0.78)) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + panel.add_child(label) + + _notification_box.add_child(panel) + _evict_oldest_notifications() + + var timer: Timer = Timer.new() + timer.one_shot = true + timer.wait_time = NOTIFICATION_LIFETIME_SEC + panel.add_child(timer) + timer.timeout.connect(panel.queue_free) + timer.start() + + +func _evict_oldest_notifications() -> void: + if _notification_box == null: + return + var children: Array[Node] = _notification_box.get_children() + while children.size() > NOTIFICATION_MAX_VISIBLE: + var oldest: Node = children[0] + oldest.queue_free() + children.remove_at(0) func update_gold(amount: int) -> void: @@ -192,24 +236,45 @@ func update_happiness(amount: int) -> void: _happiness_label.text = _format_happiness_text(amount) -func show_unit_panel( - unit_name: String, stats: String, can_found: bool, can_build: bool = false -) -> void: - _unit_panel.visible = true - _unit_name_label.text = unit_name - _unit_stats_label.text = stats - _found_city_button.visible = can_found - _build_improvement_button.visible = can_build - - -func hide_unit_panel() -> void: - _unit_panel.visible = false - - func set_end_turn_disabled(disabled: bool) -> void: _end_turn_button.disabled = disabled +## p1-19: Tutorial button stays available turns 1..TUTORIAL_BUTTON_LAST_TURN +## and then auto-hides. Once dismissed (clicked or completed) it stays hidden. +func _update_tutorial_button_visibility(turn: int) -> void: + if _tutorial_button == null: + return + if _tutorial_button_dismissed: + _tutorial_button.visible = false + return + _tutorial_button.visible = turn <= TUTORIAL_BUTTON_LAST_TURN and turn >= 1 + + +## Public hook used by world_map after the overlay launches so the button +## doesn't reappear if the player is still on turn 1..5. +func hide_tutorial_button() -> void: + _tutorial_button_dismissed = true + if _tutorial_button != null: + _tutorial_button.visible = false + + +func _tutorial_already_completed() -> bool: + if not Engine.has_singleton("SettingsManager") and not _has_settings_manager_node(): + return false + return bool(SettingsManager.get_setting("gameplay", "tutorial_completed")) + + +func _has_settings_manager_node() -> bool: + ## SettingsManager is registered as an autoload, so we can detect it via + ## the SceneTree root. Wrapped in a method so the optional dependency is + ## explicit and cheap to mock in proof scenes. + var tree: SceneTree = Engine.get_main_loop() as SceneTree + if tree == null or tree.root == null: + return false + return tree.root.has_node("SettingsManager") + + func _format_turn_text(turn: int) -> String: return "%s %d" % [ThemeVocabulary.lookup("turn"), turn] @@ -238,9 +303,7 @@ func _on_chronicle_button_pressed() -> void: chronicle_pressed.emit() -func _on_found_city_button_pressed() -> void: - found_city_pressed.emit() - - -func _on_build_improvement_button_pressed() -> void: - build_improvement_pressed.emit() +func _on_tutorial_button_pressed() -> void: + hide_tutorial_button() + tutorial_requested.emit() + EventBus.tutorial_requested.emit() diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index 0ff97f94..3b82e33a 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -64,11 +64,19 @@ var _selected_unit: RefCounted = null var _reachable_hexes: Dictionary = {} var _bombard_city: RefCounted = null var _arena_mode: bool = false +## p0-35 movement-mode state. When `_movement_mode == true` the next +## right-click confirms the move along the previewed path; ESC / left-click +## cancel back to selection. KEY_M toggles entry. +var _movement_mode: bool = false +## Last hovered axial hex, used to keep the preview in sync between hover +## ticks and discrete state changes (selection, move-mode entry). +var _last_hover_axial: Vector2i = Vector2i(-9999, -9999) @onready var _viewport_manager: Control = $ViewportWindowManager @onready var _hud: CanvasLayer = $WorldMapHud @onready var _tech_tree: CanvasLayer = $TechTree @onready var _minimap: Control = $MinimapLayer/Minimap if has_node("MinimapLayer/Minimap") else null +@onready var _unit_panel: Node = $UnitPanelLayer/UnitPanel if has_node("UnitPanelLayer/UnitPanel") else null func _ready() -> void: @@ -130,10 +138,15 @@ func _connect_signals() -> void: _viewport_manager.viewport_clicked.connect(_on_viewport_clicked) if not _arena_mode: _hud.end_turn_pressed.connect(_on_end_turn_pressed) - _hud.found_city_pressed.connect(_on_found_city_pressed) - _hud.build_improvement_pressed.connect(_on_build_improvement_pressed) _hud.tech_tree_pressed.connect(_toggle_tech_tree) _hud.chronicle_pressed.connect(_toggle_chronicle) + _hud.tutorial_requested.connect(_on_tutorial_requested) + if _unit_panel != null: + _unit_panel.move_pressed.connect(_on_move_pressed) + _unit_panel.fortify_pressed.connect(_on_fortify_pressed_from_panel) + _unit_panel.skip_pressed.connect(_on_skip_pressed_from_panel) + _unit_panel.found_city_pressed.connect(_on_found_city_pressed) + _unit_panel.build_improvement_pressed.connect(_on_build_improvement_pressed) EventBus.turn_started.connect(_on_turn_started) EventBus.turn_ended.connect(_on_turn_ended) EventBus.prologue_state_changed.connect(_on_prologue_state_changed) diff --git a/src/game/engine/scenes/world_map/world_map.tscn b/src/game/engine/scenes/world_map/world_map.tscn index 26f1b65c..85bbaddf 100644 --- a/src/game/engine/scenes/world_map/world_map.tscn +++ b/src/game/engine/scenes/world_map/world_map.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=9 format=3 uid="uid://c4w5x6y7z8a9b"] +[gd_scene load_steps=10 format=3 uid="uid://c4w5x6y7z8a9b"] [ext_resource type="Script" path="res://engine/scenes/world_map/world_map.gd" id="1"] [ext_resource type="Script" path="res://engine/src/ui/viewport_window_manager.gd" id="2"] @@ -9,6 +9,7 @@ [ext_resource type="PackedScene" uid="uid://defeat_screen" path="res://engine/scenes/menus/defeat_screen.tscn" id="7_defeat"] [ext_resource type="PackedScene" uid="uid://ai_turn_overlay_01" path="res://engine/scenes/hud/ai_turn_overlay.tscn" id="8_ai_overlay"] [ext_resource type="PackedScene" uid="uid://m1i2n3i4m5a6p7" path="res://engine/scenes/hud/minimap.tscn" id="9_minimap"] +[ext_resource type="PackedScene" uid="uid://c7u8n9i0t1p2a" path="res://engine/scenes/hud/unit_panel.tscn" id="10_unit_panel"] [node name="WorldMap" type="Node2D"] script = ExtResource("1") @@ -60,6 +61,12 @@ script = ExtResource("2") [node name="AiTurnOverlay" parent="." instance=ExtResource("8_ai_overlay")] +[node name="UnitPanelLayer" type="CanvasLayer" parent="."] +layer = 6 + +[node name="UnitPanel" parent="UnitPanelLayer" instance=ExtResource("10_unit_panel")] +unique_name_in_owner = true + [node name="MinimapLayer" type="CanvasLayer" parent="."] layer = 5 diff --git a/src/game/engine/src/autoloads/event_bus.gd b/src/game/engine/src/autoloads/event_bus.gd index 58f29f1d..1398a8e8 100644 --- a/src/game/engine/src/autoloads/event_bus.gd +++ b/src/game/engine/src/autoloads/event_bus.gd @@ -140,6 +140,16 @@ signal peace_rejected(by_player: int, against_player: int) signal trade_offer_rejected(from_player: int, to_player: int, gold: int, luxury_id: String) # -- UI signals -- +## p0-35: emitted when world_map enters movement-mode (player will preview a +## path then right-click to confirm). `unit` is the selected RefCounted unit. +## Listeners (renderers, hover hooks) reset path preview state. +signal movement_mode_entered(unit: Variant) +## p0-35: emitted when movement mode exits (confirm, cancel, or selection +## change). Listeners clear any path preview overlays. +signal movement_mode_exited +## p1-19: emitted when the player clicks the world-map HUD Tutorial button. +## world_map.gd listens and instantiates the tutorial overlay on demand. +signal tutorial_requested signal camera_moved(position: Vector2) ## Emitted each frame the player actively pans the camera (WASD/arrows/drag). ## Debounced emission, not continuous — fires once per discrete pan gesture. diff --git a/src/game/engine/src/map/pathfinder.gd b/src/game/engine/src/map/pathfinder.gd index ae59f8d5..4bd0bed9 100644 --- a/src/game/engine/src/map/pathfinder.gd +++ b/src/game/engine/src/map/pathfinder.gd @@ -142,6 +142,63 @@ static func movement_range( return cost_so_far +static func find_path_with_fog( + map: RefCounted, + start: Vector2i, + goal: Vector2i, + movement_budget: int, + unit_type: String, + scouted: Dictionary, +) -> Array[Vector2i]: + ## p0-35: fog-aware path preview. A* through tiles the player has scouted + ## (`scouted[axial] == true`), straight `HexUtilsScript.hex_line()` through + ## unscouted fog so the player isn't shown information they don't have. + ## When the goal is fully reachable through scouted territory we return + ## the regular A* path (and `movement_budget` may legitimately be exceeded + ## by the multi-turn renderer; pass a generous budget when previewing). + ## When the goal sits in fog we A* to the last scouted hex on the line and + ## append a straight hex_line continuation to the goal — the renderer can + ## label that suffix differently. + if start == goal: + return [] as Array[Vector2i] + var goal_scouted: bool = bool(scouted.get(goal, false)) + if goal_scouted: + return find_path(map, start, goal, movement_budget, unit_type) + + var line: Array[Vector2i] = HexUtilsScript.hex_line(start, goal) + ## Walk the straight line backwards from goal until we hit a scouted hex. + ## That hex becomes the A* sub-goal; the remainder is appended verbatim. + var pivot_idx: int = -1 + for i: int in range(line.size() - 1, -1, -1): + if bool(scouted.get(line[i], false)): + pivot_idx = i + break + if pivot_idx <= 0: + ## No scouted intermediary: emit the straight hex_line minus the start + ## tile. This matches the spec "straight line through unscouted fog". + var fog_only: Array[Vector2i] = [] + for j: int in range(1, line.size()): + fog_only.append(line[j]) + return fog_only + + var pivot: Vector2i = line[pivot_idx] + var scouted_path: Array[Vector2i] = find_path(map, start, pivot, movement_budget, unit_type) + if scouted_path.is_empty() and start != pivot: + ## A* failed in scouted territory (impassable goal-tile, etc.); fall + ## back to the straight line so the renderer at least shows intent. + var fallback: Array[Vector2i] = [] + for k: int in range(1, line.size()): + fallback.append(line[k]) + return fallback + + var combined: Array[Vector2i] = [] + for p: Vector2i in scouted_path: + combined.append(p) + for k: int in range(pivot_idx + 1, line.size()): + combined.append(line[k]) + return combined + + static func visible_hexes(map: RefCounted, center: Vector2i, vision_radius: int) -> Array[Vector2i]: ## Return all hex positions visible from center within vision_radius. ## A hex is visible if it is within range AND has_line_of_sight from center. diff --git a/src/game/engine/src/rendering/unit_renderer.gd b/src/game/engine/src/rendering/unit_renderer.gd index 8e0bcd99..1edbc342 100644 --- a/src/game/engine/src/rendering/unit_renderer.gd +++ b/src/game/engine/src/rendering/unit_renderer.gd @@ -26,6 +26,12 @@ const SELECTION_COLOR: Color = Color(1.0, 1.0, 0.0, 0.9) const MOVE_RANGE_COLOR: Color = Color(0.2, 0.4, 0.9, 0.25) const MOVE_RANGE_BORDER_COLOR: Color = Color(0.3, 0.5, 1.0, 0.5) +## p0-35: path preview line (orange) drawn during movement mode. +const PATH_PREVIEW_COLOR: Color = Color(0.95, 0.55, 0.15, 0.95) +const PATH_PREVIEW_WIDTH: float = 4.0 +const PATH_PREVIEW_DOT_RADIUS: float = 6.0 +const PATH_TURN_LABEL_COLOR: Color = Color(1.0, 0.92, 0.6, 1.0) + ## HP bar dimensions and position (below unit sprite) const HP_BAR_WIDTH: float = 36.0 const HP_BAR_HEIGHT: float = 4.0 @@ -64,6 +70,15 @@ var _movement_range: Dictionary = {} ## Animation pixel overrides: unit_id -> Vector2 (mid-tween position) var _anim_pixels: Dictionary = {} +## p0-35 path preview state. `_path_preview_origin` is the unit hex; the path +## itself is the ordered list of hexes the unit would traverse. +## `_path_preview_turns` is the multi-turn count rendered at the goal hex +## (1 = same turn, 2+ = additional turns required). +var _path_preview_origin: Vector2i = Vector2i.ZERO +var _path_preview: Array[Vector2i] = [] +var _path_preview_turns: int = 0 +var _path_preview_active: bool = false + ## Unit sprite cache: sprite_key -> Texture2D (null if not available) ## Keys are composed as "__" or bare "" fallback. var _unit_sprite_cache: Dictionary = {} @@ -163,6 +178,28 @@ func clear_movement_range() -> void: queue_redraw() +func show_path_preview(origin: Vector2i, path: Array[Vector2i], turns: int) -> void: + ## p0-35: render an orange polyline from `origin` through `path` (axial + ## coords). `turns` is the multi-turn count to label at the final hex + ## (1 = same turn, 2+ = additional turns required). An empty `path` + ## clears any existing preview. + if path.is_empty(): + clear_path_preview() + return + _path_preview_origin = origin + _path_preview = path + _path_preview_turns = maxi(turns, 1) + _path_preview_active = true + queue_redraw() + + +func clear_path_preview() -> void: + _path_preview.clear() + _path_preview_turns = 0 + _path_preview_active = false + queue_redraw() + + func setup_visibility(local_player: int, game_map: GameMapScript) -> void: _local_player = local_player _game_map = game_map @@ -172,6 +209,8 @@ func setup_visibility(local_player: int, game_map: GameMapScript) -> void: func _draw() -> void: # Draw movement range overlay (behind units) _draw_movement_range() + # p0-35: draw path preview overlay above movement range, below units. + _draw_path_preview() # Draw units for uid: String in _units: @@ -242,6 +281,38 @@ func _draw_hp_bar(pixel: Vector2, data: Dictionary) -> void: draw_rect(Rect2(origin, Vector2(HP_BAR_WIDTH * frac, HP_BAR_HEIGHT)), bar_color) +func _draw_path_preview() -> void: + if not _path_preview_active or _path_preview.is_empty(): + return + var pts: PackedVector2Array = PackedVector2Array() + pts.append(HexUtilsScript.axial_to_pixel(_path_preview_origin) + HexUtilsScript.hex_center) + for axial: Vector2i in _path_preview: + pts.append(HexUtilsScript.axial_to_pixel(axial) + HexUtilsScript.hex_center) + if pts.size() >= 2: + draw_polyline(pts, PATH_PREVIEW_COLOR, PATH_PREVIEW_WIDTH, true) + for axial: Vector2i in _path_preview: + var dot_center: Vector2 = HexUtilsScript.axial_to_pixel(axial) + HexUtilsScript.hex_center + draw_circle(dot_center, PATH_PREVIEW_DOT_RADIUS, PATH_PREVIEW_COLOR) + if _path_preview_turns >= 2: + var goal: Vector2i = _path_preview[_path_preview.size() - 1] + var label_pos: Vector2 = ( + HexUtilsScript.axial_to_pixel(goal) + HexUtilsScript.hex_center + + Vector2(0.0, -42.0) + ) + var font: Font = ThemeDB.fallback_font + var font_size: int = 18 + var label_text: String = ThemeVocabulary.lookup("fmt_path_preview_turns") % _path_preview_turns + if label_text == "fmt_path_preview_turns": + label_text = "%d turns" % _path_preview_turns + var text_size: Vector2 = font.get_string_size( + label_text, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size + ) + draw_string( + font, label_pos - text_size * 0.5, label_text, + HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, PATH_TURN_LABEL_COLOR, + ) + + func _draw_movement_range() -> void: if _movement_range.is_empty(): return