feat(@projects/@magic-civilization): ✨ add hotkey system and minimap updates
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
f5d3fd73fa
commit
70a965c1cd
25 changed files with 974 additions and 22 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
42
public/games/age-of-dwarves/data/palettes.json
Normal file
42
public/games/age-of-dwarves/data/palettes.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
161
src/game/engine/scenes/hud/hotkey_sheet.gd
Normal file
161
src/game/engine/scenes/hud/hotkey_sheet.gd
Normal 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)
|
||||
8
src/game/engine/scenes/hud/hotkey_sheet.tscn
Normal file
8
src/game/engine/scenes/hud/hotkey_sheet.tscn
Normal 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")
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
src/game/engine/scenes/tests/hotkey_sheet_proof.gd
Normal file
60
src/game/engine/scenes/tests/hotkey_sheet_proof.gd
Normal 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()
|
||||
6
src/game/engine/scenes/tests/hotkey_sheet_proof.tscn
Normal file
6
src/game/engine/scenes/tests/hotkey_sheet_proof.tscn
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
98
src/game/engine/tests/unit/test_hotkey_sheet.gd
Normal file
98
src/game/engine/tests/unit/test_hotkey_sheet.gd
Normal 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")
|
||||
82
src/game/engine/tests/unit/test_minimap.gd
Normal file
82
src/game/engine/tests/unit/test_minimap.gd
Normal 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()
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
87
tools/deploy-guide.sh
Executable 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
|
||||
Loading…
Add table
Reference in a new issue