2026-04-10 14:58:03 +02:00
|
|
|
using Godot;
|
|
|
|
|
using System;
|
|
|
|
|
using Chessistics.Engine.Model;
|
|
|
|
|
using Chessistics.Engine.Rules;
|
|
|
|
|
using Chessistics.Scripts.Board;
|
|
|
|
|
|
|
|
|
|
namespace Chessistics.Scripts.Input;
|
|
|
|
|
|
|
|
|
|
public partial class InputMapper : Node
|
|
|
|
|
{
|
|
|
|
|
[Signal]
|
|
|
|
|
public delegate void PlacementRequestedEventHandler(int kindIndex, int startCol, int startRow, int endCol, int endRow);
|
|
|
|
|
[Signal]
|
|
|
|
|
public delegate void RemovalRequestedEventHandler(int pieceId);
|
|
|
|
|
[Signal]
|
|
|
|
|
public delegate void CellClickedEventHandler(int col, int row);
|
|
|
|
|
[Signal]
|
|
|
|
|
public delegate void CancelledEventHandler();
|
2026-04-17 22:18:50 +02:00
|
|
|
[Signal]
|
|
|
|
|
public delegate void RelocateRequestedEventHandler(int pieceId, int newStartCol, int newStartRow, int newEndCol, int newEndRow);
|
2026-04-10 14:58:03 +02:00
|
|
|
|
|
|
|
|
public enum PlacementPhase { None, SelectingStart, SelectingEnd }
|
|
|
|
|
|
2026-04-17 22:18:50 +02:00
|
|
|
private const float DragThreshold = 8f;
|
|
|
|
|
|
2026-04-10 14:58:03 +02:00
|
|
|
private BoardView _boardView = null!;
|
|
|
|
|
private PieceKind? _selectedKind;
|
|
|
|
|
private Coords? _selectedStart;
|
|
|
|
|
private PlacementPhase _phase = PlacementPhase.None;
|
|
|
|
|
private BoardSnapshot? _snapshot;
|
2026-04-10 21:44:12 +02:00
|
|
|
private Coords? _hoverCoords;
|
2026-04-10 14:58:03 +02:00
|
|
|
|
2026-04-17 22:18:50 +02:00
|
|
|
// Drag & drop of a placed piece
|
|
|
|
|
private int? _dragPieceId;
|
|
|
|
|
private Vector2 _dragMouseStart;
|
|
|
|
|
private bool _dragging;
|
|
|
|
|
|
2026-04-10 14:58:03 +02:00
|
|
|
public PlacementPhase CurrentPhase => _phase;
|
|
|
|
|
|
|
|
|
|
public void Initialize(BoardView boardView)
|
|
|
|
|
{
|
|
|
|
|
_boardView = boardView;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:44:12 +02:00
|
|
|
public void SetSnapshot(BoardSnapshot snapshot)
|
|
|
|
|
{
|
|
|
|
|
GD.Print($"[InputMapper] SetSnapshot called — null? {snapshot == null}");
|
|
|
|
|
_snapshot = snapshot;
|
|
|
|
|
}
|
2026-04-10 14:58:03 +02:00
|
|
|
|
|
|
|
|
public void SelectPieceKind(PieceKind kind)
|
|
|
|
|
{
|
2026-04-10 21:44:12 +02:00
|
|
|
GD.Print($"[InputMapper] SelectPieceKind: {kind}, phase → SelectingStart");
|
2026-04-10 14:58:03 +02:00
|
|
|
_selectedKind = kind;
|
|
|
|
|
_selectedStart = null;
|
|
|
|
|
_phase = PlacementPhase.SelectingStart;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Cancel()
|
|
|
|
|
{
|
|
|
|
|
_selectedKind = null;
|
|
|
|
|
_selectedStart = null;
|
|
|
|
|
_phase = PlacementPhase.None;
|
|
|
|
|
_boardView.ClearHighlights();
|
|
|
|
|
EmitSignal(SignalName.Cancelled);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:44:12 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 14:58:03 +02:00
|
|
|
public override void _UnhandledInput(InputEvent @event)
|
|
|
|
|
{
|
2026-04-17 22:18:50 +02:00
|
|
|
if (@event is InputEventMouseButton mouseEvent && mouseEvent.ButtonIndex == MouseButton.Left)
|
2026-04-10 14:58:03 +02:00
|
|
|
{
|
2026-04-17 22:18:50 +02:00
|
|
|
var localPos = _boardView.GetLocalMousePosition();
|
|
|
|
|
if (mouseEvent.Pressed)
|
|
|
|
|
HandleLeftPress(localPos);
|
|
|
|
|
else
|
|
|
|
|
HandleLeftRelease(localPos);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (@event is InputEventMouseMotion && _dragPieceId != null)
|
|
|
|
|
{
|
|
|
|
|
var localPos = _boardView.GetLocalMousePosition();
|
|
|
|
|
UpdateDrag(localPos);
|
2026-04-10 14:58:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (@event is InputEventKey keyEvent && keyEvent.Pressed && keyEvent.Keycode == Key.Escape)
|
|
|
|
|
{
|
2026-04-17 22:18:50 +02:00
|
|
|
CancelDrag();
|
2026-04-10 14:58:03 +02:00
|
|
|
Cancel();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 22:18:50 +02:00
|
|
|
private void HandleLeftPress(Vector2 localPos)
|
|
|
|
|
{
|
|
|
|
|
// In placement mode, ignore drag behavior — click advances placement
|
|
|
|
|
if (_phase != PlacementPhase.None) return;
|
|
|
|
|
|
|
|
|
|
var coords = _boardView.PixelToCoords(localPos);
|
|
|
|
|
if (coords == null || _snapshot == null) return;
|
|
|
|
|
|
|
|
|
|
var piece = _snapshot.Pieces.FirstOrDefault(
|
|
|
|
|
p => p.StartCell == coords.Value || p.EndCell == coords.Value);
|
|
|
|
|
if (piece != null)
|
|
|
|
|
{
|
|
|
|
|
_dragPieceId = piece.Id;
|
|
|
|
|
_dragMouseStart = localPos;
|
|
|
|
|
_dragging = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void UpdateDrag(Vector2 localPos)
|
|
|
|
|
{
|
|
|
|
|
if (_dragPieceId == null) return;
|
|
|
|
|
|
|
|
|
|
if (!_dragging && (localPos - _dragMouseStart).Length() > DragThreshold)
|
|
|
|
|
{
|
|
|
|
|
_dragging = true;
|
|
|
|
|
HighlightLegalDropsFor(_dragPieceId.Value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void HandleLeftRelease(Vector2 localPos)
|
|
|
|
|
{
|
|
|
|
|
if (_dragging && _dragPieceId != null)
|
|
|
|
|
{
|
|
|
|
|
var dropCoords = _boardView.PixelToCoords(localPos);
|
|
|
|
|
TryRelocate(_dragPieceId.Value, dropCoords);
|
|
|
|
|
CancelDrag();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normal click flow
|
|
|
|
|
CancelDrag();
|
|
|
|
|
HandleLeftClick();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void CancelDrag()
|
|
|
|
|
{
|
|
|
|
|
_dragPieceId = null;
|
|
|
|
|
_dragging = false;
|
|
|
|
|
_boardView.ClearHighlights();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void HighlightLegalDropsFor(int pieceId)
|
|
|
|
|
{
|
|
|
|
|
var legal = ComputeLegalDrops(pieceId);
|
|
|
|
|
_boardView.ClearHighlights();
|
|
|
|
|
_boardView.HighlightCells(legal, new Color("#44FF88AA"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private List<Coords> ComputeLegalDrops(int pieceId)
|
|
|
|
|
{
|
|
|
|
|
var result = new List<Coords>();
|
|
|
|
|
if (_snapshot == null) return result;
|
|
|
|
|
|
|
|
|
|
var piece = _snapshot.Pieces.FirstOrDefault(p => p.Id == pieceId);
|
|
|
|
|
if (piece == null) return result;
|
|
|
|
|
|
|
|
|
|
var dc = piece.EndCell.Col - piece.StartCell.Col;
|
|
|
|
|
var dr = piece.EndCell.Row - piece.StartCell.Row;
|
|
|
|
|
|
|
|
|
|
var boardState = GetBoardStateForValidation();
|
|
|
|
|
if (boardState == null) return result;
|
|
|
|
|
|
|
|
|
|
for (int c = 0; c < _snapshot.Width; c++)
|
|
|
|
|
{
|
|
|
|
|
for (int r = 0; r < _snapshot.Height; r++)
|
|
|
|
|
{
|
|
|
|
|
var newStart = new Coords(c, r);
|
|
|
|
|
var newEnd = new Coords(c + dc, r + dr);
|
|
|
|
|
if (!newEnd.IsOnBoard(_snapshot.Width, _snapshot.Height)) continue;
|
|
|
|
|
if (_snapshot.Grid[newStart.Col, newStart.Row] == CellType.Wall) continue;
|
|
|
|
|
if (_snapshot.Grid[newEnd.Col, newEnd.Row] == CellType.Wall) continue;
|
|
|
|
|
if (!MoveValidator.IsLegalPlacement(piece.Kind, newStart, newEnd, boardState)) continue;
|
|
|
|
|
result.Add(newStart);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void TryRelocate(int pieceId, Coords? dropCoords)
|
|
|
|
|
{
|
|
|
|
|
if (_snapshot == null || dropCoords == null) return;
|
|
|
|
|
var piece = _snapshot.Pieces.FirstOrDefault(p => p.Id == pieceId);
|
|
|
|
|
if (piece == null) return;
|
|
|
|
|
|
|
|
|
|
var dc = piece.EndCell.Col - piece.StartCell.Col;
|
|
|
|
|
var dr = piece.EndCell.Row - piece.StartCell.Row;
|
|
|
|
|
|
|
|
|
|
var newStart = dropCoords.Value;
|
|
|
|
|
var newEnd = new Coords(newStart.Col + dc, newStart.Row + dr);
|
|
|
|
|
|
|
|
|
|
if (!newEnd.IsOnBoard(_snapshot.Width, _snapshot.Height)) return;
|
|
|
|
|
if (_snapshot.Grid[newStart.Col, newStart.Row] == CellType.Wall) return;
|
|
|
|
|
if (_snapshot.Grid[newEnd.Col, newEnd.Row] == CellType.Wall) return;
|
|
|
|
|
|
|
|
|
|
var boardState = GetBoardStateForValidation();
|
|
|
|
|
if (boardState == null) return;
|
|
|
|
|
if (!MoveValidator.IsLegalPlacement(piece.Kind, newStart, newEnd, boardState)) return;
|
|
|
|
|
|
|
|
|
|
EmitSignal(SignalName.RelocateRequested, pieceId,
|
|
|
|
|
newStart.Col, newStart.Row, newEnd.Col, newEnd.Row);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 21:44:12 +02:00
|
|
|
private void HandleLeftClick()
|
2026-04-10 14:58:03 +02:00
|
|
|
{
|
2026-04-10 21:44:12 +02:00
|
|
|
var localPos = _boardView.GetLocalMousePosition();
|
2026-04-10 14:58:03 +02:00
|
|
|
var coords = _boardView.PixelToCoords(localPos);
|
|
|
|
|
|
2026-04-10 21:44:12 +02:00
|
|
|
GD.Print($"[InputMapper] HandleLeftClick — localPos={localPos}, coords={coords}");
|
|
|
|
|
|
|
|
|
|
if (coords == null)
|
|
|
|
|
{
|
|
|
|
|
GD.Print("[InputMapper] coords is null — click outside board");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-10 14:58:03 +02:00
|
|
|
|
Add file-IPC automation harness for autonomous game testing
Launching Godot with --automation=<dir> activates an AutomationHarness
node that polls <dir>/inbox/ for JSON command files, executes them via
a thin facade over existing public surfaces (GameSim, InputMapper,
EventAnimator, ControlBar, PieceStockPanel), and writes results plus
screenshots back to disk. The black-box simulation boundary is not
crossed — every command routes through the same signals/methods a real
player would trigger.
A stdlib-only Python helper (tools/automation/harness.py) wraps the
protocol for test scripts and interactive REPLs. Smoke test passes
end-to-end: load mission, place a piece, step 10 turns, capture 14
1280x720 PNGs, handle rejections, quit cleanly. Existing 102 engine
unit tests still green.
2026-04-16 22:34:56 +02:00
|
|
|
HandleClickAt(coords.Value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void HandleClickAt(Coords coords)
|
|
|
|
|
{
|
2026-04-10 14:58:03 +02:00
|
|
|
switch (_phase)
|
|
|
|
|
{
|
|
|
|
|
case PlacementPhase.SelectingStart:
|
Add file-IPC automation harness for autonomous game testing
Launching Godot with --automation=<dir> activates an AutomationHarness
node that polls <dir>/inbox/ for JSON command files, executes them via
a thin facade over existing public surfaces (GameSim, InputMapper,
EventAnimator, ControlBar, PieceStockPanel), and writes results plus
screenshots back to disk. The black-box simulation boundary is not
crossed — every command routes through the same signals/methods a real
player would trigger.
A stdlib-only Python helper (tools/automation/harness.py) wraps the
protocol for test scripts and interactive REPLs. Smoke test passes
end-to-end: load mission, place a piece, step 10 turns, capture 14
1280x720 PNGs, handle rejections, quit cleanly. Existing 102 engine
unit tests still green.
2026-04-16 22:34:56 +02:00
|
|
|
OnStartSelected(coords);
|
2026-04-10 14:58:03 +02:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case PlacementPhase.SelectingEnd:
|
Add file-IPC automation harness for autonomous game testing
Launching Godot with --automation=<dir> activates an AutomationHarness
node that polls <dir>/inbox/ for JSON command files, executes them via
a thin facade over existing public surfaces (GameSim, InputMapper,
EventAnimator, ControlBar, PieceStockPanel), and writes results plus
screenshots back to disk. The black-box simulation boundary is not
crossed — every command routes through the same signals/methods a real
player would trigger.
A stdlib-only Python helper (tools/automation/harness.py) wraps the
protocol for test scripts and interactive REPLs. Smoke test passes
end-to-end: load mission, place a piece, step 10 turns, capture 14
1280x720 PNGs, handle rejections, quit cleanly. Existing 102 engine
unit tests still green.
2026-04-16 22:34:56 +02:00
|
|
|
OnEndSelected(coords);
|
2026-04-10 14:58:03 +02:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
default:
|
Add file-IPC automation harness for autonomous game testing
Launching Godot with --automation=<dir> activates an AutomationHarness
node that polls <dir>/inbox/ for JSON command files, executes them via
a thin facade over existing public surfaces (GameSim, InputMapper,
EventAnimator, ControlBar, PieceStockPanel), and writes results plus
screenshots back to disk. The black-box simulation boundary is not
crossed — every command routes through the same signals/methods a real
player would trigger.
A stdlib-only Python helper (tools/automation/harness.py) wraps the
protocol for test scripts and interactive REPLs. Smoke test passes
end-to-end: load mission, place a piece, step 10 turns, capture 14
1280x720 PNGs, handle rejections, quit cleanly. Existing 102 engine
unit tests still green.
2026-04-16 22:34:56 +02:00
|
|
|
EmitSignal(SignalName.CellClicked, coords.Col, coords.Row);
|
2026-04-10 14:58:03 +02:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add file-IPC automation harness for autonomous game testing
Launching Godot with --automation=<dir> activates an AutomationHarness
node that polls <dir>/inbox/ for JSON command files, executes them via
a thin facade over existing public surfaces (GameSim, InputMapper,
EventAnimator, ControlBar, PieceStockPanel), and writes results plus
screenshots back to disk. The black-box simulation boundary is not
crossed — every command routes through the same signals/methods a real
player would trigger.
A stdlib-only Python helper (tools/automation/harness.py) wraps the
protocol for test scripts and interactive REPLs. Smoke test passes
end-to-end: load mission, place a piece, step 10 turns, capture 14
1280x720 PNGs, handle rejections, quit cleanly. Existing 102 engine
unit tests still green.
2026-04-16 22:34:56 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// Same effect as a left/right click on a board cell, for automation.
|
|
|
|
|
/// Runs the exact branch HandleLeftClick runs (no InputEvent synthesis).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public void SimulateClick(Coords coords, MouseButton button)
|
|
|
|
|
{
|
|
|
|
|
if (button == MouseButton.Right)
|
|
|
|
|
{
|
|
|
|
|
Cancel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
HandleClickAt(coords);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 14:58:03 +02:00
|
|
|
private void OnStartSelected(Coords start)
|
|
|
|
|
{
|
2026-04-10 21:44:12 +02:00
|
|
|
if (_selectedKind == null || _snapshot == null)
|
|
|
|
|
{
|
|
|
|
|
GD.Print($"[InputMapper] OnStartSelected ABORT — kind={_selectedKind}, snapshot={(_snapshot != null ? "ok" : "null")}");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-10 14:58:03 +02:00
|
|
|
|
|
|
|
|
var boardState = GetBoardStateForValidation();
|
2026-04-10 21:44:12 +02:00
|
|
|
if (boardState == null)
|
|
|
|
|
{
|
|
|
|
|
GD.Print("[InputMapper] OnStartSelected ABORT — boardState is null");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-10 14:58:03 +02:00
|
|
|
|
|
|
|
|
var legalEnds = MoveValidator.GetLegalEndCells(_selectedKind.Value, start, boardState);
|
2026-04-10 21:44:12 +02:00
|
|
|
GD.Print($"[InputMapper] OnStartSelected({start}) — {legalEnds.Count} legal end cells");
|
|
|
|
|
|
2026-04-10 14:58:03 +02:00
|
|
|
if (legalEnds.Count == 0) return;
|
|
|
|
|
|
|
|
|
|
_selectedStart = start;
|
|
|
|
|
_phase = PlacementPhase.SelectingEnd;
|
|
|
|
|
|
|
|
|
|
_boardView.ClearHighlights();
|
|
|
|
|
_boardView.HighlightCells(legalEnds, new Color("#4488FF88"));
|
|
|
|
|
var startCell = _boardView.GetCellView(start);
|
|
|
|
|
startCell?.SetHighlightColor(new Color("#44FFFF44"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OnEndSelected(Coords end)
|
|
|
|
|
{
|
|
|
|
|
if (_selectedKind == null || _selectedStart == null) return;
|
|
|
|
|
|
|
|
|
|
var start = _selectedStart.Value;
|
|
|
|
|
var kind = _selectedKind.Value;
|
|
|
|
|
|
2026-04-10 21:44:12 +02:00
|
|
|
GD.Print($"[InputMapper] OnEndSelected — placing {kind} from {start} to {end}");
|
2026-04-10 14:58:03 +02:00
|
|
|
EmitSignal(SignalName.PlacementRequested, (int)kind, start.Col, start.Row, end.Col, end.Row);
|
|
|
|
|
|
|
|
|
|
// Reset placement state
|
|
|
|
|
_phase = PlacementPhase.None;
|
|
|
|
|
_selectedKind = null;
|
|
|
|
|
_selectedStart = null;
|
|
|
|
|
_boardView.ClearHighlights();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private BoardState? GetBoardStateForValidation()
|
|
|
|
|
{
|
|
|
|
|
if (_snapshot == null) return null;
|
|
|
|
|
|
|
|
|
|
var level = new LevelDef
|
|
|
|
|
{
|
|
|
|
|
Width = _snapshot.Width,
|
|
|
|
|
Height = _snapshot.Height,
|
|
|
|
|
Productions = [],
|
|
|
|
|
Demands = [],
|
|
|
|
|
Walls = [],
|
|
|
|
|
Stock = []
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var state = BoardState.FromLevel(level);
|
|
|
|
|
|
|
|
|
|
for (int c = 0; c < _snapshot.Width; c++)
|
|
|
|
|
for (int r = 0; r < _snapshot.Height; r++)
|
|
|
|
|
state.Grid[c, r] = _snapshot.Grid[c, r];
|
|
|
|
|
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
}
|