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:
Natalie 2026-06-19 20:08:47 -05:00
parent a351a4fb44
commit 0b147e66a9
20 changed files with 708 additions and 23 deletions

View 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.
```

View file

@ -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) | 🟢 |

View file

@ -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 |

View file

@ -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'>

View file

@ -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": [

View 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`.

View file

@ -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",

View 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.

View file

@ -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()

View 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()

View file

@ -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)

View file

@ -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:

View file

@ -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"

View 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()

View 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")

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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: