InputMapper tracks a mouse-down over a placed piece and promotes it to drag mode once the cursor travels past an 8px threshold. Legal drop cells (those where the piece's start→end vector still fits a legal placement) are highlighted in green. Releasing on a legal cell emits a RelocateRequested signal; Main feeds it to MovePieceCommand, which is already undoable via the existing history stack. Escape or releasing on an invalid cell cancels. The harness gains a relocate() helper so UI tests can script drag-and-drop moves without synthesizing motion events.
942 lines
25 KiB
C#
942 lines
25 KiB
C#
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!;
|
|
|
|
// 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=<dir> 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);
|
|
|
|
// --- 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;
|
|
_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<PieceStock>();
|
|
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<IWorldEvent> 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 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);
|
|
});
|
|
}
|
|
}
|