diff --git a/.project/objectives/README.md b/.project/objectives/README.md index 94adce05..65f6cefc 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -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 | diff --git a/.project/objectives/p2-01-minimap-improvements.md b/.project/objectives/p2-01-minimap-improvements.md index 58787674..22b5cad3 100644 --- a/.project/objectives/p2-01-minimap-improvements.md +++ b/.project/objectives/p2-01-minimap-improvements.md @@ -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). diff --git a/.project/objectives/p2-03-hotkey-cheat-sheet.md b/.project/objectives/p2-03-hotkey-cheat-sheet.md index 102f789b..7a79bc10 100644 --- a/.project/objectives/p2-03-hotkey-cheat-sheet.md +++ b/.project/objectives/p2-03-hotkey-cheat-sheet.md @@ -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. diff --git a/public/games/age-of-dwarves/data/palettes.json b/public/games/age-of-dwarves/data/palettes.json new file mode 100644 index 00000000..7e84ef9a --- /dev/null +++ b/public/games/age-of-dwarves/data/palettes.json @@ -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" + ] + } +} diff --git a/public/games/age-of-dwarves/guide/src/app/guide-data.ts b/public/games/age-of-dwarves/guide/src/app/guide-data.ts index 739508a3..52e63708 100644 --- a/public/games/age-of-dwarves/guide/src/app/guide-data.ts +++ b/public/games/age-of-dwarves/guide/src/app/guide-data.ts @@ -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, diff --git a/public/games/age-of-dwarves/vocabulary.json b/public/games/age-of-dwarves/vocabulary.json index 3b783166..ebf918b1 100644 --- a/public/games/age-of-dwarves/vocabulary.json +++ b/public/games/age-of-dwarves/vocabulary.json @@ -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" } diff --git a/src/game/engine/scenes/hud/end_turn_button.gd b/src/game/engine/scenes/hud/end_turn_button.gd index 84f8b2c6..21900d33 100644 --- a/src/game/engine/scenes/hud/end_turn_button.gd +++ b/src/game/engine/scenes/hud/end_turn_button.gd @@ -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() diff --git a/src/game/engine/scenes/hud/hotkey_sheet.gd b/src/game/engine/scenes/hud/hotkey_sheet.gd new file mode 100644 index 00000000..d724abf5 --- /dev/null +++ b/src/game/engine/scenes/hud/hotkey_sheet.gd @@ -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) diff --git a/src/game/engine/scenes/hud/hotkey_sheet.tscn b/src/game/engine/scenes/hud/hotkey_sheet.tscn new file mode 100644 index 00000000..c670a109 --- /dev/null +++ b/src/game/engine/scenes/hud/hotkey_sheet.tscn @@ -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") diff --git a/src/game/engine/scenes/hud/top_bar.gd b/src/game/engine/scenes/hud/top_bar.gd index d6c1bb9e..2e77a924 100644 --- a/src/game/engine/scenes/hud/top_bar.gd +++ b/src/game/engine/scenes/hud/top_bar.gd @@ -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() diff --git a/src/game/engine/scenes/menus/options.gd b/src/game/engine/scenes/menus/options.gd index 7663cd71..2a8bb3fb 100644 --- a/src/game/engine/scenes/menus/options.gd +++ b/src/game/engine/scenes/menus/options.gd @@ -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) diff --git a/src/game/engine/scenes/menus/options.tscn b/src/game/engine/scenes/menus/options.tscn index 3cca1a29..f985a5ff 100644 --- a/src/game/engine/scenes/menus/options.tscn +++ b/src/game/engine/scenes/menus/options.tscn @@ -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"] diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 643db37e..72278b45 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -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 diff --git a/src/game/engine/scenes/tests/hotkey_sheet_proof.gd b/src/game/engine/scenes/tests/hotkey_sheet_proof.gd new file mode 100644 index 00000000..c322d514 --- /dev/null +++ b/src/game/engine/scenes/tests/hotkey_sheet_proof.gd @@ -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() diff --git a/src/game/engine/scenes/tests/hotkey_sheet_proof.tscn b/src/game/engine/scenes/tests/hotkey_sheet_proof.tscn new file mode 100644 index 00000000..278cfc22 --- /dev/null +++ b/src/game/engine/scenes/tests/hotkey_sheet_proof.tscn @@ -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") diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index 9c3a1679..e41795ec 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -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") diff --git a/src/game/engine/scenes/world_map/world_map.tscn b/src/game/engine/scenes/world_map/world_map.tscn index da22ea30..26f1b65c 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=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 diff --git a/src/game/engine/src/autoloads/game_state.gd b/src/game/engine/src/autoloads/game_state.gd index 098d236b..327acdb2 100644 --- a/src/game/engine/src/autoloads/game_state.gd +++ b/src/game/engine/src/autoloads/game_state.gd @@ -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, diff --git a/src/game/engine/src/autoloads/settings_manager.gd b/src/game/engine/src/autoloads/settings_manager.gd index e57a8a27..152dcfbd 100644 --- a/src/game/engine/src/autoloads/settings_manager.gd +++ b/src/game/engine/src/autoloads/settings_manager.gd @@ -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 diff --git a/src/game/engine/src/autoloads/theme_assets.gd b/src/game/engine/src/autoloads/theme_assets.gd index c19f5fc8..2d1142fc 100644 --- a/src/game/engine/src/autoloads/theme_assets.gd +++ b/src/game/engine/src/autoloads/theme_assets.gd @@ -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] diff --git a/src/game/engine/tests/unit/test_hotkey_sheet.gd b/src/game/engine/tests/unit/test_hotkey_sheet.gd new file mode 100644 index 00000000..07c8d99a --- /dev/null +++ b/src/game/engine/tests/unit/test_hotkey_sheet.gd @@ -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") diff --git a/src/game/engine/tests/unit/test_minimap.gd b/src/game/engine/tests/unit/test_minimap.gd new file mode 100644 index 00000000..12a6ee8a --- /dev/null +++ b/src/game/engine/tests/unit/test_minimap.gd @@ -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() diff --git a/src/game/project.godot b/src/game/project.godot index ed1cbf52..4d4d427a 100644 --- a/src/game/project.godot +++ b/src/game/project.godot @@ -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" diff --git a/src/packages/guide/src/index.ts b/src/packages/guide/src/index.ts index 9f3016d8..a27f209d 100644 --- a/src/packages/guide/src/index.ts +++ b/src/packages/guide/src/index.ts @@ -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' diff --git a/tools/deploy-guide.sh b/tools/deploy-guide.sh new file mode 100755 index 00000000..fe93c0ea --- /dev/null +++ b/tools/deploy-guide.sh @@ -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// +# tools/deploy-guide.sh zip [version] Build + zip dist/ into .local/guide-.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