diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ea33b24 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,26 @@ +# Chessistics + +Jeu de logistique sur echiquier en Godot 4 / C#. Le joueur place des pieces d'echecs sur un plateau ; elles se deplacent automatiquement et transportent des ressources entre des productions et des demandes. + +## Architecture : Black-Box Simulation + +Ref: https://samuel-bouchet.fr/posts/2026-04-08-black-box-sim/ + +Le moteur de jeu (`chessistics-engine/`) est une boite noire sans aucune dependance vers Godot. Il recoit des **Commands**, mute son etat interne, et retourne des **Events**. Le code Godot (`Scripts/`) ne fait que traduire l'input en commands et les events en visuels/animations. + +``` +Input → Command → GameSim (state + rules) → Events → Presentation +``` + +- **Commands** (`PlacePieceCommand`, `StartSimulationCommand`, …) : seul moyen de modifier l'etat. +- **Events** (`PiecePlacedEvent`, `CargoDeliveredEvent`, …) : seul output du moteur. Le presenteur les consomme pour animer. +- **GameSim** : point d'entree unique. `ProcessCommand()` retourne la liste d'events. +- **Tests** : `chessistics-tests/` teste le moteur en headless, sans Godot. + +## Pieges Godot a eviter + +### MouseFilter sur les Controls enfants de Node2D + +Tout `Control` (ColorRect, Label…) a `MouseFilter = Stop` par defaut. Quand un Control est enfant d'un Node2D (ex: les ColorRect dans CellView, les Labels dans PieceView), **il participe quand meme au systeme GUI et consomme les clics**, empechant `_UnhandledInput` de recevoir l'evenement. + +**Regle** : toujours mettre `MouseFilter = Control.MouseFilterEnum.Ignore` sur les Controls purement visuels enfants de Node2D. diff --git a/Data/levels/level_01.json b/Data/levels/level_01.json index 56ec711..5ea92bb 100644 --- a/Data/levels/level_01.json +++ b/Data/levels/level_01.json @@ -5,13 +5,13 @@ "width": 4, "height": 4, "productions": [ - { "col": 0, "row": 0, "name": "Scierie", "cargo": "wood", "interval": 2 } + { "col": 0, "row": 0, "name": "Scierie", "cargo": "wood", "interval": 2 } ], "demands": [ - { "col": 3, "row": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3, "deadline": 30 } + { "col": 3, "row": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3, "deadline": 30 } ], "walls": [], "stock": [ - { "kind": "rook", "count": 3 } + { "kind": "rook", "count": 3 } ] } diff --git a/Scenes/Main.tscn b/Scenes/Main.tscn index b73ebce..1ce91e9 100644 --- a/Scenes/Main.tscn +++ b/Scenes/Main.tscn @@ -1,6 +1,6 @@ -[gd_scene load_steps=2 format=3 uid="uid://main_scene"] +[gd_scene format=3 uid="uid://6j24v4md60t7"] -[ext_resource type="Script" path="res://Scripts/Main.cs" id="1"] +[ext_resource type="Script" uid="uid://dygonjc0xhp15" path="res://Scripts/Main.cs" id="1"] -[node name="Main" type="Node2D"] +[node name="Main" type="Node2D" unique_id=2090714159] script = ExtResource("1") diff --git a/Scripts/Board/BoardView.cs b/Scripts/Board/BoardView.cs index d3fb767..63d81c2 100644 --- a/Scripts/Board/BoardView.cs +++ b/Scripts/Board/BoardView.cs @@ -6,82 +6,93 @@ namespace Chessistics.Scripts.Board; public partial class BoardView : Node2D { - public const int CellSize = 80; + public const int CellSize = 80; - private readonly Dictionary _cells = new(); - private int _width; - private int _height; + private readonly Dictionary _cells = new(); + private int _width; + private int _height; - public void BuildBoard(LevelDef level) - { - // Clear existing children - foreach (var child in GetChildren()) - child.QueueFree(); - _cells.Clear(); + public void BuildBoard(LevelDef level) + { + // Clear existing children + foreach (var child in GetChildren()) + child.QueueFree(); + _cells.Clear(); - _width = level.Width; - _height = level.Height; + _width = level.Width; + _height = level.Height; - var boardState = BoardState.FromLevel(level); + var boardState = BoardState.FromLevel(level); - for (int col = 0; col < level.Width; col++) - { - for (int row = 0; row < level.Height; row++) - { - var coords = new Coords(col, row); - var cellView = new CellView(); - cellView.Setup(coords, boardState.GetCell(coords), CellSize); - AddChild(cellView); - _cells[coords] = cellView; - } - } + for (int col = 0; col < level.Width; col++) + { + for (int row = 0; row < level.Height; row++) + { + var coords = new Coords(col, row); + var cellView = new CellView(); + cellView.Setup(coords, boardState.GetCell(coords), CellSize); + AddChild(cellView); + _cells[coords] = cellView; + } + } - // Label productions and demands - foreach (var prod in level.Productions) - { - if (_cells.TryGetValue(prod.Position, out var cell)) - cell.SetLabel(prod.Name); - } + // Label productions and demands + foreach (var prod in level.Productions) + { + if (_cells.TryGetValue(prod.Position, out var cell)) + cell.SetLabel(prod.Name); + } - foreach (var demand in level.Demands) - { - if (_cells.TryGetValue(demand.Position, out var cell)) - cell.SetLabel(demand.Name); - } - } + foreach (var demand in level.Demands) + { + if (_cells.TryGetValue(demand.Position, out var cell)) + cell.SetLabel(demand.Name); + } + } - public Coords? PixelToCoords(Vector2 localPos) - { - int col = Mathf.FloorToInt(localPos.X / CellSize); - int row = Mathf.FloorToInt(-localPos.Y / CellSize); + public Coords? PixelToCoords(Vector2 localPos) + { + int col = Mathf.FloorToInt(localPos.X / CellSize); + // Cell at row R has top-left Y = -R*CellSize, extending downward. + // floor(-Y/Size) != -floor(Y/Size) for non-integers, so use the latter. + int row = -Mathf.FloorToInt(localPos.Y / CellSize); - var coords = new Coords(col, row); - return coords.IsOnBoard(_width, _height) ? coords : null; - } + var coords = new Coords(col, row); + return coords.IsOnBoard(_width, _height) ? coords : null; + } - public Vector2 CoordsToPixel(Coords coords) - { - return new Vector2( - coords.Col * CellSize + CellSize / 2f, - -coords.Row * CellSize + CellSize / 2f - ); - } + public Vector2 CoordsToPixel(Coords coords) + { + return new Vector2( + coords.Col * CellSize + CellSize / 2f, + -coords.Row * CellSize + CellSize / 2f + ); + } - public CellView? GetCellView(Coords coords) - => _cells.GetValueOrDefault(coords); + public CellView? GetCellView(Coords coords) + => _cells.GetValueOrDefault(coords); - public void ClearHighlights() - { - foreach (var cell in _cells.Values) - cell.SetHighlight(false); - } + public void SetHoverCell(Coords? coords) + { + foreach (var cell in _cells.Values) + cell.SetHover(false); - public void HighlightCells(IEnumerable cells, Color color) - { - foreach (var coords in cells) - { - if (_cells.TryGetValue(coords, out var cellView)) - cellView.SetHighlightColor(color); - } - } + if (coords != null && _cells.TryGetValue(coords.Value, out var cellView)) + cellView.SetHover(true); + } + + public void ClearHighlights() + { + foreach (var cell in _cells.Values) + cell.SetHighlight(false); + } + + public void HighlightCells(IEnumerable cells, Color color) + { + foreach (var coords in cells) + { + if (_cells.TryGetValue(coords, out var cellView)) + cellView.SetHighlightColor(color); + } + } } diff --git a/Scripts/Board/CellView.cs b/Scripts/Board/CellView.cs index fbf0aa5..6604acc 100644 --- a/Scripts/Board/CellView.cs +++ b/Scripts/Board/CellView.cs @@ -9,6 +9,12 @@ public partial class CellView : Node2D private ColorRect _highlight = null!; private Label _label = null!; + // Hover outline (4 thin rects forming a border) + private ColorRect _hoverTop = null!; + private ColorRect _hoverBottom = null!; + private ColorRect _hoverLeft = null!; + private ColorRect _hoverRight = null!; + public Coords Coords { get; private set; } private static readonly Color LightColor = new("#F0D9B5"); @@ -17,6 +23,9 @@ public partial class CellView : Node2D private static readonly Color ProductionColor = new("#6B8E5A"); private static readonly Color DemandColor = new("#C9A833"); private static readonly Color HighlightColor = new("#44FF4444"); + private static readonly Color HoverOutlineColor = new("#FFFFFFAA"); + + private const int OutlineWidth = 2; public void Setup(Coords coords, CellType cellType, int cellSize) { @@ -26,7 +35,8 @@ public partial class CellView : Node2D _background = new ColorRect { Size = new Vector2(cellSize, cellSize), - Position = Vector2.Zero + Position = Vector2.Zero, + MouseFilter = Control.MouseFilterEnum.Ignore }; var baseColor = coords.IsLight ? LightColor : DarkColor; @@ -44,14 +54,57 @@ public partial class CellView : Node2D Size = new Vector2(cellSize, cellSize), Position = Vector2.Zero, Color = HighlightColor, - Visible = false + Visible = false, + MouseFilter = Control.MouseFilterEnum.Ignore }; AddChild(_highlight); + // Hover outline (4 border rects) + _hoverTop = new ColorRect + { + Size = new Vector2(cellSize, OutlineWidth), + Position = Vector2.Zero, + Color = HoverOutlineColor, + Visible = false, + MouseFilter = Control.MouseFilterEnum.Ignore + }; + AddChild(_hoverTop); + + _hoverBottom = new ColorRect + { + Size = new Vector2(cellSize, OutlineWidth), + Position = new Vector2(0, cellSize - OutlineWidth), + Color = HoverOutlineColor, + Visible = false, + MouseFilter = Control.MouseFilterEnum.Ignore + }; + AddChild(_hoverBottom); + + _hoverLeft = new ColorRect + { + Size = new Vector2(OutlineWidth, cellSize), + Position = Vector2.Zero, + Color = HoverOutlineColor, + Visible = false, + MouseFilter = Control.MouseFilterEnum.Ignore + }; + AddChild(_hoverLeft); + + _hoverRight = new ColorRect + { + Size = new Vector2(OutlineWidth, cellSize), + Position = new Vector2(cellSize - OutlineWidth, 0), + Color = HoverOutlineColor, + Visible = false, + MouseFilter = Control.MouseFilterEnum.Ignore + }; + AddChild(_hoverRight); + _label = new Label { Position = new Vector2(2, 2), Text = "", + MouseFilter = Control.MouseFilterEnum.Ignore }; _label.AddThemeFontSizeOverride("font_size", 10); AddChild(_label); @@ -60,9 +113,29 @@ public partial class CellView : Node2D public void SetLabel(string text) => _label.Text = text; public void SetHighlight(bool on) => _highlight.Visible = on; + public void SetHover(bool on) + { + _hoverTop.Visible = on; + _hoverBottom.Visible = on; + _hoverLeft.Visible = on; + _hoverRight.Visible = on; + } + public void SetHighlightColor(Color color) { _highlight.Color = color; _highlight.Visible = true; } + + /// + /// Brief white flash on the cell to signal production. + /// + public void FlashProduce(float duration = 0.3f) + { + _highlight.Color = new Color(1, 1, 1, 0.5f); + _highlight.Visible = true; + var tween = CreateTween(); + tween.TweenProperty(_highlight, "color", new Color(1, 1, 1, 0f), duration); + tween.TweenCallback(Callable.From(() => _highlight.Visible = false)); + } } diff --git a/Scripts/Input/InputMapper.cs b/Scripts/Input/InputMapper.cs index a66584c..0624d68 100644 --- a/Scripts/Input/InputMapper.cs +++ b/Scripts/Input/InputMapper.cs @@ -24,6 +24,7 @@ public partial class InputMapper : Node private Coords? _selectedStart; private PlacementPhase _phase = PlacementPhase.None; private BoardSnapshot? _snapshot; + private Coords? _hoverCoords; public PlacementPhase CurrentPhase => _phase; @@ -32,10 +33,15 @@ public partial class InputMapper : Node _boardView = boardView; } - public void SetSnapshot(BoardSnapshot snapshot) => _snapshot = snapshot; + public void SetSnapshot(BoardSnapshot snapshot) + { + GD.Print($"[InputMapper] SetSnapshot called — null? {snapshot == null}"); + _snapshot = snapshot; + } public void SelectPieceKind(PieceKind kind) { + GD.Print($"[InputMapper] SelectPieceKind: {kind}, phase → SelectingStart"); _selectedKind = kind; _selectedStart = null; _phase = PlacementPhase.SelectingStart; @@ -50,6 +56,20 @@ public partial class InputMapper : Node EmitSignal(SignalName.Cancelled); } + public override void _Process(double delta) + { + if (_boardView == null || !_boardView.Visible) return; + + var localPos = _boardView.GetLocalMousePosition(); + var coords = _boardView.PixelToCoords(localPos); + + if (coords != _hoverCoords) + { + _hoverCoords = coords; + _boardView.SetHoverCell(coords); + } + } + public override void _UnhandledInput(InputEvent @event) { if (@event is InputEventMouseButton mouseEvent && mouseEvent.Pressed) @@ -62,7 +82,9 @@ public partial class InputMapper : Node if (mouseEvent.ButtonIndex == MouseButton.Left) { - HandleLeftClick(mouseEvent.GlobalPosition); + var localPos = _boardView.GetLocalMousePosition(); + GD.Print($"[InputMapper] LEFT CLICK — localPos={localPos}, phase={_phase}"); + HandleLeftClick(); } } @@ -72,12 +94,18 @@ public partial class InputMapper : Node } } - private void HandleLeftClick(Vector2 globalPos) + private void HandleLeftClick() { - var localPos = _boardView.ToLocal(globalPos); + var localPos = _boardView.GetLocalMousePosition(); var coords = _boardView.PixelToCoords(localPos); - if (coords == null) return; + GD.Print($"[InputMapper] HandleLeftClick — localPos={localPos}, coords={coords}"); + + if (coords == null) + { + GD.Print("[InputMapper] coords is null — click outside board"); + return; + } switch (_phase) { @@ -97,13 +125,22 @@ public partial class InputMapper : Node private void OnStartSelected(Coords start) { - if (_selectedKind == null || _snapshot == null) return; + if (_selectedKind == null || _snapshot == null) + { + GD.Print($"[InputMapper] OnStartSelected ABORT — kind={_selectedKind}, snapshot={(_snapshot != null ? "ok" : "null")}"); + return; + } - // Build a temporary board state for move validation var boardState = GetBoardStateForValidation(); - if (boardState == null) return; + if (boardState == null) + { + GD.Print("[InputMapper] OnStartSelected ABORT — boardState is null"); + return; + } var legalEnds = MoveValidator.GetLegalEndCells(_selectedKind.Value, start, boardState); + GD.Print($"[InputMapper] OnStartSelected({start}) — {legalEnds.Count} legal end cells"); + if (legalEnds.Count == 0) return; _selectedStart = start; @@ -122,6 +159,7 @@ public partial class InputMapper : Node var start = _selectedStart.Value; var kind = _selectedKind.Value; + GD.Print($"[InputMapper] OnEndSelected — placing {kind} from {start} to {end}"); EmitSignal(SignalName.PlacementRequested, (int)kind, start.Col, start.Row, end.Col, end.Row); // Reset placement state @@ -133,12 +171,8 @@ public partial class InputMapper : Node private BoardState? GetBoardStateForValidation() { - // Reconstruct a minimal BoardState from snapshot for MoveValidator - // This is a read-only usage — we just need the grid and dimensions if (_snapshot == null) return null; - // We need a LevelDef-like structure to create a BoardState - // For validation purposes, we create a fresh one from the snapshot data var level = new LevelDef { Width = _snapshot.Width, @@ -151,7 +185,6 @@ public partial class InputMapper : Node var state = BoardState.FromLevel(level); - // Copy grid from snapshot for (int c = 0; c < _snapshot.Width; c++) for (int r = 0; r < _snapshot.Height; r++) state.Grid[c, r] = _snapshot.Grid[c, r]; diff --git a/Scripts/Main.cs b/Scripts/Main.cs index a404c3a..05bfedb 100644 --- a/Scripts/Main.cs +++ b/Scripts/Main.cs @@ -1,5 +1,6 @@ using Godot; using System.Collections.Generic; +using System.Linq; using Chessistics.Engine.Commands; using Chessistics.Engine.Events; using Chessistics.Engine.Loading; @@ -15,424 +16,500 @@ 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!; - - // Simulation timer - private Godot.Timer _simTimer = null!; - private float _simInterval = 1.0f; - private bool _running; - - private static readonly string[] LevelFiles = ["level_01.json", "level_02.json", "level_03.json"]; - - private static readonly Color BackgroundColor = new("#2D2D2D"); - - public override void _Ready() - { - RenderingServer.SetDefaultClearColor(BackgroundColor); - - BuildSceneTree(); - ConnectSignals(); - ShowLevelSelect(); - } - - private void BuildSceneTree() - { - // Camera - var camera = new Camera2D { Enabled = true }; - AddChild(camera); - - // 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); - - // Level title - _levelTitle = new Label - { - Position = new Vector2(10, 10), - Text = "CHESSISTICS" - }; - _levelTitle.AddThemeFontSizeOverride("font_size", 20); - _uiLayer.AddChild(_levelTitle); - - // Side panel (right) - var sidePanel = new VBoxContainer - { - Position = new Vector2(700, 50), - CustomMinimumSize = new Vector2(200, 500) - }; - - _objectivePanel = new ObjectivePanel(); - sidePanel.AddChild(_objectivePanel); - sidePanel.AddChild(new HSeparator()); - - _pieceStockPanel = new PieceStockPanel(); - sidePanel.AddChild(_pieceStockPanel); - - _detailPanel = new DetailPanel(); - sidePanel.AddChild(_detailPanel); - - _uiLayer.AddChild(sidePanel); - - // Control bar (bottom) - _controlBar = new ControlBar - { - Position = new Vector2(10, 600) - }; - _uiLayer.AddChild(_controlBar); - - // Metrics overlay (center) - _metricsOverlay = new MetricsOverlay - { - Position = new Vector2(200, 150), - CustomMinimumSize = new Vector2(300, 250) - }; - _uiLayer.AddChild(_metricsOverlay); - - // Level select screen - _levelSelectScreen = new LevelSelectScreen(); - _levelSelectScreen.SetAnchorsPreset(Control.LayoutPreset.FullRect); - _uiLayer.AddChild(_levelSelectScreen); - - // 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; - _eventAnimator.CollisionOccurred += OnCollision; - _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; - } - - private void OnLevelSelected(int levelIndex) - { - _currentLevelIndex = levelIndex; - LoadLevel(levelIndex); - } - - 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; - - _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 - var cam = GetNode("Camera2D"); - cam.Position = new Vector2( - _currentLevel.Width * BoardView.CellSize / 2f, - -_currentLevel.Height * BoardView.CellSize / 2f - ); - - _inputMapper.SetSnapshot(_sim.GetSnapshot()); - } - - // --- 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 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) - { - var pieceView = new PieceView(); - pieceView.Setup(placed.PieceId, placed.Kind, placed.Start, placed.End, _boardView); - _boardView.AddChild(pieceView); - - var color = placed.Kind switch - { - PieceKind.Rook => new Color("#4A7AB5"), - PieceKind.Bishop => new Color("#B54A8E"), - PieceKind.Knight => new Color("#B5824A"), - _ => Colors.White - }; - - 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 || phase == SimPhase.Collision) - { - _running = false; - _simTimer.Stop(); - } - } - - private void OnVictory() - { - _running = false; - _simTimer.Stop(); - } - - private void OnCollision() - { - _running = false; - _simTimer.Stop(); - _controlBar.UpdateForPhase(SimPhase.Collision); - } - - // --- Navigation --- - - private void OnRetry() - { - LoadLevel(_currentLevelIndex); - } - - private void OnNextLevel() - { - if (_currentLevelIndex + 1 < LevelFiles.Length) - LoadLevel(_currentLevelIndex + 1); - else - ShowLevelSelect(); - } + 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!; + + // 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"]; + + 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(); + } + + 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 BuildSceneTree() + { + // Camera + _camera = new Camera2D { Enabled = true }; + AddChild(_camera); + + // 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); + + // 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) + { + _currentLevelIndex = levelIndex; + LoadLevel(levelIndex); + } + + 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 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) + { + var pieceView = new PieceView(); + pieceView.Setup(placed.PieceId, placed.Kind, placed.Start, placed.End, _boardView); + _boardView.AddChild(pieceView); + + var color = placed.Kind switch + { + PieceKind.Rook => new Color("#4A7AB5"), + PieceKind.Bishop => new Color("#B54A8E"), + PieceKind.Knight => new Color("#B5824A"), + _ => Colors.White + }; + + 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(); + } } diff --git a/Scripts/Pieces/PieceView.cs b/Scripts/Pieces/PieceView.cs index d903547..ad55f97 100644 --- a/Scripts/Pieces/PieceView.cs +++ b/Scripts/Pieces/PieceView.cs @@ -64,7 +64,8 @@ public partial class PieceView : Node2D }, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, - Position = new Vector2(-8, -10) + Position = new Vector2(-8, -10), + MouseFilter = Control.MouseFilterEnum.Ignore }; _label.AddThemeFontSizeOverride("font_size", 16); _label.AddThemeColorOverride("font_color", Colors.White); @@ -75,7 +76,8 @@ public partial class PieceView : Node2D { Size = new Vector2(14, 14), Position = new Vector2(-7, -30), - Visible = false + Visible = false, + MouseFilter = Control.MouseFilterEnum.Ignore }; AddChild(_cargoIndicator); } diff --git a/Scripts/Presentation/EventAnimator.cs b/Scripts/Presentation/EventAnimator.cs index 5bb4494..2bb7d73 100644 --- a/Scripts/Presentation/EventAnimator.cs +++ b/Scripts/Presentation/EventAnimator.cs @@ -1,6 +1,7 @@ using Godot; using System; using System.Collections.Generic; +using System.Linq; using Chessistics.Engine.Events; using Chessistics.Engine.Model; using Chessistics.Scripts.Board; @@ -22,12 +23,19 @@ public partial class EventAnimator : Node private bool _animating; public bool IsAnimating => _animating; + private static readonly Color WoodCargoColor = new("#8B6914"); + private static readonly Color StoneCargoColor = new("#808080"); + + private const float ProduceDuration = 0.3f; + private const float TransferDuration = 0.25f; + private const float MoveDuration = 0.3f; + private const float KnightMoveDuration = 0.4f; + private const float DestroyDuration = 0.3f; + [Signal] public delegate void TurnAnimationCompletedEventHandler(); [Signal] public delegate void VictoryReachedEventHandler(); - [Signal] - public delegate void CollisionOccurredEventHandler(); public void Initialize(BoardView boardView, ObjectivePanel objectivePanel, ControlBar controlBar, MetricsOverlay metricsOverlay) @@ -64,54 +72,39 @@ public partial class EventAnimator : Node var tween = CreateTween(); tween.SetParallel(false); + var produceEvents = new List(); + var transferEvents = new List(); + var moveEvents = new List(); + var collisionEvents = new List(); + foreach (var evt in events) { switch (evt) { case TurnStartedEvent ts: + FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents); tween.TweenCallback(Callable.From(() => _controlBar.UpdateTurn(ts.TurnNumber))); break; + case CargoProducedEvent produced: + produceEvents.Add(produced); + break; + + case CargoTransferredEvent: + case DemandProgressEvent: + transferEvents.Add(evt); + break; + case PieceMovedEvent moved: - if (_pieceViews.TryGetValue(moved.PieceId, out var pv)) - { - var target = _boardView.CoordsToPixel(moved.To); - var piece = pv; - var kind = piece.Kind; - float duration = kind == PieceKind.Knight ? 0.4f : 0.3f; - tween.TweenProperty(piece, "position", target, duration); - } + moveEvents.Add(moved); break; - case CollisionDetectedEvent collision: - tween.TweenCallback(Callable.From(() => - { - FlashPiece(collision.PieceIdA); - FlashPiece(collision.PieceIdB); - EmitSignal(SignalName.CollisionOccurred); - })); - break; - - case CargoTransferredEvent transfer: - tween.TweenCallback(Callable.From(() => - { - if (transfer.GivingPieceId != null && _pieceViews.ContainsKey(transfer.GivingPieceId.Value)) - _pieceViews[transfer.GivingPieceId.Value].SetCargo(null); - if (transfer.ReceivingPieceId != null && _pieceViews.ContainsKey(transfer.ReceivingPieceId.Value)) - _pieceViews[transfer.ReceivingPieceId.Value].SetCargo(transfer.Type); - })); - tween.TweenInterval(0.15f); - break; - - case CargoProducedEvent: - break; // visual pulse could go here - - case DemandProgressEvent progress: - tween.TweenCallback(Callable.From(() => - _objectivePanel.UpdateProgress(progress.DemandCell, progress.Name, progress.Current, progress.Required))); + case PieceDestroyedEvent destroyed: + collisionEvents.Add(destroyed); break; case VictoryEvent victory: + FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents); tween.TweenCallback(Callable.From(() => { _metricsOverlay.ShowMetrics(victory.Metrics); @@ -119,16 +112,17 @@ public partial class EventAnimator : Node })); break; - case DeadlineExpiredEvent: - tween.TweenCallback(Callable.From(() => - EmitSignal(SignalName.CollisionOccurred))); // reuse for pause + case TurnEndedEvent: + FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents); break; - case TurnEndedEvent: + default: break; } } + FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents); + tween.TweenCallback(Callable.From(() => { _animating = false; @@ -136,6 +130,141 @@ public partial class EventAnimator : Node })); } + private void FlushPhases( + Tween tween, + List produceEvents, + List transferEvents, + List moveEvents, + List collisionEvents) + { + // Phase 1: Produce — flash production cells + if (produceEvents.Count > 0) + { + tween.TweenCallback(Callable.From(() => + { + foreach (var evt in produceEvents.ToList()) + { + var cell = _boardView.GetCellView(evt.ProductionCell); + cell?.FlashProduce(ProduceDuration); + } + })); + tween.TweenInterval(ProduceDuration); + produceEvents.Clear(); + } + + // Phase 2: Transfers — animate cargo sliding from giver to receiver + if (transferEvents.Count > 0) + { + // Capture the events list before clearing + var eventsToAnimate = transferEvents.ToList(); + + // Step 1: remove cargo from givers + spawn sliding cargo sprites + tween.TweenCallback(Callable.From(() => + { + foreach (var evt in eventsToAnimate) + { + if (evt is CargoTransferredEvent transfer) + { + // Remove cargo indicator from giver + if (transfer.GivingPieceId != null && _pieceViews.ContainsKey(transfer.GivingPieceId.Value)) + _pieceViews[transfer.GivingPieceId.Value].SetCargo(null); + + // Create sliding cargo sprite + SpawnCargoSlide(transfer); + } + } + })); + + // Step 2: wait for slide, then show cargo on receivers + update demand progress + tween.TweenInterval(TransferDuration); + tween.TweenCallback(Callable.From(() => + { + foreach (var evt in eventsToAnimate) + { + if (evt is CargoTransferredEvent transfer) + { + if (transfer.ReceivingPieceId != null && _pieceViews.ContainsKey(transfer.ReceivingPieceId.Value)) + _pieceViews[transfer.ReceivingPieceId.Value].SetCargo(transfer.Type); + } + else if (evt is DemandProgressEvent progress) + { + _objectivePanel.UpdateProgress(progress.DemandCell, progress.Name, progress.Current, progress.Required); + } + } + })); + + transferEvents.Clear(); + } + + // Phase 3: Movement — all pieces move simultaneously + if (moveEvents.Count > 0) + { + tween.SetParallel(true); + foreach (var moved in moveEvents) + { + if (_pieceViews.TryGetValue(moved.PieceId, out var pv)) + { + var target = _boardView.CoordsToPixel(moved.To); + float duration = pv.Kind == PieceKind.Knight ? KnightMoveDuration : MoveDuration; + tween.TweenProperty(pv, "position", target, duration); + } + } + tween.SetParallel(false); + moveEvents.Clear(); + } + + // Phase 4: Collision/Destruction + if (collisionEvents.Count > 0) + { + tween.SetParallel(true); + foreach (var destroyed in collisionEvents) + { + var pieceId = destroyed.PieceId; + tween.TweenCallback(Callable.From(() => + { + FlashPiece(pieceId); + UnregisterPiece(pieceId); + })); + } + tween.SetParallel(false); + tween.TweenInterval(DestroyDuration); + collisionEvents.Clear(); + } + } + + /// + /// Creates a temporary colored square that slides from the giver to the receiver. + /// + private void SpawnCargoSlide(CargoTransferredEvent transfer) + { + var from = _boardView.CoordsToPixel(transfer.From); + var to = _boardView.CoordsToPixel(transfer.To); + var color = transfer.Type switch + { + CargoType.Wood => WoodCargoColor, + CargoType.Stone => StoneCargoColor, + _ => Colors.White + }; + + var sprite = new ColorRect + { + Size = new Vector2(14, 14), + Position = new Vector2(-7, -7), + Color = color, + MouseFilter = Control.MouseFilterEnum.Ignore + }; + + var container = new Node2D { Position = from }; + container.AddChild(sprite); + _boardView.AddChild(container); + + var slideTween = container.CreateTween(); + slideTween.TweenProperty(container, "position", to, TransferDuration) + .SetEase(Tween.EaseType.InOut) + .SetTrans(Tween.TransitionType.Cubic); + slideTween.TweenCallback(Callable.From(() => container.QueueFree())); + } + private void FlashPiece(int pieceId) { if (!_pieceViews.TryGetValue(pieceId, out var pv)) return; diff --git a/Scripts/UI/LevelSelectScreen.cs b/Scripts/UI/LevelSelectScreen.cs index 9af7ef3..bee0ab2 100644 --- a/Scripts/UI/LevelSelectScreen.cs +++ b/Scripts/UI/LevelSelectScreen.cs @@ -17,94 +17,199 @@ public partial class LevelSelectScreen : Control public override void _Ready() { - var panel = new PanelContainer(); - panel.SetAnchorsPreset(LayoutPreset.FullRect); + // Full-screen dark background + var bg = new PanelContainer(); + bg.SetAnchorsPreset(LayoutPreset.FullRect); + var bgStyle = new StyleBoxFlat { BgColor = new Color(0.12f, 0.12f, 0.14f) }; + bg.AddThemeStyleboxOverride("panel", bgStyle); + bg.MouseFilter = MouseFilterEnum.Ignore; + AddChild(bg); + // Outer margin var margin = new MarginContainer(); - margin.AddThemeConstantOverride("margin_left", 60); - margin.AddThemeConstantOverride("margin_right", 60); + margin.SetAnchorsPreset(LayoutPreset.FullRect); + margin.AddThemeConstantOverride("margin_left", 80); + margin.AddThemeConstantOverride("margin_right", 80); margin.AddThemeConstantOverride("margin_top", 60); margin.AddThemeConstantOverride("margin_bottom", 60); + margin.MouseFilter = MouseFilterEnum.Ignore; - var vbox = new VBoxContainer(); + var outerVBox = new VBoxContainer(); + outerVBox.AddThemeConstantOverride("separation", 0); + outerVBox.MouseFilter = MouseFilterEnum.Ignore; + + // --- Header section --- + var headerBox = new VBoxContainer(); + headerBox.AddThemeConstantOverride("separation", 4); + headerBox.MouseFilter = MouseFilterEnum.Ignore; var title = new Label { Text = "CHESSISTICS", HorizontalAlignment = HorizontalAlignment.Center }; - title.AddThemeFontSizeOverride("font_size", 32); + title.AddThemeFontSizeOverride("font_size", 48); title.AddThemeColorOverride("font_color", new Color("#FFD700")); - vbox.AddChild(title); + headerBox.AddChild(title); var subtitle = new Label { - Text = "Prototype — Selectionnez un niveau", + Text = "Selectionnez un niveau", HorizontalAlignment = HorizontalAlignment.Center }; - subtitle.AddThemeFontSizeOverride("font_size", 14); - subtitle.AddThemeColorOverride("font_color", new Color("#AAAAAA")); - vbox.AddChild(subtitle); + subtitle.AddThemeFontSizeOverride("font_size", 15); + subtitle.AddThemeColorOverride("font_color", new Color("#777777")); + headerBox.AddChild(subtitle); - vbox.AddChild(new HSeparator()); + outerVBox.AddChild(headerBox); - var grid = new HBoxContainer(); - grid.Alignment = BoxContainer.AlignmentMode.Center; + // Spacer + outerVBox.AddChild(new Control { CustomMinimumSize = new Vector2(0, 48) }); + + // --- Level cards --- + var cardRow = new HBoxContainer(); + cardRow.Alignment = BoxContainer.AlignmentMode.Center; + cardRow.AddThemeConstantOverride("separation", 28); + cardRow.SizeFlagsVertical = SizeFlags.ExpandFill; + cardRow.MouseFilter = MouseFilterEnum.Ignore; for (int i = 0; i < _levels.Length; i++) { var (name, desc) = _levels[i]; - var card = CreateLevelCard(i, name, desc); - grid.AddChild(card); + cardRow.AddChild(CreateLevelCard(i, name, desc)); } - vbox.AddChild(grid); - margin.AddChild(vbox); - panel.AddChild(margin); - AddChild(panel); + outerVBox.AddChild(cardRow); + + // Bottom spacer + outerVBox.AddChild(new Control + { + SizeFlagsVertical = SizeFlags.ExpandFill, + CustomMinimumSize = new Vector2(0, 40) + }); + + margin.AddChild(outerVBox); + AddChild(margin); } private Control CreateLevelCard(int index, string name, string description) { var card = new PanelContainer { - CustomMinimumSize = new Vector2(220, 160) + CustomMinimumSize = new Vector2(300, 240), + SizeFlagsVertical = SizeFlags.ShrinkCenter }; - var vbox = new VBoxContainer(); + var cardStyle = new StyleBoxFlat + { + BgColor = new Color(0.17f, 0.17f, 0.19f), + BorderColor = new Color(0.28f, 0.28f, 0.32f), + BorderWidthBottom = 1, + BorderWidthTop = 1, + BorderWidthLeft = 1, + BorderWidthRight = 1, + CornerRadiusTopLeft = 8, + CornerRadiusTopRight = 8, + CornerRadiusBottomLeft = 8, + CornerRadiusBottomRight = 8, + ContentMarginLeft = 24, + ContentMarginRight = 24, + ContentMarginTop = 24, + ContentMarginBottom = 24 + }; + card.AddThemeStyleboxOverride("panel", cardStyle); + var vbox = new VBoxContainer(); + vbox.AddThemeConstantOverride("separation", 10); + + // Level number var numLabel = new Label { Text = $"Niveau {index + 1}", HorizontalAlignment = HorizontalAlignment.Center }; numLabel.AddThemeFontSizeOverride("font_size", 12); - numLabel.AddThemeColorOverride("font_color", new Color("#AAAAAA")); + numLabel.AddThemeColorOverride("font_color", new Color("#666666")); vbox.AddChild(numLabel); + // Level name var nameLabel = new Label { Text = name, HorizontalAlignment = HorizontalAlignment.Center }; - nameLabel.AddThemeFontSizeOverride("font_size", 18); + nameLabel.AddThemeFontSizeOverride("font_size", 22); + nameLabel.AddThemeColorOverride("font_color", new Color("#EEEEEE")); vbox.AddChild(nameLabel); + // Thin separator + var sep = new HSeparator { CustomMinimumSize = new Vector2(0, 4) }; + vbox.AddChild(sep); + + // Description var descLabel = new Label { Text = description, HorizontalAlignment = HorizontalAlignment.Center, - AutowrapMode = TextServer.AutowrapMode.Word + AutowrapMode = TextServer.AutowrapMode.Word, + CustomMinimumSize = new Vector2(240, 0) }; - descLabel.AddThemeFontSizeOverride("font_size", 11); - descLabel.AddThemeColorOverride("font_color", new Color("#CCCCCC")); + descLabel.AddThemeFontSizeOverride("font_size", 13); + descLabel.AddThemeColorOverride("font_color", new Color("#999999")); vbox.AddChild(descLabel); + // Flexible spacer + vbox.AddChild(new Control { SizeFlagsVertical = SizeFlags.ExpandFill }); + + // Play button var playBtn = new Button { Text = "Jouer", - CustomMinimumSize = new Vector2(100, 32) + CustomMinimumSize = new Vector2(120, 38), + SizeFlagsHorizontal = SizeFlags.ShrinkCenter }; + + var btnNormal = new StyleBoxFlat + { + BgColor = new Color("#8B6914"), + CornerRadiusTopLeft = 6, + CornerRadiusTopRight = 6, + CornerRadiusBottomLeft = 6, + CornerRadiusBottomRight = 6, + ContentMarginLeft = 24, + ContentMarginRight = 24, + ContentMarginTop = 8, + ContentMarginBottom = 8 + }; + var btnHover = new StyleBoxFlat + { + BgColor = new Color("#B8860B"), + CornerRadiusTopLeft = 6, + CornerRadiusTopRight = 6, + CornerRadiusBottomLeft = 6, + CornerRadiusBottomRight = 6, + ContentMarginLeft = 24, + ContentMarginRight = 24, + ContentMarginTop = 8, + ContentMarginBottom = 8 + }; + var btnPressed = new StyleBoxFlat + { + BgColor = new Color("#6B5010"), + CornerRadiusTopLeft = 6, + CornerRadiusTopRight = 6, + CornerRadiusBottomLeft = 6, + CornerRadiusBottomRight = 6, + ContentMarginLeft = 24, + ContentMarginRight = 24, + ContentMarginTop = 8, + ContentMarginBottom = 8 + }; + playBtn.AddThemeStyleboxOverride("normal", btnNormal); + playBtn.AddThemeStyleboxOverride("hover", btnHover); + playBtn.AddThemeStyleboxOverride("pressed", btnPressed); + playBtn.AddThemeFontSizeOverride("font_size", 15); + var idx = index; playBtn.Pressed += () => EmitSignal(SignalName.LevelSelected, idx); vbox.AddChild(playBtn); diff --git a/Scripts/UI/ObjectivePanel.cs b/Scripts/UI/ObjectivePanel.cs index 4dda112..da74fc5 100644 --- a/Scripts/UI/ObjectivePanel.cs +++ b/Scripts/UI/ObjectivePanel.cs @@ -16,6 +16,7 @@ public partial class ObjectivePanel : VBoxContainer var title = new Label { Text = "OBJECTIFS" }; title.AddThemeFontSizeOverride("font_size", 16); + title.AddThemeColorOverride("font_color", new Color("#FFD700")); AddChild(title); AddChild(new HSeparator()); diff --git a/Scripts/UI/PieceStockPanel.cs b/Scripts/UI/PieceStockPanel.cs index cc1bf13..639d058 100644 --- a/Scripts/UI/PieceStockPanel.cs +++ b/Scripts/UI/PieceStockPanel.cs @@ -24,6 +24,7 @@ public partial class PieceStockPanel : VBoxContainer var title = new Label { Text = "PIECES" }; title.AddThemeFontSizeOverride("font_size", 16); + title.AddThemeColorOverride("font_color", new Color("#FFD700")); AddChild(title); AddChild(new HSeparator()); diff --git a/chessistics-engine/Commands/WorldCommands.cs b/chessistics-engine/Commands/WorldCommands.cs index 2f92123..7b0c76d 100644 --- a/chessistics-engine/Commands/WorldCommands.cs +++ b/chessistics-engine/Commands/WorldCommands.cs @@ -10,12 +10,14 @@ public class PlacePieceCommand : WorldCommand public PieceKind Kind { get; } public Coords Start { get; } public Coords End { get; } + public int Level { get; } - public PlacePieceCommand(PieceKind kind, Coords start, Coords end) + public PlacePieceCommand(PieceKind kind, Coords start, Coords end, int level = 1) { Kind = kind; Start = start; End = end; + Level = level; } public override void AssertApplicationConditions(BoardState state) @@ -44,7 +46,7 @@ public class PlacePieceCommand : WorldCommand protected override void DoApply(BoardState state, List changeList) { var piece = new PieceState( - state.NextPieceId++, Kind, Start, End, state.Pieces.Count); + state.NextPieceId++, Kind, Start, End, state.Pieces.Count, Level); piece.CargoFilter = InferCargoFilter(state, piece); @@ -189,7 +191,7 @@ public class StepSimulationCommand : WorldCommand TurnExecutor.ExecuteTurn(state, changeList); - // After a step, remain in Paused unless victory/defeat/collision occurred + // After a step, remain in Paused unless victory/defeat occurred if (state.Phase == SimPhase.Running) state.Phase = SimPhase.Paused; } @@ -206,6 +208,10 @@ public class StopSimulationCommand : WorldCommand protected override void DoApply(BoardState state, List changeList) { + // Restore destroyed pieces + state.Pieces.AddRange(state.DestroyedPieces); + state.DestroyedPieces.Clear(); + foreach (var piece in state.Pieces) { piece.CurrentCell = piece.StartCell; @@ -213,7 +219,7 @@ public class StopSimulationCommand : WorldCommand } foreach (var pos in state.ProductionBuffers.Keys.ToList()) - state.ProductionBuffers[pos] = null; + state.ProductionBuffers[pos] = 0; foreach (var demand in state.Demands.Values) demand.ReceivedCount = 0; diff --git a/chessistics-engine/Events/WorldEvents.cs b/chessistics-engine/Events/WorldEvents.cs index d399f79..9e2c853 100644 --- a/chessistics-engine/Events/WorldEvents.cs +++ b/chessistics-engine/Events/WorldEvents.cs @@ -15,13 +15,13 @@ public record SimulationResumedEvent : IWorldEvent; public record SimulationStoppedEvent : IWorldEvent; public record LevelResetEvent : IWorldEvent; -// Turn events +// Turn events — all carry TurnNumber for animation grouping public record TurnStartedEvent(int TurnNumber) : IWorldEvent; -public record PieceMovedEvent(int PieceId, Coords From, Coords To) : IWorldEvent; -public record CollisionDetectedEvent(int PieceIdA, int PieceIdB, Coords Cell) : IWorldEvent; -public record CargoTransferredEvent(Coords From, Coords To, CargoType Type, int? GivingPieceId, int? ReceivingPieceId) : IWorldEvent; -public record CargoProducedEvent(Coords ProductionCell, CargoType Type) : IWorldEvent; -public record DemandProgressEvent(Coords DemandCell, string Name, int Current, int Required) : IWorldEvent; -public record VictoryEvent(Metrics Metrics) : IWorldEvent; -public record DeadlineExpiredEvent(Coords DemandCell, string Name) : IWorldEvent; +public record PieceMovedEvent(int TurnNumber, int PieceId, Coords From, Coords To) : IWorldEvent; +public record PieceDestroyedEvent(int TurnNumber, int PieceId, int? DestroyerPieceId, Coords Cell) : IWorldEvent; +public record CargoTransferredEvent(int TurnNumber, Coords From, Coords To, CargoType Type, int? GivingPieceId, int? ReceivingPieceId) : IWorldEvent; +public record CargoProducedEvent(int TurnNumber, Coords ProductionCell, CargoType Type) : IWorldEvent; +public record DemandProgressEvent(int TurnNumber, Coords DemandCell, string Name, int Current, int Required) : IWorldEvent; +public record VictoryEvent(int TurnNumber, Metrics Metrics) : IWorldEvent; +public record DeadlineExpiredEvent(int TurnNumber, Coords DemandCell, string Name) : IWorldEvent; public record TurnEndedEvent(int TurnNumber) : IWorldEvent; diff --git a/chessistics-engine/Loading/LevelLoader.cs b/chessistics-engine/Loading/LevelLoader.cs index f3d07f9..6a15e09 100644 --- a/chessistics-engine/Loading/LevelLoader.cs +++ b/chessistics-engine/Loading/LevelLoader.cs @@ -27,13 +27,13 @@ public static class LevelLoader Width = dto.Width, Height = dto.Height, Productions = dto.Productions.Select(p => new ProductionDef( - new Coords(p.Col, p.Row), p.Name, ParseCargo(p.Cargo), p.Interval + new Coords(p.Col, p.Row), p.Name, ParseCargo(p.Cargo), p.Amount )).ToList(), Demands = dto.Demands.Select(d => new DemandDef( new Coords(d.Col, d.Row), d.Name, ParseCargo(d.Cargo), d.Amount, d.Deadline )).ToList(), Walls = dto.Walls?.Select(w => new Coords(w.Col, w.Row)).ToList() ?? [], - Stock = dto.Stock.Select(s => new PieceStock(ParseKind(s.Kind), s.Count)).ToList() + Stock = dto.Stock.Select(s => new PieceStock(ParseKind(s.Kind), s.Count, s.Level)).ToList() }; } @@ -92,7 +92,7 @@ public static class LevelLoader public int Row { get; set; } public string Name { get; set; } = ""; public string Cargo { get; set; } = ""; - public int Interval { get; set; } + public int Amount { get; set; } = 1; } private class DemandDto @@ -115,5 +115,6 @@ public static class LevelLoader { public string Kind { get; set; } = ""; public int Count { get; set; } + public int Level { get; set; } = 1; } } diff --git a/chessistics-engine/Model/BoardSnapshot.cs b/chessistics-engine/Model/BoardSnapshot.cs index 5038340..2585518 100644 --- a/chessistics-engine/Model/BoardSnapshot.cs +++ b/chessistics-engine/Model/BoardSnapshot.cs @@ -24,7 +24,7 @@ public class BoardSnapshot Array.Copy(state.Grid, Grid, state.Grid.Length); Productions = state.Productions.Values - .Select(p => new ProductionSnapshot(p.Position, p.Name, p.Cargo, p.Interval, state.ProductionBuffers[p.Position])) + .Select(p => new ProductionSnapshot(p.Position, p.Name, p.Cargo, p.Amount, state.ProductionBuffers[p.Position])) .ToList(); Demands = state.Demands.Values @@ -32,13 +32,13 @@ public class BoardSnapshot .ToList(); Pieces = state.Pieces - .Select(p => new PieceSnapshot(p.Id, p.Kind, p.StartCell, p.EndCell, p.CurrentCell, p.Cargo, p.CargoFilter, p.SocialStatus)) + .Select(p => new PieceSnapshot(p.Id, p.Kind, p.Level, p.StartCell, p.EndCell, p.CurrentCell, p.Cargo, p.CargoFilter, p.SocialStatus)) .ToList(); RemainingStock = new Dictionary(state.RemainingStock); } } -public record ProductionSnapshot(Coords Position, string Name, CargoType Cargo, int Interval, CargoType? Buffer); +public record ProductionSnapshot(Coords Position, string Name, CargoType Cargo, int Amount, int BufferCount); public record DemandSnapshot(Coords Position, string Name, CargoType Cargo, int Required, int Deadline, int ReceivedCount, bool IsSatisfied); -public record PieceSnapshot(int Id, PieceKind Kind, Coords StartCell, Coords EndCell, Coords CurrentCell, CargoType? Cargo, CargoType? CargoFilter, int SocialStatus); +public record PieceSnapshot(int Id, PieceKind Kind, int Level, Coords StartCell, Coords EndCell, Coords CurrentCell, CargoType? Cargo, CargoType? CargoFilter, int SocialStatus); diff --git a/chessistics-engine/Model/BoardState.cs b/chessistics-engine/Model/BoardState.cs index df2c8a2..5872723 100644 --- a/chessistics-engine/Model/BoardState.cs +++ b/chessistics-engine/Model/BoardState.cs @@ -10,7 +10,8 @@ public class BoardState public Dictionary Productions { get; } public Dictionary Demands { get; } public List Pieces { get; } - public Dictionary ProductionBuffers { get; } + public List DestroyedPieces { get; } = new(); + public Dictionary ProductionBuffers { get; } public SimPhase Phase { get; set; } public int TurnNumber { get; set; } public int NextPieceId { get; set; } @@ -34,7 +35,7 @@ public class BoardState Productions = new Dictionary(); Demands = new Dictionary(); Pieces = new List(); - ProductionBuffers = new Dictionary(); + ProductionBuffers = new Dictionary(); RemainingStock = new Dictionary(); OccupiedCells = new HashSet(); @@ -56,7 +57,7 @@ public class BoardState { Grid[prod.Position.Col, prod.Position.Row] = CellType.Production; Productions[prod.Position] = prod; - ProductionBuffers[prod.Position] = null; + ProductionBuffers[prod.Position] = 0; } // Place demands @@ -119,6 +120,7 @@ public class BoardState public void ResetFromLevel() { Pieces.Clear(); + DestroyedPieces.Clear(); Productions.Clear(); Demands.Clear(); ProductionBuffers.Clear(); @@ -140,7 +142,7 @@ public class BoardState { Grid[prod.Position.Col, prod.Position.Row] = CellType.Production; Productions[prod.Position] = prod; - ProductionBuffers[prod.Position] = null; + ProductionBuffers[prod.Position] = 0; } foreach (var demand in _levelDef.Demands) diff --git a/chessistics-engine/Model/PieceState.cs b/chessistics-engine/Model/PieceState.cs index a0f08ef..7208276 100644 --- a/chessistics-engine/Model/PieceState.cs +++ b/chessistics-engine/Model/PieceState.cs @@ -4,6 +4,7 @@ public class PieceState { public int Id { get; } public PieceKind Kind { get; } + public int Level { get; } public Coords StartCell { get; } public Coords EndCell { get; } public Coords CurrentCell { get; set; } @@ -12,10 +13,11 @@ public class PieceState public int SocialStatus { get; } public int PlacementOrder { get; } - public PieceState(int id, PieceKind kind, Coords startCell, Coords endCell, int placementOrder) + public PieceState(int id, PieceKind kind, Coords startCell, Coords endCell, int placementOrder, int level = 1) { Id = id; Kind = kind; + Level = level; StartCell = startCell; EndCell = endCell; CurrentCell = startCell; diff --git a/chessistics-engine/Model/PieceStock.cs b/chessistics-engine/Model/PieceStock.cs index 4892bcf..f5ee55b 100644 --- a/chessistics-engine/Model/PieceStock.cs +++ b/chessistics-engine/Model/PieceStock.cs @@ -1,3 +1,3 @@ namespace Chessistics.Engine.Model; -public record PieceStock(PieceKind Kind, int Count); +public record PieceStock(PieceKind Kind, int Count, int Level = 1); diff --git a/chessistics-engine/Model/ProductionDef.cs b/chessistics-engine/Model/ProductionDef.cs index c924e5a..aeec72d 100644 --- a/chessistics-engine/Model/ProductionDef.cs +++ b/chessistics-engine/Model/ProductionDef.cs @@ -1,3 +1,3 @@ namespace Chessistics.Engine.Model; -public record ProductionDef(Coords Position, string Name, CargoType Cargo, int Interval); +public record ProductionDef(Coords Position, string Name, CargoType Cargo, int Amount = 1); diff --git a/chessistics-engine/Model/SimPhase.cs b/chessistics-engine/Model/SimPhase.cs index 9786a00..241c625 100644 --- a/chessistics-engine/Model/SimPhase.cs +++ b/chessistics-engine/Model/SimPhase.cs @@ -5,7 +5,6 @@ public enum SimPhase Edit, Running, Paused, - Collision, Victory, Defeat } diff --git a/chessistics-engine/Rules/CollisionDetector.cs b/chessistics-engine/Rules/CollisionDetector.cs deleted file mode 100644 index 8d2501d..0000000 --- a/chessistics-engine/Rules/CollisionDetector.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Chessistics.Engine.Model; - -namespace Chessistics.Engine.Rules; - -public static class CollisionDetector -{ - public static IReadOnlyList<(int PieceIdA, int PieceIdB, Coords Cell)> DetectCollisions( - IReadOnlyList pieces) - { - var collisions = new List<(int, int, Coords)>(); - var byCell = new Dictionary>(); - - foreach (var piece in pieces) - { - if (!byCell.TryGetValue(piece.CurrentCell, out var list)) - { - list = []; - byCell[piece.CurrentCell] = list; - } - list.Add(piece); - } - - foreach (var (cell, occupants) in byCell) - { - if (occupants.Count < 2) continue; - - for (int i = 0; i < occupants.Count; i++) - for (int j = i + 1; j < occupants.Count; j++) - collisions.Add((occupants[i].Id, occupants[j].Id, cell)); - } - - return collisions; - } -} diff --git a/chessistics-engine/Rules/CollisionDetector.cs.uid b/chessistics-engine/Rules/CollisionDetector.cs.uid deleted file mode 100644 index 5ad7b39..0000000 --- a/chessistics-engine/Rules/CollisionDetector.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ddq5b3ayhu50e diff --git a/chessistics-engine/Rules/CollisionResolver.cs b/chessistics-engine/Rules/CollisionResolver.cs new file mode 100644 index 0000000..026be90 --- /dev/null +++ b/chessistics-engine/Rules/CollisionResolver.cs @@ -0,0 +1,55 @@ +using Chessistics.Engine.Model; + +namespace Chessistics.Engine.Rules; + +public static class CollisionResolver +{ + /// + /// Resolves collisions after movement. For each cell with 2+ pieces, + /// the strongest piece survives and destroys the others. + /// Priority: SocialStatus desc → Level desc → mutual destruction on exact tie. + /// + public static List<(PieceState? Survivor, List Destroyed, Coords Cell)> ResolveCollisions( + IReadOnlyList pieces) + { + var results = new List<(PieceState? Survivor, List Destroyed, Coords Cell)>(); + var byCell = new Dictionary>(); + + foreach (var piece in pieces) + { + if (!byCell.TryGetValue(piece.CurrentCell, out var list)) + { + list = []; + byCell[piece.CurrentCell] = list; + } + list.Add(piece); + } + + foreach (var (cell, occupants) in byCell) + { + if (occupants.Count < 2) continue; + + // Sort by priority: highest status first, then highest level + var sorted = occupants + .OrderByDescending(p => p.SocialStatus) + .ThenByDescending(p => p.Level) + .ToList(); + + var top = sorted[0]; + var second = sorted[1]; + + // If top two have same status AND same level → mutual destruction + if (top.SocialStatus == second.SocialStatus && top.Level == second.Level) + { + results.Add((null, sorted, cell)); + } + else + { + var destroyed = sorted.Skip(1).ToList(); + results.Add((top, destroyed, cell)); + } + } + + return results; + } +} diff --git a/chessistics-engine/Rules/CollisionResolver.cs.uid b/chessistics-engine/Rules/CollisionResolver.cs.uid new file mode 100644 index 0000000..a3b3430 --- /dev/null +++ b/chessistics-engine/Rules/CollisionResolver.cs.uid @@ -0,0 +1 @@ +uid://ddl7yl8k7h4qp diff --git a/chessistics-engine/Rules/TransferResolver.cs b/chessistics-engine/Rules/TransferResolver.cs index 5631154..7cfa291 100644 --- a/chessistics-engine/Rules/TransferResolver.cs +++ b/chessistics-engine/Rules/TransferResolver.cs @@ -26,29 +26,33 @@ public static class TransferResolver { // Sort productions deterministically (by position) var productions = state.Productions.Values - .Where(p => state.ProductionBuffers[p.Position] != null) + .Where(p => state.ProductionBuffers[p.Position] > 0) .OrderBy(p => p.Position.Col).ThenBy(p => p.Position.Row) .ToList(); foreach (var prod in productions) { - var cargoType = state.ProductionBuffers[prod.Position]!.Value; + var cargoType = prod.Cargo; // Find adjacent pieces without cargo that accept this cargo type var receivers = GetAdjacentPiecesWithoutCargo(state, prod.Position, participated, cargoType: cargoType); - if (receivers.Count == 0) continue; + foreach (var receiver in receivers) + { + if (state.ProductionBuffers[prod.Position] <= 0) break; - var receiver = receivers[0]; - receiver.Cargo = cargoType; - state.ProductionBuffers[prod.Position] = null; - participated.Add(receiver.Id); - productionGave.Add(prod.Position); + receiver.Cargo = cargoType; + state.ProductionBuffers[prod.Position]--; + participated.Add(receiver.Id); - events.Add(new CargoTransferredEvent( - prod.Position, receiver.CurrentCell, cargoType, - GivingPieceId: null, ReceivingPieceId: receiver.Id)); + events.Add(new CargoTransferredEvent( + state.TurnNumber, prod.Position, receiver.CurrentCell, cargoType, + GivingPieceId: null, ReceivingPieceId: receiver.Id)); + } + + if (state.ProductionBuffers[prod.Position] < prod.Amount) + productionGave.Add(prod.Position); } } @@ -59,8 +63,7 @@ public static class TransferResolver var givers = state.Pieces .Where(p => p.Cargo != null && !participated.Contains(p.Id)) .OrderByDescending(p => p.SocialStatus) - .ThenBy(p => MinDistanceToProduction(p.CurrentCell, state, p.Cargo)) - .ThenBy(p => p.PlacementOrder) + .ThenByDescending(p => p.Level) .ToList(); foreach (var giver in givers) @@ -69,7 +72,7 @@ public static class TransferResolver var cargoType = giver.Cargo!.Value; - // Priority 1: deliver to adjacent demand + // Priority 1: deliver to adjacent demand (always accepts matching cargo, even when satisfied) var adjacentDemand = GetAdjacentCompatibleDemand(state, giver.CurrentCell, cargoType); if (adjacentDemand != null) { @@ -78,20 +81,19 @@ public static class TransferResolver participated.Add(giver.Id); events.Add(new CargoTransferredEvent( - giver.CurrentCell, adjacentDemand.Position, cargoType, + state.TurnNumber, giver.CurrentCell, adjacentDemand.Position, cargoType, GivingPieceId: giver.Id, ReceivingPieceId: null)); events.Add(new DemandProgressEvent( - adjacentDemand.Position, adjacentDemand.Name, + state.TurnNumber, adjacentDemand.Position, adjacentDemand.Name, adjacentDemand.ReceivedCount, adjacentDemand.Required)); continue; } // Priority 2: transfer to adjacent piece without cargo - // Prefer receivers farther from production (push cargo forward in chain) var receivers = GetAdjacentPiecesWithoutCargo(state, giver.CurrentCell, participated, - forwardDirection: true, cargoType: cargoType); + cargoType: cargoType); if (receivers.Count == 0) continue; var receiver = receivers[0]; @@ -101,32 +103,26 @@ public static class TransferResolver participated.Add(receiver.Id); events.Add(new CargoTransferredEvent( - giver.CurrentCell, receiver.CurrentCell, cargoType, + state.TurnNumber, giver.CurrentCell, receiver.CurrentCell, cargoType, GivingPieceId: giver.Id, ReceivingPieceId: receiver.Id)); } } private static List GetAdjacentPiecesWithoutCargo( BoardState state, Coords position, HashSet participated, - bool forwardDirection = false, CargoType? cargoType = null) + CargoType? cargoType = null) { var adjacent = position.GetAdjacent4(state.Width, state.Height); - var query = state.Pieces + return state.Pieces .Where(p => p.Cargo == null && !participated.Contains(p.Id) && adjacent.Contains(p.CurrentCell) && (p.CargoFilter == null || cargoType == null || p.CargoFilter == cargoType)) - .OrderByDescending(p => p.SocialStatus); - - // For piece-to-piece transfers, prefer receivers farther from production - // (pushes cargo forward through relay chains instead of backward). - // For production pickups, prefer receivers closer to production. - var sorted = forwardDirection - ? query.ThenByDescending(p => MinDistanceToProduction(p.CurrentCell, state, cargoType)) - : query.ThenBy(p => MinDistanceToProduction(p.CurrentCell, state, cargoType)); - - return sorted.ThenBy(p => p.PlacementOrder).ToList(); + .OrderByDescending(p => p.SocialStatus) + .ThenByDescending(p => p.Level) + .ThenBy(p => ClockwiseOrder(p.CurrentCell, position, state.TurnNumber)) + .ToList(); } private static DemandState? GetAdjacentCompatibleDemand( @@ -135,20 +131,35 @@ public static class TransferResolver var adjacent = position.GetAdjacent4(state.Width, state.Height); return state.Demands.Values - .Where(d => !d.IsSatisfied - && d.Cargo == cargoType + .Where(d => d.Cargo == cargoType && adjacent.Contains(d.Position)) .FirstOrDefault(); } - private static int MinDistanceToProduction(Coords cell, BoardState state, CargoType? cargoType = null) + /// + /// Returns a sort key (0-3) based on cardinal direction from center to piece. + /// In y-up coordinates, clockwise from 0° (right): + /// right(1,0)=0, up(0,1)=1, left(-1,0)=2, down(0,-1)=3 + /// On even turns, start from right (0°). On odd turns, start from left (180°). + /// + private static int ClockwiseOrder(Coords pieceCell, Coords center, int turnNumber) { - var productions = cargoType != null - ? state.Productions.Where(kv => kv.Value.Cargo == cargoType).Select(kv => kv.Key) - : state.Productions.Keys; + int dx = pieceCell.Col - center.Col; + int dy = pieceCell.Row - center.Row; - var prodList = productions.ToList(); - if (prodList.Count == 0) return int.MaxValue; - return prodList.Min(p => cell.ManhattanDistance(p)); + int baseOrder = (dx, dy) switch + { + (1, 0) => 0, // right + (0, 1) => 1, // up (y-up) + (-1, 0) => 2, // left + (0, -1) => 3, // down (y-up) + _ => 4 // non-adjacent, shouldn't happen + }; + + // Odd turns: rotate by 2 (start from left instead of right) + if (turnNumber % 2 == 1) + baseOrder = (baseOrder + 2) % 4; + + return baseOrder; } } diff --git a/chessistics-engine/Simulation/TurnExecutor.cs b/chessistics-engine/Simulation/TurnExecutor.cs index a85cbb1..549a54b 100644 --- a/chessistics-engine/Simulation/TurnExecutor.cs +++ b/chessistics-engine/Simulation/TurnExecutor.cs @@ -11,39 +11,41 @@ public static class TurnExecutor state.TurnNumber++; changeList.Add(new TurnStartedEvent(state.TurnNumber)); - // Sub-phase 1: MOVEMENT - ExecuteMovement(state, changeList); + // Sub-phase 1: PRODUCTION + ExecuteProduction(state, changeList); - // Sub-phase 2: COLLISION DETECTION - var collisions = CollisionDetector.DetectCollisions(state.Pieces); - if (collisions.Count > 0) - { - foreach (var (idA, idB, cell) in collisions) - changeList.Add(new CollisionDetectedEvent(idA, idB, cell)); - - state.Phase = SimPhase.Collision; - changeList.Add(new TurnEndedEvent(state.TurnNumber)); - return; - } - - // Sub-phase 3: TRANSFERS + // Sub-phase 2: TRANSFERS var transferEvents = TransferResolver.ResolveTransfers(state); changeList.AddRange(transferEvents); - // Sub-phase 4: PRODUCTION - ExecuteProduction(state, changeList); + // Sub-phase 3: MOVEMENT + ExecuteMovement(state, changeList); + + // Sub-phase 4: COLLISION RESOLUTION + var collisions = CollisionResolver.ResolveCollisions(state.Pieces); + foreach (var (survivor, destroyed, cell) in collisions) + { + foreach (var victim in destroyed) + { + state.Pieces.Remove(victim); + state.DestroyedPieces.Add(victim); + victim.Cargo = null; + changeList.Add(new PieceDestroyedEvent( + state.TurnNumber, victim.Id, survivor?.Id, cell)); + } + } // Check victory / defeat if (VictoryChecker.AllDemandsMet(state)) { state.Phase = SimPhase.Victory; - changeList.Add(new VictoryEvent(ComputeMetrics(state))); + changeList.Add(new VictoryEvent(state.TurnNumber, ComputeMetrics(state))); } else if (VictoryChecker.AnyDeadlineExpired(state)) { state.Phase = SimPhase.Defeat; foreach (var demand in VictoryChecker.GetExpiredDemands(state)) - changeList.Add(new DeadlineExpiredEvent(demand.Position, demand.Name)); + changeList.Add(new DeadlineExpiredEvent(state.TurnNumber, demand.Position, demand.Name)); } changeList.Add(new TurnEndedEvent(state.TurnNumber)); @@ -59,7 +61,7 @@ public static class TurnExecutor { piece.CurrentCell = to; state.OccupiedCells.Add(to); - changeList.Add(new PieceMovedEvent(piece.Id, from, to)); + changeList.Add(new PieceMovedEvent(state.TurnNumber, piece.Id, from, to)); } } @@ -67,21 +69,15 @@ public static class TurnExecutor { foreach (var (pos, prod) in state.Productions) { - if (state.ProductionBuffers[pos] != null) - continue; // buffer already full - - if (state.TurnNumber % prod.Interval == 0) - { - state.ProductionBuffers[pos] = prod.Cargo; - changeList.Add(new CargoProducedEvent(pos, prod.Cargo)); - } + state.ProductionBuffers[pos] = prod.Amount; + changeList.Add(new CargoProducedEvent(state.TurnNumber, pos, prod.Cargo)); } } private static Metrics ComputeMetrics(BoardState state) { return new Metrics( - PiecesUsed: state.Pieces.Count, + PiecesUsed: state.Pieces.Count + state.DestroyedPieces.Count, TurnsTaken: state.TurnNumber, CellsOccupied: state.OccupiedCells.Count ); diff --git a/chessistics-tests/Helpers/BoardBuilder.cs b/chessistics-tests/Helpers/BoardBuilder.cs index a40ce9a..f14fe15 100644 --- a/chessistics-tests/Helpers/BoardBuilder.cs +++ b/chessistics-tests/Helpers/BoardBuilder.cs @@ -17,9 +17,9 @@ public class BoardBuilder _height = height; } - public BoardBuilder WithProduction(int col, int row, string name, CargoType cargo, int interval = 2) + public BoardBuilder WithProduction(int col, int row, string name, CargoType cargo, int amount = 1) { - _productions.Add(new ProductionDef(new Coords(col, row), name, cargo, interval)); + _productions.Add(new ProductionDef(new Coords(col, row), name, cargo, amount)); return this; } diff --git a/chessistics-tests/Loading/LevelLoaderTests.cs b/chessistics-tests/Loading/LevelLoaderTests.cs index 1fdd0e6..1c57ada 100644 --- a/chessistics-tests/Loading/LevelLoaderTests.cs +++ b/chessistics-tests/Loading/LevelLoaderTests.cs @@ -42,8 +42,6 @@ public class LevelLoaderTests Assert.Equal(new Coords(0, 0), prod.Position); Assert.Equal("Scierie", prod.Name); Assert.Equal(CargoType.Wood, prod.Cargo); - Assert.Equal(2, prod.Interval); - Assert.Single(level.Demands); var demand = level.Demands[0]; Assert.Equal(new Coords(3, 0), demand.Position); diff --git a/chessistics-tests/Rules/CollisionDetectorTests.cs b/chessistics-tests/Rules/CollisionDetectorTests.cs deleted file mode 100644 index 10b6cf6..0000000 --- a/chessistics-tests/Rules/CollisionDetectorTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Chessistics.Engine.Model; -using Chessistics.Engine.Rules; -using Xunit; - -namespace Chessistics.Tests.Rules; - -public class CollisionDetectorTests -{ - [Fact] - public void NoCollision_PiecesOnDifferentCells() - { - var pieces = new List - { - new(1, PieceKind.Rook, new Coords(0, 0), new Coords(1, 0), 0) { CurrentCell = new Coords(0, 0) }, - new(2, PieceKind.Rook, new Coords(2, 0), new Coords(3, 0), 1) { CurrentCell = new Coords(2, 0) } - }; - - var collisions = CollisionDetector.DetectCollisions(pieces); - Assert.Empty(collisions); - } - - [Fact] - public void Collision_TwoPiecesSameCell() - { - var cell = new Coords(1, 0); - var pieces = new List - { - new(1, PieceKind.Rook, new Coords(0, 0), cell, 0) { CurrentCell = cell }, - new(2, PieceKind.Rook, new Coords(2, 0), cell, 1) { CurrentCell = cell } - }; - - var collisions = CollisionDetector.DetectCollisions(pieces); - Assert.Single(collisions); - Assert.Equal((1, 2, cell), collisions[0]); - } - - [Fact] - public void Collision_ThreePiecesSameCell() - { - var cell = new Coords(1, 0); - var pieces = new List - { - new(1, PieceKind.Rook, new Coords(0, 0), cell, 0) { CurrentCell = cell }, - new(2, PieceKind.Rook, new Coords(2, 0), cell, 1) { CurrentCell = cell }, - new(3, PieceKind.Rook, new Coords(3, 0), cell, 2) { CurrentCell = cell } - }; - - var collisions = CollisionDetector.DetectCollisions(pieces); - // 3 pairs: (1,2), (1,3), (2,3) - Assert.Equal(3, collisions.Count); - } -} diff --git a/chessistics-tests/Rules/CollisionDetectorTests.cs.uid b/chessistics-tests/Rules/CollisionDetectorTests.cs.uid deleted file mode 100644 index 0b26dec..0000000 --- a/chessistics-tests/Rules/CollisionDetectorTests.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://gigwqhqsob8f diff --git a/chessistics-tests/Rules/CollisionResolverTests.cs b/chessistics-tests/Rules/CollisionResolverTests.cs new file mode 100644 index 0000000..33a68d1 --- /dev/null +++ b/chessistics-tests/Rules/CollisionResolverTests.cs @@ -0,0 +1,87 @@ +using Chessistics.Engine.Model; +using Chessistics.Engine.Rules; +using Xunit; + +namespace Chessistics.Tests.Rules; + +public class CollisionResolverTests +{ + [Fact] + public void NoCollision_PiecesOnDifferentCells() + { + var pieces = new List + { + new(1, PieceKind.Rook, new Coords(0, 0), new Coords(1, 0), 0) { CurrentCell = new Coords(0, 0) }, + new(2, PieceKind.Rook, new Coords(2, 0), new Coords(3, 0), 1) { CurrentCell = new Coords(2, 0) } + }; + + var results = CollisionResolver.ResolveCollisions(pieces); + Assert.Empty(results); + } + + [Fact] + public void HigherStatus_Survives() + { + var cell = new Coords(1, 0); + var rook = new PieceState(1, PieceKind.Rook, new Coords(0, 0), cell, 0) { CurrentCell = cell }; + var knight = new PieceState(2, PieceKind.Knight, new Coords(2, 0), cell, 1) { CurrentCell = cell }; + var pieces = new List { rook, knight }; + + var results = CollisionResolver.ResolveCollisions(pieces); + Assert.Single(results); + var (survivor, destroyed, resultCell) = results[0]; + Assert.Equal(rook, survivor); + Assert.Single(destroyed); + Assert.Equal(knight, destroyed[0]); + Assert.Equal(cell, resultCell); + } + + [Fact] + public void SameStatusAndLevel_MutualDestruction() + { + var cell = new Coords(1, 0); + var pieces = new List + { + new(1, PieceKind.Knight, new Coords(0, 0), cell, 0) { CurrentCell = cell }, + new(2, PieceKind.Bishop, new Coords(2, 0), cell, 1) { CurrentCell = cell } + }; + + var results = CollisionResolver.ResolveCollisions(pieces); + Assert.Single(results); + var (survivor, destroyed, _) = results[0]; + Assert.Null(survivor); + Assert.Equal(2, destroyed.Count); + } + + [Fact] + public void HigherLevel_SurvivesWhenSameStatus() + { + var cell = new Coords(1, 0); + var knightL2 = new PieceState(1, PieceKind.Knight, new Coords(0, 0), cell, 0, level: 2) { CurrentCell = cell }; + var knightL1 = new PieceState(2, PieceKind.Knight, new Coords(2, 0), cell, 1, level: 1) { CurrentCell = cell }; + var pieces = new List { knightL1, knightL2 }; + + var results = CollisionResolver.ResolveCollisions(pieces); + Assert.Single(results); + var (survivor, destroyed, _) = results[0]; + Assert.Equal(knightL2, survivor); + Assert.Single(destroyed); + Assert.Equal(knightL1, destroyed[0]); + } + + [Fact] + public void ThreePieces_StrongestSurvives_OthersDestroyed() + { + var cell = new Coords(1, 0); + var rook = new PieceState(1, PieceKind.Rook, new Coords(0, 0), cell, 0) { CurrentCell = cell }; + var bishop = new PieceState(2, PieceKind.Bishop, new Coords(2, 0), cell, 1) { CurrentCell = cell }; + var knight = new PieceState(3, PieceKind.Knight, new Coords(3, 0), cell, 2) { CurrentCell = cell }; + var pieces = new List { rook, bishop, knight }; + + var results = CollisionResolver.ResolveCollisions(pieces); + Assert.Single(results); + var (survivor, destroyed, _) = results[0]; + Assert.Equal(rook, survivor); + Assert.Equal(2, destroyed.Count); + } +} diff --git a/chessistics-tests/Rules/CollisionResolverTests.cs.uid b/chessistics-tests/Rules/CollisionResolverTests.cs.uid new file mode 100644 index 0000000..da1b3c5 --- /dev/null +++ b/chessistics-tests/Rules/CollisionResolverTests.cs.uid @@ -0,0 +1 @@ +uid://dvjr7naqe8v28 diff --git a/chessistics-tests/Rules/MoveValidatorTests.cs b/chessistics-tests/Rules/MoveValidatorTests.cs index e00db6a..2ea2049 100644 --- a/chessistics-tests/Rules/MoveValidatorTests.cs +++ b/chessistics-tests/Rules/MoveValidatorTests.cs @@ -9,7 +9,7 @@ public class MoveValidatorTests { private BoardState EmptyBoard(int size = 5) => new BoardBuilder(size, size) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(size - 1, 0, "D", CargoType.Wood, 1, 99) .WithStock(PieceKind.Rook, 10) .WithStock(PieceKind.Bishop, 10) @@ -37,7 +37,7 @@ public class MoveValidatorTests public void Rook_BlockedByWall() { var board = new BoardBuilder(5, 5) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(4, 0, "D", CargoType.Wood, 1, 99) .WithWall(1, 0) .WithStock(PieceKind.Rook, 5) @@ -75,7 +75,7 @@ public class MoveValidatorTests public void Rook_CannotLandOnWall() { var board = new BoardBuilder(5, 5) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(4, 0, "D", CargoType.Wood, 1, 99) .WithWall(3, 2) .WithStock(PieceKind.Rook, 5) @@ -109,7 +109,7 @@ public class MoveValidatorTests public void Bishop_BlockedByWall() { var board = new BoardBuilder(5, 5) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(4, 0, "D", CargoType.Wood, 1, 99) .WithWall(3, 3) .WithStock(PieceKind.Bishop, 5) @@ -142,7 +142,7 @@ public class MoveValidatorTests public void Knight_JumpsOverWalls() { var board = new BoardBuilder(5, 5) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(4, 0, "D", CargoType.Wood, 1, 99) .WithWall(1, 2) .WithWall(2, 1) @@ -178,7 +178,7 @@ public class MoveValidatorTests public void Knight_CannotLandOnWall() { var board = new BoardBuilder(5, 5) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(4, 0, "D", CargoType.Wood, 1, 99) .WithWall(3, 4) .WithStock(PieceKind.Knight, 5) @@ -192,7 +192,7 @@ public class MoveValidatorTests public void StartCell_CannotBeWall() { var board = new BoardBuilder(5, 5) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(4, 0, "D", CargoType.Wood, 1, 99) .WithWall(2, 2) .WithStock(PieceKind.Rook, 5) diff --git a/chessistics-tests/Rules/TransferResolverTests.cs b/chessistics-tests/Rules/TransferResolverTests.cs index cb7d78f..1c62541 100644 --- a/chessistics-tests/Rules/TransferResolverTests.cs +++ b/chessistics-tests/Rules/TransferResolverTests.cs @@ -12,7 +12,7 @@ public class TransferResolverTests public void Production_GivesToAdjacentEmptyPiece() { var board = new BoardBuilder(4, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(3, 0, "D", CargoType.Wood, 1, 99) .WithStock(PieceKind.Rook, 3) .BuildState(); @@ -23,7 +23,7 @@ public class TransferResolverTests board.Pieces.Add(piece); // Fill production buffer - board.ProductionBuffers[new Coords(0, 0)] = CargoType.Wood; + board.ProductionBuffers[new Coords(0, 0)] = 1; var events = TransferResolver.ResolveTransfers(board); @@ -33,14 +33,14 @@ public class TransferResolverTests && ct.Type == CargoType.Wood); Assert.Equal(CargoType.Wood, piece.Cargo); - Assert.Null(board.ProductionBuffers[new Coords(0, 0)]); + Assert.Equal(0, board.ProductionBuffers[new Coords(0, 0)]); } [Fact] public void Production_DoesNotGiveToPieceWithCargo() { var board = new BoardBuilder(4, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(3, 0, "D", CargoType.Wood, 1, 99) .WithStock(PieceKind.Rook, 3) .BuildState(); @@ -49,20 +49,20 @@ public class TransferResolverTests piece.CurrentCell = new Coords(1, 0); piece.Cargo = CargoType.Wood; // already carrying board.Pieces.Add(piece); - board.ProductionBuffers[new Coords(0, 0)] = CargoType.Wood; + board.ProductionBuffers[new Coords(0, 0)] = 1; var events = TransferResolver.ResolveTransfers(board); // No transfer from production — piece already has cargo Assert.DoesNotContain(events, e => e is CargoTransferredEvent ct && ct.From == new Coords(0, 0)); - Assert.Equal(CargoType.Wood, board.ProductionBuffers[new Coords(0, 0)]); + Assert.Equal(1, board.ProductionBuffers[new Coords(0, 0)]); } [Fact] public void Piece_TransfersToAdjacentEmptyPiece() { var board = new BoardBuilder(4, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(3, 0, "D", CargoType.Wood, 1, 99) .WithStock(PieceKind.Rook, 3) .BuildState(); @@ -88,7 +88,7 @@ public class TransferResolverTests public void Piece_DeliversToDemand() { var board = new BoardBuilder(4, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(3, 0, "D", CargoType.Wood, 3, 99) .WithStock(PieceKind.Rook, 3) .BuildState(); @@ -111,7 +111,7 @@ public class TransferResolverTests public void Piece_DoesNotDeliverWrongType() { var board = new BoardBuilder(4, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(3, 0, "D", CargoType.Wood, 3, 99) // wants Wood .WithStock(PieceKind.Rook, 3) .BuildState(); @@ -131,7 +131,7 @@ public class TransferResolverTests public void HigherStatus_ReceivesFirst() { var board = new BoardBuilder(4, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(3, 0, "D", CargoType.Wood, 1, 99) .WithStock(PieceKind.Rook, 3) .WithStock(PieceKind.Knight, 3) @@ -162,7 +162,7 @@ public class TransferResolverTests public void HigherStatus_GivesFirst() { var board = new BoardBuilder(4, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(3, 0, "D", CargoType.Wood, 1, 99) .WithStock(PieceKind.Rook, 3) .WithStock(PieceKind.Knight, 3) @@ -203,41 +203,71 @@ public class TransferResolverTests } [Fact] - public void TieBreaker_PlacementOrder() + public void TieBreaker_ClockwiseDirection_EvenTurn() { var board = new BoardBuilder(4, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(3, 0, "D", CargoType.Wood, 1, 99) .WithStock(PieceKind.Knight, 5) .BuildState(); - // Two knights (same status 3) with cargo, both adjacent to same empty receiver - var knight1 = new PieceState(1, PieceKind.Knight, new Coords(2, 0), new Coords(0, 1), 0); // earlier - knight1.CurrentCell = new Coords(2, 0); - knight1.Cargo = CargoType.Wood; + // Giver at (1,1) with cargo, two receivers: right(2,1) and up(1,2) + // On even turn (TurnNumber=0), clockwise from right: right=0 < up=1 + var giver = new PieceState(1, PieceKind.Rook, new Coords(1, 1), new Coords(2, 1), 0); + giver.CurrentCell = new Coords(1, 1); + giver.Cargo = CargoType.Wood; - var knight2 = new PieceState(2, PieceKind.Knight, new Coords(2, 2), new Coords(0, 3), 1); // later - knight2.CurrentCell = new Coords(2, 2); - knight2.Cargo = CargoType.Wood; + var receiverRight = new PieceState(2, PieceKind.Rook, new Coords(2, 1), new Coords(3, 1), 1); + receiverRight.CurrentCell = new Coords(2, 1); // right of giver - var receiver = new PieceState(3, PieceKind.Knight, new Coords(2, 1), new Coords(0, 2), 2); - receiver.CurrentCell = new Coords(2, 1); // adjacent to both (2,0) and (2,2) + var receiverUp = new PieceState(3, PieceKind.Rook, new Coords(1, 2), new Coords(1, 3), 2); + receiverUp.CurrentCell = new Coords(1, 2); // up of giver - board.Pieces.AddRange([knight1, knight2, receiver]); + board.Pieces.AddRange([giver, receiverRight, receiverUp]); + board.TurnNumber = 2; // even turn var events = TransferResolver.ResolveTransfers(board); var transfer = events.OfType().First(); - // knight1 is closer to production at (0,0): dist = 2, knight2: dist = 4 - // So knight1 gives first due to proximity (tiebreaker before placement order) - Assert.Equal(1, transfer.GivingPieceId); + // On even turn, right(0) has priority over up(1) + Assert.Equal(2, transfer.ReceivingPieceId); // receiverRight + } + + [Fact] + public void TieBreaker_ClockwiseDirection_OddTurn() + { + var board = new BoardBuilder(4, 4) + .WithProduction(0, 0, "P", CargoType.Wood) + .WithDemand(3, 0, "D", CargoType.Wood, 1, 99) + .WithStock(PieceKind.Knight, 5) + .BuildState(); + + // Same setup but on odd turn: left=0 < down=1 < right=2 < up=3 + var giver = new PieceState(1, PieceKind.Rook, new Coords(1, 1), new Coords(2, 1), 0); + giver.CurrentCell = new Coords(1, 1); + giver.Cargo = CargoType.Wood; + + var receiverRight = new PieceState(2, PieceKind.Rook, new Coords(2, 1), new Coords(3, 1), 1); + receiverRight.CurrentCell = new Coords(2, 1); // right of giver + + var receiverLeft = new PieceState(3, PieceKind.Rook, new Coords(0, 1), new Coords(0, 2), 2); + receiverLeft.CurrentCell = new Coords(0, 1); // left of giver + + board.Pieces.AddRange([giver, receiverRight, receiverLeft]); + board.TurnNumber = 1; // odd turn + + var events = TransferResolver.ResolveTransfers(board); + var transfer = events.OfType().First(); + + // On odd turn, left(0) has priority over right(2) + Assert.Equal(3, transfer.ReceivingPieceId); // receiverLeft } [Fact] public void Cargo_MovesOneHopPerTurn() { var board = new BoardBuilder(5, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(4, 0, "D", CargoType.Wood, 1, 99) .WithStock(PieceKind.Rook, 5) .BuildState(); @@ -269,7 +299,7 @@ public class TransferResolverTests public void NoCrossTransfer_NonAdjacent() { var board = new BoardBuilder(5, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(4, 0, "D", CargoType.Wood, 1, 99) .WithStock(PieceKind.Rook, 5) .BuildState(); @@ -293,7 +323,7 @@ public class TransferResolverTests public void DemandPriority_OverPieceReceiver() { var board = new BoardBuilder(4, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(2, 0, "D", CargoType.Wood, 3, 99) .WithStock(PieceKind.Rook, 5) .BuildState(); diff --git a/chessistics-tests/Simulation/FullLevelTests.cs b/chessistics-tests/Simulation/FullLevelTests.cs index 3801cd4..1cf9830 100644 --- a/chessistics-tests/Simulation/FullLevelTests.cs +++ b/chessistics-tests/Simulation/FullLevelTests.cs @@ -13,7 +13,7 @@ public class FullLevelTests // GDD Level 1: 4x4, Scierie(0,0) → Depot(3,0), 3 Rooks // Solution: single rook relay at (1,0)↔(2,0) var level = new BoardBuilder(4, 4) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 2) + .WithProduction(0, 0, "Scierie", CargoType.Wood) .WithDemand(3, 0, "Depot Royal", CargoType.Wood, 3, 30) .WithStock(PieceKind.Rook, 3) .Build(); @@ -38,7 +38,7 @@ public class FullLevelTests // Bishop(3,2↔4,3), G(4,3↔5,3) // Total needed: 6 Rooks + 1 Bishop var level = new BoardBuilder(6, 6) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 2) + .WithProduction(0, 0, "Scierie", CargoType.Wood) .WithDemand(5, 0, "Depot Royal", CargoType.Wood, 2, 50) .WithDemand(5, 4, "Caserne", CargoType.Wood, 2, 50) .WithStock(PieceKind.Rook, 6) @@ -83,8 +83,8 @@ public class FullLevelTests // K2(2,0↔1,2), S3(1,2↔1,3), S4(1,3↔1,4), S5(1,4↔0,4) // Total: 10 Rooks + 2 Knights var level = new BoardBuilder(6, 6) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 2) - .WithProduction(5, 0, "Carriere", CargoType.Stone, 2) + .WithProduction(0, 0, "Scierie", CargoType.Wood) + .WithProduction(5, 0, "Carriere", CargoType.Stone) .WithDemand(5, 5, "Depot Royal", CargoType.Wood, 2, 60) .WithDemand(0, 5, "Forge", CargoType.Stone, 2, 60) .WithWall(2, 2).WithWall(2, 3).WithWall(2, 4).WithWall(3, 4).WithWall(4, 4) @@ -118,7 +118,7 @@ public class FullLevelTests public void Level1_InsufficientPieces_NoVictory() { var level = new BoardBuilder(4, 4) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 2) + .WithProduction(0, 0, "Scierie", CargoType.Wood) .WithDemand(3, 3, "Depot Royal", CargoType.Wood, 3, 5) .WithStock(PieceKind.Rook, 1) .Build(); diff --git a/chessistics-tests/Simulation/GameSimTests.cs b/chessistics-tests/Simulation/GameSimTests.cs index e1c5c55..55f7160 100644 --- a/chessistics-tests/Simulation/GameSimTests.cs +++ b/chessistics-tests/Simulation/GameSimTests.cs @@ -11,7 +11,7 @@ public class GameSimTests private SimHelper CreateLevel1Sim() { var level = new BoardBuilder(4, 4) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 2) + .WithProduction(0, 0, "Scierie", CargoType.Wood) .WithDemand(3, 0, "Depot Royal", CargoType.Wood, 3, 30) .WithStock(PieceKind.Rook, 3) .Build(); @@ -120,18 +120,17 @@ public class GameSimTests } [Fact] - public void Production_GeneratesOnInterval() + public void Production_GeneratesEveryTurn() { var sim = CreateLevel1Sim(); - // Place rook adjacent to production so it picks up cargo, freeing the buffer sim.Place(PieceKind.Rook, (0, 0), (1, 0)); sim.Start(); var allEvents = sim.StepN(6); var prodEvents = allEvents.OfType().ToList(); - // With interval 2, produces on turns 2, 4, 6 (buffer freed each time by adjacent piece) - Assert.True(prodEvents.Count >= 2, $"Expected at least 2 productions, got {prodEvents.Count}"); + // Production fires every turn + Assert.Equal(6, prodEvents.Count); } [Fact] @@ -139,7 +138,7 @@ public class GameSimTests { // Tiny level: prod adjacent to demand, just need one piece to relay var level = new BoardBuilder(3, 1) - .WithProduction(0, 0, "P", CargoType.Wood, 1) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(2, 0, "D", CargoType.Wood, 1, 30) .WithStock(PieceKind.Rook, 2) .Build(); @@ -159,7 +158,7 @@ public class GameSimTests { // Demand with very tight deadline, piece placed far from demand var level = new BoardBuilder(4, 4) - .WithProduction(0, 0, "P", CargoType.Wood, 2) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(3, 3, "D", CargoType.Wood, 10, 3) // need 10 in 3 turns — impossible .WithStock(PieceKind.Rook, 3) .Build(); diff --git a/chessistics-tests/Simulation/SolvabilityTests.cs b/chessistics-tests/Simulation/SolvabilityTests.cs index eed1ea0..c55cb37 100644 --- a/chessistics-tests/Simulation/SolvabilityTests.cs +++ b/chessistics-tests/Simulation/SolvabilityTests.cs @@ -19,7 +19,7 @@ public class SolvabilityTests // Delivery happens when rook returns to (1,0), adjacent to demand at (2,0). // Wait — (1,0) is adjacent to (2,0) ✓ so delivery from (1,0). var level = new BoardBuilder(3, 1) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 1) + .WithProduction(0, 0, "Scierie", CargoType.Wood) .WithDemand(2, 0, "Depot", CargoType.Wood, 2, 30) .WithStock(PieceKind.Rook, 1) .Build(); @@ -42,7 +42,7 @@ public class SolvabilityTests // Odd turns: A@(2,0) B@(3,0) C@(4,0) // Even turns: A@(1,0) B@(2,0) C@(3,0) var level = new BoardBuilder(5, 2) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 1) + .WithProduction(0, 0, "Scierie", CargoType.Wood) .WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40) .WithStock(PieceKind.Rook, 3) .Build(); @@ -72,7 +72,7 @@ public class SolvabilityTests // Rook B(0,1↔0,2): picks up at (0,1), delivers to D2 from (0,1). // Both rooks compete for the same buffer; A gets priority (placed first). var level = new BoardBuilder(4, 3) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 1) + .WithProduction(0, 0, "Scierie", CargoType.Wood) .WithDemand(2, 0, "Depot Royal", CargoType.Wood, 2, 30) .WithDemand(0, 2, "Caserne", CargoType.Wood, 2, 30) .WithStock(PieceKind.Rook, 2) @@ -100,8 +100,8 @@ public class SolvabilityTests // Row 1: Prod_Stone(0,1) — Rook B(1,1↔2,1) — Demand_Stone(3,1) // Proves two cargo types flow independently to their matching demands. var level = new BoardBuilder(4, 2) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 1) - .WithProduction(0, 1, "Carriere", CargoType.Stone, 1) + .WithProduction(0, 0, "Scierie", CargoType.Wood) + .WithProduction(0, 1, "Carriere", CargoType.Stone) .WithDemand(3, 0, "Depot Royal", CargoType.Wood, 2, 30) .WithDemand(3, 1, "Forge", CargoType.Stone, 2, 30) .WithStock(PieceKind.Rook, 2) @@ -133,7 +133,7 @@ public class SolvabilityTests // Even turns: Rook@(0,1), Bishop@(1,1) — adjacent, transfer. // Odd turns: Rook@(0,0), Bishop@(2,2) — bishop delivers. var level = new BoardBuilder(4, 3) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 1) + .WithProduction(0, 0, "Scierie", CargoType.Wood) .WithDemand(2, 1, "Depot", CargoType.Wood, 2, 30) .WithStock(PieceKind.Rook, 1) .WithStock(PieceKind.Bishop, 1) @@ -160,7 +160,7 @@ public class SolvabilityTests // Even turns: Rook@(1,0), Knight@(1,1) — adjacent, transfer. // Odd turns: Knight@(3,0), adjacent to demand — delivers. var level = new BoardBuilder(5, 3) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 1) + .WithProduction(0, 0, "Scierie", CargoType.Wood) .WithDemand(4, 0, "Depot", CargoType.Wood, 2, 30) .WithWall(2, 0).WithWall(2, 1).WithWall(2, 2) .WithStock(PieceKind.Rook, 1) @@ -184,7 +184,7 @@ public class SolvabilityTests { // 3x1: single rook relay, verify PiecesUsed, TurnsTaken, CellsOccupied. var level = new BoardBuilder(3, 1) - .WithProduction(0, 0, "P", CargoType.Wood, 1) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(2, 0, "D", CargoType.Wood, 2, 30) .WithStock(PieceKind.Rook, 1) .Build(); @@ -208,7 +208,7 @@ public class SolvabilityTests // Two rooks sharing a relay point never collide. // A(1,0↔2,0), B(2,0↔3,0) — share cell (2,0) but occupy it on alternate turns. var level = new BoardBuilder(5, 2) - .WithProduction(0, 0, "P", CargoType.Wood, 1) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(4, 0, "D", CargoType.Wood, 1, 40) .WithStock(PieceKind.Rook, 2) .Build(); @@ -220,7 +220,7 @@ public class SolvabilityTests var allEvents = sim.StepN(20); - Assert.DoesNotContain(allEvents, e => e is CollisionDetectedEvent); + Assert.DoesNotContain(allEvents, e => e is PieceDestroyedEvent); } [Fact] @@ -233,8 +233,8 @@ public class SolvabilityTests // With CargoFilter, A's start (1,0) is adjacent to prod_Wood(0,0), // so A is filtered to Wood and ignores Stone. var level = new BoardBuilder(4, 1) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 1) - .WithProduction(3, 0, "Carriere", CargoType.Stone, 1) + .WithProduction(0, 0, "Scierie", CargoType.Wood) + .WithProduction(3, 0, "Carriere", CargoType.Stone) .WithDemand(2, 0, "D_Wood", CargoType.Wood, 2, 20) .WithStock(PieceKind.Rook, 1) .Build(); @@ -261,7 +261,7 @@ public class SolvabilityTests // 5x2: chain of 3 rooks, first adjacent to Wood production. // All should inherit Wood filter via relay chain propagation. var level = new BoardBuilder(5, 2) - .WithProduction(0, 0, "Scierie", CargoType.Wood, 1) + .WithProduction(0, 0, "Scierie", CargoType.Wood) .WithDemand(4, 0, "Depot", CargoType.Wood, 2, 40) .WithStock(PieceKind.Rook, 3) .Build(); @@ -282,7 +282,7 @@ public class SolvabilityTests { // Stepping from Edit phase should auto-start without needing Start command. var level = new BoardBuilder(3, 1) - .WithProduction(0, 0, "P", CargoType.Wood, 1) + .WithProduction(0, 0, "P", CargoType.Wood) .WithDemand(2, 0, "D", CargoType.Wood, 1, 30) .WithStock(PieceKind.Rook, 1) .Build(); diff --git a/docs/GDD_prototype.md b/docs/GDD_prototype.md index 84d3e59..1c55e8a 100644 --- a/docs/GDD_prototype.md +++ b/docs/GDD_prototype.md @@ -51,8 +51,8 @@ Le plateau est un damier avec des cases claires et sombres alternees. Chaque cas | **Case claire** | Carre clair du damier | Traversable normalement | | **Case sombre** | Carre sombre du damier | Traversable (les Fous sont limites a une couleur) | | **Mur** | Case barree, gris fonce | Bloque toutes les pieces sauf le Cavalier (qui saute) | -| **Production** | Icone ressource + nom (ex: "Scierie") | Produit 1 cargaison tous les N coups. Donne automatiquement a une piece adjacente disponible. | -| **Demande** | Icone cible + nom (ex: "Depot") + jauge | Recoit la cargaison d'une piece adjacente qui arrive avec un colis compatible. | +| **Production** | Icone ressource + nom (ex: "Scierie") | Produit M cargaisons tous les N coups (M=1 dans le proto, futur: 2-4). Le buffer est ecrase a chaque production — les cargaisons non recuperees sont perdues. Donne automatiquement aux pieces adjacentes disponibles. | +| **Demande** | Icone cible + nom (ex: "Depot") + jauge | Recoit la cargaison d'une piece adjacente qui arrive avec un colis compatible. Accepte toujours un colis du bon type, meme si l'objectif est deja atteint. | ### 2.3 Cargaison @@ -85,7 +85,9 @@ C'est tout. Pas de programmation, pas de route multi-etapes. La piece fait l'all ### 3.2 Pieces disponibles dans le prototype -3 types, niveau unique : +3 types. Chaque piece a un **niveau** (I, II, III…) qui determine sa puissance relative au sein d'un meme type. Dans le prototype, toutes les pieces sont de niveau fixe — le systeme de niveaux sera exploite dans les versions futures. + +3 types : #### Tour (niveau II) @@ -130,13 +132,15 @@ C'est tout. Pas de programmation, pas de route multi-etapes. La piece fait l'all - **Saute par-dessus** les murs et les autres pieces - Statut social : **3** -> A statut egal (Fou et Cavalier = 3), la piece la plus proche de la production a la priorite. Si egalite parfaite, la piece la plus anciennement placee a la priorite. +> A statut egal, la piece de **niveau le plus eleve** est consideree superieure (ex: Tour II > Tour I, Fou III > Cavalier II). En cas d'egalite parfaite, le departage se fait par **direction en sens horaire** depuis la piece qui donne (voir 4.3). -### 3.3 Occupation et blocage +### 3.3 Occupation et collision - Chaque piece **occupe sa case actuelle** (depart ou arrivee selon le coup) - Une piece bloque le passage des autres pieces (sauf le Cavalier qui saute) -- Deux pieces ne peuvent **jamais** occuper la meme case au meme coup +- Si deux pieces arrivent sur la meme case apres le mouvement, la piece de **statut le plus eleve** detruit l'autre. A statut egal, le **niveau** departage. A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle**. +- La piece survivante reste sur la case avec sa cargaison intacte. La cargaison des pieces detruites est perdue. +- Les pieces detruites sont restaurees quand le joueur arrete la simulation (retour en mode edition). --- @@ -159,13 +163,15 @@ Un transfert se produit quand, a la **fin d'un coup** (une fois que toutes les p Le transfert est **instantane** : le colis passe d'une entite a l'autre entre deux coups. -### 4.3 Priorite par statut social +### 4.3 Priorite et departage -Quand plusieurs transferts sont possibles au meme point, le **statut social** determine l'ordre : +Quand plusieurs transferts sont possibles au meme point, la priorite determine l'ordre : -**Regle de don** : parmi les pieces avec colis adjacentes a un meme receveur, celle de **statut le plus eleve donne en premier**. +**Chaine de priorite** : statut social (desc) → niveau de piece (desc) → direction en sens horaire. -**Regle de reception** : parmi les pieces sans colis adjacentes a un meme donneur, celle de **statut le plus eleve recoit en premier**. +**Regle de don** : parmi les pieces avec colis adjacentes a un meme receveur, celle de priorite la plus elevee donne en premier. + +**Regle de reception** : parmi les pieces sans colis adjacentes a un meme donneur, celle de priorite la plus elevee recoit en premier. ``` Hierarchie de statut social (proto) : @@ -174,6 +180,12 @@ Hierarchie de statut social (proto) : Cavalier 3 ``` +**Departage par direction** (en y-up, sens horaire) : +- Coups pairs : priorite depuis 0° (droite) → droite, haut, gauche, bas +- Coups impairs : priorite depuis 180° (gauche) → gauche, bas, droite, haut + +Cette alternance empeche un biais permanent vers une direction et cree des patterns de routage dynamiques. + **Exemple** : ``` Tour (colis) ─adjacent─ case vide ─adjacent─ Cavalier (vide) @@ -217,30 +229,33 @@ Gerer l'espace sur le plateau pour eviter les interferences EST le puzzle. Le jo ### 5.1 Sequence d'un coup -A chaque coup, dans cet ordre : +A chaque coup, toutes les pieces jouent chaque etape **simultanement**, dans cet ordre : ``` -1. MOUVEMENT : toutes les pieces bougent simultanement +1. PRODUCTION : les cases de production remplissent leur buffer + (M cargaisons, ecrase le buffer precedent — les restes sont perdus) + +2. TRANSFERTS : tous les transferts automatiques se resolvent + (productions → pieces, pieces → pieces, pieces → demandes) + En respectant la chaine de priorite (statut → niveau → direction) + +3. MOUVEMENT : toutes les pieces bougent simultanement (chaque piece avance de Depart→Arrivee ou de Arrivee→Depart) -2. DETECTION DE COLLISION : si deux pieces sont sur la meme case → erreur - -3. TRANSFERTS : tous les transferts automatiques se resolvent - (productions → pieces, pieces → pieces, pieces → demandes) - En respectant l'ordre de statut social - -4. PRODUCTION : les cases de production generent un colis - (si elles n'en ont pas deja un en attente) +4. RESOLUTION DE COLLISION : si deux pieces sont sur la meme case, + la plus forte detruit les autres (voir 3.3) ``` ### 5.2 Collisions -Deux pieces ne peuvent pas occuper la meme case au meme coup. Si cela arrive : -- Les deux pieces clignotent en rouge -- La simulation se met en **pause** -- Le joueur doit reorganiser ses pieces (revenir en mode edition) +Quand deux pieces ou plus occupent la meme case apres le mouvement : +- La piece de **statut le plus eleve** survit, les autres sont **detruites** +- A statut egal, le **niveau** departage (niveau superieur survit) +- A egalite parfaite (meme statut ET meme niveau), **destruction mutuelle** (aucun survivant) +- La piece survivante conserve sa cargaison. Les cargaisons detruites sont perdues. +- La simulation **continue** (pas de pause automatique) -Les collisions sont le signal que les chaines sont mal agencees. Le joueur doit repenser l'espacement ou le timing (pieces de portees differentes arrivent a des moments differents). +Les collisions deviennent un element strategique — le joueur doit anticiper les croisements de trajectoires et espacer ses chaines pour les eviter. ### 5.3 Condition de victoire @@ -673,7 +688,13 @@ Chessistics/ |----------|---------|----------------| | Adjacence pour transfert ? | 4-connecte (bords) vs 8-connecte (bords + coins) | **4-connecte** — plus de contrainte = plus de puzzle | | La piece tourne a vide ? | Oui (aller-retour permanent) vs attend un colis | **Oui, tourne en permanence** — plus visuel, plus simple | -| Collision = erreur stricte ? | Stricte (pause) vs tolerante | **Stricte** — le joueur voit et corrige. Plus simple a implementer. | +| Collision = destruction ? | Destruction (fort mange faible) vs erreur stricte (pause) | **Destruction** — la piece de plus haut statut/niveau survit, les autres sont detruites. Destruction mutuelle si egalite parfaite. | | Sources infinies ? | Oui (production periodique sans stock) vs stock limite | **Production periodique infinie** — le proto teste le reseau, pas la gestion de stock | | Pieces fixes par niveau ? | Fixes (catalogue impose) vs achat libre | **Fixes** — plus facile a designer. L'achat/fabrication est post-proto. | -| Egalite de statut social ? | Proximite > anciennete | **Proximite puis anciennete** — intuitif, pas de regle arbitraire | +| Egalite de statut social ? | Niveau > direction horaire | **Niveau puis direction horaire alternee** (pairs: depuis droite, impairs: depuis gauche) — deterministe, pas de biais permanent | + +--- + +## 12. Lore + +Les pions sont en manque d'un roi. Ils vont se mettre a produire toutes les pieces intermediaires car ils en ont besoin pour aller chercher plus loin les ressources requises pour fabriquer le roi. A la fin, le roi execute tout le monde. Game over. diff --git a/project.godot b/project.godot index 55e6bb3..7a74924 100644 --- a/project.godot +++ b/project.godot @@ -15,6 +15,11 @@ run/main_scene="res://Scenes/Main.tscn" config/features=PackedStringArray("4.6", "C#", "GL Compatibility") config/icon="res://icon.svg" +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=720 + [dotnet] project/assembly_name="Chessistics"