feat(@projects/@magic-civilization): add hotkey system and minimap updates

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 02:46:31 -07:00
parent f5d3fd73fa
commit 70a965c1cd
25 changed files with 974 additions and 22 deletions

View file

@ -10,8 +10,8 @@
| Status | Count |
|---|---|
| ✅ done | 19 |
| 🟡 partial | 20 |
| ✅ done | 20 |
| 🟡 partial | 19 |
| 🔴 stub | 0 |
| ❌ missing | 2 |
| ⚫ oos | 4 |
@ -61,7 +61,7 @@
| ID | Status | Title | Owner | Updated |
|---|---|---|---|---|
| [p2-01](p2-01-minimap-improvements.md) | 🟡 partial | Minimap — fog reflection and unit markers | — | 2026-04-17 |
| [p2-01](p2-01-minimap-improvements.md) | ✅ done | Minimap — fog reflection and unit markers | [shipwright](../team-leads/shipwright.md) | 2026-04-17 |
| [p2-02](p2-02-hud-tooltips.md) | 🟡 partial | Tooltips on all HUD elements | — | 2026-04-17 |
| [p2-03](p2-03-hotkey-cheat-sheet.md) | ❌ missing | Hotkey cheat sheet (F1 / ?) | — | 2026-04-17 |
| [p2-04](p2-04-localization-audit.md) | 🟡 partial | Localization audit — no hardcoded strings | — | 2026-04-17 |

View file

@ -2,19 +2,60 @@
id: p2-01
title: Minimap — fog reflection and unit markers
priority: p2
status: partial
status: done
scope: game1
owner: shipwright
updated_at: 2026-04-17
evidence:
- src/game/engine/scenes/hud/minimap.tscn
- src/game/engine/scenes/hud/minimap.gd
- src/game/engine/scenes/world_map/world_map.tscn
- src/game/engine/scenes/world_map/world_map.gd
- src/game/engine/tests/unit/test_minimap.gd
---
## Summary
Minimap scene exists; depth of fog/unit reflection unverified. Nice-to-have for navigation on large maps.
The minimap controller was already complete — terrain raster, per-tile fog
color, per-player unit/city dots, click-to-emit `EventBus.camera_moved`,
viewport-rect indicator. The gap was that nothing mounted it in
`world_map.tscn` and no consumer listened for `camera_moved`. This bundle
mounts the minimap in a `CanvasLayer` anchored bottom-right of the world map
and wires both directions: `minimap.set_camera(bg_camera)` +
`camera.set_minimap(minimap)` for viewport-rect + auto-hide at strategic
zoom, and `EventBus.camera_moved → _on_minimap_click → cam.center_on(world)`.
## Acceptance
- Fog state on minimap matches main map.
- Own units as dots in player color; visible enemy units as dots in their color.
- Click minimap to jump camera.
- ✓ Fog state on minimap matches main map — `minimap.gd:177-190` `_draw_fog()`
reads per-tile `visibility` dict via `_get_tile_visibility()`, draws
`FOG_COLOR` (0.7 alpha black) for stale/seen tiles and `UNEXPLORED_COLOR`
(0.9 alpha) for unexplored. Subscribes to `EventBus.tile_visibility_changed`
at `minimap.gd:67` so state stays in sync.
- ✓ Own units as dots in player color; enemy units in their color —
`minimap.gd:237-245` `_draw_unit_dots(player)` iterates `player.units` and
draws a `UNIT_DOT_RADIUS=3.0` filled circle in `player.color`. Called once
per player in the main draw loop at `minimap.gd:168-172`, so enemy units
render in their own clan color. Cities use the larger
`CITY_DOT_RADIUS=4.0` with a white ring for hierarchy.
- ✓ Click minimap to jump camera — `minimap.gd:296-302` `_on_gui_input()`
emits `EventBus.camera_moved(_mini_to_world(mb.position))` on left-click;
`world_map.gd:_on_minimap_click()` (new) calls `bg_camera.center_on()` on
the background camera. Right-clicks are ignored at `minimap.gd:300`.
## Tests
- `src/game/engine/tests/unit/test_minimap.gd` — round-trips mini↔world
mapping, verifies divide-by-zero safety, asserts FOG_COLOR ≠ UNEXPLORED_COLOR
with correct alpha ordering, checks dot-drawing helpers exist with correct
hierarchy, and verifies left-click emits `camera_moved` while right-click
does not.
## Integration
- `world_map.tscn` now mounts `MinimapLayer/Minimap` as a CanvasLayer (layer 5)
anchored bottom-right at 328×208 px.
- `world_map.gd:_start_game()` calls `_minimap.set_camera(bg_camera)` and
`bg_camera.set_minimap(_minimap)` so the viewport rect + auto-hide at
`minimap_hide_zoom < 0.12` work.
- Arena mode skips the minimap mount (`not _arena_mode` guard).

View file

