feat(@projects/@magic-civilization): 🎭 hotseat multiplayer with per-seat views (p3-15)
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 <noreply@anthropic.com>
This commit is contained in:
parent
a351a4fb44
commit
0b147e66a9
20 changed files with 708 additions and 23 deletions
150
.project/designs/p3-15-hotseat-multiplayer-design.md
Normal file
150
.project/designs/p3-15-hotseat-multiplayer-design.md
Normal file
|
|
@ -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 <name>" + 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 **<name>**", 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.
|
||||
```
|
||||
|
|
@ -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) | 🟢 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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** |
|
||||
|
||||
</td><td valign='top' style='padding-left:2em'>
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
47
.project/objectives/p3-15-hotseat-multiplayer.md
Normal file
47
.project/objectives/p3-15-hotseat-multiplayer.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
96
src/game/engine/docs/TURN_SEQUENCE.md
Normal file
96
src/game/engine/docs/TURN_SEQUENCE.md
Normal file
|
|
@ -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 "<name> 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 **"<name>'s turn"** banner. For an
|
||||
**AI**, hide the banner (the `ai_turn_overlay` "<name> 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.
|
||||
|
|
@ -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 "<name>'s turn" banner.
|
||||
_completed = true
|
||||
_dismiss()
|
||||
|
||||
|
||||
func _on_dismiss_timeout() -> void:
|
||||
if _completed:
|
||||
_dismiss()
|
||||
|
|
|
|||
97
src/game/engine/scenes/hud/hotseat_handoff.gd
Normal file
97
src/game/engine/scenes/hud/hotseat_handoff.gd
Normal file
|
|
@ -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()
|
||||
|
|
@ -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 "<name>'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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
73
src/game/engine/scenes/tests/hotseat_handoff_proof.gd
Normal file
73
src/game/engine/scenes/tests/hotseat_handoff_proof.gd
Normal file
|
|
@ -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()
|
||||
6
src/game/engine/scenes/tests/hotseat_handoff_proof.tscn
Normal file
6
src/game/engine/scenes/tests/hotseat_handoff_proof.tscn
Normal file
|
|
@ -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")
|
||||
|
|
@ -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 "<player>'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 "<player>'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 "<player>'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 ("<name> 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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue