feat(@projects/@magic-civilization): add race gate home assets & movement system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-18 03:45:02 -07:00
parent 916dcac056
commit 09a9d6dc89
14 changed files with 455 additions and 120 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

BIN
mcp_prod_build_home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View file

@ -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
},
{

View file

@ -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 15)",
"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",

View file

@ -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:

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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 "<type_id>_<race_id>_<sex>" or bare "<type_id>" 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