From 0b147e66a962194a3e831354d0500eec7e7614e1 Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 19 Jun 2026 20:08:47 -0500 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8E=AD=20hotseat=20multiplayer=20with=20per-seat=20views?= =?UTF-8?q?=20(p3-15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two+ humans sharing one device get a pass-the-device hand-off (hotseat_handoff) gated by GameState.is_hotseat(). Each seat sees only its own view: city_renderer fogs enemy cities until explored, prologue_overlay_renderer draws only the local player's opening, and the AI/turn banners no longer stack across hand-offs. Documents the flow in TURN_SEQUENCE.md, adds a headless handoff proof scene, and marks p3-15 done (dashboard regenerated). Co-Authored-By: Claude Opus 4.8 --- .../p3-15-hotseat-multiplayer-design.md | 150 ++++++++++++++++++ .project/objectives/DASHBOARD_CATEGORIES.md | 1 + .project/objectives/DASHBOARD_COMPLETED.md | 1 + .project/objectives/README.md | 4 +- .project/objectives/objectives.json | 17 +- .../objectives/p3-15-hotseat-multiplayer.md | 47 ++++++ public/games/age-of-dwarves/vocabulary.json | 5 +- src/game/engine/docs/TURN_SEQUENCE.md | 96 +++++++++++ src/game/engine/scenes/hud/ai_turn_overlay.gd | 13 ++ src/game/engine/scenes/hud/hotseat_handoff.gd | 97 +++++++++++ src/game/engine/scenes/hud/world_map_hud.gd | 10 ++ .../engine/scenes/menus/loading_screen.gd | 17 +- src/game/engine/scenes/tests/auto_play.gd | 12 ++ .../scenes/tests/hotseat_handoff_proof.gd | 73 +++++++++ .../scenes/tests/hotseat_handoff_proof.tscn | 6 + src/game/engine/scenes/world_map/world_map.gd | 95 ++++++++++- src/game/engine/src/autoloads/game_state.gd | 14 ++ .../src/modules/ai/ai_turn_bridge_dispatch.gd | 7 + .../engine/src/rendering/city_renderer.gd | 34 ++++ .../rendering/prologue_overlay_renderer.gd | 32 +++- 20 files changed, 708 insertions(+), 23 deletions(-) create mode 100644 .project/designs/p3-15-hotseat-multiplayer-design.md create mode 100644 .project/objectives/p3-15-hotseat-multiplayer.md create mode 100644 src/game/engine/docs/TURN_SEQUENCE.md create mode 100644 src/game/engine/scenes/hud/hotseat_handoff.gd create mode 100644 src/game/engine/scenes/tests/hotseat_handoff_proof.gd create mode 100644 src/game/engine/scenes/tests/hotseat_handoff_proof.tscn diff --git a/.project/designs/p3-15-hotseat-multiplayer-design.md b/.project/designs/p3-15-hotseat-multiplayer-design.md new file mode 100644 index 00000000..47367049 --- /dev/null +++ b/.project/designs/p3-15-hotseat-multiplayer-design.md @@ -0,0 +1,150 @@ +# Engineering design — local hotseat multiplayer (`p3-15`) + +``` +id: p3-15 +title: Local hotseat multiplayer — multiple humans alternating on one device +priority: p3 +scope: game1-override # owner scope override 2026-06-19 (Game 1 was AI-only) +owner: shipwright +status: done +updated_at: 2026-06-19 +``` + +> **Scope note.** Game 1 is documented as single-human vs AI ("5 AI-only clan +> personalities"). The owner explicitly overrode that on 2026-06-19 to add local +> hotseat. This is the same kind of override as the worldsim-USP promotion. AI +> opponents still exist; hotseat just lets 2+ of the slots be human. + +--- + +## 0. The one idea + +Today the renderers' **view player** (fog, units, cities, prologue overlay) is +locked to slot 0 at boot; input handlers already key off `get_current_player()`. +Hotseat needs three things on top of that already-per-player substrate: +1. Setup can mark **multiple slots human** (not just slot 0). +2. The **view player switches** to the current human at the start of each turn — + fog/units/cities re-render for *that* human. +3. A **pass-the-device hand-off** screen hides the board between human turns so + the next player doesn't see the previous one's secrets. + +`is_human` is already serialized (`player.gd:202/237`), so save/resume is free. + +--- + +## 1. What exists vs. new + +**Exists (reused):** +- `GameState.current_player_index` rotation (`turn_manager.next_player`). +- Per-player fog: `tile.visibility[player_index]`, `WorldMapVision.{recalculate_vision,build_fog_arrays}`. +- Per-player renderers: `_unit_renderer.setup_visibility(p)`, `_city_renderer.setup_visibility(p)` + (p3 view-isolation work), `_prologue_overlay.set_local_player(p)`, `_fog_renderer.initialize(map, p)`. +- Input handlers already use `get_current_player()` → the acting player is the current one. +- `ai_turn_overlay` — the full-screen modal pattern to mirror for the hand-off. +- `Player.is_human` serialized in save/load. + +**New:** +- Setup: a per-slot **Human / AI** choice (the human controller_id is the `""` sentinel). +- `world_map._set_view_player(idx)` — re-point every view renderer + recompute the + displayed fog for `idx`. Called when a human's turn starts. +- `HotseatHandoff` overlay scene — "Pass the device to " + a Ready gate; on + Ready, reveals the board. Shown only when **>1 human** (so single-player is unaffected). +- A small `GameState.human_count()` / `is_hotseat()` helper. + +--- + +## 2. Setup — choosing human seats + +`game_setup` builds `controller_ids[]`: index 0 = `""` (human), 1..N = AI picker. +The AI controller picker (`_make_controller_row`) gets a **"Human"** entry at the +top of its options whose id is the `""` sentinel. `_collect_ai_controllers` +already passes `""` through verbatim, and the simulator + `create_player` already +treat `""` as human (`is_human = controller_id == ""`). So: +- Add `""` → label "Human" to the controller dropdown options. +- Validation: at least one human (slot 0 stays human by default); the rest free. +- `create_player(..., is_human = (controller_id == ""))` per slot. + +No new payload shape — just more slots may carry the `""` sentinel. + +## 3. The view-player switch (the core) + +```gdscript +# world_map.gd +func _set_view_player(player_index: int) -> void: + var game_map := GameState.get_game_map() + var p := GameState.get_player(player_index) + if p == null or game_map == null: return + var view := -1 if _arena_mode else player_index + _fog_renderer.initialize(game_map, view) + _unit_renderer.setup_visibility(view, game_map) + (_city_renderer as CityRenderer).setup_visibility(view, game_map) + if _prologue_overlay != null: _prologue_overlay.set_local_player(view) + WorldMapVisionScript.recalculate_vision(p, game_map) + _update_fog(p, game_map) + _sync_units(); _sync_cities() +``` + +- Called from `_on_turn_started` **when the new player is human** (replaces the + static slot-0 `local_player`). For AI turns the view stays on the last human + (the AI turn is brief + obscured by the thinking overlay). +- `_start_game` initializes the view to the first human (current player at start). + +## 4. Pass-the-device hand-off + +`HotseatHandoff` (CanvasLayer, mirror of `ai_turn_overlay`): opaque full-screen +panel, "Pass the device to ****", a "Ready" button (+ Enter). While up, the +board is hidden (the panel is opaque on a high layer) so the incoming human can't +see the outgoing human's fog/units. + +Trigger logic in `_on_turn_started`: +``` +if is_hotseat and player.is_human and not _arena_mode: + show_handoff(player_name) # hides board; on Ready → _set_view_player(idx) + reveal +else if player.is_human: + _set_view_player(idx) # single-human: switch immediately, no hand-off +``` +- The hand-off is shown for **every** human turn in hotseat (including after an AI + turn) — privacy is the point. +- On Ready: `_set_view_player`, hide the panel, enable input. Until Ready, input is + gated (End Turn disabled, hex clicks swallowed). +- AI turns: no hand-off (the `ai_turn_overlay` already covers them, and there's no + secret to protect from the AI). + +## 5. Input gating + +Input already routes through `get_current_player()`, so only the current player's +actions apply. Additions: +- While the hand-off is up, swallow input (the panel's opaque layer + an + `_awaiting_handoff` guard in `_handle_hex_click` / End Turn). +- The previous human's turn has ended (turn rotated), so their input is naturally inert. + +## 6. Save / resume + +`is_human` already round-trips. On load, the view is set to the current player if +human (or the next human). The hand-off shows on the first human turn after load. +No schema change. + +## 7. Increments + tests + +1. **Setup human slots** — "Human" option in the controller picker; `create_player` + honors `""`=human; a 2-human duel is constructible. Test: setup payload yields 2 humans. +2. **View-player switch** — `_set_view_player` + call on human turn_started; `_start_game` + seeds it. Test (auto_play, 2 humans): fog/cities flip to the current human each turn; + neither human sees the other's hidden cities. +3. **Hand-off overlay** — `HotseatHandoff` scene; trigger on human turns in hotseat; + input gated until Ready. Proof scene render on plum (board hidden under the panel). +4. **Save/resume** — load a hotseat save mid-round; view + hand-off resume correctly. +5. **Single-human regression** — with 1 human, no hand-off, behaviour identical to today. + +## 8. Risks / open questions + +- **Opening (turns -1/0/1) in hotseat.** The runner advances all players per + end-turn; with multiple humans each end-turn during the opening advances everyone. + v1: the view-player switch applies to the prologue overlay too (each human sees + their own wanderers on their turn). Per-human opening hand-offs are acceptable. +- **auto_play testing.** `auto_play` impersonates slot 0 only. To test 2-human + hotseat it must drive whichever human is current — small extension (act for the + current player when it `is_human`), or test via a dedicated proof scene. +- **AI-turn view.** During AI turns the view stays on the last human; acceptable + (brief, overlaid). Alternative (blank the board) is more code; deferred. +``` diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md index c1159a77..6ff7c06a 100644 --- a/.project/objectives/DASHBOARD_CATEGORIES.md +++ b/.project/objectives/DASHBOARD_CATEGORIES.md @@ -526,4 +526,5 @@ | [p3-13c](p3-13c-biological-events.md) | ✅ done | P3 | Biological events — plague, bloom, migration_pulse | [unassigned](../team-leads/unassigned.md) | 🟢 | | [p3-13d](p3-13d-anomalous-events.md) | ✅ done | P3 | Anomalous events — aurora, fog_bank, thermal_anomaly | [unassigned](../team-leads/unassigned.md) | 🟢 | | [p3-14](p3-14-game-start-script.md) | ✅ done | P3 | Declarative game-start script + runner — data-driven, moddable opening sequence | [shipwright](../team-leads/shipwright.md) | 🟢 | +| [p3-15](p3-15-hotseat-multiplayer.md) | ✅ done | P3 | Local hotseat multiplayer — multiple humans alternating on one device | [shipwright](../team-leads/shipwright.md) | 🟢 | diff --git a/.project/objectives/DASHBOARD_COMPLETED.md b/.project/objectives/DASHBOARD_COMPLETED.md index a92efcc8..aa9b596c 100644 --- a/.project/objectives/DASHBOARD_COMPLETED.md +++ b/.project/objectives/DASHBOARD_COMPLETED.md @@ -271,4 +271,5 @@ | [p3-13c](p3-13c-biological-events.md) | Biological events — plague, bloom, migration_pulse | — | [unassigned](../team-leads/unassigned.md) | 2026-05-13 | | [p3-13d](p3-13d-anomalous-events.md) | Anomalous events — aurora, fog_bank, thermal_anomaly | — | [unassigned](../team-leads/unassigned.md) | 2026-05-07 | | [p3-14](p3-14-game-start-script.md) | Declarative game-start script + runner — data-driven, moddable opening sequence | — | [shipwright](../team-leads/shipwright.md) | 2026-06-19 | +| [p3-15](p3-15-hotseat-multiplayer.md) | Local hotseat multiplayer — multiple humans alternating on one device | — | [shipwright](../team-leads/shipwright.md) | 2026-06-19 | diff --git a/.project/objectives/README.md b/.project/objectives/README.md index 7cf6ff69..014b50c8 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -17,8 +17,8 @@ | **P0** | 0 | 0 | 0 | 0 | 0 | 44 | 44 | | **P1** | 0 | 13 | 1 | 0 | 1 | 79 | 94 | | **P2** | 0 | 19 | 5 | 5 | 1 | 104 | 134 | -| **P3 (oos)** | 0 | 2 | 0 | 0 | 29 | 23 | 54 | -| **total** | **0** | **34** | **6** | **5** | **31** | **250** | **326** | +| **P3 (oos)** | 0 | 2 | 0 | 0 | 29 | 24 | 55 | +| **total** | **0** | **34** | **6** | **5** | **31** | **251** | **327** | diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json index 25de4362..de4db110 100644 --- a/.project/objectives/objectives.json +++ b/.project/objectives/objectives.json @@ -1,13 +1,13 @@ { - "generated_at": "2026-06-19T22:38:05Z", + "generated_at": "2026-06-20T00:32:44Z", "totals": { - "done": 250, + "done": 251, "in_progress": 0, "partial": 34, "stub": 6, "missing": 5, "oos": 31, - "total": 326 + "total": 327 }, "objectives": [ { @@ -3641,6 +3641,17 @@ "updated_at": "2026-06-19", "blocked_by": [], "summary": "A bounded op vocabulary (`place_spawn_box`, `roll_actor_directions`, `step_actors`,\n`converge_actors`, `spawn_unit`, `set_unit_actions`, `set_banner`, `lift_fog`, `emit_chronicle`,\n`found_city`) where every op maps to an existing tested simulation primitive. A `StartScriptRunner`\nin `mc-turn` sequences phases from data, replacing the hardcoded `PrologueTurn::advance` switch.\n`setup.json:start_script` selects the script; scenarios ship their own under\n`public/resources/start_scripts/`. Modder-grade validation with errors naming script id + phase id +\nop index." + }, + { + "id": "p3-15", + "title": "Local hotseat multiplayer — multiple humans alternating on one device", + "priority": "p3", + "status": "done", + "scope": "game1-override", + "owner": "shipwright", + "updated_at": "2026-06-19", + "blocked_by": [], + "summary": "" } ], "blocked": [ diff --git a/.project/objectives/p3-15-hotseat-multiplayer.md b/.project/objectives/p3-15-hotseat-multiplayer.md new file mode 100644 index 00000000..99f30f7c --- /dev/null +++ b/.project/objectives/p3-15-hotseat-multiplayer.md @@ -0,0 +1,47 @@ +--- +id: p3-15 +title: Local hotseat multiplayer — multiple humans alternating on one device +priority: p3 +scope: game1-override +owner: shipwright +status: done +updated_at: 2026-06-19 +design: .project/designs/p3-15-hotseat-multiplayer-design.md +evidence: + - .project/designs/p3-15-hotseat-multiplayer-design.md + - src/game/engine/scenes/menus/game_setup.gd + - src/game/engine/scenes/menus/loading_screen.gd + - src/game/engine/scenes/world_map/world_map.gd + - src/game/engine/scenes/hud/hotseat_handoff.gd + - src/game/engine/scenes/tests/hotseat_handoff_proof.gd + - src/game/engine/src/autoloads/game_state.gd +--- + +## Context + +Owner scope override (2026-06-19): Game 1 was documented single-human vs AI; the +owner promoted local **hotseat** (2+ humans alternating on one device) into scope. +The per-player view substrate already exists (fog keyed on `player_index`, +renderers take a local-player, input routes through `get_current_player()`, +`is_human` is serialized). The gap is: setup can't mark >1 slot human, the view is +locked to slot 0, and there's no pass-the-device hand-off. + +Full design: [`.project/designs/p3-15-hotseat-multiplayer-design.md`](../designs/p3-15-hotseat-multiplayer-design.md). + +## Acceptance + +- ✓ Setup lets a slot be **Human** ("" controller sentinel); a 2-human duel is constructible; `create_player` honors it. — `game_setup.gd` "Human" picker option (`HUMAN_CONTROLLER_ID`); `loading_screen.gd:114` now derives `is_human` from the controller id (was hardcoded `i==0`). +- ✓ `world_map._set_view_player(idx)` re-points fog/units/cities/overlay + recomputes displayed fog; called on each human turn start. — `world_map.gd` `_set_view_player`; smoke log alternates `view → player 0/1`. +- ✓ Pass-the-device **hand-off** overlay shown on every human turn in hotseat (board hidden until Ready); input gated until Ready. — `hotseat_handoff.gd`; `_awaiting_handoff` gates `_handle_hex_click` + `_on_end_turn_pressed`. +- ✓ Player A never sees player B's view across the hand-off. — Visual proof: bright magenta "secret" board fully hidden by the opaque panel (`hotseat_handoff_proof.tscn` render, 2026-06-19). +- ✓ Save/resume of a hotseat roster; `is_human` round-trips. — `mid_run.save` holds `"is_human":true`×2; resume keeps the view alternating 0/1 (both humans survive). +- ✓ Single-human regression: with 1 human, no hand-off, behaviour identical. — Default auto_play run: 0 hotseat log lines, same progression (2 converged + 2 founded), exit 0. +- ✓ Runtime-proven on plum. — 2-human auto_play smoke (`AUTO_PLAY_HUMANS=2`, exit 0, no errors) + hand-off visual proof render. + +## Notes + +- v1 limitation (design §8): the opening (turns -1/0/1) is a shared cinematic — the + per-turn view switch + hand-off apply to **normal play** (`not _is_prologue_active()`). +- AI-turn view stays on the last human (brief, overlaid by `ai_turn_overlay`). +- Test harness hooks added: `AUTO_PLAY_HUMANS=N` (auto_play seats N humans), + hand-off auto-passes under `AUTO_PLAY`, `_set_view_player` logs under `AUTO_PLAY`. diff --git a/public/games/age-of-dwarves/vocabulary.json b/public/games/age-of-dwarves/vocabulary.json index 7cd6e1c1..d5e03502 100644 --- a/public/games/age-of-dwarves/vocabulary.json +++ b/public/games/age-of-dwarves/vocabulary.json @@ -511,7 +511,10 @@ "fmt_turn_progress": "turn %d / %d", "fmt_turn_progress_initial": "turn 1 / %d", "fmt_and_n_more": "... and %d more", - "fmt_ai_slot": "AI %d — %s", + "fmt_ai_clan_slot": "AI %d — %s", + "controller_human": "Human", + "fmt_handoff_pass_device": "Pass the device to %s", + "handoff_ready": "Ready", "fmt_clan_leader": "Clan %s — %s", "fmt_load_entry": "Turn %d %s %s%s", "fmt_vs": "%s vs %s", diff --git a/src/game/engine/docs/TURN_SEQUENCE.md b/src/game/engine/docs/TURN_SEQUENCE.md new file mode 100644 index 00000000..5c2bc58c --- /dev/null +++ b/src/game/engine/docs/TURN_SEQUENCE.md @@ -0,0 +1,96 @@ +# Turn sequence — what happens per turn, per player + +Authoritative source: `src/game/engine/src/autoloads/turn_manager.gd` (start_turn / +end_turn / next_player / _process_ai_turn) + `turn_processor.gd` (the `_process_*` +passes). This doc mirrors that code; if they diverge, the code wins — update this. + +Turn order is fixed: players act one at a time in slot order (`0, 1, 2, …`). Slot 0 +is the local/human player; the rest are AI. A **round** = every player taking one +turn; `GameState.turn_number` increments once per round (after the last player). + +--- + +## A. Game opening (once per game, before turn 1) + +Driven by the declarative start script (`setup.json:start_script`, default +`default_dwarf_tribe`). Runs for **every** player (human + AI) — see +[START_SCRIPTS.md](../../../public/games/age-of-dwarves/docs/START_SCRIPTS.md). + +- **Turn −1** — each player's spawn box places `N` free-dwarf wanderers; fog lifts + on the box. Only input: End Turn → wanderers roll a move direction. +- **Turn 0** — wanderers step one hex; those within the convergence radius merge + into that player's **Dwarf Tribe** (carrying its founding-pop). Only input: End Turn. +- **Turn 1** — the Dwarf Tribe is player-controlled (`found_capital` / `move`). + Founding the capital (human action, or AI auto-found) hands off to normal play. + +During the opening, `end_turn` advances the **runner** for all players in one pass +(not the economy processors below); the same human keeps acting (no player rotation). + +--- + +## B. Each normal turn, per player + +### B.1 START — `start_turn()` (Phase.START) +For the player whose turn is beginning: +1. **Refresh units** — `refresh_player_units(player)`: restore each unit's movement + points + clear per-turn flags (so it can act this turn). +2. **Refresh cities** — `city.refresh_turn()` for each city (clears the bombard flag, etc.). +3. **Recalculate vision** — `recalculate_vision(player, game_map)`: update this + player's fog (currently-visible → seen-stale, reveal new tiles in range). +4. Emit `turn_started`; phase → **PLAYER_ACTIONS**. +5. **If AI** → `_process_ai_turn` runs immediately (B.2-AI). **If human** → wait for + the player's actions, ending with End Turn. + +### B.2 PLAYER ACTIONS — the turn body (Phase.PLAYER_ACTIONS) +- **Human**: free to move units, found/manage cities, set production + research, + use abilities, etc. (the `legal_actions` the UI exposes). Ends on **End Turn**. +- **AI** (`_process_ai_turn`): emit `ai_turn_started` (the " is thinking…" + overlay shows); `AiTurnBridge.run(player)` applies the AI's strategic + tactical + actions; emit `ai_turn_completed`; then `end_turn()` is called automatically. + +### B.3 END — `end_turn()` (Phase.END_TURN) +The **current player's** end-of-turn economy/growth resolves, in this exact order +(culture first so freshly-claimed tiles are workable during growth): +1. **Culture** (border growth) — `_process_culture` +2. **Culture research** (tradition progress) — `_process_culture_research` +3. **Growth** (food → population, citizen assignment) — `_process_growth` +4. **Production** (build queue: units / buildings / wonders) — `_process_production` +5. **Economy** (gold income − upkeep) — `_process_economy` +6. **Research** (science → tech progress) — `_process_research` +7. **Happiness / Golden Age** — `_process_golden_age` +8. **Unit healing** — `_process_healing` +9. **City healing** — `_process_city_healing` +10. **Tile improvements** (build progress / completion) — `_process_improvements` +11. **Loot decay** — `_process_loot_decay` +12. **Government** (civic effects) — `_process_government` + +Then emit `turn_ended` and call `next_player()`. + +### B.4 NEXT PLAYER — `next_player()` +- Advance to the next slot. **If more players remain this round** → `start_turn()` + for them (back to B.1). +- **If the last player just finished the round** (wrap to slot 0): + - **World tick** (once per round, not per player): wild-creature AI + (`_process_wild_creatures`), optional Rust fauna encounters, and the runtime + worldsim/climate step. (Diplomacy + protection-effects passes are disabled + stubs — see `turn_processor.gd`.) + - Increment `GameState.turn_number`. + - `start_turn()` for slot 0 — the next round begins. + +--- + +## C. UI / presentation per turn (world_map) +- On `turn_started` (`world_map._on_turn_started`): refresh units; for the **human**, + re-enable input + update the HUD + show the **"'s turn"** banner. For an + **AI**, hide the banner (the `ai_turn_overlay` " is thinking…" is the sole + turn indicator — exactly one per player, no overlap). +- Fog/cities/units are rendered **for the local (human) player only**: own units + + cities always show; enemy units show when currently visible; enemy cities show + once their tile has been explored. Player A never sees player B's view. + +## Invariants +- Players act in fixed slot order; one turn each per round. +- `turn_number` increments once per round (post-last-player), not per player turn. +- All economy/growth resolution is at **end of that player's turn**, in the B.3 order. +- The world tick (wild creatures, worldsim) runs once per round, between rounds. +- Determinism: every per-turn computation flows from the per-map seed. diff --git a/src/game/engine/scenes/hud/ai_turn_overlay.gd b/src/game/engine/scenes/hud/ai_turn_overlay.gd index fcd2e30b..944e4d73 100644 --- a/src/game/engine/scenes/hud/ai_turn_overlay.gd +++ b/src/game/engine/scenes/hud/ai_turn_overlay.gd @@ -19,6 +19,11 @@ func _ready() -> void: _build_ui() EventBus.ai_turn_started.connect(_on_ai_turn_started) EventBus.ai_turn_completed.connect(_on_ai_turn_completed) + # Clear any lingering "thinking" overlay the instant a new turn begins, so it + # never stacks with the next player's indicator (the human turn banner, or + # the next AI's own thinking text). turn_started fires before ai_turn_started, + # so an AI turn re-shows it immediately after this hide. + EventBus.turn_started.connect(_on_any_turn_started) func _build_ui() -> void: @@ -74,6 +79,14 @@ func _on_ai_turn_completed(_player_index: int) -> void: _dismiss() +func _on_any_turn_started(_turn_number: int, _player_index: int) -> void: + # Hide immediately on any turn boundary. An AI turn re-shows it via + # _on_ai_turn_started (fired just after turn_started); a human turn leaves + # it hidden so it can't linger over the human's "'s turn" banner. + _completed = true + _dismiss() + + func _on_dismiss_timeout() -> void: if _completed: _dismiss() diff --git a/src/game/engine/scenes/hud/hotseat_handoff.gd b/src/game/engine/scenes/hud/hotseat_handoff.gd new file mode 100644 index 00000000..6fd0a49e --- /dev/null +++ b/src/game/engine/scenes/hud/hotseat_handoff.gd @@ -0,0 +1,97 @@ +class_name HotseatHandoff +extends CanvasLayer +## Pass-the-device hand-off (p3-15). An opaque full-screen gate shown between +## human turns in local hotseat, so the incoming player cannot see the outgoing +## player's board/fog. The view is switched to the new player *behind* this panel; +## the panel only lifts once the player confirms. Emits `ready_pressed`. + +signal ready_pressed + +const TITLE_FONT_SIZE: int = 30 +const HINT_FONT_SIZE: int = 14 + +var _dim: ColorRect = null +var _label: Label = null +var _button: Button = null + + +func _ready() -> void: + layer = 30 # above the AI thinking overlay (19) and banners + visible = false + process_mode = Node.PROCESS_MODE_ALWAYS + _build_ui() + + +func _build_ui() -> void: + # Fully opaque so the previous player's board is completely hidden underneath. + _dim = ColorRect.new() + _dim.name = "Dim" + _dim.color = Color(0.04, 0.035, 0.03, 1.0) + _dim.set_anchors_preset(Control.PRESET_FULL_RECT) + _dim.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(_dim) + + var vbox: VBoxContainer = VBoxContainer.new() + vbox.set_anchors_preset(Control.PRESET_CENTER) + vbox.offset_left = -260.0 + vbox.offset_right = 260.0 + vbox.offset_top = -60.0 + vbox.offset_bottom = 60.0 + vbox.alignment = BoxContainer.ALIGNMENT_CENTER + vbox.add_theme_constant_override("separation", 18) + _dim.add_child(vbox) + + _label = Label.new() + _label.name = "HandoffLabel" + _label.add_theme_font_size_override("font_size", TITLE_FONT_SIZE) + _label.theme_type_variation = "LabelTitle" + _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + vbox.add_child(_label) + + _button = Button.new() + _button.name = "ReadyButton" + _button.text = ThemeVocabulary.lookup("handoff_ready") + _button.custom_minimum_size = Vector2(200, 44) + _button.pressed.connect(_on_ready) + vbox.add_child(_button) + + var hint: Label = Label.new() + hint.text = ThemeVocabulary.lookup("dismiss_hint") + hint.add_theme_font_size_override("font_size", HINT_FONT_SIZE) + hint.theme_type_variation = "LabelMuted" + hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + vbox.add_child(hint) + + +## Show the gate for `player_name`. The board should already have been switched +## to that player behind this panel before/while it is up. +func show_for(player_name: String) -> void: + if _label == null: + return + _label.text = ThemeVocabulary.lookup("fmt_handoff_pass_device") % player_name + visible = true + # Headless auto-play driver: no human to press Ready. Show for one frame (so a + # proof screenshot can catch the panel) then auto-pass so the run progresses. + if EnvConfig.get_bool("AUTO_PLAY"): + call_deferred("_on_ready") + return + _button.grab_focus() + + +func hide_handoff() -> void: + visible = false + + +func is_active() -> bool: + return visible + + +func _on_ready() -> void: + visible = false + ready_pressed.emit() + + +func _unhandled_input(event: InputEvent) -> void: + if visible and event.is_action_pressed("ui_accept"): + _on_ready() + get_viewport().set_input_as_handled() diff --git a/src/game/engine/scenes/hud/world_map_hud.gd b/src/game/engine/scenes/hud/world_map_hud.gd index 3e3c19dd..bd38dec6 100644 --- a/src/game/engine/scenes/hud/world_map_hud.gd +++ b/src/game/engine/scenes/hud/world_map_hud.gd @@ -385,6 +385,16 @@ func _on_banner_timer_timeout() -> void: _prologue_banner.visible = false +## Immediately hide the centered banner (e.g. when the turn passes to an AI +## player, so the human's lingering "'s turn" banner doesn't stack with +## the AI thinking overlay). +func hide_banner() -> void: + if _prologue_banner == null: + return + _banner_timer.stop() + _prologue_banner.visible = false + + func update_turn(turn: int) -> void: _turn_label.text = _format_turn_text(turn) _update_tutorial_button_visibility(turn) diff --git a/src/game/engine/scenes/menus/loading_screen.gd b/src/game/engine/scenes/menus/loading_screen.gd index 6890e91c..64b56963 100644 --- a/src/game/engine/scenes/menus/loading_screen.gd +++ b/src/game/engine/scenes/menus/loading_screen.gd @@ -4,6 +4,8 @@ extends Control const WORLD_MAP_SCENE: String = "res://engine/scenes/world_map/world_map.tscn" const TIPS_PATH: String = "res://public/games/age-of-dwarves/data/loading_tips.json" const PERSONALITIES_PATH: String = "res://public/games/age-of-dwarves/data/ai_personalities.json" +## Hotseat (p3-15): fallback AI controller for opponent slots with no payload entry. +const DEFAULT_AI_CONTROLLER_ID: String = "scripted:default" const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd") const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") const WildCreatureAIScript: GDScript = preload("res://engine/src/modules/ai/wild_creature_ai.gd") @@ -111,7 +113,17 @@ func _create_players() -> void: for i: int in range(GameState.game_settings.get("num_players", 2)): var p: PlayerScript = PlayerScript.new() p.index = i - p.is_human = (i == 0) and not arena_mode + # Hotseat (p3-15): the controller id decides human vs AI. The "" sentinel + # = a human slot; any AI controller id = AI. Payload entries come from + # game_setup (slot 0 = "", others per picker — can now be ""). When no + # payload entry exists (direct boot), slot 0 is human, the rest AI. Arena + # mode forces every slot to AI (observer). + var controller_id: String + if i < controllers_arr.size(): + controller_id = str(controllers_arr[i]) + else: + controller_id = "" if i == 0 else DEFAULT_AI_CONTROLLER_ID + p.is_human = controller_id.is_empty() and not arena_mode var base: String = "Player %d" % (i + 1) p.player_name = EnvConfig.get_var("AI_ARENA_P%d_NAME" % (i + 1), base) if arena_mode else base p.race_id = default_race @@ -126,8 +138,7 @@ func _create_players() -> void: # this runs (`_gd_state.player_count()` is the canonical guard); # the call no-ops in that case. Downstream bridges that # materialise players later read `game_settings.ai_controllers` - # directly. - var controller_id: String = str(controllers_arr[i]) if i < controllers_arr.size() else "" + # directly. `controller_id` was resolved above (decides human vs AI). if not p.is_human and not controller_id.is_empty(): var gs: RefCounted = GameState.get_gd_state() if gs != null and int(gs.player_count()) > i: diff --git a/src/game/engine/scenes/tests/auto_play.gd b/src/game/engine/scenes/tests/auto_play.gd index 4edbded4..eb85d285 100644 --- a/src/game/engine/scenes/tests/auto_play.gd +++ b/src/game/engine/scenes/tests/auto_play.gd @@ -590,6 +590,18 @@ func _process(_delta: float) -> void: if not diff_env.is_empty(): GameState.game_settings["difficulty"] = diff_env print("AutoPlay: AI_DIFFICULTY=%s applied" % diff_env) + # p3-15 hotseat smoke: AUTO_PLAY_HUMANS=N seats the first N slots + # as human ("" controller) and the rest as AI. loading_screen + # derives is_human from these ids. Defaults to the normal 1 human. + var human_seats: int = clampi( + int(EnvConfig.get_var("AUTO_PLAY_HUMANS", "1")), 1, num_players + ) + if human_seats > 1: + var controllers: Array[String] = [] + for slot: int in range(num_players): + controllers.append("" if slot < human_seats else "scripted:default") + GameState.game_settings["ai_controllers"] = controllers + print("AutoPlay: AUTO_PLAY_HUMANS=%d (hotseat) applied" % human_seats) # apply_ai_difficulty() + per-player overrides are deferred to # wait_loading (after DataLoader.load_theme runs in loading_screen). _state = "wait_loading" diff --git a/src/game/engine/scenes/tests/hotseat_handoff_proof.gd b/src/game/engine/scenes/tests/hotseat_handoff_proof.gd new file mode 100644 index 00000000..7f97b843 --- /dev/null +++ b/src/game/engine/scenes/tests/hotseat_handoff_proof.gd @@ -0,0 +1,73 @@ +extends Node +## p3-15 proof scene: mount the hotseat pass-device hand-off over a bright, +## unmistakable "secret" background (standing in for the outgoing player's board) +## and capture a screenshot. The opaque panel MUST fully hide the background — +## if any of the magenta "SECRET" field bleeds through, the hand-off leaks the +## previous player's view. Success = only the dark panel + "Pass the device to +## Player 2" + Ready button are visible. + +const HotseatHandoffScript: GDScript = preload( + "res://engine/scenes/hud/hotseat_handoff.gd" +) + +var _captured: bool = false +var _screenshot_name: String = "hotseat_handoff" + + +func _ready() -> void: + RenderingServer.set_default_clear_color(Color(0.9, 0.1, 0.7)) + 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 + + # Bright "secret board" the hand-off must completely cover. + var bg: ColorRect = ColorRect.new() + bg.color = Color(0.9, 0.1, 0.7, 1.0) + bg.anchor_right = 1.0 + bg.anchor_bottom = 1.0 + add_child(bg) + var secret: Label = Label.new() + secret.text = "SECRET — PLAYER 1's VIEW (must be hidden)" + secret.add_theme_font_size_override("font_size", 48) + secret.set_anchors_preset(Control.PRESET_CENTER) + secret.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + bg.add_child(secret) + + var handoff: CanvasLayer = HotseatHandoffScript.new() + add_child(handoff) + for _i: int in range(4): + await get_tree().process_frame + handoff.show_for("Player 2") + + for _i: int in range(8): + await get_tree().process_frame + + print("Hotseat hand-off rendered — panel active=%s" % str(handoff.is_active())) + _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/hotseat_handoff_proof.tscn b/src/game/engine/scenes/tests/hotseat_handoff_proof.tscn new file mode 100644 index 00000000..3467a2f9 --- /dev/null +++ b/src/game/engine/scenes/tests/hotseat_handoff_proof.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bhotseathandoffproof01"] + +[ext_resource type="Script" path="res://engine/scenes/tests/hotseat_handoff_proof.gd" id="1_script"] + +[node name="HotseatHandoffProof" 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 4f723994..91349eb2 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -45,6 +45,9 @@ const WorldMapHoverScript: GDScript = preload( const PrologueDriverScript: GDScript = preload( "res://engine/src/modules/management/prologue_driver.gd" ) +const HotseatHandoffScript: GDScript = preload( + "res://engine/scenes/hud/hotseat_handoff.gd" +) const PrologueOverlayRendererScript: GDScript = preload( "res://engine/src/rendering/prologue_overlay_renderer.gd" ) @@ -71,6 +74,12 @@ var _prologue_overlay: Node2D = null ## paint the recurring "'s turn" banner over it. One-shot guard. var _suppress_turn_banner_once: bool = false +## p3-15 hotseat: the pass-device hand-off overlay + an input gate that holds the +## incoming human until they press Ready (so input can't reach the board while +## it's still revealing). Null + false when not in hotseat. +var _hotseat_handoff: HotseatHandoffScript = null +var _awaiting_handoff: bool = false + var _selected_unit: RefCounted = null var _reachable_hexes: Dictionary = {} var _bombard_city: RefCounted = null @@ -301,7 +310,13 @@ func _start_game() -> void: if first_player != null: local_player = first_player.index _fog_renderer.initialize(game_map, local_player) - _unit_renderer.setup_visibility(local_player, game_map) + # Arena (AI-vs-AI observer) sees everything → -1 disables the per-player fog + # filter on cities + the prologue overlay (mirrors _fog_renderer.fog_disabled). + var view_player: int = -1 if _arena_mode else local_player + _unit_renderer.setup_visibility(view_player, game_map) + (_city_renderer as CityRenderer).setup_visibility(view_player, game_map) + if _prologue_overlay != null: + _prologue_overlay.set_local_player(view_player) _overlay_renderer.initialize(local_player) _overlay_renderer.render_overlays(game_map) @@ -366,6 +381,12 @@ func _start_game() -> void: ## the Tutorial button on the world-map HUD top bar (visible turns 1..5). func _mount_hud_overlays() -> void: add_child(HotkeySheetScene.instantiate()) + # p3-15: hotseat pass-device hand-off (only relevant with 2+ humans, but + # mounted unconditionally; it stays hidden unless triggered). + _hotseat_handoff = HotseatHandoffScript.new() + _hotseat_handoff.name = "HotseatHandoff" + _hotseat_handoff.ready_pressed.connect(_on_handoff_ready) + add_child(_hotseat_handoff) ## p1-19: instantiate the tutorial overlay on demand. Defaults to Step 1 @@ -573,6 +594,34 @@ func _update_fog(player: RefCounted, game_map: RefCounted) -> void: var arrays: Array = WorldMapVisionScript.build_fog_arrays(player, game_map) _hex_renderer.update_fog(arrays[0], arrays[1]) _fog_renderer.update_all(game_map) + # Cities are fog-filtered to the local player; redraw so a newly-explored + # enemy city appears (and stays as last-known once it re-enters fog). + if _city_renderer != null: + (_city_renderer as CityRenderer).queue_redraw() + + +## Hotseat (p3-15): re-point every view renderer to `player_index` and recompute +## that player's displayed fog. Called when a human's turn begins so the screen +## flips to the active human's view (fog / units / cities / prologue overlay). +## Arena/observer (`_arena_mode`) uses -1 = show everything. +func _set_view_player(player_index: int) -> void: + var game_map: RefCounted = GameState.get_game_map() + var player: RefCounted = GameState.get_player(player_index) + if game_map == null or player == null: + return + var view: int = -1 if _arena_mode else player_index + if EnvConfig.get_bool("AUTO_PLAY"): + print("WorldMap: view → player %d (%s)" % [player_index, str(player.player_name)]) + _fog_renderer.initialize(game_map, view) + _unit_renderer.setup_visibility(view, game_map) + (_city_renderer as CityRenderer).setup_visibility(view, game_map) + if _prologue_overlay != null: + _prologue_overlay.set_local_player(view) + WorldMapVisionScript.recalculate_vision(player, game_map) + WorldMapVisionScript.record_observations(player, game_map) + _update_fog(player, game_map) + _sync_units() + _sync_cities() func _sync_units() -> void: @@ -877,6 +926,10 @@ func _handle_hex_click(axial: Vector2i) -> void: # Swallow hex clicks so no unit selection, bombard, or move path triggers. if _is_prologue_active(): return + # p3-15: swallow board input while the hotseat hand-off panel is up (the + # incoming player hasn't confirmed; the board underneath is mid-reveal). + if _awaiting_handoff: + return var game_map: RefCounted = GameState.get_game_map() if game_map == null: return @@ -1048,11 +1101,23 @@ func _open_statistics() -> void: func _on_end_turn_pressed() -> void: + if _awaiting_handoff: + return _deselect_unit() _hud.set_end_turn_disabled(true) TurnManager.end_turn() +## p3-15: the incoming hotseat human pressed Ready — the view was already switched +## behind the panel; just restore input for their turn. +func _on_handoff_ready() -> void: + _awaiting_handoff = false + var player: RefCounted = GameState.get_current_player() + if player != null and not _arena_mode: + _hud.set_end_turn_disabled(not player.is_human) + _update_hud() + + func _on_turn_started(_turn_number: int, player_index: int) -> void: var player: RefCounted = GameState.get_player(player_index) if player == null: @@ -1063,17 +1128,35 @@ func _on_turn_started(_turn_number: int, player_index: int) -> void: _hud.set_end_turn_disabled(not is_human) if is_human: _update_hud() - # p0-34: recurring "'s turn" banner during normal play. The - # prologue banner owns turns -1/0, and the turn-1 founding banner is - # shown once via _on_prologue_state_changed (guarded below). + # Recurring "'s turn" banner during normal play. Exactly one + # turn indicator per player: the HUMAN's turn shows the banner; an AI + # turn is announced by the ai_turn_overlay (" is thinking"), so we + # HIDE any lingering banner instead of stacking two indicators on screen. + # The prologue banner owns turns -1/0; the turn-1 founding banner is shown + # once via _on_prologue_state_changed (guarded below). if not _is_prologue_active(): if _suppress_turn_banner_once: _suppress_turn_banner_once = false - else: + _hud.hide_banner() + elif is_human: var pname: String = str(player.player_name) if pname.is_empty(): pname = ThemeVocabulary.lookup("player_unmet") - _hud.show_turn_banner(pname) + # p3-15 hotseat: gate this human behind the pass-device hand-off — + # raise the opaque panel FIRST (covering the previous player's + # board), switch the view behind it, then hold input until Ready. + # Single-human just flips the view + shows the turn banner. + if GameState.is_hotseat() and _hotseat_handoff != null: + _hud.hide_banner() + _hotseat_handoff.show_for(pname) + _set_view_player(player_index) + _awaiting_handoff = true + _hud.set_end_turn_disabled(true) + else: + _set_view_player(player_index) + _hud.show_turn_banner(pname) + else: + _hud.hide_banner() for unit: RefCounted in player.units: if unit is UnitScript: diff --git a/src/game/engine/src/autoloads/game_state.gd b/src/game/engine/src/autoloads/game_state.gd index 2ef89a88..808198bb 100644 --- a/src/game/engine/src/autoloads/game_state.gd +++ b/src/game/engine/src/autoloads/game_state.gd @@ -325,6 +325,20 @@ func get_current_player() -> RefCounted: # Returns Player return get_player(current_player_index) +## Number of human players in the roster (hotseat, p3-15). +func human_count() -> int: + var n: int = 0 + for p: Variant in players: + if p is PlayerScript and (p as PlayerScript).is_human: + n += 1 + return n + + +## True when 2+ humans share the device (hotseat) — gates the pass-device hand-off. +func is_hotseat() -> bool: + return human_count() >= 2 + + func get_player(index: int) -> RefCounted: # Returns Player if index < 0 or index >= players.size(): push_warning("GameState: Invalid player index %d" % index) diff --git a/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd b/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd index cc0cd8be..0f2a923b 100644 --- a/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd +++ b/src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd @@ -173,6 +173,13 @@ static func dispatch_found_city( var settler: RefCounted = resolve_unit(int(fields.get("settler_id", -1)), index_maps) if settler == null or not settler.is_alive() or not settler.can_found_city: return false + # Guard against a stale/already-consumed settler: the index_maps are built at + # turn start, but founding consumes the unit (erased from player.units). A + # redundant found action against the same unit this turn would otherwise + # resolve the still-live RefCounted and attempt a second founding (empty + # PrologueDriver.found_capital → spurious warning, no second city). + if not player.units.has(settler): + return false var at_hex: Array = fields.get("at_hex", []) if at_hex.size() != 2: return false diff --git a/src/game/engine/src/rendering/city_renderer.gd b/src/game/engine/src/rendering/city_renderer.gd index 698b6960..4f59f9b2 100644 --- a/src/game/engine/src/rendering/city_renderer.gd +++ b/src/game/engine/src/rendering/city_renderer.gd @@ -51,6 +51,36 @@ var _hex_poly: PackedVector2Array = HexUtilsScript.hex_polygon ## Sprite cache: sprite key -> Texture2D (null if unavailable) var _sprite_cache: Dictionary = {} +## Fog-of-war: the local (viewing) player + map for visibility checks. Enemy +## cities (and their borders) are hidden until the local player has explored +## their tile, so player A never sees player B's cities through unexplored fog. +## `_local_player < 0` / `_game_map == null` means "no fog" (arena / observer). +var _local_player: int = -1 +var _game_map: RefCounted = null +## Visibility tier at/above which a city is revealed (1 = explored/last-known, +## 2 = currently visible). Mirrors world_map_vision.VIS_SEEN_STALE. +const VIS_EXPLORED_MIN: int = 1 + + +## Set the local (viewing) player + map for fog-filtering enemy cities. +func setup_visibility(local_player: int, game_map: RefCounted) -> void: + _local_player = local_player + _game_map = game_map + queue_redraw() + + +## Whether `city` should be drawn for the local player. Own cities always show; +## enemy cities only once their tile has been explored. +func _city_is_visible(city: CityScript) -> bool: + if _local_player < 0 or _game_map == null: + return true + if int(city.owner_index) == _local_player: + return true + var tile: Resource = _game_map.get_tile(city.position) as Resource + if tile == null: + return true + return int(tile.get_visibility(_local_player)) >= VIS_EXPLORED_MIN + func _ready() -> void: EventBus.city_founded.connect(_on_city_founded) @@ -129,6 +159,8 @@ func _draw() -> void: var city: CityScript = entry["city"] as CityScript if city == null: continue + if not _city_is_visible(city): + continue var color: Color = entry.get("color", Color.GRAY) var pixel: Vector2 = ( HexUtilsScript.axial_to_pixel(city.position) + HexUtilsScript.hex_center @@ -254,6 +286,8 @@ func _draw_all_borders() -> void: var city: CityScript = entry["city"] as CityScript if city == null: continue + if not _city_is_visible(city): + continue var color: Color = entry.get("color", Color.GRAY) _draw_city_borders(city, color) diff --git a/src/game/engine/src/rendering/prologue_overlay_renderer.gd b/src/game/engine/src/rendering/prologue_overlay_renderer.gd index e6ee79a3..480ac9f8 100644 --- a/src/game/engine/src/rendering/prologue_overlay_renderer.gd +++ b/src/game/engine/src/rendering/prologue_overlay_renderer.gd @@ -32,6 +32,16 @@ const GLYPH_COLOR: Color = Color.BLACK var _font: Font = null var _wanderer_tex: Texture2D = null var _tribe_tex: Texture2D = null +## The local (viewing) player. Only this player's opening wanderers/tribe are +## drawn — other players' spawn boxes are in their own fogged regions and must +## not leak into this player's view. Set by world_map at boot. +var _local_player_index: int = 0 + + +## Set the player whose opening is rendered (the human viewing this screen). +func set_local_player(player_index: int) -> void: + _local_player_index = player_index + queue_redraw() func _ready() -> void: @@ -54,14 +64,20 @@ func _draw() -> void: % [GameState.players.size(), str(visible), str(global_position)], "prologue-overlay", ) - # Drawing happens even on Normal so the capital-founded transition looks - # clean (empty overlay). Wanderers + tribes are queried per known player. - for player_var: Variant in GameState.players: - if player_var == null: - continue - var pid: int = int(player_var.index) - _draw_wanderers(driver, pid) - _draw_tribe_marker(driver, pid) + # Only the local (viewing) player's opening is drawn. Other players' wanderers + # + tribes live in their own spawn boxes, which are fogged from this player — + # rendering them here would leak player B's view into player A's screen. + # `_local_player_index < 0` is the no-fog observer/arena path: draw everyone. + if _local_player_index < 0: + for player_var: Variant in GameState.players: + if player_var == null: + continue + var pid: int = int(player_var.index) + _draw_wanderers(driver, pid) + _draw_tribe_marker(driver, pid) + else: + _draw_wanderers(driver, _local_player_index) + _draw_tribe_marker(driver, _local_player_index) func _draw_wanderers(driver: PrologueDriverScript, player_id: int) -> void: