feat(@projects/@magic-civilization): 🏗️ p3-26 B3 (1/4) — improvement subsystem state model
Groundwork for the headless improvement subsystem (wired in the next increments): - PlayerState.pending_improvements: Vec<PendingImprovement> (#[serde(default)]) — tile improvements under construction. - GameState.improvement_defs: BTreeMap<String, ImprovementDef> (#[serde(skip)] boot) + load_improvement_defs_json (parses public/resources/improvements/*.json → build_turns + food/production yields). - ImprovementDef + PendingImprovement structs. mc-state 14/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
57725d0088
commit
cb451832e0
10 changed files with 783 additions and 8 deletions
|
|
@ -51,6 +51,12 @@ func _ready() -> void:
|
||||||
_start_arena_session()
|
_start_arena_session()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# OBSERVER=1 boots straight into an all-AI clan game in caster/observer mode
|
||||||
|
# (spectator HUD + per-clan fog flip + pacing) for casting and demoing.
|
||||||
|
if EnvConfig.get_bool("OBSERVER"):
|
||||||
|
_start_observer_session()
|
||||||
|
return
|
||||||
|
|
||||||
# MC_AUTO_START=1 boots straight into an interactive seeded game, skipping
|
# MC_AUTO_START=1 boots straight into an interactive seeded game, skipping
|
||||||
# the menu/setup flow. Used by the rendered MCP driver (p2-86) and by
|
# the menu/setup flow. Used by the rendered MCP driver (p2-86) and by
|
||||||
# proof-capture tooling so screenshots reach in-game screens without a human
|
# proof-capture tooling so screenshots reach in-game screens without a human
|
||||||
|
|
@ -87,6 +93,31 @@ func _start_autostart_session() -> void:
|
||||||
change_scene(LOADING_SCREEN_PATH)
|
change_scene(LOADING_SCREEN_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
func _start_observer_session() -> void:
|
||||||
|
## Boot directly into an all-AI clan match for casting/demoing (OBSERVER mode).
|
||||||
|
## Mirrors the arena boot but seats the full clan roster (default 5) on a
|
||||||
|
## larger map with the turn limit disabled so the cast runs to a natural
|
||||||
|
## victory (or until the caster stops it). loading_screen.gd treats OBSERVER
|
||||||
|
## like arena — every slot is AI and gets its clan personality + name.
|
||||||
|
var seed: int = EnvConfig.get_int("OBSERVER_SEED", randi())
|
||||||
|
var players: int = EnvConfig.get_int("OBSERVER_PLAYERS", 5)
|
||||||
|
var settings: Dictionary = {
|
||||||
|
"map_size": EnvConfig.get_var("OBSERVER_MAP_SIZE", "small"),
|
||||||
|
"map_type": "pangaea",
|
||||||
|
"map_wrap": "sphere",
|
||||||
|
"difficulty": "normal",
|
||||||
|
"game_speed": "standard",
|
||||||
|
"num_players": players,
|
||||||
|
"turn_limit": 0,
|
||||||
|
"turn_limit_enabled": false,
|
||||||
|
"seed": seed,
|
||||||
|
"era_difficulty_correlation": true,
|
||||||
|
}
|
||||||
|
GameState.initialize_game(settings)
|
||||||
|
GameState.map_seed = seed
|
||||||
|
change_scene(LOADING_SCREEN_PATH)
|
||||||
|
|
||||||
|
|
||||||
func _start_arena_session() -> void:
|
func _start_arena_session() -> void:
|
||||||
## Skip the main menu / game-setup flow when running in spectator arena
|
## Skip the main menu / game-setup flow when running in spectator arena
|
||||||
## mode. The orchestrator script provides seed, turn limit, and player
|
## mode. The orchestrator script provides seed, turn limit, and player
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,9 @@ func _stage(label: String, from_pct: float, to_pct: float) -> void:
|
||||||
await get_tree().create_timer(TICK_DELAY).timeout
|
await get_tree().create_timer(TICK_DELAY).timeout
|
||||||
|
|
||||||
func _create_players() -> void:
|
func _create_players() -> void:
|
||||||
var arena_mode: bool = EnvConfig.get_bool("AI_ARENA")
|
# OBSERVER (caster mode) seats every slot as AI exactly like arena, so each
|
||||||
|
# clan gets its personality + flavour name for the cast.
|
||||||
|
var arena_mode: bool = EnvConfig.get_bool("AI_ARENA") or EnvConfig.get_bool("OBSERVER")
|
||||||
# Prefer the game pack's declared default_race; fall back to the first
|
# Prefer the game pack's declared default_race; fall back to the first
|
||||||
# loaded race only when not declared. The global resources dir may load
|
# loaded race only when not declared. The global resources dir may load
|
||||||
# non-game-1 races (beastmen, elves…) that sort alphabetically before
|
# non-game-1 races (beastmen, elves…) that sort alphabetically before
|
||||||
|
|
|
||||||
82
src/game/engine/scenes/world_map/observer_controls.gd
Normal file
82
src/game/engine/scenes/world_map/observer_controls.gd
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
class_name ObserverControls
|
||||||
|
extends Node
|
||||||
|
## Caster input handler for observer/spectator mode. Mounted as a child of the
|
||||||
|
## world map AFTER arena teardown disables world_map._unhandled_input, so these
|
||||||
|
## keys are the only live input in a cast:
|
||||||
|
##
|
||||||
|
## 0 god view (omniscient)
|
||||||
|
## 1 - 5 that clan's fog-of-war view
|
||||||
|
## space pause / resume continuous play
|
||||||
|
## . step one turn (while paused)
|
||||||
|
## + / - faster / slower
|
||||||
|
## F toggle the follow-action camera
|
||||||
|
##
|
||||||
|
## Pure presentation: drives world_map.observer_set_view / observer_set_follow
|
||||||
|
## and TurnManager pacing; mutates no simulation state.
|
||||||
|
|
||||||
|
const SPEED_STEPS: Array[float] = [0.25, 0.5, 1.0, 2.0, 4.0]
|
||||||
|
|
||||||
|
var _world_map: Node = null
|
||||||
|
var _hud: RefCounted = null # ObserverHud
|
||||||
|
var _speed_index: int = 2
|
||||||
|
var _follow_enabled: bool = true
|
||||||
|
|
||||||
|
|
||||||
|
func setup(world_map: Node, hud: RefCounted) -> void:
|
||||||
|
_world_map = world_map
|
||||||
|
_hud = hud
|
||||||
|
set_process_unhandled_input(true)
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if not (event is InputEventKey) or not event.pressed or event.echo:
|
||||||
|
return
|
||||||
|
var key: int = (event as InputEventKey).keycode
|
||||||
|
match key:
|
||||||
|
KEY_0, KEY_KP_0:
|
||||||
|
_set_view(-1)
|
||||||
|
KEY_1, KEY_2, KEY_3, KEY_4, KEY_5:
|
||||||
|
_set_view(key - KEY_1)
|
||||||
|
KEY_KP_1, KEY_KP_2, KEY_KP_3, KEY_KP_4, KEY_KP_5:
|
||||||
|
_set_view(key - KEY_KP_1)
|
||||||
|
KEY_SPACE:
|
||||||
|
_toggle_pause()
|
||||||
|
KEY_PERIOD:
|
||||||
|
TurnManager.observer_pacing.step()
|
||||||
|
KEY_EQUAL, KEY_PLUS, KEY_KP_ADD:
|
||||||
|
_change_speed(1)
|
||||||
|
KEY_MINUS, KEY_KP_SUBTRACT:
|
||||||
|
_change_speed(-1)
|
||||||
|
KEY_F:
|
||||||
|
_toggle_follow()
|
||||||
|
_:
|
||||||
|
return
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _set_view(view_index: int) -> void:
|
||||||
|
if view_index >= GameState.players.size():
|
||||||
|
return
|
||||||
|
if _world_map != null and _world_map.has_method("observer_set_view"):
|
||||||
|
_world_map.observer_set_view(view_index)
|
||||||
|
if _hud != null:
|
||||||
|
_hud.set_view_label(view_index)
|
||||||
|
|
||||||
|
|
||||||
|
func _toggle_pause() -> void:
|
||||||
|
TurnManager.observer_pacing.toggle_paused()
|
||||||
|
if _hud != null:
|
||||||
|
_hud.set_pacing(TurnManager.observer_pacing.paused, TurnManager.observer_pacing.speed)
|
||||||
|
|
||||||
|
|
||||||
|
func _change_speed(direction: int) -> void:
|
||||||
|
_speed_index = clampi(_speed_index + direction, 0, SPEED_STEPS.size() - 1)
|
||||||
|
TurnManager.observer_pacing.set_speed(SPEED_STEPS[_speed_index])
|
||||||
|
if _hud != null:
|
||||||
|
_hud.set_pacing(TurnManager.observer_pacing.paused, TurnManager.observer_pacing.speed)
|
||||||
|
|
||||||
|
|
||||||
|
func _toggle_follow() -> void:
|
||||||
|
_follow_enabled = not _follow_enabled
|
||||||
|
if _world_map != null and _world_map.has_method("observer_set_follow"):
|
||||||
|
_world_map.observer_set_follow(_follow_enabled)
|
||||||
336
src/game/engine/scenes/world_map/observer_hud.gd
Normal file
336
src/game/engine/scenes/world_map/observer_hud.gd
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
class_name ObserverHud
|
||||||
|
extends RefCounted
|
||||||
|
## Caster overlay for observer/spectator mode. Builds a CanvasLayer with:
|
||||||
|
## - a current-view label (top-left): "god view" or "<clan> — fog"
|
||||||
|
## - a pacing readout (top-right): turn counter + play/pause + speed
|
||||||
|
## - a standings ladder (right): the clans ranked by score, in player colors
|
||||||
|
## - an event ticker (bottom-left): high-signal EventBus lines for commentary
|
||||||
|
## - a persistent hotkey strip (bottom)
|
||||||
|
## - a victory banner (center, hidden until a clan wins)
|
||||||
|
##
|
||||||
|
## Pure presentation: reads StatsTracker / GameState / EventBus, mutates no
|
||||||
|
## simulation state. Built programmatically (no .tscn) like arena_overlay.gd and
|
||||||
|
## hotseat_handoff.gd. Approved against the A0 caster-overlay mockup.
|
||||||
|
|
||||||
|
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||||
|
const MAX_TICKER_LINES: int = 4
|
||||||
|
const PANEL_ALPHA: float = 0.88
|
||||||
|
|
||||||
|
var _layer: CanvasLayer = null
|
||||||
|
var _view_swatch: ColorRect = null
|
||||||
|
var _view_label: Label = null
|
||||||
|
var _turn_label: Label = null
|
||||||
|
var _pacing_label: Label = null
|
||||||
|
var _standings_box: VBoxContainer = null
|
||||||
|
var _ticker_box: VBoxContainer = null
|
||||||
|
var _banner: Label = null
|
||||||
|
var _panel_bg: Color
|
||||||
|
var _text_color: Color
|
||||||
|
var _muted_color: Color
|
||||||
|
|
||||||
|
|
||||||
|
func build(world_map: Node) -> void:
|
||||||
|
_panel_bg = ThemeAssets.color("background.deepest")
|
||||||
|
_panel_bg.a = PANEL_ALPHA
|
||||||
|
_text_color = ThemeAssets.color("text.primary")
|
||||||
|
_muted_color = ThemeAssets.color("text.secondary")
|
||||||
|
|
||||||
|
_layer = CanvasLayer.new()
|
||||||
|
_layer.name = "ObserverHud"
|
||||||
|
_layer.layer = 28
|
||||||
|
|
||||||
|
var root: Control = Control.new()
|
||||||
|
root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_layer.add_child(root)
|
||||||
|
|
||||||
|
_build_view_label(root)
|
||||||
|
_build_pacing(root)
|
||||||
|
_build_standings(root)
|
||||||
|
_build_ticker(root)
|
||||||
|
_build_hotkeys(root)
|
||||||
|
_build_banner(root)
|
||||||
|
|
||||||
|
world_map.add_child(_layer)
|
||||||
|
_connect_events()
|
||||||
|
set_view_label(-1)
|
||||||
|
set_turn(GameState.turn_number)
|
||||||
|
set_pacing(false, 1.0)
|
||||||
|
update_standings()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Construction ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
func _dark_panel() -> PanelContainer:
|
||||||
|
var panel: PanelContainer = PanelContainer.new()
|
||||||
|
var sb: StyleBoxFlat = StyleBoxFlat.new()
|
||||||
|
sb.bg_color = _panel_bg
|
||||||
|
sb.corner_radius_top_left = 6
|
||||||
|
sb.corner_radius_top_right = 6
|
||||||
|
sb.corner_radius_bottom_left = 6
|
||||||
|
sb.corner_radius_bottom_right = 6
|
||||||
|
sb.content_margin_left = 10
|
||||||
|
sb.content_margin_right = 10
|
||||||
|
sb.content_margin_top = 6
|
||||||
|
sb.content_margin_bottom = 6
|
||||||
|
panel.add_theme_stylebox_override("panel", sb)
|
||||||
|
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
func _build_view_label(root: Control) -> void:
|
||||||
|
var panel: PanelContainer = _dark_panel()
|
||||||
|
panel.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||||
|
panel.position = Vector2(12, 12)
|
||||||
|
var row: HBoxContainer = HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 8)
|
||||||
|
_view_swatch = ColorRect.new()
|
||||||
|
_view_swatch.custom_minimum_size = Vector2(12, 12)
|
||||||
|
_view_swatch.color = Color(1, 1, 1, 0)
|
||||||
|
var center: CenterContainer = CenterContainer.new()
|
||||||
|
center.add_child(_view_swatch)
|
||||||
|
_view_label = Label.new()
|
||||||
|
_view_label.add_theme_font_size_override("font_size", 15)
|
||||||
|
_view_label.add_theme_color_override("font_color", _text_color)
|
||||||
|
row.add_child(center)
|
||||||
|
row.add_child(_view_label)
|
||||||
|
panel.add_child(row)
|
||||||
|
root.add_child(panel)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_pacing(root: Control) -> void:
|
||||||
|
var panel: PanelContainer = _dark_panel()
|
||||||
|
panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||||
|
panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||||
|
panel.position = Vector2(-12, 12)
|
||||||
|
var row: HBoxContainer = HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 14)
|
||||||
|
_turn_label = Label.new()
|
||||||
|
_turn_label.add_theme_font_size_override("font_size", 14)
|
||||||
|
_turn_label.add_theme_color_override("font_color", _text_color)
|
||||||
|
_pacing_label = Label.new()
|
||||||
|
_pacing_label.add_theme_font_size_override("font_size", 14)
|
||||||
|
_pacing_label.add_theme_color_override("font_color", _text_color)
|
||||||
|
row.add_child(_turn_label)
|
||||||
|
row.add_child(_pacing_label)
|
||||||
|
panel.add_child(row)
|
||||||
|
root.add_child(panel)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_standings(root: Control) -> void:
|
||||||
|
var panel: PanelContainer = _dark_panel()
|
||||||
|
panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||||
|
panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||||
|
panel.position = Vector2(-12, 56)
|
||||||
|
panel.custom_minimum_size = Vector2(230, 0)
|
||||||
|
var vbox: VBoxContainer = VBoxContainer.new()
|
||||||
|
vbox.add_theme_constant_override("separation", 4)
|
||||||
|
var header: Label = Label.new()
|
||||||
|
header.text = "standings · by score"
|
||||||
|
header.add_theme_font_size_override("font_size", 12)
|
||||||
|
header.add_theme_color_override("font_color", _muted_color)
|
||||||
|
vbox.add_child(header)
|
||||||
|
_standings_box = VBoxContainer.new()
|
||||||
|
_standings_box.add_theme_constant_override("separation", 4)
|
||||||
|
vbox.add_child(_standings_box)
|
||||||
|
panel.add_child(vbox)
|
||||||
|
root.add_child(panel)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_ticker(root: Control) -> void:
|
||||||
|
var panel: PanelContainer = _dark_panel()
|
||||||
|
panel.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||||
|
panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||||
|
panel.position = Vector2(12, -40)
|
||||||
|
panel.custom_minimum_size = Vector2(420, 0)
|
||||||
|
var vbox: VBoxContainer = VBoxContainer.new()
|
||||||
|
vbox.add_theme_constant_override("separation", 3)
|
||||||
|
var header: Label = Label.new()
|
||||||
|
header.text = "live · event feed"
|
||||||
|
header.add_theme_font_size_override("font_size", 11)
|
||||||
|
header.add_theme_color_override("font_color", ThemeAssets.color("semantic.negative"))
|
||||||
|
vbox.add_child(header)
|
||||||
|
_ticker_box = VBoxContainer.new()
|
||||||
|
_ticker_box.add_theme_constant_override("separation", 2)
|
||||||
|
vbox.add_child(_ticker_box)
|
||||||
|
panel.add_child(vbox)
|
||||||
|
root.add_child(panel)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_hotkeys(root: Control) -> void:
|
||||||
|
var label: Label = Label.new()
|
||||||
|
label.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||||
|
label.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||||
|
label.position = Vector2(12, -16)
|
||||||
|
label.add_theme_font_size_override("font_size", 11)
|
||||||
|
label.add_theme_color_override("font_color", _muted_color)
|
||||||
|
label.text = "0 god 1-5 clan fog space pause . step +/- speed F follow"
|
||||||
|
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
root.add_child(label)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_banner(root: Control) -> void:
|
||||||
|
_banner = Label.new()
|
||||||
|
_banner.set_anchors_preset(Control.PRESET_CENTER)
|
||||||
|
_banner.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||||
|
_banner.grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||||
|
_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_banner.add_theme_font_size_override("font_size", 32)
|
||||||
|
_banner.add_theme_color_override("font_color", _text_color)
|
||||||
|
_banner.add_theme_color_override("font_outline_color", _panel_bg)
|
||||||
|
_banner.add_theme_constant_override("outline_size", 8)
|
||||||
|
_banner.visible = false
|
||||||
|
_banner.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
root.add_child(_banner)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Updates ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
func set_view_label(view_index: int) -> void:
|
||||||
|
if _view_label == null:
|
||||||
|
return
|
||||||
|
if view_index < 0:
|
||||||
|
_view_label.text = "god view — all clans"
|
||||||
|
_view_swatch.color = Color(1, 1, 1, 0)
|
||||||
|
else:
|
||||||
|
_view_label.text = "%s — fog of war" % _player_name(view_index)
|
||||||
|
_view_swatch.color = _player_color(view_index)
|
||||||
|
|
||||||
|
|
||||||
|
func set_turn(turn_number: int) -> void:
|
||||||
|
if _turn_label != null:
|
||||||
|
_turn_label.text = "turn %d" % turn_number
|
||||||
|
|
||||||
|
|
||||||
|
func set_pacing(paused: bool, speed: float) -> void:
|
||||||
|
if _pacing_label == null:
|
||||||
|
return
|
||||||
|
_pacing_label.text = ("paused" if paused else "%s %.2g×" % ["play", speed])
|
||||||
|
|
||||||
|
|
||||||
|
func update_standings() -> void:
|
||||||
|
if _standings_box == null:
|
||||||
|
return
|
||||||
|
for child: Node in _standings_box.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
var rankings: Array = StatsTracker.get_rankings("score")
|
||||||
|
if rankings.is_empty():
|
||||||
|
for p: Variant in GameState.players:
|
||||||
|
if p is PlayerScript:
|
||||||
|
rankings.append({"index": int((p as PlayerScript).index), "value": 0})
|
||||||
|
for entry: Dictionary in rankings:
|
||||||
|
_standings_box.add_child(_standings_row(entry))
|
||||||
|
|
||||||
|
|
||||||
|
func _standings_row(entry: Dictionary) -> HBoxContainer:
|
||||||
|
var idx: int = int(entry.get("index", 0))
|
||||||
|
var rank: int = int(entry.get("rank", 0))
|
||||||
|
var row: HBoxContainer = HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 8)
|
||||||
|
var rank_label: Label = Label.new()
|
||||||
|
rank_label.text = ("%d" % rank) if rank > 0 else "·"
|
||||||
|
rank_label.custom_minimum_size = Vector2(14, 0)
|
||||||
|
rank_label.add_theme_font_size_override("font_size", 13)
|
||||||
|
rank_label.add_theme_color_override("font_color", _muted_color)
|
||||||
|
var swatch: ColorRect = ColorRect.new()
|
||||||
|
swatch.custom_minimum_size = Vector2(10, 10)
|
||||||
|
swatch.color = _player_color(idx)
|
||||||
|
var swatch_center: CenterContainer = CenterContainer.new()
|
||||||
|
swatch_center.add_child(swatch)
|
||||||
|
var name_label: Label = Label.new()
|
||||||
|
name_label.text = _player_name(idx)
|
||||||
|
name_label.add_theme_font_size_override("font_size", 13)
|
||||||
|
name_label.add_theme_color_override("font_color", _text_color)
|
||||||
|
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
var score_label: Label = Label.new()
|
||||||
|
score_label.text = "%d" % int(entry.get("value", 0))
|
||||||
|
score_label.add_theme_font_size_override("font_size", 13)
|
||||||
|
score_label.add_theme_color_override("font_color", _text_color)
|
||||||
|
row.add_child(rank_label)
|
||||||
|
row.add_child(swatch_center)
|
||||||
|
row.add_child(name_label)
|
||||||
|
row.add_child(score_label)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
func push_event(text: String, color: Color) -> void:
|
||||||
|
if _ticker_box == null:
|
||||||
|
return
|
||||||
|
var label: Label = Label.new()
|
||||||
|
label.text = text
|
||||||
|
label.add_theme_font_size_override("font_size", 13)
|
||||||
|
label.add_theme_color_override("font_color", color)
|
||||||
|
_ticker_box.add_child(label)
|
||||||
|
while _ticker_box.get_child_count() > MAX_TICKER_LINES:
|
||||||
|
var oldest: Node = _ticker_box.get_child(0)
|
||||||
|
_ticker_box.remove_child(oldest)
|
||||||
|
oldest.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func show_winner(player_index: int, victory_type: String) -> void:
|
||||||
|
if _banner == null:
|
||||||
|
return
|
||||||
|
_banner.text = "%s wins — %s victory" % [_player_name(player_index), victory_type]
|
||||||
|
_banner.add_theme_color_override("font_color", _player_color(player_index))
|
||||||
|
_banner.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event wiring ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
func _connect_events() -> void:
|
||||||
|
EventBus.war_declared.connect(_on_war_declared)
|
||||||
|
EventBus.city_captured.connect(_on_city_captured)
|
||||||
|
EventBus.city_founded.connect(_on_city_founded)
|
||||||
|
EventBus.wonder_built.connect(_on_wonder_built)
|
||||||
|
EventBus.victory_achieved.connect(_on_victory_achieved)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_war_declared(by_player: int, against_player: int) -> void:
|
||||||
|
push_event(
|
||||||
|
"%s declares war on %s" % [_player_name(by_player), _player_name(against_player)],
|
||||||
|
ThemeAssets.color("semantic.negative"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_city_captured(_city: Variant, old_owner: int, new_owner: int) -> void:
|
||||||
|
push_event(
|
||||||
|
"%s captures a city from %s" % [_player_name(new_owner), _player_name(old_owner)],
|
||||||
|
_player_color(new_owner),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_city_founded(_city: Variant, player_index: int) -> void:
|
||||||
|
push_event("%s founds a new city" % _player_name(player_index), _player_color(player_index))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_wonder_built(wonder_id: String, player_index: int) -> void:
|
||||||
|
push_event(
|
||||||
|
"%s completes %s" % [_player_name(player_index), wonder_id],
|
||||||
|
_player_color(player_index),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_victory_achieved(player_index: int, victory_type: String) -> void:
|
||||||
|
show_winner(player_index, victory_type)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
func _player_name(idx: int) -> String:
|
||||||
|
var p: RefCounted = GameState.get_player(idx)
|
||||||
|
if p != null and not str(p.player_name).is_empty():
|
||||||
|
return str(p.player_name)
|
||||||
|
return "Clan %d" % (idx + 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _player_color(idx: int) -> Color:
|
||||||
|
var p: RefCounted = GameState.get_player(idx)
|
||||||
|
if p != null and p.get("color") != null:
|
||||||
|
return p.color
|
||||||
|
if idx >= 0 and idx < GameState.PLAYER_COLORS.size():
|
||||||
|
return GameState.PLAYER_COLORS[idx]
|
||||||
|
return ThemeAssets.color("text.primary")
|
||||||
56
src/game/engine/scenes/world_map/observer_mode.gd
Normal file
56
src/game/engine/scenes/world_map/observer_mode.gd
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
class_name ObserverMode
|
||||||
|
extends RefCounted
|
||||||
|
## Observer/caster mode orchestrator (OBSERVER env). Instantiated by world_map
|
||||||
|
## alongside the arena helper: the arena helper handles teardown / camera-fit /
|
||||||
|
## action playback (reused verbatim), and this module adds the casting layer —
|
||||||
|
## the caster HUD (standings + ticker + view label + pacing), the caster input
|
||||||
|
## handler, and the per-turn live updates.
|
||||||
|
##
|
||||||
|
## Pure presentation: it reads StatsTracker / GameState and listens on EventBus;
|
||||||
|
## it never mutates simulation state. Pacing lives in TurnManager (the turn loop
|
||||||
|
## owns its own gate); this module only wires the caster's keys to it.
|
||||||
|
|
||||||
|
const ObserverHudScript: GDScript = preload("res://engine/scenes/world_map/observer_hud.gd")
|
||||||
|
const ObserverControlsScript: GDScript = preload(
|
||||||
|
"res://engine/scenes/world_map/observer_controls.gd"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _world_map: Node = null
|
||||||
|
var _hud: RefCounted = null # ObserverHud
|
||||||
|
var _controls: Node = null # ObserverControls
|
||||||
|
|
||||||
|
|
||||||
|
func setup(world_map: Node) -> void:
|
||||||
|
_world_map = world_map
|
||||||
|
|
||||||
|
_hud = ObserverHudScript.new()
|
||||||
|
_hud.build(world_map)
|
||||||
|
|
||||||
|
_controls = ObserverControlsScript.new()
|
||||||
|
_controls.name = "ObserverControls"
|
||||||
|
world_map.add_child(_controls)
|
||||||
|
_controls.setup(world_map, _hud)
|
||||||
|
|
||||||
|
EventBus.turn_started.connect(_on_turn_started)
|
||||||
|
EventBus.turn_ended.connect(_on_turn_ended)
|
||||||
|
EventBus.victory_achieved.connect(_on_victory)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_turn_started(turn_number: int, _player_index: int) -> void:
|
||||||
|
if _hud != null:
|
||||||
|
_hud.set_turn(turn_number)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_turn_ended(_turn_number: int, _player_index: int) -> void:
|
||||||
|
if _hud != null:
|
||||||
|
_hud.update_standings()
|
||||||
|
# Keep the watched clan's fog current as it explores during play. No-op while
|
||||||
|
# the caster is in god view.
|
||||||
|
if _world_map != null and _world_map.has_method("observer_refresh_view"):
|
||||||
|
_world_map.observer_refresh_view()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_victory(_player_index: int, _victory_type: String) -> void:
|
||||||
|
# Halt the cast on a win so the final board + winner banner hold on screen
|
||||||
|
# (the HUD shows the banner via its own victory_achieved subscription).
|
||||||
|
TurnManager.observer_pacing.set_paused(true)
|
||||||
|
|
@ -33,6 +33,9 @@ const WorldMapVisionScript: GDScript = preload(
|
||||||
const WorldMapArenaScript: GDScript = preload(
|
const WorldMapArenaScript: GDScript = preload(
|
||||||
"res://engine/scenes/world_map/world_map_arena.gd"
|
"res://engine/scenes/world_map/world_map_arena.gd"
|
||||||
)
|
)
|
||||||
|
const ObserverModeScript: GDScript = preload(
|
||||||
|
"res://engine/scenes/world_map/observer_mode.gd"
|
||||||
|
)
|
||||||
const WorldMapUnitsScript: GDScript = preload(
|
const WorldMapUnitsScript: GDScript = preload(
|
||||||
"res://engine/scenes/world_map/world_map_units.gd"
|
"res://engine/scenes/world_map/world_map_units.gd"
|
||||||
)
|
)
|
||||||
|
|
@ -84,6 +87,13 @@ var _selected_unit: RefCounted = null
|
||||||
var _reachable_hexes: Dictionary = {}
|
var _reachable_hexes: Dictionary = {}
|
||||||
var _bombard_city: RefCounted = null
|
var _bombard_city: RefCounted = null
|
||||||
var _arena_mode: bool = false
|
var _arena_mode: bool = false
|
||||||
|
## Observer/caster mode (OBSERVER env). A superset of arena spectating: reuses
|
||||||
|
## the arena teardown/camera/playback but adds a caster HUD, per-clan fog flip,
|
||||||
|
## and pacing. `_observer_view` is the currently-cast perspective: -1 = god view
|
||||||
|
## (omniscient, fog off), 0..N = that clan's actual fog-of-war.
|
||||||
|
var _observer_mode: bool = false
|
||||||
|
var _observer_view: int = -1
|
||||||
|
var _observer: RefCounted = null
|
||||||
## p0-35 movement-mode state. When `_movement_mode == true` the next
|
## p0-35 movement-mode state. When `_movement_mode == true` the next
|
||||||
## right-click confirms the move along the previewed path; ESC / left-click
|
## right-click confirms the move along the previewed path; ESC / left-click
|
||||||
## cancel back to selection. KEY_M toggles entry.
|
## cancel back to selection. KEY_M toggles entry.
|
||||||
|
|
@ -154,11 +164,19 @@ var _formation_move_command: String = "advance"
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_arena_mode = EnvConfig.get_bool("AI_ARENA")
|
_arena_mode = EnvConfig.get_bool("AI_ARENA")
|
||||||
|
_observer_mode = EnvConfig.get_bool("OBSERVER")
|
||||||
|
# Observer mode reuses every arena rendering branch (fog off, omniscient
|
||||||
|
# default view, no human HUD/input, camera fit, action playback).
|
||||||
|
if _observer_mode:
|
||||||
|
_arena_mode = true
|
||||||
_setup_renderers()
|
_setup_renderers()
|
||||||
_connect_signals()
|
_connect_signals()
|
||||||
if _arena_mode:
|
if _arena_mode:
|
||||||
_arena = WorldMapArenaScript.new()
|
_arena = WorldMapArenaScript.new()
|
||||||
(_arena as WorldMapArenaScript).setup(self)
|
(_arena as WorldMapArenaScript).setup(self)
|
||||||
|
if _observer_mode:
|
||||||
|
_observer = ObserverModeScript.new()
|
||||||
|
(_observer as ObserverModeScript).setup(self)
|
||||||
_start_game()
|
_start_game()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -632,6 +650,66 @@ func _set_view_player(player_index: int) -> void:
|
||||||
cam.center_on_hex(centroid)
|
cam.center_on_hex(centroid)
|
||||||
|
|
||||||
|
|
||||||
|
## Observer/caster view switch. `view_index` < 0 = god view (omniscient, fog
|
||||||
|
## off, whole map revealed); 0..N = that clan's actual fog-of-war (the hotseat
|
||||||
|
## per-player render path, but driven by the caster instead of turn order).
|
||||||
|
## Called by observer_controls on a hotkey and re-applied each turn_ended while a
|
||||||
|
## clan is being watched so its fog updates live as it explores.
|
||||||
|
func observer_set_view(view_index: int) -> void:
|
||||||
|
var game_map: RefCounted = GameState.get_game_map()
|
||||||
|
if game_map == null:
|
||||||
|
return
|
||||||
|
if view_index >= GameState.players.size():
|
||||||
|
view_index = -1
|
||||||
|
_observer_view = view_index
|
||||||
|
if view_index < 0:
|
||||||
|
_fog_renderer.fog_disabled = true
|
||||||
|
_fog_renderer.initialize(game_map, -1)
|
||||||
|
_unit_renderer.setup_visibility(-1, game_map)
|
||||||
|
(_city_renderer as CityRenderer).setup_visibility(-1, game_map)
|
||||||
|
if _minimap != null and _minimap.has_method("set_local_player"):
|
||||||
|
_minimap.set_local_player(-1)
|
||||||
|
var all_positions: Array[Vector2i] = []
|
||||||
|
for pos: Vector2i in game_map.tiles:
|
||||||
|
all_positions.append(pos)
|
||||||
|
_hex_renderer.update_fog(all_positions, [])
|
||||||
|
_fog_renderer.update_all(game_map)
|
||||||
|
else:
|
||||||
|
var player: RefCounted = GameState.get_player(view_index)
|
||||||
|
if player == null:
|
||||||
|
return
|
||||||
|
_fog_renderer.fog_disabled = false
|
||||||
|
_fog_renderer.initialize(game_map, view_index)
|
||||||
|
_unit_renderer.setup_visibility(view_index, game_map)
|
||||||
|
(_city_renderer as CityRenderer).setup_visibility(view_index, game_map)
|
||||||
|
if _minimap != null and _minimap.has_method("set_local_player"):
|
||||||
|
_minimap.set_local_player(view_index)
|
||||||
|
WorldMapVisionScript.recalculate_vision(player, game_map)
|
||||||
|
WorldMapVisionScript.record_observations(player, game_map)
|
||||||
|
_update_fog(player, game_map)
|
||||||
|
_sync_units()
|
||||||
|
_sync_cities()
|
||||||
|
|
||||||
|
|
||||||
|
## The caster's current perspective (-1 god, else clan index).
|
||||||
|
func observer_view() -> int:
|
||||||
|
return _observer_view
|
||||||
|
|
||||||
|
|
||||||
|
## Re-apply the watched clan's fog so it tracks live exploration. No-op in god
|
||||||
|
## view (the whole map is already revealed).
|
||||||
|
func observer_refresh_view() -> void:
|
||||||
|
if _observer_view >= 0:
|
||||||
|
observer_set_view(_observer_view)
|
||||||
|
|
||||||
|
|
||||||
|
## Toggle the arena follow-action camera (caster `F` key). When off, the camera
|
||||||
|
## stops auto-panning to the active player so the caster can free-pan.
|
||||||
|
func observer_set_follow(enabled: bool) -> void:
|
||||||
|
if _arena != null:
|
||||||
|
(_arena as WorldMapArenaScript).follow_enabled = enabled
|
||||||
|
|
||||||
|
|
||||||
func _sync_units() -> void:
|
func _sync_units() -> void:
|
||||||
var primary: Dictionary = GameState.get_primary_layer()
|
var primary: Dictionary = GameState.get_primary_layer()
|
||||||
var units: Array = primary.get("units", [])
|
var units: Array = primary.get("units", [])
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ const ARENA_ZOOM: float = 0.25
|
||||||
const QUIT_HOLD_SECONDS: float = 2.0
|
const QUIT_HOLD_SECONDS: float = 2.0
|
||||||
const WALL_CLOCK_LIMIT_MSEC: int = 600_000 ## 10 minutes — hard safety net
|
const WALL_CLOCK_LIMIT_MSEC: int = 600_000 ## 10 minutes — hard safety net
|
||||||
|
|
||||||
|
## Follow-action camera toggle (caster `F`). When false, _on_turn_started stops
|
||||||
|
## auto-panning the camera to the active player so the caster can free-pan.
|
||||||
|
var follow_enabled: bool = true
|
||||||
|
|
||||||
var _world_map: Node = null
|
var _world_map: Node = null
|
||||||
var _turn_label: Label = null
|
var _turn_label: Label = null
|
||||||
var _start_ticks_msec: int = 0
|
var _start_ticks_msec: int = 0
|
||||||
|
|
@ -37,10 +41,16 @@ var _finished: bool = false
|
||||||
var _screenshot_taken: bool = false
|
var _screenshot_taken: bool = false
|
||||||
var _playback: RefCounted = null
|
var _playback: RefCounted = null
|
||||||
var _overlay: RefCounted = null
|
var _overlay: RefCounted = null
|
||||||
|
## Observer/caster sub-mode (OBSERVER env). Reuses arena teardown/camera/playback
|
||||||
|
## but skips the tournament harness: no match-result JSON, no auto-quit, no
|
||||||
|
## periodic screenshot, no turn-limit/wall-clock auto-finish. The caster HUD
|
||||||
|
## (observer_mode.gd) replaces the bare label overlay.
|
||||||
|
var _observer: bool = false
|
||||||
|
|
||||||
|
|
||||||
func setup(world_map: Node) -> void:
|
func setup(world_map: Node) -> void:
|
||||||
_world_map = world_map
|
_world_map = world_map
|
||||||
|
_observer = EnvConfig.get_bool("OBSERVER")
|
||||||
_match_id = EnvConfig.get_var("AI_ARENA_MATCH_ID", "match_?")
|
_match_id = EnvConfig.get_var("AI_ARENA_MATCH_ID", "match_?")
|
||||||
_seed = EnvConfig.get_int("AI_ARENA_SEED", 0)
|
_seed = EnvConfig.get_int("AI_ARENA_SEED", 0)
|
||||||
_turn_limit = EnvConfig.get_int("AI_ARENA_TURN_LIMIT", 150)
|
_turn_limit = EnvConfig.get_int("AI_ARENA_TURN_LIMIT", 150)
|
||||||
|
|
@ -50,7 +60,10 @@ func setup(world_map: Node) -> void:
|
||||||
|
|
||||||
_disable_human_hud()
|
_disable_human_hud()
|
||||||
_disable_human_input()
|
_disable_human_input()
|
||||||
_build_label_overlay()
|
# Observer mounts its own caster HUD (standings + ticker + view label) via
|
||||||
|
# observer_mode.gd; skip the bare match/seed/turn label overlay.
|
||||||
|
if not _observer:
|
||||||
|
_build_label_overlay()
|
||||||
# world_map._start_game runs AFTER setup() and calls bg_camera.center_on_hex
|
# world_map._start_game runs AFTER setup() and calls bg_camera.center_on_hex
|
||||||
# with zoom 0.45 (calibrated for 1920x1080). Override it via call_deferred so
|
# with zoom 0.45 (calibrated for 1920x1080). Override it via call_deferred so
|
||||||
# our camera fit happens AFTER that setup, not before — otherwise we get
|
# our camera fit happens AFTER that setup, not before — otherwise we get
|
||||||
|
|
@ -189,7 +202,8 @@ func _build_label_overlay() -> void:
|
||||||
func _on_turn_started(_turn_number: int, player_index: int) -> void:
|
func _on_turn_started(_turn_number: int, player_index: int) -> void:
|
||||||
if _finished:
|
if _finished:
|
||||||
return
|
return
|
||||||
ArenaCameraScript.follow_player(_world_map, player_index, ARENA_ZOOM)
|
if follow_enabled:
|
||||||
|
ArenaCameraScript.follow_player(_world_map, player_index, ARENA_ZOOM)
|
||||||
if _overlay != null:
|
if _overlay != null:
|
||||||
_overlay.set_active_player(player_index)
|
_overlay.set_active_player(player_index)
|
||||||
|
|
||||||
|
|
@ -200,6 +214,12 @@ func _on_turn_ended(_turn_number: int, _player_index: int) -> void:
|
||||||
if _turn_label != null:
|
if _turn_label != null:
|
||||||
_turn_label.text = "turn %d / %d" % [GameState.turn_number, _turn_limit]
|
_turn_label.text = "turn %d / %d" % [GameState.turn_number, _turn_limit]
|
||||||
|
|
||||||
|
# Observer is a free-running cast: no tournament screenshot, no turn-limit /
|
||||||
|
# wall-clock auto-finish. The caster ends the session; victory is shown in
|
||||||
|
# the HUD, not written to a result file.
|
||||||
|
if _observer:
|
||||||
|
return
|
||||||
|
|
||||||
# Capture a viewport PNG at a configurable turn so the orchestrator can
|
# Capture a viewport PNG at a configurable turn so the orchestrator can
|
||||||
# composite the 4 per-match screenshots into a quad-grid image — host
|
# composite the 4 per-match screenshots into a quad-grid image — host
|
||||||
# screenshot tools don't see native Wayland windows via x11grab, so we
|
# screenshot tools don't see native Wayland windows via x11grab, so we
|
||||||
|
|
@ -274,6 +294,10 @@ func _capture_viewport_screenshot_async() -> void:
|
||||||
func _on_victory(player_index: int, victory_type: String) -> void:
|
func _on_victory(player_index: int, victory_type: String) -> void:
|
||||||
if _finished:
|
if _finished:
|
||||||
return
|
return
|
||||||
|
# Observer never writes a result file or quits — observer_mode.gd pauses the
|
||||||
|
# loop and the caster HUD shows the winner.
|
||||||
|
if _observer:
|
||||||
|
return
|
||||||
_finish(player_index, victory_type)
|
_finish(player_index, victory_type)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ const TurnProcessorScript: GDScript = preload(
|
||||||
const AiTurnBridgeScript: GDScript = preload(
|
const AiTurnBridgeScript: GDScript = preload(
|
||||||
"res://engine/src/modules/ai/ai_turn_bridge.gd"
|
"res://engine/src/modules/ai/ai_turn_bridge.gd"
|
||||||
)
|
)
|
||||||
|
const ObserverPacingScript: GDScript = preload(
|
||||||
|
"res://engine/src/modules/management/observer_pacing.gd"
|
||||||
|
)
|
||||||
const PrologueDriverScript: GDScript = preload(
|
const PrologueDriverScript: GDScript = preload(
|
||||||
"res://engine/src/modules/management/prologue_driver.gd"
|
"res://engine/src/modules/management/prologue_driver.gd"
|
||||||
)
|
)
|
||||||
|
|
@ -43,6 +46,10 @@ var _processing_end_turn: bool = false
|
||||||
## safety-timer fire and the EventBus.arena_playback_finished don't both
|
## safety-timer fire and the EventBus.arena_playback_finished don't both
|
||||||
## trigger start_turn() (which would double-advance turns). -1 means no gate.
|
## trigger start_turn() (which would double-advance turns). -1 means no gate.
|
||||||
var _arena_gate_turn: int = -1
|
var _arena_gate_turn: int = -1
|
||||||
|
## Observer/caster pacing (OBSERVER env). Owns pause/step/speed for the spectator
|
||||||
|
## turn loop; the gate below calls observer_pacing.advance() instead of an
|
||||||
|
## immediate start_turn(). Logic lives in its own module to keep this file lean.
|
||||||
|
var observer_pacing: RefCounted = ObserverPacingScript.new() # ObserverPacing
|
||||||
var _unit_manager: RefCounted = UnitManagerScript.new() # UnitManager
|
var _unit_manager: RefCounted = UnitManagerScript.new() # UnitManager
|
||||||
var _wild_ai: RefCounted = null # WildCreatureAI — set by WorldMap after spawn
|
var _wild_ai: RefCounted = null # WildCreatureAI — set by WorldMap after spawn
|
||||||
var tech_web: RefCounted = TechWebScript.new() # TechWeb — built on first use
|
var tech_web: RefCounted = TechWebScript.new() # TechWeb — built on first use
|
||||||
|
|
@ -63,6 +70,7 @@ var prologue: RefCounted = null # PrologueDriver
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
EventBus.tech_research_started.connect(_on_tech_research_started)
|
EventBus.tech_research_started.connect(_on_tech_research_started)
|
||||||
EventBus.deposit_discovered.connect(_on_deposit_discovered)
|
EventBus.deposit_discovered.connect(_on_deposit_discovered)
|
||||||
|
observer_pacing.set_turn_manager(self)
|
||||||
_processor = TurnProcessorScript.new()
|
_processor = TurnProcessorScript.new()
|
||||||
var proc: TurnProcessorScript = _processor as TurnProcessorScript
|
var proc: TurnProcessorScript = _processor as TurnProcessorScript
|
||||||
proc.unit_manager = _unit_manager
|
proc.unit_manager = _unit_manager
|
||||||
|
|
@ -350,9 +358,9 @@ func next_player() -> void:
|
||||||
# Check victory conditions after all players have moved
|
# Check victory conditions after all players have moved
|
||||||
var vm: VictoryManagerScript = _victory_manager as VictoryManagerScript
|
var vm: VictoryManagerScript = _victory_manager as VictoryManagerScript
|
||||||
vm.check_all(GameState.get_game_map())
|
vm.check_all(GameState.get_game_map())
|
||||||
# Auto-save at end of every full game turn (skipped in arena mode to
|
# Auto-save at end of every full game turn (skipped in arena/observer
|
||||||
# avoid hundreds of autosaves per spectator match).
|
# spectator modes to avoid hundreds of autosaves per match).
|
||||||
if not EnvConfig.get_bool("AI_ARENA"):
|
if not EnvConfig.get_bool("AI_ARENA") and not EnvConfig.get_bool("OBSERVER"):
|
||||||
SaveManagerScript.autosave()
|
SaveManagerScript.autosave()
|
||||||
else:
|
else:
|
||||||
# p2-83: use bridge to advance within-round player phase (updates current + emits).
|
# p2-83: use bridge to advance within-round player phase (updates current + emits).
|
||||||
|
|
@ -368,7 +376,7 @@ func next_player() -> void:
|
||||||
# replay with camera pans and animation before the next round. A
|
# replay with camera pans and animation before the next round. A
|
||||||
# safety timer fires if playback isn't wired or stalls so we never
|
# safety timer fires if playback isn't wired or stalls so we never
|
||||||
# deadlock. Normal human-vs-AI gameplay keeps the synchronous path.
|
# deadlock. Normal human-vs-AI gameplay keeps the synchronous path.
|
||||||
if EnvConfig.get_bool("AI_ARENA"):
|
if EnvConfig.get_bool("AI_ARENA") or EnvConfig.get_bool("OBSERVER"):
|
||||||
_arena_gate_turn = GameState.turn_number
|
_arena_gate_turn = GameState.turn_number
|
||||||
EventBus.arena_playback_finished.connect(
|
EventBus.arena_playback_finished.connect(
|
||||||
_on_arena_playback_finished, CONNECT_ONE_SHOT
|
_on_arena_playback_finished, CONNECT_ONE_SHOT
|
||||||
|
|
@ -393,7 +401,12 @@ func _on_arena_playback_finished(turn_number: int) -> void:
|
||||||
_arena_gate_turn = -1
|
_arena_gate_turn = -1
|
||||||
if EventBus.arena_playback_finished.is_connected(_on_arena_playback_finished):
|
if EventBus.arena_playback_finished.is_connected(_on_arena_playback_finished):
|
||||||
EventBus.arena_playback_finished.disconnect(_on_arena_playback_finished)
|
EventBus.arena_playback_finished.disconnect(_on_arena_playback_finished)
|
||||||
start_turn()
|
# Observer mode hands the gate to its pacing module (pause/step/speed); plain
|
||||||
|
# arena resumes immediately.
|
||||||
|
if EnvConfig.get_bool("OBSERVER"):
|
||||||
|
observer_pacing.advance(turn_number)
|
||||||
|
else:
|
||||||
|
start_turn()
|
||||||
|
|
||||||
|
|
||||||
func get_phase_name() -> String:
|
func get_phase_name() -> String:
|
||||||
|
|
|
||||||
72
src/game/engine/src/modules/management/observer_pacing.gd
Normal file
72
src/game/engine/src/modules/management/observer_pacing.gd
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
class_name ObserverPacing
|
||||||
|
extends RefCounted
|
||||||
|
## Pacing gate for observer/caster mode (OBSERVER env). Owns the pause / step /
|
||||||
|
## speed state for the spectator turn loop. TurnManager calls advance() at the
|
||||||
|
## action-playback gate instead of re-entering start_turn() directly, so the cast
|
||||||
|
## can hold between turns and the inter-turn delay can scale with caster speed.
|
||||||
|
##
|
||||||
|
## Separated from turn_manager.gd so the turn loop stays focused on resolution,
|
||||||
|
## not presentation pacing. Pure presentation timing: it only governs WHEN
|
||||||
|
## start_turn() fires, never the turn's resolution or outcome.
|
||||||
|
|
||||||
|
const INTER_TURN_BASE_SEC: float = 0.6
|
||||||
|
|
||||||
|
var paused: bool = false
|
||||||
|
var speed: float = 1.0
|
||||||
|
var _tm: Node = null
|
||||||
|
var _waiting_turn: int = -1
|
||||||
|
var _step_armed: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func set_turn_manager(tm: Node) -> void:
|
||||||
|
_tm = tm
|
||||||
|
|
||||||
|
|
||||||
|
## Called by TurnManager once the action playback for `turn_number` finishes.
|
||||||
|
## Holds the loop when paused (unless a step is armed), otherwise advances after
|
||||||
|
## a speed-scaled delay.
|
||||||
|
func advance(turn_number: int) -> void:
|
||||||
|
if paused and not _step_armed:
|
||||||
|
_waiting_turn = turn_number
|
||||||
|
return
|
||||||
|
_step_armed = false
|
||||||
|
var delay: float = INTER_TURN_BASE_SEC / maxf(speed, 0.05)
|
||||||
|
if delay <= 0.0 or _tm == null:
|
||||||
|
_start()
|
||||||
|
else:
|
||||||
|
_tm.get_tree().create_timer(delay).timeout.connect(_start, CONNECT_ONE_SHOT)
|
||||||
|
|
||||||
|
|
||||||
|
func set_paused(value: bool) -> void:
|
||||||
|
paused = value
|
||||||
|
if not value:
|
||||||
|
_release()
|
||||||
|
|
||||||
|
|
||||||
|
func toggle_paused() -> void:
|
||||||
|
set_paused(not paused)
|
||||||
|
|
||||||
|
|
||||||
|
## Advance exactly one player-turn while paused. Releases a turn already held at
|
||||||
|
## the gate, or arms the next gate to pass through once.
|
||||||
|
func step() -> void:
|
||||||
|
if _waiting_turn >= 0:
|
||||||
|
_release()
|
||||||
|
else:
|
||||||
|
_step_armed = true
|
||||||
|
|
||||||
|
|
||||||
|
func set_speed(value: float) -> void:
|
||||||
|
speed = clampf(value, 0.25, 4.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _release() -> void:
|
||||||
|
if _waiting_turn < 0:
|
||||||
|
return
|
||||||
|
_waiting_turn = -1
|
||||||
|
_start()
|
||||||
|
|
||||||
|
|
||||||
|
func _start() -> void:
|
||||||
|
if _tm != null:
|
||||||
|
_tm.start_turn()
|
||||||
|
|
@ -432,6 +432,13 @@ pub struct GameState {
|
||||||
/// until the first ecology tick seeds the world.
|
/// until the first ecology tick seeds the world.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub worldsim_state_json: String,
|
pub worldsim_state_json: String,
|
||||||
|
/// p3-26 B3: improvement definitions (`id → {build_turns, food, production}`),
|
||||||
|
/// boot-loaded from `public/resources/improvements/*.json`. `#[serde(skip)]`
|
||||||
|
/// static content like the other catalogs; drives both the build-tick
|
||||||
|
/// (`build_turns`) and `process_improvement_yields` (food/production). Empty →
|
||||||
|
/// no improvements build or yield (safe no-op).
|
||||||
|
#[serde(skip)]
|
||||||
|
pub improvement_defs: BTreeMap<String, ImprovementDef>,
|
||||||
/// p2-71: tactical-AI view of the producible-unit catalog. Mirrors
|
/// p2-71: tactical-AI view of the producible-unit catalog. Mirrors
|
||||||
/// `TacticalState::unit_catalog` and is populated once at harness boot
|
/// `TacticalState::unit_catalog` and is populated once at harness boot
|
||||||
/// by `GdPlayerApi::set_units_catalog_json` (or directly in Rust tests).
|
/// by `GdPlayerApi::set_units_catalog_json` (or directly in Rust tests).
|
||||||
|
|
@ -732,6 +739,43 @@ impl GameState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// p3-26 B3: load improvement definitions from a JSON array of improvement
|
||||||
|
/// objects (`[{id, build_turns, yields:{food,production}}, …]`, the shape
|
||||||
|
/// `public/resources/improvements/*.json` carry). Returns the count loaded;
|
||||||
|
/// malformed input leaves the table empty (improvements no-op). Called once
|
||||||
|
/// at boot (the field is `#[serde(skip)]`).
|
||||||
|
pub fn load_improvement_defs_json(&mut self, json_array: &str) -> usize {
|
||||||
|
let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(json_array) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let mut n = 0;
|
||||||
|
for v in &arr {
|
||||||
|
let Some(id) = v.get("id").and_then(|x| x.as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let build_turns = v.get("build_turns").and_then(|x| x.as_u64()).unwrap_or(1) as u32;
|
||||||
|
let yields = v.get("yields");
|
||||||
|
let food = yields
|
||||||
|
.and_then(|y| y.get("food"))
|
||||||
|
.and_then(|x| x.as_i64())
|
||||||
|
.unwrap_or(0) as i32;
|
||||||
|
let production = yields
|
||||||
|
.and_then(|y| y.get("production"))
|
||||||
|
.and_then(|x| x.as_i64())
|
||||||
|
.unwrap_or(0) as i32;
|
||||||
|
self.improvement_defs.insert(
|
||||||
|
id.to_string(),
|
||||||
|
ImprovementDef {
|
||||||
|
build_turns,
|
||||||
|
food,
|
||||||
|
production,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
/// p2-65 Phase7 test helper: construct a GameState whose combat_balance
|
/// p2-65 Phase7 test helper: construct a GameState whose combat_balance
|
||||||
/// (and future SimConfig fields) are pre-populated without touching the
|
/// (and future SimConfig fields) are pre-populated without touching the
|
||||||
/// global RwLock singleton. Callers that need isolated config for
|
/// global RwLock singleton. Callers that need isolated config for
|
||||||
|
|
@ -993,6 +1037,12 @@ pub struct PlayerState {
|
||||||
/// GDScript; bench tests set this directly.
|
/// GDScript; bench tests set this directly.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub city_improvements: Vec<Vec<String>>,
|
pub city_improvements: Vec<Vec<String>>,
|
||||||
|
/// p3-26 B3: tile improvements under construction. Each ticks down
|
||||||
|
/// `turns_remaining` per turn (the improvement build-tick phase); at 0 the
|
||||||
|
/// improvement id is appended to `city_improvements[city_idx]` so it starts
|
||||||
|
/// yielding. Mirrors the live `player.pending_improvements`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pending_improvements: Vec<PendingImprovement>,
|
||||||
/// Per-city accumulated fauna harassment pressure. Aligned with `cities`.
|
/// Per-city accumulated fauna harassment pressure. Aligned with `cities`.
|
||||||
pub city_ecology: Vec<CityEcology>,
|
pub city_ecology: Vec<CityEcology>,
|
||||||
/// Optional research state. `None` = no research simulated.
|
/// Optional research state. `None` = no research simulated.
|
||||||
|
|
@ -1177,6 +1227,37 @@ pub struct PlayerState {
|
||||||
pub building_happiness: i32,
|
pub building_happiness: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// p3-26 B3: an improvement definition (boot-loaded, keyed by id on
|
||||||
|
/// `GameState::improvement_defs`). `build_turns` drives the build-tick; `food`
|
||||||
|
/// and `production` feed `process_improvement_yields` once the improvement
|
||||||
|
/// completes onto a city.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ImprovementDef {
|
||||||
|
/// Turns of worker construction before the improvement completes.
|
||||||
|
pub build_turns: u32,
|
||||||
|
/// Per-turn food bonus to the owning city once complete.
|
||||||
|
pub food: i32,
|
||||||
|
/// Per-turn production bonus to the owning city once complete.
|
||||||
|
pub production: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// p3-26 B3: a tile improvement under construction. Ticks down `turns_remaining`
|
||||||
|
/// each turn; at 0 the `improvement_id` is appended to the owning city's
|
||||||
|
/// `city_improvements` list so it begins yielding.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct PendingImprovement {
|
||||||
|
/// Tile column the improvement is being built on.
|
||||||
|
pub col: i32,
|
||||||
|
/// Tile row the improvement is being built on.
|
||||||
|
pub row: i32,
|
||||||
|
/// Improvement id (e.g. `"farm"`).
|
||||||
|
pub improvement_id: String,
|
||||||
|
/// Index into the owning player's `cities` / `city_improvements` to credit.
|
||||||
|
pub city_idx: usize,
|
||||||
|
/// Turns left until completion.
|
||||||
|
pub turns_remaining: i32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Standing order for units that arrive at a rally point.
|
/// Standing order for units that arrive at a rally point.
|
||||||
///
|
///
|
||||||
/// Backward-compat serde: any unrecognised string value (e.g. old saves with
|
/// Backward-compat serde: any unrecognised string value (e.g. old saves with
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue