using Godot; using System; using System.Collections.Generic; using System.Linq; using Chessistics.Engine.Commands; using Chessistics.Engine.Events; using Chessistics.Engine.Loading; using Chessistics.Engine.Model; using Chessistics.Engine.Simulation; using Chessistics.Scripts.Automation; using Chessistics.Scripts.Board; using Chessistics.Scripts.Input; using Chessistics.Scripts.Pieces; using Chessistics.Scripts.Presentation; using Chessistics.Scripts.UI; namespace Chessistics.Scripts; public partial class Main : Node2D { private GameSim? _sim; private CampaignDef? _campaignDef; // Views private BoardView _boardView = null!; private InputMapper _inputMapper = null!; private EventAnimator _eventAnimator = null!; // UI private CanvasLayer _uiLayer = null!; private ObjectivePanel _objectivePanel = null!; private PieceStockPanel _pieceStockPanel = null!; private DetailPanel _detailPanel = null!; private ControlBar _controlBar = null!; private MetricsOverlay _metricsOverlay = null!; private LevelSelectScreen _titleScreen = null!; private Label _levelTitle = null!; private PanelContainer _sidePanel = null!; private PanelContainer _controlBarWrapper = null!; private Camera2D _camera = null!; private ColorRect _fadeOverlay = null!; private FlavorBanner _flavorBanner = null!; private Label _collisionToast = null!; // Simulation timer private Godot.Timer _simTimer = null!; private float _simInterval = 1.0f; private bool _running; private bool _panning; private bool _rightDragged; private bool _collisionPauseOccurred; private const float CameraKeyboardSpeed = 400f; private const float SidePanelWidth = 280f; private const float ControlBarHeight = 48f; private const float TitleBarHeight = 40f; private static readonly Color BackgroundColor = new("#2D2D2D"); // Automation harness (active only when --automation= CLI flag is given) private string? _automationDir; private AutomationHarness? _automationHarness; public override void _Ready() { RenderingServer.SetDefaultClearColor(BackgroundColor); _automationDir = ParseAutomationArg(); BuildSceneTree(); ConnectSignals(); ShowTitleScreen(); if (_automationDir != null) { // Skip the opening fade so the harness sees a stable frame immediately. _fadeOverlay.Color = new Color(0, 0, 0, 0); MountAutomationHarness(_automationDir); } else { FadeIn(0.5f); } } private static string? ParseAutomationArg() { foreach (var arg in OS.GetCmdlineArgs()) { if (arg.StartsWith("--automation=", StringComparison.Ordinal)) return arg.Substring("--automation=".Length); } return null; } private void MountAutomationHarness(string dir) { var facade = new AutomationFacade( sim: () => _sim, input: _inputMapper, animator: _eventAnimator, stock: _pieceStockPanel, controlBar: _controlBar, loadMission: HarnessLoadMission, play: OnPlay, pause: OnPause, step: OnStep, togglePlayPause: TogglePlayPause, backToMenu: HarnessBackToMenu, setSpeed: HarnessSetSpeed, quit: () => GetTree().Quit(), quickSave: OnQuickSave, quickLoad: OnQuickLoad, undo: OnUndo); _automationHarness = new AutomationHarness(dir, facade); AddChild(_automationHarness); } private void HarnessLoadMission(string campaignName, int missionIndex) { var path = $"res://Data/campaigns/{campaignName}.json"; LoadCampaignDirect(path); // Fast-forward to the requested mission by completing prior ones synthetically. while (_sim != null && _campaignDef != null && _sim.GetSnapshot().Campaign is { } campSnap && campSnap.CurrentMissionIndex < missionIndex) { _sim.ProcessCommand(new PauseSimulationCommand()); // no-op if already paused // Cheat the phase to MissionComplete then Advance — only valid in automation. // Simpler: refuse non-zero missionIndex for now and require AdvanceMission // via gameplay. Breaking the break keeps harness predictable. GD.PrintErr($"[Automation] missionIndex > 0 not supported yet; staying at mission {campSnap.CurrentMissionIndex}"); break; } } private void HarnessBackToMenu() { _running = false; _simTimer.Stop(); _eventAnimator.ClearAll(); ShowTitleScreen(); } private void HarnessSetSpeed(float interval) { _simInterval = interval; OnSpeedChanged(interval); } public override void _UnhandledInput(InputEvent @event) { if (@event is InputEventMouseButton mb) { if (mb.ButtonIndex == MouseButton.Middle) { _panning = mb.Pressed; } else if (mb.ButtonIndex == MouseButton.Right) { if (mb.Pressed) { _panning = true; _rightDragged = false; } else { _panning = false; if (!_rightDragged) { _inputMapper.Cancel(); _pieceStockPanel.ClearSelection(); } } } else if (mb.Pressed && mb.ButtonIndex == MouseButton.WheelUp) ZoomCamera(1.1f); else if (mb.Pressed && mb.ButtonIndex == MouseButton.WheelDown) ZoomCamera(0.9f); } else if (@event is InputEventMouseMotion motion && _panning) { _rightDragged = true; _camera.Position -= motion.Relative / _camera.Zoom; } else if (@event is InputEventKey key && key.Pressed && !key.Echo) { if (key.Keycode == Key.Space) { TogglePlayPause(); GetViewport().SetInputAsHandled(); } else if (key.Keycode == Key.F5) { OnQuickSave(); GetViewport().SetInputAsHandled(); } else if (key.Keycode == Key.F9) { OnQuickLoad(); GetViewport().SetInputAsHandled(); } else if (key.Keycode == Key.Z && key.CtrlPressed) { OnUndo(); GetViewport().SetInputAsHandled(); } } } public override void _Process(double delta) { var dir = Vector2.Zero; if (Godot.Input.IsKeyPressed(Key.Z) || Godot.Input.IsKeyPressed(Key.W)) dir.Y -= 1; if (Godot.Input.IsKeyPressed(Key.S)) dir.Y += 1; if (Godot.Input.IsKeyPressed(Key.Q) || Godot.Input.IsKeyPressed(Key.A)) dir.X -= 1; if (Godot.Input.IsKeyPressed(Key.D)) dir.X += 1; if (dir != Vector2.Zero) _camera.Position += dir.Normalized() * CameraKeyboardSpeed * (float)delta / _camera.Zoom.X; } private void ZoomCamera(float factor) { var newZoom = _camera.Zoom * factor; newZoom = newZoom.Clamp(new Vector2(0.3f, 0.3f), new Vector2(3f, 3f)); _camera.Zoom = newZoom; } private void FadeIn(float duration) { _fadeOverlay.Color = new Color(0, 0, 0, 1); var tween = CreateTween(); tween.TweenProperty(_fadeOverlay, "color:a", 0f, duration) .SetEase(Tween.EaseType.Out); } private void FadeOut(float duration, Action onComplete) { _fadeOverlay.Color = new Color(0, 0, 0, 0); var tween = CreateTween(); tween.TweenProperty(_fadeOverlay, "color:a", 1f, duration) .SetEase(Tween.EaseType.In); tween.TweenCallback(Callable.From(onComplete)); } private void BuildSceneTree() { _camera = new Camera2D { Enabled = true }; AddChild(_camera); var sfx = new SfxManager(); AddChild(sfx); _boardView = new BoardView(); AddChild(_boardView); _inputMapper = new InputMapper(); _inputMapper.Initialize(_boardView); AddChild(_inputMapper); _eventAnimator = new EventAnimator(); AddChild(_eventAnimator); _simTimer = new Godot.Timer { OneShot = false, WaitTime = _simInterval }; _simTimer.Timeout += OnSimTimerTick; AddChild(_simTimer); // --- UI Layer --- _uiLayer = new CanvasLayer(); AddChild(_uiLayer); var uiRoot = new Control(); uiRoot.SetAnchorsPreset(Control.LayoutPreset.FullRect); uiRoot.MouseFilter = Control.MouseFilterEnum.Ignore; _uiLayer.AddChild(uiRoot); // Title bar var titleBar = new HBoxContainer(); titleBar.SetAnchorsPreset(Control.LayoutPreset.TopLeft); titleBar.OffsetLeft = 12; titleBar.OffsetTop = 8; titleBar.AddThemeConstantOverride("separation", 12); var backButton = new Button { Text = "← Menu", CustomMinimumSize = new Vector2(70, 28) }; backButton.AddThemeFontSizeOverride("font_size", 11); var backStyle = new StyleBoxFlat { BgColor = new Color("#2A2A2E"), BorderColor = new Color("#444448"), BorderWidthBottom = 1, BorderWidthTop = 1, BorderWidthLeft = 1, BorderWidthRight = 1, CornerRadiusTopLeft = 4, CornerRadiusTopRight = 4, CornerRadiusBottomLeft = 4, CornerRadiusBottomRight = 4, ContentMarginLeft = 8, ContentMarginRight = 8, ContentMarginTop = 2, ContentMarginBottom = 2 }; backButton.AddThemeStyleboxOverride("normal", backStyle); backButton.Pressed += OnBackToMenu; titleBar.AddChild(backButton); _levelTitle = new Label { Text = "CHESSISTICS" }; _levelTitle.AddThemeFontSizeOverride("font_size", 20); _levelTitle.MouseFilter = Control.MouseFilterEnum.Ignore; titleBar.AddChild(_levelTitle); uiRoot.AddChild(titleBar); // --- Side Panel --- _sidePanel = new PanelContainer(); _sidePanel.AnchorLeft = 1.0f; _sidePanel.AnchorRight = 1.0f; _sidePanel.AnchorTop = 0.0f; _sidePanel.AnchorBottom = 1.0f; _sidePanel.OffsetLeft = -SidePanelWidth; _sidePanel.OffsetRight = 0; _sidePanel.OffsetTop = 0; _sidePanel.OffsetBottom = -ControlBarHeight; var sidePanelStyle = new StyleBoxFlat { BgColor = new Color(0.14f, 0.14f, 0.16f, 0.97f), BorderColor = new Color(0.25f, 0.25f, 0.28f), BorderWidthLeft = 1, ContentMarginLeft = 16, ContentMarginRight = 16, ContentMarginTop = 16, ContentMarginBottom = 16 }; _sidePanel.AddThemeStyleboxOverride("panel", sidePanelStyle); var sideScroll = new ScrollContainer { HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; var sideVBox = new VBoxContainer(); sideVBox.AddThemeConstantOverride("separation", 12); _objectivePanel = new ObjectivePanel(); sideVBox.AddChild(_objectivePanel); sideVBox.AddChild(new HSeparator()); _pieceStockPanel = new PieceStockPanel(); sideVBox.AddChild(_pieceStockPanel); _detailPanel = new DetailPanel(); sideVBox.AddChild(_detailPanel); sideScroll.AddChild(sideVBox); _sidePanel.AddChild(sideScroll); uiRoot.AddChild(_sidePanel); // --- Control Bar --- _controlBarWrapper = new PanelContainer(); _controlBarWrapper.AnchorLeft = 0.0f; _controlBarWrapper.AnchorRight = 1.0f; _controlBarWrapper.AnchorTop = 1.0f; _controlBarWrapper.AnchorBottom = 1.0f; _controlBarWrapper.OffsetTop = -ControlBarHeight; _controlBarWrapper.OffsetRight = -SidePanelWidth; var controlBarStyle = new StyleBoxFlat { BgColor = new Color(0.11f, 0.11f, 0.13f, 0.97f), BorderColor = new Color(0.25f, 0.25f, 0.28f), BorderWidthTop = 1, ContentMarginLeft = 12, ContentMarginRight = 12, ContentMarginTop = 4, ContentMarginBottom = 4 }; _controlBarWrapper.AddThemeStyleboxOverride("panel", controlBarStyle); _controlBar = new ControlBar(); _controlBarWrapper.AddChild(_controlBar); uiRoot.AddChild(_controlBarWrapper); // --- Metrics Overlay --- var metricsCenter = new CenterContainer(); metricsCenter.SetAnchorsPreset(Control.LayoutPreset.FullRect); metricsCenter.OffsetRight = -SidePanelWidth; metricsCenter.OffsetBottom = -ControlBarHeight; metricsCenter.MouseFilter = Control.MouseFilterEnum.Ignore; _metricsOverlay = new MetricsOverlay(); _metricsOverlay.CustomMinimumSize = new Vector2(340, 260); metricsCenter.AddChild(_metricsOverlay); uiRoot.AddChild(metricsCenter); // --- Title Screen --- _titleScreen = new LevelSelectScreen(); _titleScreen.SetAnchorsPreset(Control.LayoutPreset.FullRect); uiRoot.AddChild(_titleScreen); // --- Collision toast (hidden by default, shown on collision) --- _collisionToast = new Label { Text = "", Visible = false, MouseFilter = Control.MouseFilterEnum.Ignore }; _collisionToast.AddThemeFontSizeOverride("font_size", 14); _collisionToast.AddThemeColorOverride("font_color", new Color("#FFE8D0")); _collisionToast.AddThemeColorOverride("font_outline_color", new Color(0, 0, 0, 0.8f)); _collisionToast.AddThemeConstantOverride("outline_size", 4); _collisionToast.AnchorLeft = 0.0f; _collisionToast.AnchorRight = 0.0f; _collisionToast.AnchorTop = 1.0f; _collisionToast.AnchorBottom = 1.0f; _collisionToast.OffsetLeft = 20; _collisionToast.OffsetRight = 400; _collisionToast.OffsetTop = -ControlBarHeight - 60; _collisionToast.OffsetBottom = -ControlBarHeight - 20; uiRoot.AddChild(_collisionToast); // --- Flavor Banner (narrative text) --- _flavorBanner = new FlavorBanner(); _flavorBanner.AnchorLeft = 0.1f; _flavorBanner.AnchorRight = 0.7f; _flavorBanner.AnchorTop = 0.0f; _flavorBanner.AnchorBottom = 0.0f; _flavorBanner.OffsetTop = 44; // Below title bar _flavorBanner.OffsetBottom = 100; uiRoot.AddChild(_flavorBanner); // --- Fade overlay --- _fadeOverlay = new ColorRect { Color = new Color(0, 0, 0, 1), MouseFilter = Control.MouseFilterEnum.Ignore }; _fadeOverlay.SetAnchorsPreset(Control.LayoutPreset.FullRect); uiRoot.AddChild(_fadeOverlay); _eventAnimator.Initialize(_boardView, _objectivePanel, _controlBar, _metricsOverlay); } private void ConnectSignals() { _titleScreen.StartCampaignPressed += OnStartCampaign; _pieceStockPanel.PieceSelected += OnPieceKindSelected; _inputMapper.PlacementRequested += OnPlacementRequested; _inputMapper.Cancelled += OnPlacementCancelled; _inputMapper.RelocateRequested += OnRelocateRequested; _controlBar.PlayPressed += OnPlay; _controlBar.PausePressed += OnPause; _controlBar.StepPressed += OnStep; _controlBar.SpeedChanged += OnSpeedChanged; _eventAnimator.TurnAnimationCompleted += OnTurnAnimationCompleted; _eventAnimator.VictoryReached += OnCampaignComplete; _eventAnimator.MissionAdvanced += OnMissionAdvanced; _eventAnimator.CollisionOccurred += OnCollisionOccurred; _metricsOverlay.NextLevelPressed += OnBackToMenu; _detailPanel.RemoveRequested += OnRemoveRequested; _inputMapper.CellClicked += OnCellClicked; } private void OnCellClicked(int col, int row) { if (_sim == null) return; var snap = _sim.GetSnapshot(); _boardView.ClearHighlights(); var coords = new Coords(col, row); var piece = snap.Pieces.FirstOrDefault(p => p.StartCell == coords || p.EndCell == coords); if (piece != null) { _detailPanel.ShowPiece(piece); var pieceColor = PieceView.GetPieceColor(piece.Kind); var highlightColor = new Color(pieceColor, 0.3f); _boardView.HighlightCells([piece.StartCell, piece.EndCell], highlightColor); } else { _detailPanel.Hide(); } } // --- Campaign Management --- private void ShowTitleScreen() { _titleScreen.Visible = true; _boardView.Visible = false; _sidePanel.Visible = false; _controlBarWrapper.Visible = false; _levelTitle.Visible = false; } private void OnStartCampaign() { SfxManager.Instance?.PlayClick(); FadeOut(0.25f, () => { LoadCampaign(); FadeIn(0.3f); }); } private void LoadCampaign() => LoadCampaignDirect("res://Data/campaigns/campaign_01.json"); private void LoadCampaignDirect(string path) { var file = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read); if (file == null) { GD.PrintErr($"Cannot open campaign file: {path}"); return; } var json = file.GetAsText(); file.Close(); _campaignDef = CampaignLoader.Load(json); _sim = new GameSim(_campaignDef); // Load campaign: applies mission 0 terrain, stock, unlocked pieces var loadEvents = _sim.ProcessCommand(new LoadCampaignCommand()); foreach (var evt in loadEvents) { if (evt is CommandRejectedEvent r) { GD.PrintErr($"Cannot load campaign: {r.Reason}"); return; } } _titleScreen.Visible = false; _boardView.Visible = true; _sidePanel.Visible = true; _controlBarWrapper.Visible = true; _levelTitle.Visible = true; var snap = _sim.GetSnapshot(); var mission = _campaignDef.Missions[snap.Campaign!.CurrentMissionIndex]; BuildBoardFromSnapshot(snap); SetupUIForMission(snap, mission); CenterCameraOnBoard(snap.Width, snap.Height); _inputMapper.SetSnapshot(snap); } private void BuildBoardFromSnapshot(BoardSnapshot snap) { _eventAnimator.ClearAll(); _boardView.BuildBoardFromSnapshot(snap); // Recreate piece visuals if any exist foreach (var ps in snap.Pieces) { var pieceView = new PieceView(); pieceView.Setup(ps.Id, ps.Kind, ps.StartCell, ps.EndCell, _boardView); _boardView.AddChild(pieceView); var color = PieceView.GetPieceColor(ps.Kind); var trajectView = new TrajectView(); trajectView.Setup(ps.Id, _boardView.CoordsToPixel(ps.StartCell), _boardView.CoordsToPixel(ps.EndCell), color); _boardView.AddChild(trajectView); _eventAnimator.RegisterPiece(ps.Id, pieceView, trajectView); } } private void SetupUIForMission(BoardSnapshot snap, MissionDef mission) { // Show all demands (current + previous missions) var allDemands = snap.Demands .Select(d => new DemandDef(d.Position, d.Name, d.Cargo, d.Required)) .ToList(); _objectivePanel.Setup(allDemands); // Setup stock panel with only unlocked piece kinds var availableStock = new List(); foreach (var (kind, remaining) in snap.RemainingStock) { if (snap.Campaign != null && !snap.Campaign.AvailablePieceKinds.Contains(kind)) continue; availableStock.Add(new PieceStock(kind, remaining)); } _pieceStockPanel.Setup(availableStock); _controlBar.UpdateForPhase(snap.Phase); _controlBar.ResetTurn(); _metricsOverlay.Hide(); _detailPanel.Hide(); var missionNum = snap.Campaign!.CurrentMissionIndex + 1; var totalMissions = _campaignDef!.Missions.Count; _levelTitle.Text = $"CHESSISTICS — Mission {missionNum}/{totalMissions}: {mission.Name}"; // Show narrative flavor text _flavorBanner.ShowFlavor(mission.Flavor); } private void CenterCameraOnBoard(int width, int height) { _camera.Position = new Vector2( width * BoardView.CellSize / 2f, -height * BoardView.CellSize / 2f + BoardView.CellSize ); // Offset: shift view to account for side panel (right) and title/control bars // Positive X → camera looks right → board appears left (compensates right panel) // Positive Y → camera looks down → board appears up (compensates bottom bar) _camera.Offset = new Vector2(SidePanelWidth / 2f, (ControlBarHeight - TitleBarHeight) / 2f); } // --- Placement --- private void OnPieceKindSelected(int kindIndex) { _inputMapper.SelectPieceKind((PieceKind)kindIndex); } private void OnPlacementRequested(int kindIndex, int startCol, int startRow, int endCol, int endRow) { if (_sim == null) return; var kind = (PieceKind)kindIndex; var start = new Coords(startCol, startRow); var end = new Coords(endCol, endRow); var events = _sim.ProcessCommand(new PlacePieceCommand(kind, start, end)); HandleEditEvents(events); _inputMapper.SetSnapshot(_sim.GetSnapshot()); _pieceStockPanel.ClearSelection(); } private void OnPlacementCancelled() { _pieceStockPanel.ClearSelection(); } private void OnRelocateRequested(int pieceId, int newStartCol, int newStartRow, int newEndCol, int newEndRow) { if (_sim == null) return; var newStart = new Coords(newStartCol, newStartRow); var newEnd = new Coords(newEndCol, newEndRow); var events = _sim.ProcessCommand(new MovePieceCommand(pieceId, newStart, newEnd)); foreach (var evt in events) { switch (evt) { case PieceMovedByPlayerEvent moved: UpdatePieceVisualPosition(moved); _detailPanel.Hide(); break; case CommandRejectedEvent rejected: GD.Print($"Move rejected: {rejected.Reason}"); break; } } _inputMapper.SetSnapshot(_sim.GetSnapshot()); } private void UpdatePieceVisualPosition(PieceMovedByPlayerEvent moved) { // Refresh from snapshot — the cleanest path is a full rebuild of this piece's visuals if (_sim == null) return; _eventAnimator.UnregisterPiece(moved.PieceId); var snap = _sim.GetSnapshot(); var ps = snap.Pieces.FirstOrDefault(p => p.Id == moved.PieceId); if (ps == null) return; var pieceView = new PieceView(); pieceView.Setup(ps.Id, ps.Kind, ps.StartCell, ps.EndCell, _boardView); _boardView.AddChild(pieceView); var color = PieceView.GetPieceColor(ps.Kind); var trajectView = new TrajectView(); trajectView.Setup(ps.Id, _boardView.CoordsToPixel(ps.StartCell), _boardView.CoordsToPixel(ps.EndCell), color); _boardView.AddChild(trajectView); _eventAnimator.RegisterPiece(ps.Id, pieceView, trajectView); } private void OnRemoveRequested(int pieceId) { if (_sim == null) return; var events = _sim.ProcessCommand(new RemovePieceCommand(pieceId)); HandleEditEvents(events); _inputMapper.SetSnapshot(_sim.GetSnapshot()); } private void HandleEditEvents(IReadOnlyList events) { foreach (var evt in events) { switch (evt) { case PiecePlacedEvent placed: CreatePieceVisual(placed); UpdateStockFromSnapshot(); break; case PieceRemovedEvent removed: _eventAnimator.UnregisterPiece(removed.PieceId); UpdateStockFromSnapshot(); _detailPanel.Hide(); break; case PlacementRejectedEvent rejected: GD.Print($"Placement rejected: {rejected.Reason}"); break; case CommandRejectedEvent rejected: GD.Print($"Command rejected: {rejected.Reason}"); break; } } } private void CreatePieceVisual(PiecePlacedEvent placed) { SfxManager.Instance?.PlayPlace(); var pieceView = new PieceView(); pieceView.Setup(placed.PieceId, placed.Kind, placed.Start, placed.End, _boardView); _boardView.AddChild(pieceView); var color = PieceView.GetPieceColor(placed.Kind); var trajectView = new TrajectView(); trajectView.Setup(placed.PieceId, _boardView.CoordsToPixel(placed.Start), _boardView.CoordsToPixel(placed.End), color); _boardView.AddChild(trajectView); _eventAnimator.RegisterPiece(placed.PieceId, pieceView, trajectView); } private void UpdateStockFromSnapshot() { if (_sim == null) return; var snap = _sim.GetSnapshot(); foreach (var (kind, remaining) in snap.RemainingStock) _pieceStockPanel.UpdateCount(kind, remaining); } // --- Simulation Control --- private void TogglePlayPause() { if (_sim == null) return; var phase = _sim.GetSnapshot().Phase; if (phase == SimPhase.Running) OnPause(); else if (phase == SimPhase.Paused) OnPlay(); } private void OnPlay() { if (_sim == null) return; var snap = _sim.GetSnapshot(); if (snap.Phase == SimPhase.Paused || snap.Phase == SimPhase.MissionComplete) { _sim.ProcessCommand(new ResumeSimulationCommand()); } _running = true; _controlBar.UpdateForPhase(SimPhase.Running); _simTimer.WaitTime = _simInterval; _simTimer.Start(); } private void OnPause() { if (_sim == null) return; _sim.ProcessCommand(new PauseSimulationCommand()); _running = false; _simTimer.Stop(); _controlBar.UpdateForPhase(SimPhase.Paused); } private void OnStep() { if (_sim == null || _eventAnimator.IsAnimating) return; _collisionPauseOccurred = false; var events = _sim.ProcessCommand(new StepSimulationCommand()); // Detect if a collision auto-pause happened this step _collisionPauseOccurred = events.Any(e => e is PieceReturnedToStockEvent); _eventAnimator.ProcessEvents(events); _controlBar.UpdateForPhase(_sim.GetSnapshot().Phase); } private void OnSpeedChanged(float interval) { _simInterval = interval; if (_simTimer.TimeLeft > 0) _simTimer.WaitTime = interval; } private void OnSimTimerTick() { if (_sim == null || _eventAnimator.IsAnimating) return; _collisionPauseOccurred = false; var events = _sim.ProcessCommand(new StepSimulationCommand()); _collisionPauseOccurred = events.Any(e => e is PieceReturnedToStockEvent); _eventAnimator.ProcessEvents(events); } private void OnTurnAnimationCompleted() { if (_sim == null) return; var phase = _sim.GetSnapshot().Phase; _controlBar.UpdateForPhase(phase); if (phase == SimPhase.MissionComplete) { // Stop auto-running, show mission complete overlay _running = false; _simTimer.Stop(); } else if (_collisionPauseOccurred && _running) { // Collision caused auto-pause — stop the timer _running = false; _simTimer.Stop(); _collisionPauseOccurred = false; } // Otherwise: if _running, the timer will fire next step automatically } private void OnCampaignComplete() { // Last mission complete — show victory overlay _running = false; _simTimer.Stop(); if (_sim == null || _campaignDef == null) return; var snap = _sim.GetSnapshot(); _metricsOverlay.ShowMissionComplete( snap.Campaign!.CurrentMissionIndex + 1, snap.TurnNumber, true ); } private void OnQuickSave() { if (_sim == null) return; var events = _sim.QuickSave(); foreach (var evt in events) { if (evt is StateSavedEvent saved) GD.Print($"[QuickSave] Slot {saved.SlotId} — turn {saved.TurnNumber}"); } } private void OnQuickLoad() { if (_sim == null || _campaignDef == null) return; if (!_sim.HasSave()) { GD.Print("[QuickLoad] No save available"); return; } // Halt any running simulation loop before rebuilding visuals _running = false; _simTimer.Stop(); var events = _sim.QuickLoad(); foreach (var evt in events) { if (evt is StateRestoredEvent restored) { GD.Print($"[QuickLoad] Slot {restored.SlotId} — turn {restored.Snapshot.TurnNumber}"); ApplyRestoredSnapshot(restored.Snapshot); } } } private void OnCollisionOccurred(int col, int row, string victimKind, int destroyerId) { if (_sim == null) return; // Pan + slight zoom to the collision cell var cellPixel = _boardView.CoordsToPixel(new Coords(col, row)); var originalZoom = _camera.Zoom; var targetZoom = originalZoom * 1.4f; var tween = CreateTween(); tween.SetParallel(true); tween.TweenProperty(_camera, "position", cellPixel, 0.45f) .SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); tween.TweenProperty(_camera, "zoom", targetZoom, 0.45f) .SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic); // Compose toast message string message; if (destroyerId < 0) { message = $"{victimKind} détruit (collision mutuelle) — retourné au stock"; } else { var snap = _sim.GetSnapshot(); var dp = snap.Pieces.FirstOrDefault(p => p.Id == destroyerId); var destroyer = dp?.Kind.ToString() ?? "?"; message = $"{victimKind} détruit par {destroyer} — retourné au stock"; } _collisionToast.Text = message; _collisionToast.Modulate = new Color(1, 1, 1, 0); _collisionToast.Visible = true; var fadeIn = _collisionToast.CreateTween(); fadeIn.TweenProperty(_collisionToast, "modulate:a", 1f, 0.2f); fadeIn.TweenInterval(3.0f); fadeIn.TweenProperty(_collisionToast, "modulate:a", 0f, 0.5f); fadeIn.TweenCallback(Callable.From(() => _collisionToast.Visible = false)); } private void OnUndo() { if (_sim == null) return; if (!_sim.CanUndo) { GD.Print("[Undo] Nothing to undo"); return; } _running = false; _simTimer.Stop(); var events = _sim.Undo(); foreach (var evt in events) { if (evt is StateRestoredEvent restored) { GD.Print($"[Undo] Reverted to turn {restored.Snapshot.TurnNumber}"); ApplyRestoredSnapshot(restored.Snapshot); } } } private void ApplyRestoredSnapshot(BoardSnapshot snap) { if (_campaignDef == null) return; var mission = _campaignDef.Missions[snap.Campaign!.CurrentMissionIndex]; BuildBoardFromSnapshot(snap); SetupUIForMission(snap, mission); CenterCameraOnBoard(snap.Width, snap.Height); _inputMapper.SetSnapshot(snap); _controlBar.UpdateTurn(snap.TurnNumber); _controlBar.UpdateForPhase(snap.Phase); } private void OnMissionAdvanced() { // Auto-advance happened during simulation — rebuild board seamlessly if (_sim == null || _campaignDef == null) return; var snap = _sim.GetSnapshot(); var mission = _campaignDef.Missions[snap.Campaign!.CurrentMissionIndex]; BuildBoardFromSnapshot(snap); SetupUIForMission(snap, mission); CenterCameraOnBoard(snap.Width, snap.Height); _inputMapper.SetSnapshot(snap); } // --- Navigation --- private void OnBackToMenu() { SfxManager.Instance?.PlayClick(); _running = false; _simTimer.Stop(); _eventAnimator.ClearAll(); FadeOut(0.2f, () => { ShowTitleScreen(); FadeIn(0.3f); }); } }