@ -2,18 +2,85 @@
id: p2-03
title: Hotkey cheat sheet (F1 / ?)
priority: p2
status: missing
status: partial
scope: game1
owner: shipwright
updated_at: 2026-04-17
evidence: []
evidence:
- src/game/engine/scenes/hud/hotkey_sheet.tscn
- src/game/engine/scenes/hud/hotkey_sheet.gd
- src/game/engine/scenes/tests/hotkey_sheet_proof.tscn
- src/game/engine/scenes/tests/hotkey_sheet_proof.gd
- src/game/engine/tests/unit/test_hotkey_sheet.gd
- src/game/project.godot
- public/games/age-of-dwarves/vocabulary.json
---
## Summary
World map uses hotkeys (T, C, B, ESC, end-turn) with no in-game reference. F1 or `?` should toggle a non-modal overlay listing all bindings.
Non-modal hotkey cheat-sheet overlay shipped. `hotkey_sheet.tscn` is a
`CanvasLayer` (layer=110, above all game HUD) that renders a two-column
binding table grouped into four contexts: **World Map**, **Map Overlays**,
**Menus & Panels**, **Turn Actions**. All 19 entries resolve through
`ThemeVocabulary.lookup("hotkey_*")` — zero hardcoded strings. The
controller exposes `toggle()` / `show_sheet()` / `hide_sheet()` /
`is_sheet_visible()` and routes `_unhandled_input` through the new
`ui_help` InputMap action (toggle) and `ui_cancel` (close-when-open).
`project.godot` now declares an `[input]` section with `ui_help` bound
to **F1** and **Shift+/** (`?`).
GUT test `test_hotkey_sheet.gd` (9/9 passing on apricot, Godot 4.6.2,
GUT 9.6.0, 0.476s) covers: starts-hidden invariant, toggle show/hide
round-trip, show_sheet / hide_sheet behavior, `InputMap.has_action`
confirmation, ui_help-opens, ui_help-closes, ui_cancel-closes,
ui_cancel-while-hidden is a no-op, and binding-table group coverage.
Proof screenshot captured via
`src/game/engine/scenes/tests/hotkey_sheet_proof.tscn`, saved to
`$SCREENSHOT_HOST:~/Desktop/magic_civ_hotkey_sheet.png` (1920×1080).
Verified in conversation: title, 4 grouped sections, all 19 bindings
visible, vocab-resolved labels, gold-bordered panel, footer.
**Partial**, not done, for two honest reasons:
1. **F1 collision with encyclopedia.** `top_bar.gd:279` still owns a
raw `KEY_F1` handler that opens the encyclopedia panel. The new
cheat sheet lives on a higher CanvasLayer (layer=110) and consumes
F1 via `ui_help` first, so the cheat sheet wins — but that means
the encyclopedia's F1 keybind is now dead on world_map. Proper
resolution is to migrate the encyclopedia to its own InputMap
action (`ui_encyclopedia`) bound to a different key (F2 / F4) and
remove the keycode-literal handler. That's cross-cutting work
outside the ≤200 LOC scope for this objective.
2. **Spec said "read from InputMap.get_actions()"** — but 95% of the
game's existing hotkeys are keycode-literal matches inside
`_unhandled_key_input` (see `world_map.gd:253-266`,
`overlay_panel.gd:137-173`, `camera.gd:147-153`, etc.), not
registered InputMap actions. Delivered as a curated
`const BINDINGS` table reflecting the current actual keybinds.
A full migration of all handlers to InputMap — then dynamically
rendering `InputMap.get_actions()` — would be a separate
follow-up objective.
## Acceptance
- `ui_help` input action bound to F1 and `?`.
- Overlay lists all bindings grouped by context (Map / City / Combat / Menus).
- Closable with same key or ESC.
- ✓ `ui_help` input action bound to F1 and `?` — verified by
`test_ui_help_action_opens_sheet` asserting
`InputMap.has_action("ui_help")` and the action routing through
`_unhandled_input`.
- ✓ Overlay lists all bindings grouped by context (Map / City /
Combat / Menus) — delivered as 4 groups (World Map / Map Overlays /
Menus & Panels / Turn Actions). City and Combat contexts don't yet
have hotkeys (no city-screen keybinds; combat is mouse-driven), so
the spec's "City" and "Combat" columns are absent; when those
surfaces grow keybinds, append to `BINDINGS` in `hotkey_sheet.gd`.
- ✓ Closable with same key or ESC — both paths tested
(`test_ui_help_action_closes_sheet_when_open`,
`test_ui_cancel_closes_sheet_when_open`).
- ◻ F1-collision with encyclopedia hotkey resolved — open, see Summary.
- ◻ Sheet wired into world_map / game HUD so players can actually
press F1 in a live game and see it — the scene and action exist
but no HUD currently instantiates the sheet. Follow-up: add to
`world_map_hud.tscn` or `main.gd` as a persistent child.

View file

@ -0,0 +1,42 @@
{
"default": {
"id": "default",
"name": "Default",
"description": "Standard high-saturation palette. Best overall legibility for most players.",
"player_colors": [
"3366ff", "e63333", "33cc4d", "e6cc1a",
"b24de6", "e6801a", "1accd9", "cc4d80",
"806659", "999999", "66b366", "4d4d99"
]
},
"deuteranopia": {
"id": "deuteranopia",
"name": "Deuteranopia (red-green)",
"description": "Remaps the red↔green axis to blue↔orange. Safe for the most common form of colorblindness.",
"player_colors": [
"0072b2", "e69f00", "56b4e9", "f0e442",
"cc79a7", "d55e00", "009e73", "882255",
"806659", "bbbbbb", "66b366", "332288"
]
},
"protanopia": {
"id": "protanopia",
"name": "Protanopia (red-weak)",
"description": "Replaces pure reds with amber and deep blues for protanopic viewers.",
"player_colors": [
"1166ff", "ffa500", "00cccc", "e6cc1a",
"6633aa", "ff7f00", "1accd9", "aa3377",
"806659", "999999", "4a90b4", "333377"
]
},
"tritanopia": {
"id": "tritanopia",
"name": "Tritanopia (blue-yellow)",
"description": "Shifts blue↔yellow into magenta↔teal for blue-yellow colorblindness.",
"player_colors": [
"ff4477", "e63333", "00b3a4", "d1d1d1",
"9b30a0", "ff7f50", "00b3a4", "aa0055",
"806659", "999999", "4a9aaa", "550077"
]
}
}

View file

@ -4,7 +4,7 @@
*/
import type { GuideDataContextValue, SpeciesObservationLens, ObservedSpecies } from '@magic-civ/guide-engine'
import { SPECIES_LIBRARY, applyObservationLens } from '@magic-civ/guide-engine'
import gameManifest from '@/game.json'
import gameManifest from '../../../game.json'
import {
techTierMap, allTerrains, allKeywords, climateSpec, climateParams, hydrologyParams,
allEventCategories, crossTriggers, weatherEvents, leyChanneling,

View file

@ -78,6 +78,27 @@
"leader": "Leader",
"unknown": "Unknown",
"credits": "Credits",
"tooltip_turn": "Current game turn (of the turn limit)",
"tooltip_era": "Current era — gates buildings, units, and events",
"tooltip_gold": "Gold per turn (treasury total in parens)",
"tooltip_science": "Science per turn — funds technology research",
"tooltip_happiness": "Empire happiness — unhappy cities stop growing and may revolt",
"tooltip_golden_age": "Golden Age active — +20% yields across the empire",
"tooltip_diplomacy": "Relations with rival clans (hover for details)",
"tooltip_stats": "Demographics — population, military, and score charts (F9)",
"tooltip_encyclopedia": "Encyclopedia — units, buildings, techs reference (F1)",
"tooltip_bug_report": "Report a bug — attach screenshots and game state",
"tooltip_end_turn": "End the current turn and advance to the next (Enter)",
"tooltip_tech_tree": "Open the tech web (T)",
"tooltip_chronicle": "Open the chronicle log (C)",
"tooltip_attack": "Attack strength — used when initiating combat",
"tooltip_defense": "Defense strength — used when defending against attacks",
"tooltip_hit_points": "Current hit points / maximum hit points",
"tooltip_movement": "Movement remaining this turn / total per turn",
"tooltip_fortify": "Fortify — skip the turn and gain a defense bonus",
"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)",
"ruin_site": "Tribal Village",
"threat_site": "Lair",
"independent_settlement": "Freepeople Haven",
@ -281,5 +302,34 @@
"tutorial_step_4_title": "The Tech Web",
"tutorial_step_4_body": "Open the Tech Web from the HUD to research Construction, Bestiary, War, Commerce, and Science pillars. Research unlocks new units, buildings, improvements, and wonders.",
"tutorial_step_5_title": "End Turn",
"tutorial_step_5_body": "When your units are moved and your orders are set, press End Turn (or Enter) to advance the game. Every rival clan takes their turn before yours resumes. Good luck."
"tutorial_step_5_body": "When your units are moved and your orders are set, press End Turn (or Enter) to advance the game. Every rival clan takes their turn before yours resumes. Good luck.",
"hotkey_sheet_title": "Keyboard Shortcuts",
"hotkey_sheet_footer": "Press F1 or ? to close",
"hotkey_group_map": "World Map",
"hotkey_group_overlays": "Map Overlays",
"hotkey_group_menus": "Menus & Panels",
"hotkey_group_turn": "Turn Actions",
"hotkey_pan_wasd": "Pan camera",
"hotkey_pan_arrows": "Pan camera (arrows)",
"hotkey_zoom_wheel": "Zoom in / out",
"hotkey_drag_middle": "Drag to pan",
"hotkey_overlay_temperature": "Temperature heatmap",
"hotkey_overlay_moisture": "Moisture heatmap",
"hotkey_overlay_wind": "Wind heatmap",
"hotkey_overlay_weather": "Active weather",
"hotkey_overlay_land_value": "Land-value scorer",
"hotkey_overlay_water": "Rivers and water",
"hotkey_overlay_elevation": "Elevation",
"hotkey_overlay_corruption": "Corruption pressure",
"hotkey_overlay_pressure": "Barometric pressure",
"hotkey_overlay_humidity": "Humidity",
"hotkey_overlay_ley_line": "Ley lines",
"hotkey_cycle_view": "Cycle view center",
"hotkey_encyclopedia": "Encyclopedia",
"hotkey_stats": "Stats panel",
"hotkey_ingame_menu": "In-game menu / Close",
"hotkey_help": "This cheat sheet",
"hotkey_end_turn": "End turn",
"hotkey_select_move": "Select / move unit"
}

View file

@ -11,7 +11,7 @@ func _ready() -> void:
_apply_panel_style()
_apply_button_style()
_end_turn_btn.pressed.connect(_on_end_turn_pressed)
_end_turn_btn.tooltip_text = ThemeVocabulary.lookup("end_turn")
_end_turn_btn.tooltip_text = ThemeVocabulary.lookup("tooltip_end_turn")
EventBus.turn_started.connect(_on_turn_started)
_update_turn_display()

View file

@ -0,0 +1,161 @@
class_name HotkeySheet
extends CanvasLayer
## Hotkey cheat-sheet overlay. Toggles on `ui_help` (F1 or `?`), closes on
## `ui_help` again or `ui_cancel` (Esc). Lists keybinds grouped by context.
## All labels resolve through ThemeVocabulary.
const BINDINGS: Array[Dictionary] = [
{"group": "hotkey_group_map", "keys": "W A S D", "label": "hotkey_pan_wasd"},
{"group": "hotkey_group_map", "keys": "Arrows", "label": "hotkey_pan_arrows"},
{"group": "hotkey_group_map", "keys": "Wheel", "label": "hotkey_zoom_wheel"},
{"group": "hotkey_group_map", "keys": "Middle Drag", "label": "hotkey_drag_middle"},
{"group": "hotkey_group_map", "keys": "F", "label": "hotkey_cycle_view"},
{"group": "hotkey_group_overlays", "keys": "T", "label": "hotkey_overlay_temperature"},
{"group": "hotkey_group_overlays", "keys": "M", "label": "hotkey_overlay_moisture"},
{"group": "hotkey_group_overlays", "keys": "W", "label": "hotkey_overlay_wind"},
{"group": "hotkey_group_overlays", "keys": "Y", "label": "hotkey_overlay_weather"},
{"group": "hotkey_group_overlays", "keys": "V", "label": "hotkey_overlay_land_value"},
{"group": "hotkey_group_overlays", "keys": "R", "label": "hotkey_overlay_water"},
{"group": "hotkey_group_overlays", "keys": "E", "label": "hotkey_overlay_elevation"},
{"group": "hotkey_group_overlays", "keys": "L", "label": "hotkey_overlay_ley_line"},
{"group": "hotkey_group_menus", "keys": "F1", "label": "hotkey_encyclopedia"},
{"group": "hotkey_group_menus", "keys": "F9", "label": "hotkey_stats"},
{"group": "hotkey_group_menus", "keys": "?", "label": "hotkey_help"},
{"group": "hotkey_group_menus", "keys": "Esc", "label": "hotkey_ingame_menu"},
{"group": "hotkey_group_turn", "keys": "Enter", "label": "hotkey_end_turn"},
{"group": "hotkey_group_turn", "keys": "L / R Click", "label": "hotkey_select_move"},
]
var _panel: PanelContainer = null
var _visible: bool = false
func _ready() -> void:
layer = 110
visible = false
_build_ui()
func _build_ui() -> void:
var dim: ColorRect = ColorRect.new()
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
add_child(dim)
_panel = PanelContainer.new()
_panel.anchor_left = 0.5
_panel.anchor_top = 0.5
_panel.anchor_right = 0.5
_panel.anchor_bottom = 0.5
_panel.custom_minimum_size = Vector2(760, 560)
_panel.offset_left = -380
_panel.offset_top = -280
_panel.offset_right = 380
_panel.offset_bottom = 280
_apply_panel_style(_panel)
add_child(_panel)
var margin: MarginContainer = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 32)
margin.add_theme_constant_override("margin_right", 32)
margin.add_theme_constant_override("margin_top", 24)
margin.add_theme_constant_override("margin_bottom", 24)
_panel.add_child(margin)
var vbox: VBoxContainer = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 10)
margin.add_child(vbox)
var title: Label = Label.new()
title.text = ThemeVocabulary.lookup("hotkey_sheet_title")
title.add_theme_font_size_override("font_size", 24)
title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.3))
vbox.add_child(title)
var grid: GridContainer = GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 48)
grid.add_theme_constant_override("v_separation", 6)
grid.size_flags_vertical = Control.SIZE_EXPAND_FILL
vbox.add_child(grid)
var last_group: String = ""
for entry: Dictionary in BINDINGS:
var group_key: String = entry["group"]
if group_key != last_group:
_add_group_header(grid, ThemeVocabulary.lookup(group_key))
last_group = group_key
_add_row(grid, entry["keys"], ThemeVocabulary.lookup(entry["label"]))
var footer: Label = Label.new()
footer.text = ThemeVocabulary.lookup("hotkey_sheet_footer")
footer.add_theme_font_size_override("font_size", 12)
footer.add_theme_color_override("font_color", Color(0.7, 0.65, 0.45, 0.9))
footer.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vbox.add_child(footer)
func _add_group_header(grid: GridContainer, text: String) -> void:
var spacer: Control = Control.new()
spacer.custom_minimum_size = Vector2(0, 6)
grid.add_child(spacer)
var header: Label = Label.new()
header.text = text
header.add_theme_font_size_override("font_size", 15)
header.add_theme_color_override("font_color", Color(0.9, 0.75, 0.35))
grid.add_child(header)
func _add_row(grid: GridContainer, keys: String, description: String) -> void:
var key_label: Label = Label.new()
key_label.text = keys
key_label.add_theme_font_size_override("font_size", 13)
key_label.add_theme_color_override("font_color", Color(0.95, 0.85, 0.55))
key_label.custom_minimum_size = Vector2(140, 0)
grid.add_child(key_label)
var desc_label: Label = Label.new()
desc_label.text = description
desc_label.add_theme_font_size_override("font_size", 13)
desc_label.add_theme_color_override("font_color", Color(0.85, 0.8, 0.72))
grid.add_child(desc_label)
func toggle() -> void:
_visible = not _visible
visible = _visible
func show_sheet() -> void:
_visible = true
visible = true
func hide_sheet() -> void:
_visible = false
visible = false
func is_sheet_visible() -> bool:
return _visible
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_help"):
toggle()
get_viewport().set_input_as_handled()
return
if _visible and event.is_action_pressed("ui_cancel"):
hide_sheet()
get_viewport().set_input_as_handled()
func _apply_panel_style(panel: PanelContainer) -> void:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(0.06, 0.05, 0.09, 0.96)
style.border_color = Color(0.6, 0.45, 0.12, 0.9)
style.set_border_width_all(2)
style.set_corner_radius_all(6)
panel.add_theme_stylebox_override("panel", style)

View file

@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3 uid="uid://bhotkeysheet00001"]
[ext_resource type="Script" path="res://engine/scenes/hud/hotkey_sheet.gd" id="1_script"]
[node name="HotkeySheet" type="CanvasLayer"]
layer = 110
visible = false
script = ExtResource("1_script")

View file

@ -54,9 +54,31 @@ func _ready() -> void:
%StatsButton.pressed.connect(_on_stats_pressed)
%EncyclopediaButton.pressed.connect(_on_encyclopedia_pressed)
_apply_tooltips()
_refresh_all()
## Resolve every top-bar element's tooltip_text through ThemeVocabulary so
## themes can localize hover copy without touching the scene. Labels need
## mouse_filter = STOP (not the default PASS) for Godot to fire their
## tooltips — stat rows on this panel are otherwise transparent to hover.
func _apply_tooltips() -> void:
%TurnLabel.tooltip_text = ThemeVocabulary.lookup("tooltip_turn")
%TurnLabel.mouse_filter = Control.MOUSE_FILTER_STOP
%EraLabel.tooltip_text = ThemeVocabulary.lookup("tooltip_era")
%EraLabel.mouse_filter = Control.MOUSE_FILTER_STOP
%GoldLabel.tooltip_text = ThemeVocabulary.lookup("tooltip_gold")
%GoldLabel.mouse_filter = Control.MOUSE_FILTER_STOP
%ScienceLabel.tooltip_text = ThemeVocabulary.lookup("tooltip_science")
%ScienceLabel.mouse_filter = Control.MOUSE_FILTER_STOP
%HappinessLabel.tooltip_text = ThemeVocabulary.lookup("tooltip_happiness")
%GoldenAgeBadge.tooltip_text = ThemeVocabulary.lookup("tooltip_golden_age")
%DiplomacySegment.tooltip_text = ThemeVocabulary.lookup("tooltip_diplomacy")
%StatsButton.tooltip_text = ThemeVocabulary.lookup("tooltip_stats")
%EncyclopediaButton.tooltip_text = ThemeVocabulary.lookup("tooltip_encyclopedia")
%BugReportButton.tooltip_text = ThemeVocabulary.lookup("tooltip_bug_report")
func _on_turn_started(_turn_number: int, _player_index: int) -> void:
_refresh_all()
_update_diplomacy_readout()

View file

@ -13,6 +13,10 @@ var _defaults: RefCounted
@onready var _vsync_check: CheckBox = %VSyncCheck
@onready var _scale_slider: HSlider = %UIScaleSlider
@onready var _scale_val: Label = %UIScaleValueLabel
@onready var _palette_prev: Button = %PalettePrevButton
@onready var _palette_next: Button = %PaletteNextButton
@onready var _palette_val: Label = %PaletteValueLabel
@onready var _palette_swatches: HBoxContainer = %PaletteSwatchRow
# -- Audio --
@onready var _master_slider: HSlider = %MasterVolumeSlider
@ -86,6 +90,8 @@ func _connect_signals() -> void:
_wm_next.pressed.connect(_on_window_mode_next)
_vsync_check.toggled.connect(_on_vsync_toggled)
_scale_slider.value_changed.connect(_on_ui_scale_changed)
_palette_prev.pressed.connect(_on_palette_prev)
_palette_next.pressed.connect(_on_palette_next)
_master_slider.value_changed.connect(_on_master_changed)
_music_slider.value_changed.connect(_on_music_changed)

View file

@ -183,6 +183,54 @@ text = "100%"
horizontal_alignment = 2
vertical_alignment = 1
; -- Palette / Colorblind --
[node name="PaletteRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/ScrollContainer/ContentVBox"]
layout_mode = 2
custom_minimum_size = Vector2(0, 40)
theme_override_constants/separation = 12
[node name="PaletteLabel" type="Label" parent="MarginContainer/VBoxContainer/ScrollContainer/ContentVBox/PaletteRow"]
layout_mode = 2
custom_minimum_size = Vector2(220, 0)
theme_override_font_sizes/font_size = 15
text = "Color Palette"
vertical_alignment = 1
[node name="Spacer" type="Control" parent="MarginContainer/VBoxContainer/ScrollContainer/ContentVBox/PaletteRow"]
layout_mode = 2
size_flags_horizontal = 3
[node name="PalettePrevButton" type="Button" parent="MarginContainer/VBoxContainer/ScrollContainer/ContentVBox/PaletteRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 4
custom_minimum_size = Vector2(36, 32)
text = "<"
[node name="PaletteValueLabel" type="Label" parent="MarginContainer/VBoxContainer/ScrollContainer/ContentVBox/PaletteRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 4
custom_minimum_size = Vector2(240, 32)
theme_override_font_sizes/font_size = 14
text = "Default"
horizontal_alignment = 1
vertical_alignment = 1
[node name="PaletteNextButton" type="Button" parent="MarginContainer/VBoxContainer/ScrollContainer/ContentVBox/PaletteRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 4
custom_minimum_size = Vector2(36, 32)
text = ">"
[node name="PaletteSwatchRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/ScrollContainer/ContentVBox"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(0, 20)
theme_override_constants/separation = 4
; ===================== AUDIO =====================
[node name="AudioSectionRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/ScrollContainer/ContentVBox"]

View file

@ -1953,7 +1953,7 @@ func _build_player_stats() -> Dictionary:
mil += 1
_ensure_stats(idx)
var pstat: Dictionary = _stats[idx]
var luxuries: int = HappinessScript._collect_unique_luxury_ids(p, game_map).size()
var luxuries: int = HappinessScript._collect_luxury_happiness_map(p, game_map).size()
var happiness: int = int(p.get("happiness")) if p.get("happiness") != null else 0
var gpt: int = int(p.get("gold_per_turn")) if p.get("gold_per_turn") != null else 0
var gold: int = int(p.get("gold")) if p.get("gold") != null else 0

View file

@ -0,0 +1,60 @@
extends Node
## p2-03 proof scene: load vocabulary, instantiate hotkey sheet, open it,
## capture screenshot. Shows the full binding table with 4 grouped sections.
const HotkeySheetScene: PackedScene = preload(
"res://engine/scenes/hud/hotkey_sheet.tscn"
)
var _captured: bool = false
var _screenshot_name: String = "hotkey_sheet"
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.08, 0.07, 0.12))
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
ThemeVocabulary.load_vocabulary("age-of-dwarves")
await get_tree().process_frame
var bg: ColorRect = ColorRect.new()
bg.color = Color(0.04, 0.03, 0.05, 1.0)
bg.anchor_right = 1.0
bg.anchor_bottom = 1.0
add_child(bg)
var sheet: CanvasLayer = HotkeySheetScene.instantiate()
add_child(sheet)
sheet.show_sheet()
for _i: int in range(8):
await get_tree().process_frame
print("Hotkey sheet rendered, visible=%s" % str(sheet.is_sheet_visible()))
_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()

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://bhotkeysheetproof01"]
[ext_resource type="Script" path="res://engine/scenes/tests/hotkey_sheet_proof.gd" id="1_script"]
[node name="HotkeySheetProof" type="Node"]
script = ExtResource("1_script")

View file

@ -56,6 +56,7 @@ var _arena_mode: bool = false
@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
func _ready() -> void:
@ -125,6 +126,18 @@ func _connect_signals() -> void:
EventBus.village_discovered.connect(_on_village_discovered)
EventBus.city_unit_completed.connect(_on_city_unit_completed)
EventBus.city_building_completed.connect(_on_city_building_completed)
EventBus.camera_moved.connect(_on_minimap_click)
## Consume minimap's click-to-center signal by centering the background camera
## on the clicked world position. Minimap emits raw world coordinates via
## EventBus.camera_moved; we just forward them to the camera.
func _on_minimap_click(world_position: Vector2) -> void:
if _viewport_manager == null:
return
var cam: Camera2D = _viewport_manager.get_background_camera()
if cam != null and cam.has_method("center_on"):
cam.center_on(world_position)
func _process(delta: float) -> void:
@ -158,6 +171,9 @@ func _start_game() -> void:
var bg_camera: Camera2D = _viewport_manager.get_background_camera()
bg_camera.setup_bounds(game_map.width, game_map.height)
if _minimap != null and not _arena_mode:
_minimap.set_camera(bg_camera)
bg_camera.set_minimap(_minimap)
if GameState.players.is_empty():
push_error("WorldMap: No players in GameState")

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=8 format=3 uid="uid://c4w5x6y7z8a9b"]
[gd_scene load_steps=9 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"]
@ -8,6 +8,7 @@
[ext_resource type="PackedScene" uid="uid://victory_screen" path="res://engine/scenes/menus/victory_screen.tscn" id="6_victory"]
[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"]
[node name="WorldMap" type="Node2D"]
script = ExtResource("1")
@ -58,3 +59,20 @@ script = ExtResource("2")
[node name="DefeatScreen" parent="." instance=ExtResource("7_defeat")]
[node name="AiTurnOverlay" parent="." instance=ExtResource("8_ai_overlay")]
[node name="MinimapLayer" type="CanvasLayer" parent="."]
layer = 5
[node name="Minimap" parent="MinimapLayer" instance=ExtResource("9_minimap")]
unique_name_in_owner = true
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -340.0
offset_top = -220.0
offset_right = -12.0
offset_bottom = -12.0
grow_horizontal = 0
grow_vertical = 0

View file

@ -154,12 +154,23 @@ func get_player(index: int) -> RefCounted: # Returns Player
func add_player(player: RefCounted) -> int: # Expects Player
player.index = players.size()
if player.index < PLAYER_COLORS.size():
player.color = PLAYER_COLORS[player.index]
player.color = _player_color_for_index(player.index)
players.append(player)
return player.index
func _player_color_for_index(idx: int) -> Color:
## Route through ThemeAssets so the active palette variant (default or
## colorblind-safe) wins over the built-in PLAYER_COLORS fallback.
var tree: SceneTree = Engine.get_main_loop() as SceneTree
if tree != null and tree.root != null and tree.root.has_node("ThemeAssets"):
if ThemeAssets.has_palette_color(idx):
return ThemeAssets.get_player_color(idx)
if idx >= 0 and idx < PLAYER_COLORS.size():
return PLAYER_COLORS[idx]
return Color(0.6, 0.6, 0.6)
func create_player(
player_name: String,
race_id: String,

View file

@ -23,6 +23,7 @@ const DEFAULTS: Dictionary = {
"window_mode": 0,
"vsync": true,
"ui_scale": 100,
"palette_variant": "default",
},
"audio":
{
@ -205,6 +206,11 @@ func _apply_display(key: String, value: Variant) -> void:
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ENABLED)
else:
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
"palette_variant":
var variant_id: String = str(value)
var tree: SceneTree = Engine.get_main_loop() as SceneTree
if tree != null and tree.root != null and tree.root.has_node("ThemeAssets"):
ThemeAssets.set_palette_variant(variant_id)
"ui_scale":
var scale_pct: int = clampi(int(value), 50, 200)
var tree: SceneTree = Engine.get_main_loop() as SceneTree

View file

@ -1,16 +1,30 @@
extends Node
## Resolves asset paths relative to the active theme's assets directory.
## All sprite/audio loading goes through this — engine code never hardcodes asset paths.
## Also loads palette variants (default + colorblind-safe) and applies them to
## the player-color list. All sprite/audio loading goes through this —
## engine code never hardcodes asset paths.
signal palette_changed(variant_id: String)
const PALETTE_JSON_REL: String = "data/palettes.json"
const DEFAULT_VARIANT: String = "default"
const VALID_VARIANTS: Array[String] = [
"default", "deuteranopia", "protanopia", "tritanopia",
]
var _active_theme: String = ""
var _base_path: String = ""
var _texture_cache: Dictionary = {}
var _palettes: Dictionary = {}
var _active_palette_variant: String = DEFAULT_VARIANT
func set_theme(theme_id: String) -> void:
_active_theme = theme_id
_base_path = "res://public/games/%s/assets" % theme_id
_texture_cache.clear()
_load_palettes(theme_id)
_active_palette_variant = DEFAULT_VARIANT
func resolve(relative_path: String) -> String:
@ -74,3 +88,96 @@ func clear_cache() -> void:
func get_active_theme() -> String:
return _active_theme
# -- Palette variants -----------------------------------------------------
## Switches the active color palette (default / deuteranopia / protanopia /
## tritanopia). Emits palette_changed so renderers/HUD can refresh.
func set_palette_variant(variant_id: String) -> void:
if not VALID_VARIANTS.has(variant_id):
push_warning("ThemeAssets: unknown palette variant '%s'" % variant_id)
return
if not _palettes.has(variant_id):
push_warning("ThemeAssets: palette '%s' not loaded" % variant_id)
return
_active_palette_variant = variant_id
_apply_palette_to_game_state()
palette_changed.emit(variant_id)
func get_palette_variant() -> String:
return _active_palette_variant
func list_palette_variants() -> Array:
var ids: Array = _palettes.keys()
ids.sort()
return ids
func get_palette(variant_id: String) -> Dictionary:
return _palettes.get(variant_id, {}) as Dictionary
## Returns the player color for a given index under the active palette, or
## has_palette_color()==false when the palette doesn't cover the slot so the
## caller can apply its own fallback.
func get_player_color(index: int) -> Color:
var colors: Array = _palette_player_colors(_active_palette_variant)
if index >= 0 and index < colors.size():
return colors[index]
return Color(0.6, 0.6, 0.6)
func has_palette_color(index: int) -> bool:
var colors: Array = _palette_player_colors(_active_palette_variant)
return index >= 0 and index < colors.size()
func _load_palettes(theme_id: String) -> void:
_palettes.clear()
var path: String = "res://public/games/%s/%s" % [theme_id, PALETTE_JSON_REL]
if not FileAccess.file_exists(path):
push_warning("ThemeAssets: palettes.json missing at %s" % path)
return
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
return
var text: String = file.get_as_text()
file.close()
var parsed: Dictionary = JSON.parse_string(text) as Dictionary
if parsed == null:
push_error("ThemeAssets: palettes.json parse error")
return
for key: String in parsed:
if parsed[key] is Dictionary:
_palettes[key] = parsed[key]
func _palette_player_colors(variant_id: String) -> Array:
var data: Dictionary = _palettes.get(variant_id, {}) as Dictionary
var raw: Array = data.get("player_colors", []) as Array
var out: Array = []
for hex: String in raw:
out.append(Color.html("#%s" % hex) if not hex.begins_with("#") else Color.html(hex))
return out
## Refreshes any already-spawned players so the palette swap takes effect
## without a restart. GameState._player_color_for_index() routes through
## get_player_color(), so new players always pick up the active variant.
func _apply_palette_to_game_state() -> void:
var tree: SceneTree = Engine.get_main_loop() as SceneTree
if tree == null or tree.root == null or not tree.root.has_node("GameState"):
return
var colors: Array = _palette_player_colors(_active_palette_variant)
if colors.is_empty():
return
for player: RefCounted in GameState.players:
if player == null:
continue
var idx: int = int(player.get("index"))
if idx >= 0 and idx < colors.size():
player.color = colors[idx]

View file

@ -0,0 +1,98 @@
extends GutTest
## HotkeySheet unit tests — covers toggle visibility, F1-close, Esc-close.
## Uses the sheet's public API (toggle / show_sheet / hide_sheet) and the
## `_unhandled_input` entry point so we can simulate InputMap action
## presses without standing up a full scene tree with focus handling.
const HotkeySheetScript: GDScript = preload(
"res://engine/scenes/hud/hotkey_sheet.gd"
)
var _sheet: Node = null
func before_each() -> void:
_sheet = HotkeySheetScript.new()
add_child_autofree(_sheet)
## ── visibility / toggle ────────────────────────────────────────────────
func test_starts_hidden() -> void:
assert_false(_sheet.is_sheet_visible(), "sheet must start hidden")
assert_false(_sheet.visible, "CanvasLayer.visible must be false on init")
func test_toggle_shows_then_hides() -> void:
_sheet.toggle()
assert_true(_sheet.is_sheet_visible(), "first toggle must show the sheet")
_sheet.toggle()
assert_false(_sheet.is_sheet_visible(), "second toggle must hide the sheet")
func test_show_sheet_makes_visible() -> void:
_sheet.show_sheet()
assert_true(_sheet.visible, "show_sheet must set visible=true")
assert_true(_sheet.is_sheet_visible(), "is_sheet_visible must agree")
func test_hide_sheet_makes_invisible() -> void:
_sheet.show_sheet()
_sheet.hide_sheet()
assert_false(_sheet.visible, "hide_sheet must set visible=false")
assert_false(_sheet.is_sheet_visible(), "is_sheet_visible must agree")
## ── input action routing ───────────────────────────────────────────────
func test_ui_help_action_opens_sheet() -> void:
assert_true(
InputMap.has_action("ui_help"),
"ui_help action must be registered in project.godot"
)
var ev: InputEventAction = InputEventAction.new()
ev.action = "ui_help"
ev.pressed = true
_sheet._unhandled_input(ev)
assert_true(_sheet.is_sheet_visible(), "ui_help press must open the sheet")
func test_ui_help_action_closes_sheet_when_open() -> void:
_sheet.show_sheet()
var ev: InputEventAction = InputEventAction.new()
ev.action = "ui_help"
ev.pressed = true
_sheet._unhandled_input(ev)
assert_false(_sheet.is_sheet_visible(), "ui_help press must close the open sheet")
func test_ui_cancel_closes_sheet_when_open() -> void:
_sheet.show_sheet()
var ev: InputEventAction = InputEventAction.new()
ev.action = "ui_cancel"
ev.pressed = true
_sheet._unhandled_input(ev)
assert_false(_sheet.is_sheet_visible(), "ui_cancel press must close the open sheet")
func test_ui_cancel_ignored_when_sheet_hidden() -> void:
var ev: InputEventAction = InputEventAction.new()
ev.action = "ui_cancel"
ev.pressed = true
_sheet._unhandled_input(ev)
assert_false(
_sheet.is_sheet_visible(),
"ui_cancel while hidden must not toggle visibility"
)
## ── binding table sanity ───────────────────────────────────────────────
func test_bindings_cover_required_groups() -> void:
var groups: Dictionary = {}
for entry: Dictionary in HotkeySheetScript.BINDINGS:
groups[entry["group"]] = true
assert_true(groups.has("hotkey_group_map"), "must list Map group")
assert_true(groups.has("hotkey_group_overlays"), "must list Overlays group")
assert_true(groups.has("hotkey_group_menus"), "must list Menus group")
assert_true(groups.has("hotkey_group_turn"), "must list Turn group")

View file

@ -0,0 +1,82 @@
extends GutTest
## Minimap tests. Covers the mini↔world coordinate mapping used by click-to-
## center, verifies fog color state triangulation, and confirms all three
## acceptance bullets' helpers exist on the script.
const MinimapScript: GDScript = preload("res://engine/scenes/hud/minimap.gd")
func test_mini_to_world_roundtrips_through_scale() -> void:
var mm: Control = MinimapScript.new()
mm._minimap_scale = Vector2(0.1, 0.1)
var world: Vector2 = mm._mini_to_world(Vector2(50.0, 30.0))
assert_almost_eq(world.x, 500.0, 0.01, "mini x 50 at scale 0.1 -> world 500")
assert_almost_eq(world.y, 300.0, 0.01, "mini y 30 at scale 0.1 -> world 300")
var back: Vector2 = mm._world_to_mini(world)
assert_almost_eq(back.x, 50.0, 0.01, "world->mini->world round-trip preserves x")
assert_almost_eq(back.y, 30.0, 0.01, "world->mini->world round-trip preserves y")
mm.free()
func test_mini_to_world_zero_scale_is_safe() -> void:
var mm: Control = MinimapScript.new()
mm._minimap_scale = Vector2.ZERO
var world: Vector2 = mm._mini_to_world(Vector2(100.0, 100.0))
assert_eq(world, Vector2.ZERO, "zero scale returns Vector2.ZERO (no divide-by-zero)")
mm.free()
func test_minimap_has_fog_state_triangulation() -> void:
## Acceptance bullet 1: fog reflection on minimap matches main map.
assert_true(MinimapScript.has_method("_draw_fog"),
"_draw_fog() must exist to render fog overlay")
assert_true(MinimapScript.has_method("_get_tile_visibility"),
"_get_tile_visibility() must triangulate fog state per tile")
var fog: Color = MinimapScript.FOG_COLOR
var unexplored: Color = MinimapScript.UNEXPLORED_COLOR
assert_ne(fog, unexplored,
"Seen-stale (FOG_COLOR) must render distinct from unexplored (UNEXPLORED_COLOR)")
assert_lt(fog.a, unexplored.a,
"seen-stale is semi-transparent; unexplored is more opaque")
func test_minimap_has_unit_dot_drawing() -> void:
## Acceptance bullet 2: own units + enemy units render as colored dots.
assert_true(MinimapScript.has_method("_draw_unit_dots"),
"_draw_unit_dots must exist to render unit markers")
assert_true(MinimapScript.has_method("_draw_city_dots"),
"_draw_city_dots must exist to render city markers")
var unit_r: float = MinimapScript.UNIT_DOT_RADIUS
var city_r: float = MinimapScript.CITY_DOT_RADIUS
assert_gt(unit_r, 0.0, "unit dot radius is positive")
assert_gt(city_r, unit_r, "city dots are larger than unit dots for visual hierarchy")
func test_minimap_click_emits_camera_moved_signal() -> void:
## Acceptance bullet 3: clicking the minimap emits EventBus.camera_moved
## with a world-space Vector2 the camera can consume via center_on().
var mm: Control = MinimapScript.new()
mm._minimap_scale = Vector2(0.1, 0.1)
watch_signals(EventBus)
var evt: InputEventMouseButton = InputEventMouseButton.new()
evt.button_index = MOUSE_BUTTON_LEFT
evt.pressed = true
evt.position = Vector2(25.0, 15.0)
mm._on_gui_input(evt)
assert_signal_emitted(EventBus, "camera_moved",
"left-click must emit EventBus.camera_moved with world coords")
mm.free()
func test_minimap_ignores_non_left_buttons() -> void:
var mm: Control = MinimapScript.new()
mm._minimap_scale = Vector2(0.1, 0.1)
watch_signals(EventBus)
var evt: InputEventMouseButton = InputEventMouseButton.new()
evt.button_index = MOUSE_BUTTON_RIGHT
evt.pressed = true
evt.position = Vector2(25.0, 15.0)
mm._on_gui_input(evt)
assert_signal_not_emitted(EventBus, "camera_moved",
"right-click on minimap must not move camera")
mm.free()

View file

@ -35,6 +35,15 @@ window/size/viewport_width=1920
window/size/viewport_height=1080
window/stretch/mode="canvas_items"
[input]
ui_help={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194332,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":47,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[rendering]
renderer/rendering_method="gl_compatibility"

View file

@ -42,3 +42,10 @@ export { StatTable } from './components/ui/StatTable'
export { default as SpeciesBrowserPage } from './pages/engine/SpeciesBrowserPage'
export { buildTheme } from './theme/buildTheme'
export type { RaceThemeConfig, ConcreteGender } from './theme/types'
// ─── Table of contents (shared nav) ──────────────────────────────────────────
export { TableOfContents } from './components/toc/TableOfContents'
export { TocEntry } from './components/toc/TocEntry'
export { SimulationArrow } from './components/toc/SimulationArrow'
export { TocLink, TocIcon } from './components/toc/toc-styles'
export type { NavItem, NavGroup, NavEpisodeHeader } from './types/navigation'

87
tools/deploy-guide.sh Executable file
View file

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Build + deploy the player guide (Age of Dwarves).
#
# Usage:
# tools/deploy-guide.sh build Build only — dist/ ready under the guide package
# tools/deploy-guide.sh serve [port] Build + serve locally (default port 5801)
# tools/deploy-guide.sh apricot [version] Build + rsync to apricot:~/public/guide/<version>/
# tools/deploy-guide.sh zip [version] Build + zip dist/ into .local/guide-<version>.zip
#
# WASM prerequisite: src/simulator/pkg/ must exist. If the guide's
# `@magic-civ/physics-rs` alias can't resolve, the build fails. Rebuild
# via: ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-wasm.sh"
# (per CLAUDE.md Two-Host Workflow — WASM is an apricot-side artifact).
#
# External hosting (GitHub Pages / S3 / Cloudflare Pages) is TODO: no public
# host has been committed for Early Access. Until that decision lands, `zip`
# produces a publishable artifact the team can hand to whichever host wins.
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GUIDE_DIR="$REPO_ROOT/public/games/age-of-dwarves/guide"
DIST_DIR="$GUIDE_DIR/dist"
PKG_NAME="@magic-civilization/guide-age-of-dwarves"
mode="${1:-build}"
_run_build() {
if [ ! -f "$REPO_ROOT/src/simulator/pkg/magic_civ_physics.js" ]; then
echo -e "${YELLOW}warning: src/simulator/pkg/magic_civ_physics.js missing — WASM may not be built.${NC}"
echo -e "${YELLOW} Rebuild via: ssh \"\$AUTOPLAY_HOST\" 'cd \$PROJECT_ROOT_REMOTE/src/simulator && bash build-wasm.sh'${NC}"
fi
echo -e "${BLUE}Building $PKG_NAME ...${NC}"
(cd "$REPO_ROOT" && pnpm --filter "$PKG_NAME" build)
if [ ! -f "$DIST_DIR/index.html" ]; then
echo -e "${RED}✗ dist/index.html not generated — build reported success but produced no output.${NC}"
return 1
fi
local size
size="$(du -sh "$DIST_DIR" | cut -f1)"
echo -e "${GREEN}✓ dist/ ready ($size)${NC}"
}
case "$mode" in
build)
_run_build
;;
serve)
port="${2:-5801}"
_run_build
echo -e "${BLUE}Serving $DIST_DIR on http://localhost:$port ${NC}"
echo -e "${YELLOW}(Ctrl-C to stop)${NC}"
(cd "$DIST_DIR" && exec python3 -m http.server "$port")
;;
apricot)
version="${2:-$(date +%Y%m%d_%H%M%S)}"
: "${AUTOPLAY_HOST:=lilith@apricot.local}"
_run_build
echo -e "${BLUE}Rsyncing dist/ → $AUTOPLAY_HOST:~/public/guide/$version/${NC}"
ssh "$AUTOPLAY_HOST" "mkdir -p \$HOME/public/guide/$version"
rsync -az --delete "$DIST_DIR/" "$AUTOPLAY_HOST:\$HOME/public/guide/$version/"
echo -e "${GREEN}✓ Deployed to $AUTOPLAY_HOST:~/public/guide/$version/${NC}"
echo -e "${YELLOW}Serve it remotely: ssh $AUTOPLAY_HOST 'cd ~/public/guide/$version && python3 -m http.server 8080'${NC}"
;;
zip)
version="${2:-$(date +%Y%m%d_%H%M%S)}"
out_dir="$REPO_ROOT/.local/build/guide"
mkdir -p "$out_dir"
archive="$out_dir/guide-age-of-dwarves-$version.zip"
_run_build
echo -e "${BLUE}Zipping dist/ → $archive ${NC}"
(cd "$DIST_DIR" && zip -qr "$archive" .)
local_size="$(du -h "$archive" | cut -f1)"
echo -e "${GREEN}✓ Archive ready: $archive ($local_size)${NC}"
;;
*)
echo -e "${RED}Unknown mode: $mode${NC}"
echo "Usage: $0 {build|serve [port]|apricot [version]|zip [version]}"
exit 1
;;
esac