Sound (SfxManager.cs): - Procedural audio synthesis via AudioStreamWav — no external files - Distinct tones for place, produce, transfer, deliver, move, destroy, victory - Simple ADSR envelope, sine/triangle waveforms, filtered noise for swooshes Pieces (PieceView.cs): - Warm earthy palette: sage green, deep teal, dusty rose, burnt sienna - Drop shadow under each piece for depth - 3-stop radial gradient (bright center → main → dark rim) - Scale bounce on placement (0 → 1.15 → 1.0 with back-out easing) - Cargo indicator pulses gently when carrying Trajectories (TrajectView.cs): - Arrowhead at endpoint showing movement direction - Antialiased lines with piece-matched colors Cells (CellView.cs): - Warmer palette: parchment/walnut board, deep forest production, aged gold demand - Production flash uses warm golden glow instead of white - Subtle inner shadow for visual depth Animations (EventAnimator.cs): - Production: golden particles burst from production cells - Transfer: cargo slides with 2-particle trail + back-out whip easing - Destruction: pieces shrink + spin + red particle explosion - Victory: 40 confetti particles rain across the screen - All phases trigger appropriate SFX UI polish: - ControlBar: styled buttons with rounded corners, disabled states - MetricsOverlay: fade-in + scale animation, sequential metric reveals - ObjectivePanel: animated progress bars, styled fills, green flash on completion - Main: fade-in/out transitions between level select and gameplay Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
553 lines
15 KiB
C#
553 lines
15 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.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 LevelDef? _currentLevel;
|
|
private int _currentLevelIndex;
|
|
|
|
// 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 _levelSelectScreen = null!;
|
|
private Label _levelTitle = null!;
|
|
private PanelContainer _sidePanel = null!;
|
|
private PanelContainer _controlBarWrapper = null!;
|
|
private Camera2D _camera = null!;
|
|
private ColorRect _fadeOverlay = null!;
|
|
|
|
// Simulation timer
|
|
private Godot.Timer _simTimer = null!;
|
|
private float _simInterval = 1.0f;
|
|
private bool _running;
|
|
private bool _panning;
|
|
|
|
private static readonly string[] LevelFiles = ["level_01.json", "level_02.json", "level_03.json", "level_04.json", "level_05.json", "level_06.json"];
|
|
|
|
private const float SidePanelWidth = 280f;
|
|
private const float ControlBarHeight = 48f;
|
|
|
|
private static readonly Color BackgroundColor = new("#2D2D2D");
|
|
|
|
public override void _Ready()
|
|
{
|
|
RenderingServer.SetDefaultClearColor(BackgroundColor);
|
|
|
|
BuildSceneTree();
|
|
ConnectSignals();
|
|
ShowLevelSelect();
|
|
|
|
// Fade in from black on startup
|
|
FadeIn(0.5f);
|
|
}
|
|
|
|
public override void _UnhandledInput(InputEvent @event)
|
|
{
|
|
if (@event is InputEventMouseButton mb)
|
|
{
|
|
if (mb.ButtonIndex == MouseButton.Middle)
|
|
_panning = mb.Pressed;
|
|
}
|
|
else if (@event is InputEventMouseMotion motion && _panning)
|
|
{
|
|
_camera.Position -= motion.Relative / _camera.Zoom;
|
|
}
|
|
}
|
|
|
|
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
|
|
_camera = new Camera2D { Enabled = true };
|
|
AddChild(_camera);
|
|
|
|
// SFX
|
|
var sfx = new SfxManager();
|
|
AddChild(sfx);
|
|
|
|
// Board
|
|
_boardView = new BoardView();
|
|
AddChild(_boardView);
|
|
|
|
// Input
|
|
_inputMapper = new InputMapper();
|
|
_inputMapper.Initialize(_boardView);
|
|
AddChild(_inputMapper);
|
|
|
|
// Animator
|
|
_eventAnimator = new EventAnimator();
|
|
AddChild(_eventAnimator);
|
|
|
|
// Sim timer
|
|
_simTimer = new Godot.Timer { OneShot = false, WaitTime = _simInterval };
|
|
_simTimer.Timeout += OnSimTimerTick;
|
|
AddChild(_simTimer);
|
|
|
|
// --- UI Layer ---
|
|
_uiLayer = new CanvasLayer();
|
|
AddChild(_uiLayer);
|
|
|
|
// Root control anchored to viewport (required for child anchoring)
|
|
var uiRoot = new Control();
|
|
uiRoot.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
|
uiRoot.MouseFilter = Control.MouseFilterEnum.Ignore;
|
|
_uiLayer.AddChild(uiRoot);
|
|
|
|
// Level title (top-left)
|
|
_levelTitle = new Label { Text = "CHESSISTICS" };
|
|
_levelTitle.SetAnchorsPreset(Control.LayoutPreset.TopLeft);
|
|
_levelTitle.OffsetLeft = 16;
|
|
_levelTitle.OffsetTop = 12;
|
|
_levelTitle.AddThemeFontSizeOverride("font_size", 20);
|
|
_levelTitle.MouseFilter = Control.MouseFilterEnum.Ignore;
|
|
uiRoot.AddChild(_levelTitle);
|
|
|
|
// --- Side Panel (anchored to right edge) ---
|
|
_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 (anchored to bottom, left of side panel) ---
|
|
_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 (centered in board area) ---
|
|
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);
|
|
|
|
// --- Level Select Screen (full viewport) ---
|
|
_levelSelectScreen = new LevelSelectScreen();
|
|
_levelSelectScreen.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
|
uiRoot.AddChild(_levelSelectScreen);
|
|
|
|
// --- Fade overlay (on top of everything) ---
|
|
_fadeOverlay = new ColorRect
|
|
{
|
|
Color = new Color(0, 0, 0, 1),
|
|
MouseFilter = Control.MouseFilterEnum.Ignore
|
|
};
|
|
_fadeOverlay.SetAnchorsPreset(Control.LayoutPreset.FullRect);
|
|
uiRoot.AddChild(_fadeOverlay);
|
|
|
|
// Initialize animator
|
|
_eventAnimator.Initialize(_boardView, _objectivePanel, _controlBar, _metricsOverlay);
|
|
}
|
|
|
|
private void ConnectSignals()
|
|
{
|
|
_levelSelectScreen.LevelSelected += OnLevelSelected;
|
|
_pieceStockPanel.PieceSelected += OnPieceKindSelected;
|
|
_inputMapper.PlacementRequested += OnPlacementRequested;
|
|
_inputMapper.Cancelled += OnPlacementCancelled;
|
|
_controlBar.PlayPressed += OnPlay;
|
|
_controlBar.PausePressed += OnPause;
|
|
_controlBar.StepPressed += OnStep;
|
|
_controlBar.StopPressed += OnStop;
|
|
_controlBar.SpeedChanged += OnSpeedChanged;
|
|
_eventAnimator.TurnAnimationCompleted += OnTurnAnimationCompleted;
|
|
_eventAnimator.VictoryReached += OnVictory;
|
|
_metricsOverlay.RetryPressed += OnRetry;
|
|
_metricsOverlay.NextLevelPressed += OnNextLevel;
|
|
_detailPanel.RemoveRequested += OnRemoveRequested;
|
|
_inputMapper.CellClicked += OnCellClicked;
|
|
}
|
|
|
|
private void OnCellClicked(int col, int row)
|
|
{
|
|
if (_sim == null) return;
|
|
var snap = _sim.GetSnapshot();
|
|
if (snap.Phase != SimPhase.Edit) return;
|
|
|
|
var coords = new Coords(col, row);
|
|
var piece = snap.Pieces.FirstOrDefault(p => p.StartCell == coords || p.EndCell == coords);
|
|
if (piece != null)
|
|
_detailPanel.ShowPiece(piece);
|
|
else
|
|
_detailPanel.Hide();
|
|
}
|
|
|
|
// --- Level Management ---
|
|
|
|
private void ShowLevelSelect()
|
|
{
|
|
_levelSelectScreen.Visible = true;
|
|
_boardView.Visible = false;
|
|
_sidePanel.Visible = false;
|
|
_controlBarWrapper.Visible = false;
|
|
_levelTitle.Visible = false;
|
|
}
|
|
|
|
private void OnLevelSelected(int levelIndex)
|
|
{
|
|
SfxManager.Instance?.PlayClick();
|
|
_currentLevelIndex = levelIndex;
|
|
|
|
// Fade out, load, fade in
|
|
FadeOut(0.25f, () =>
|
|
{
|
|
LoadLevel(levelIndex);
|
|
FadeIn(0.3f);
|
|
});
|
|
}
|
|
|
|
private void LoadLevel(int index)
|
|
{
|
|
if (index < 0 || index >= LevelFiles.Length) return;
|
|
|
|
var path = $"res://Data/levels/{LevelFiles[index]}";
|
|
var file = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
|
|
if (file == null)
|
|
{
|
|
GD.PrintErr($"Cannot open level file: {path}");
|
|
return;
|
|
}
|
|
|
|
var json = file.GetAsText();
|
|
file.Close();
|
|
|
|
_currentLevel = LevelLoader.Load(json);
|
|
_sim = new GameSim(_currentLevel);
|
|
|
|
_levelSelectScreen.Visible = false;
|
|
_boardView.Visible = true;
|
|
_sidePanel.Visible = true;
|
|
_controlBarWrapper.Visible = true;
|
|
_levelTitle.Visible = true;
|
|
|
|
_boardView.BuildBoard(_currentLevel);
|
|
_objectivePanel.Setup(_currentLevel.Demands);
|
|
_pieceStockPanel.Setup(_currentLevel.Stock);
|
|
_controlBar.UpdateForPhase(SimPhase.Edit);
|
|
_controlBar.ResetTurn();
|
|
_metricsOverlay.Hide();
|
|
_detailPanel.Hide();
|
|
_eventAnimator.ClearAll();
|
|
|
|
_levelTitle.Text = $"CHESSISTICS — {_currentLevel.Name}";
|
|
|
|
// Center camera on board
|
|
// Board X spans 0..W*Cell, Y spans -(H-1)*Cell .. Cell
|
|
_camera.Position = new Vector2(
|
|
_currentLevel.Width * BoardView.CellSize / 2f,
|
|
-_currentLevel.Height * BoardView.CellSize / 2f + BoardView.CellSize
|
|
);
|
|
_camera.Offset = new Vector2(-SidePanelWidth / 2f, -ControlBarHeight / 2f);
|
|
|
|
var snapshot = _sim.GetSnapshot();
|
|
GD.Print($"[Main] LoadLevel — snapshot null? {snapshot == null}, width={snapshot?.Width}, height={snapshot?.Height}");
|
|
_inputMapper.SetSnapshot(snapshot);
|
|
}
|
|
|
|
// --- Edit Phase ---
|
|
|
|
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());
|
|
}
|
|
|
|
private void OnPlacementCancelled()
|
|
{
|
|
_pieceStockPanel.ClearSelection();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// --- Exec Phase ---
|
|
|
|
private void OnPlay()
|
|
{
|
|
if (_sim == null) return;
|
|
|
|
var snap = _sim.GetSnapshot();
|
|
if (snap.Phase == SimPhase.Edit)
|
|
{
|
|
var events = _sim.ProcessCommand(new StartSimulationCommand());
|
|
foreach (var evt in events)
|
|
{
|
|
if (evt is CommandRejectedEvent r)
|
|
{
|
|
GD.Print($"Cannot start: {r.Reason}");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
else if (snap.Phase == SimPhase.Paused)
|
|
{
|
|
_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;
|
|
var events = _sim.ProcessCommand(new StepSimulationCommand());
|
|
_eventAnimator.ProcessEvents(events);
|
|
_controlBar.UpdateForPhase(_sim.GetSnapshot().Phase);
|
|
}
|
|
|
|
private void OnStop()
|
|
{
|
|
if (_sim == null) return;
|
|
_running = false;
|
|
_simTimer.Stop();
|
|
_sim.ProcessCommand(new StopSimulationCommand());
|
|
_eventAnimator.ResetPiecePositions(_sim.GetSnapshot());
|
|
_controlBar.UpdateForPhase(SimPhase.Edit);
|
|
_controlBar.ResetTurn();
|
|
_metricsOverlay.Hide();
|
|
_inputMapper.SetSnapshot(_sim.GetSnapshot());
|
|
|
|
// Reset objective panel
|
|
if (_currentLevel != null)
|
|
_objectivePanel.Setup(_currentLevel.Demands);
|
|
}
|
|
|
|
private void OnSpeedChanged(float interval)
|
|
{
|
|
_simInterval = interval;
|
|
if (_simTimer.TimeLeft > 0)
|
|
_simTimer.WaitTime = interval;
|
|
}
|
|
|
|
private void OnSimTimerTick()
|
|
{
|
|
if (_sim == null || _eventAnimator.IsAnimating) return;
|
|
|
|
var events = _sim.ProcessCommand(new StepSimulationCommand());
|
|
_eventAnimator.ProcessEvents(events);
|
|
}
|
|
|
|
private void OnTurnAnimationCompleted()
|
|
{
|
|
if (_sim == null) return;
|
|
var phase = _sim.GetSnapshot().Phase;
|
|
_controlBar.UpdateForPhase(phase);
|
|
|
|
if (phase == SimPhase.Victory || phase == SimPhase.Defeat)
|
|
{
|
|
_running = false;
|
|
_simTimer.Stop();
|
|
}
|
|
}
|
|
|
|
private void OnVictory()
|
|
{
|
|
_running = false;
|
|
_simTimer.Stop();
|
|
}
|
|
|
|
// --- Navigation ---
|
|
|
|
private void OnRetry()
|
|
{
|
|
LoadLevel(_currentLevelIndex);
|
|
}
|
|
|
|
private void OnNextLevel()
|
|
{
|
|
if (_currentLevelIndex + 1 < LevelFiles.Length)
|
|
LoadLevel(_currentLevelIndex + 1);
|
|
else
|
|
ShowLevelSelect();
|
|
}
|
|
}
|