Compare commits

..

5 commits

Author SHA1 Message Date
e1218b3eaa Normalize indentation in level_01.json
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:52:07 +02:00
4afe20842e Add levels 4-6 to level selector, use grid layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:39:06 +02:00
672b831fc1 Add Pion piece, surplus stock, and levels 4-6
- Pion (Pawn): orthogonal range 1, social status 1 (lowest), green color
- All existing levels get surplus stock including pawns for player choice
- Level 4 "Le Carrefour": 8x8, dual cargo diagonal routes, center wall block
- Level 5 "Le Labyrinthe": 8x6, wall corridors, knights essential for shortcuts
- Level 6 "Trois Royaumes": 10x8, 3 productions + 3 demands, full network puzzle
- Production interval concept removed (all produce every turn)
- GDD updated with Pion section and 6 level descriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:03:36 +02:00
a7280b1a5a Overhaul turn mechanics, collision destruction, and visual animations
- New turn order: produce -> transfer -> move -> collision resolution
- Collisions now destroy weaker pieces (status > level > mutual destruction)
  instead of halting the simulation. SimPhase.Collision removed.
- Add piece Level property (all level 1 in proto, prepared for future)
- Production fires every turn (interval concept removed), buffer = Amount
  (default 1, future 2-4), leftovers overwritten each turn
- Transfer tiebreaker: status > level > clockwise direction (alternating
  even/odd turns in y-up coords), replaces distance-to-production
- Demands always accept matching cargo even when already satisfied
- TurnNumber added to all turn events for animation grouping
- Simultaneous animations: produce flash, cargo slide, parallel piece moves
- Camera centering fix + middle-click pan
- GDD updated with new rules + lore section added

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:44:12 +02:00
dd43df8820 Add technical files 2026-04-10 17:14:09 +02:00
99 changed files with 1963 additions and 876 deletions

91
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,91 @@
FROM node:20
ARG TZ
ENV TZ="$TZ"
ARG CLAUDE_CODE_VERSION=latest
# Install basic development tools and iptables/ipset
RUN apt-get update && apt-get install -y --no-install-recommends \
less \
git \
procps \
sudo \
fzf \
zsh \
man-db \
unzip \
gnupg2 \
gh \
iptables \
ipset \
iproute2 \
dnsutils \
aggregate \
jq \
nano \
vim \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Ensure default node user has access to /usr/local/share
RUN mkdir -p /usr/local/share/npm-global && \
chown -R node:node /usr/local/share
ARG USERNAME=node
# Persist bash history.
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
&& mkdir /commandhistory \
&& touch /commandhistory/.bash_history \
&& chown -R $USERNAME /commandhistory
# Set `DEVCONTAINER` environment variable to help with orientation
ENV DEVCONTAINER=true
# Create workspace and config directories and set permissions
RUN mkdir -p /workspace /home/node/.claude && \
chown -R node:node /workspace /home/node/.claude
WORKDIR /workspace
ARG GIT_DELTA_VERSION=0.18.2
RUN ARCH=$(dpkg --print-architecture) && \
wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
sudo dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
# Set up non-root user
USER node
# Install global packages
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Set the default shell to zsh rather than sh
ENV SHELL=/bin/zsh
# Set the default editor and visual
ENV EDITOR=nano
ENV VISUAL=nano
# Default powerline10k theme
ARG ZSH_IN_DOCKER_VERSION=1.2.0
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
-p git \
-p fzf \
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
-x
# Install Claude
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
# Copy and set up firewall script
COPY init-firewall.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/init-firewall.sh && \
echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \
chmod 0440 /etc/sudoers.d/node-firewall
USER node

View file

@ -0,0 +1,30 @@
{
"name": "Claude Code Sandbox",
"build": {
"dockerfile": "Dockerfile",
"args": {
"TZ": "${localEnv:TZ:Europe/Paris}",
"CLAUDE_CODE_VERSION": "latest",
"GIT_DELTA_VERSION": "0.18.2",
"ZSH_IN_DOCKER_VERSION": "1.2.0"
}
},
"runArgs": [
"--cap-add=NET_ADMIN",
"--cap-add=NET_RAW"
],
"remoteUser": "node",
"mounts": [
"source=claude-code-bashhistory-${devcontainerId},target=/commandhistory,type=volume",
"source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume"
],
"containerEnv": {
"NODE_OPTIONS": "--max-old-space-size=4096",
"CLAUDE_CONFIG_DIR": "/home/node/.claude",
"POWERLEVEL9K_DISABLE_GITSTATUS": "true"
},
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
"waitFor": "postStartCommand"
}

View file

@ -0,0 +1,134 @@
#!/bin/bash
set -euo pipefail # Exit on error, undefined vars, and pipeline failures
IFS=$'\n\t' # Stricter word splitting
# 1. Extract Docker DNS info BEFORE any flushing
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
# Flush existing rules and delete existing ipsets
iptables -F
iptables -X
iptables -t nat -F
iptables -t nat -X
iptables -t mangle -F
iptables -t mangle -X
ipset destroy allowed-domains 2>/dev/null || true
# 2. Selectively restore ONLY internal Docker DNS resolution
if [ -n "$DOCKER_DNS_RULES" ]; then
echo "Restoring Docker DNS rules..."
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
else
echo "No Docker DNS rules to restore"
fi
# First allow DNS and localhost before any restrictions
# Allow outbound DNS
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
# Allow inbound DNS responses
iptables -A INPUT -p udp --sport 53 -j ACCEPT
# Allow outbound SSH
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
# Allow inbound SSH responses
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
# Allow localhost
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
# Create ipset with CIDR support
ipset create allowed-domains hash:net
# Fetch GitHub meta information and aggregate + add their IP ranges
echo "Fetching GitHub IP ranges..."
gh_ranges=$(curl -s https://api.github.com/meta)
if [ -z "$gh_ranges" ]; then
echo "ERROR: Failed to fetch GitHub IP ranges"
exit 1
fi
if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then
echo "ERROR: GitHub API response missing required fields"
exit 1
fi
echo "Processing GitHub IPs..."
while read -r cidr; do
if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
echo "ERROR: Invalid CIDR range from GitHub meta: $cidr"
exit 1
fi
echo "Adding GitHub range $cidr"
ipset add allowed-domains "$cidr"
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
# Resolve and add other allowed domains
for domain in \
"registry.npmjs.org" \
"api.anthropic.com" \
"sentry.io" \
"statsig.anthropic.com" \
"statsig.com"; do
echo "Resolving $domain..."
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
if [ -z "$ips" ]; then
echo "ERROR: Failed to resolve $domain"
exit 1
fi
while read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
echo "ERROR: Invalid IP from DNS for $domain: $ip"
exit 1
fi
echo "Adding $ip for $domain"
ipset add allowed-domains "$ip"
done < <(echo "$ips")
done
# Get host IP from default route
HOST_IP=$(ip route | grep default | cut -d" " -f3)
if [ -z "$HOST_IP" ]; then
echo "ERROR: Failed to detect host IP"
exit 1
fi
HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
echo "Host network detected as: $HOST_NETWORK"
# Set up remaining iptables rules
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
# Set default policies to DROP first
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
# First allow established connections for already approved traffic
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Then allow only specific outbound traffic to allowed domains
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# Explicitly REJECT all other outbound traffic for immediate feedback
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
echo "Firewall configuration complete"
echo "Verifying firewall rules..."
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - was able to reach https://example.com"
exit 1
else
echo "Firewall verification passed - unable to reach https://example.com as expected"
fi
# Verify GitHub API access
if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
exit 1
else
echo "Firewall verification passed - able to reach https://api.github.com as expected"
fi

1
.gitignore vendored
View file

@ -20,3 +20,4 @@ Thumbs.db
# Claude Code
.claude/
.idea

26
CLAUDE.md Normal file
View file

@ -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.

View file

@ -5,13 +5,14 @@
"width": 4,
"height": 4,
"productions": [
{ "col": 0, "row": 0, "name": "Scierie", "cargo": "wood", "interval": 2 }
{ "col": 0, "row": 0, "name": "Scierie", "cargo": "wood" }
],
"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": "pawn", "count": 4 },
{ "kind": "rook", "count": 2 }
]
}

View file

@ -5,7 +5,7 @@
"width": 6,
"height": 6,
"productions": [
{ "col": 0, "row": 0, "name": "Scierie", "cargo": "wood", "interval": 2 }
{ "col": 0, "row": 0, "name": "Scierie", "cargo": "wood" }
],
"demands": [
{ "col": 5, "row": 0, "name": "Depot Royal", "cargo": "wood", "amount": 2, "deadline": 30 },
@ -13,6 +13,7 @@
],
"walls": [],
"stock": [
{ "kind": "pawn", "count": 6 },
{ "kind": "rook", "count": 4 },
{ "kind": "bishop", "count": 1 }
]

View file

@ -5,8 +5,8 @@
"width": 6,
"height": 6,
"productions": [
{ "col": 0, "row": 0, "name": "Scierie", "cargo": "wood", "interval": 2 },
{ "col": 5, "row": 0, "name": "Carriere", "cargo": "stone", "interval": 2 }
{ "col": 0, "row": 0, "name": "Scierie", "cargo": "wood" },
{ "col": 5, "row": 0, "name": "Carriere", "cargo": "stone" }
],
"demands": [
{ "col": 5, "row": 5, "name": "Depot Royal", "cargo": "wood", "amount": 2, "deadline": 40 },
@ -20,7 +20,8 @@
{ "col": 4, "row": 4 }
],
"stock": [
{ "kind": "rook", "count": 4 },
{ "kind": "pawn", "count": 6 },
{ "kind": "rook", "count": 6 },
{ "kind": "bishop", "count": 1 },
{ "kind": "knight", "count": 2 }
]

27
Data/levels/level_04.json Normal file
View file

@ -0,0 +1,27 @@
{
"id": 4,
"name": "Le Carrefour",
"description": "Deux productions, deux demandes, et un carrefour au centre.",
"width": 8,
"height": 8,
"productions": [
{ "col": 0, "row": 0, "name": "Scierie", "cargo": "wood" },
{ "col": 7, "row": 7, "name": "Carriere", "cargo": "stone" }
],
"demands": [
{ "col": 7, "row": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3, "deadline": 40 },
{ "col": 0, "row": 7, "name": "Forge", "cargo": "stone", "amount": 3, "deadline": 40 }
],
"walls": [
{ "col": 3, "row": 3 },
{ "col": 4, "row": 4 },
{ "col": 3, "row": 4 },
{ "col": 4, "row": 3 }
],
"stock": [
{ "kind": "pawn", "count": 8 },
{ "kind": "rook", "count": 4 },
{ "kind": "bishop", "count": 2 },
{ "kind": "knight", "count": 2 }
]
}

35
Data/levels/level_05.json Normal file
View file

@ -0,0 +1,35 @@
{
"id": 5,
"name": "Le Labyrinthe",
"description": "Un couloir etroit serpente a travers les murs. Seuls les cavaliers peuvent prendre des raccourcis.",
"width": 8,
"height": 6,
"productions": [
{ "col": 0, "row": 0, "name": "Scierie", "cargo": "wood" },
{ "col": 0, "row": 5, "name": "Carriere", "cargo": "stone" }
],
"demands": [
{ "col": 7, "row": 5, "name": "Depot Royal", "cargo": "wood", "amount": 3, "deadline": 50 },
{ "col": 7, "row": 0, "name": "Forge", "cargo": "stone", "amount": 3, "deadline": 50 }
],
"walls": [
{ "col": 2, "row": 0 },
{ "col": 2, "row": 1 },
{ "col": 2, "row": 2 },
{ "col": 2, "row": 3 },
{ "col": 4, "row": 2 },
{ "col": 4, "row": 3 },
{ "col": 4, "row": 4 },
{ "col": 4, "row": 5 },
{ "col": 6, "row": 0 },
{ "col": 6, "row": 1 },
{ "col": 6, "row": 2 },
{ "col": 6, "row": 3 }
],
"stock": [
{ "kind": "pawn", "count": 10 },
{ "kind": "rook", "count": 4 },
{ "kind": "bishop", "count": 2 },
{ "kind": "knight", "count": 3 }
]
}

35
Data/levels/level_06.json Normal file
View file

@ -0,0 +1,35 @@
{
"id": 6,
"name": "Trois Royaumes",
"description": "Trois productions, trois demandes. Gerez un reseau complet sans interferences.",
"width": 10,
"height": 8,
"productions": [
{ "col": 0, "row": 0, "name": "Scierie", "cargo": "wood" },
{ "col": 0, "row": 7, "name": "Carriere", "cargo": "stone" },
{ "col": 9, "row": 3, "name": "Scierie Est", "cargo": "wood" }
],
"demands": [
{ "col": 9, "row": 0, "name": "Depot Royal", "cargo": "wood", "amount": 3, "deadline": 50 },
{ "col": 9, "row": 7, "name": "Forge", "cargo": "stone", "amount": 3, "deadline": 50 },
{ "col": 4, "row": 7, "name": "Chantier", "cargo": "wood", "amount": 3, "deadline": 50 }
],
"walls": [
{ "col": 3, "row": 2 },
{ "col": 3, "row": 3 },
{ "col": 3, "row": 4 },
{ "col": 3, "row": 5 },
{ "col": 6, "row": 2 },
{ "col": 6, "row": 3 },
{ "col": 6, "row": 4 },
{ "col": 6, "row": 5 },
{ "col": 4, "row": 3 },
{ "col": 5, "row": 3 }
],
"stock": [
{ "kind": "pawn", "count": 14 },
{ "kind": "rook", "count": 6 },
{ "kind": "bishop", "count": 3 },
{ "kind": "knight", "count": 4 }
]
}

24
PLAN.md
View file

@ -20,25 +20,13 @@
- GDD stock corrections: Level 2 = 6R+1B, Level 3 = 10R+2K
- 60 tests passing including 2 new CargoFilter tests
## Phase 3: Surplus stock and puzzle difficulty tuning
## Phase 3: Pion, surplus stock, levels 4-6 (DONE)
**Goal**: Levels give more pieces than the minimum, creating genuine puzzle space.
- With forward-preferring transfers working, longer chains are viable.
- Design levels where the player has choice: multiple valid solutions with different
efficiency scores (PiecesUsed, TurnsTaken, CellsOccupied).
- Add scoring/star system based on Metrics.
- Levels 4-6: increasing board size (8x8, 10x10), more complex wall layouts, multiple
productions and demands.
## Phase 4: New piece — Pion (Pawn)
**Goal**: Add a one-directional piece for asymmetric relay constraints.
- Pion moves forward only (one direction, range 1).
- Cheap to place (low piece cost if scoring is added).
- Creates interesting constraints: must plan direction of cargo flow.
- Test levels specifically designed around Pion usage.
- Pion: orthogonal range 1, status 1 (lowest), cheap relay maillon
- Surplus stock on all levels (more pieces than minimum solution)
- Levels 4-6: Le Carrefour (8x8), Le Labyrinthe (8x6), Trois Royaumes (10x8)
- Production interval removed: all productions fire every turn
- GDD updated with Pion, 6 levels
## Phase 5: Network levels and Dame (Queen)

View file

@ -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")

View file

@ -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<Coords, CellView> _cells = new();
private int _width;
private int _height;
private readonly Dictionary<Coords, CellView> _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<Coords> 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<Coords> cells, Color color)
{
foreach (var coords in cells)
{
if (_cells.TryGetValue(coords, out var cellView))
cellView.SetHighlightColor(color);
}
}
}

View file

@ -0,0 +1 @@
uid://njfxxe08s5w

View file

@ -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;
}
/// <summary>
/// Brief white flash on the cell to signal production.
/// </summary>
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));
}
}

View file

@ -0,0 +1 @@
uid://dt08gv2w0t3kj

View file

@ -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];

View file

@ -0,0 +1 @@
uid://cfpek0cba5h50

View file

@ -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>("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<IWorldEvent> events)
{
foreach (var evt in events)
{
switch (evt)
{
case PiecePlacedEvent placed:
CreatePieceVisual(placed);
UpdateStockFromSnapshot();
break;
case PieceRemovedEvent removed:
_eventAnimator.UnregisterPiece(removed.PieceId);
UpdateStockFromSnapshot();
_detailPanel.Hide();
break;
case PlacementRejectedEvent rejected:
GD.Print($"Placement rejected: {rejected.Reason}");
break;
case CommandRejectedEvent rejected:
GD.Print($"Command rejected: {rejected.Reason}");
break;
}
}
}
private void CreatePieceVisual(PiecePlacedEvent placed)
{
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", "level_04.json", "level_05.json", "level_06.json"];
private const float SidePanelWidth = 280f;
private const float ControlBarHeight = 48f;
private static readonly Color BackgroundColor = new("#2D2D2D");
public override void _Ready()
{
RenderingServer.SetDefaultClearColor(BackgroundColor);
BuildSceneTree();
ConnectSignals();
ShowLevelSelect();
}
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<IWorldEvent> events)
{
foreach (var evt in events)
{
switch (evt)
{
case PiecePlacedEvent placed:
CreatePieceVisual(placed);
UpdateStockFromSnapshot();
break;
case PieceRemovedEvent removed:
_eventAnimator.UnregisterPiece(removed.PieceId);
UpdateStockFromSnapshot();
_detailPanel.Hide();
break;
case PlacementRejectedEvent rejected:
GD.Print($"Placement rejected: {rejected.Reason}");
break;
case CommandRejectedEvent rejected:
GD.Print($"Command rejected: {rejected.Reason}");
break;
}
}
}
private void CreatePieceVisual(PiecePlacedEvent placed)
{
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();
}
}

1
Scripts/Main.cs.uid Normal file
View file

@ -0,0 +1 @@
uid://dygonjc0xhp15

View file

@ -15,6 +15,7 @@ public partial class PieceView : Node2D
public Coords StartCell { get; private set; }
public Coords EndCell { get; private set; }
private static readonly Color PawnColor = new("#7AB54A");
private static readonly Color RookColor = new("#4A7AB5");
private static readonly Color BishopColor = new("#B54A8E");
private static readonly Color KnightColor = new("#B5824A");
@ -32,6 +33,7 @@ public partial class PieceView : Node2D
var color = kind switch
{
PieceKind.Pawn => PawnColor,
PieceKind.Rook => RookColor,
PieceKind.Bishop => BishopColor,
PieceKind.Knight => KnightColor,
@ -57,6 +59,7 @@ public partial class PieceView : Node2D
{
Text = kind switch
{
PieceKind.Pawn => "P",
PieceKind.Rook => "T",
PieceKind.Bishop => "F",
PieceKind.Knight => "C",
@ -64,7 +67,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 +79,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);
}

View file

@ -0,0 +1 @@
uid://cqnkhh3lwxrcg

View file

@ -0,0 +1 @@
uid://d4mdddae1m0ia

View file

@ -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<CargoProducedEvent>();
var transferEvents = new List<IWorldEvent>();
var moveEvents = new List<PieceMovedEvent>();
var collisionEvents = new List<PieceDestroyedEvent>();
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<CargoProducedEvent> produceEvents,
List<IWorldEvent> transferEvents,
List<PieceMovedEvent> moveEvents,
List<PieceDestroyedEvent> 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();
}
}
/// <summary>
/// Creates a temporary colored square that slides from the giver to the receiver.
/// </summary>
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;

View file

@ -0,0 +1 @@
uid://cq8o11cyd42nr

View file

@ -0,0 +1 @@
uid://0lb2jejsmsyu

View file

@ -0,0 +1 @@
uid://b7pd55grwrmb7

View file

@ -12,99 +12,210 @@ public partial class LevelSelectScreen : Control
[
("Premier Convoi", "Acheminez du bois de la scierie au depot."),
("Deux Clients", "Fournissez deux destinations depuis une seule scierie."),
("Le Col", "Franchissez le mur et gerez deux types de cargaison.")
("Le Col", "Franchissez le mur et gerez deux types de cargaison."),
("Le Carrefour", "Deux productions, deux demandes, et un carrefour au centre."),
("Le Labyrinthe", "Un couloir etroit serpente a travers les murs."),
("Trois Royaumes", "Trois productions, trois demandes. Gerez un reseau complet.")
];
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 in a scrollable grid ---
var scroll = new ScrollContainer
{
SizeFlagsVertical = SizeFlags.ExpandFill,
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled
};
var grid = new GridContainer
{
Columns = 3,
SizeFlagsHorizontal = SizeFlags.ShrinkCenter,
MouseFilter = MouseFilterEnum.Ignore
};
grid.AddThemeConstantOverride("h_separation", 28);
grid.AddThemeConstantOverride("v_separation", 28);
for (int i = 0; i < _levels.Length; i++)
{
var (name, desc) = _levels[i];
var card = CreateLevelCard(i, name, desc);
grid.AddChild(card);
grid.AddChild(CreateLevelCard(i, name, desc));
}
vbox.AddChild(grid);
margin.AddChild(vbox);
panel.AddChild(margin);
AddChild(panel);
scroll.AddChild(grid);
outerVBox.AddChild(scroll);
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);

View file

@ -0,0 +1 @@
uid://dhg770rwx2gop

View file

@ -0,0 +1 @@
uid://dpi1a85m4gva8

View file

@ -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());

View file

@ -0,0 +1 @@
uid://c5q1qrjsbrp6m

View file

@ -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());
@ -92,6 +93,7 @@ public partial class PieceStockPanel : VBoxContainer
private static string GetPieceName(PieceKind kind) => kind switch
{
PieceKind.Pawn => "Pion",
PieceKind.Rook => "Tour II",
PieceKind.Bishop => "Fou II",
PieceKind.Knight => "Cavalier",

View file

@ -0,0 +1 @@
uid://b7vf8ury0hdts

View file

@ -0,0 +1 @@
uid://ci3ilv3dv7wxh

View file

@ -0,0 +1 @@
uid://qb3ybyegoh7k

View file

@ -0,0 +1 @@
uid://dhrp4e6pkpkym

View file

@ -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<IWorldEvent> 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<IWorldEvent> 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;

View file

@ -0,0 +1 @@
uid://blxykw7srkq2x

View file

@ -0,0 +1 @@
uid://cguse2y3ma1on

View file

@ -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;

View file

@ -0,0 +1 @@
uid://cq5huqxbjpw2a

View file

@ -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()
};
}
@ -52,6 +52,7 @@ public static class LevelLoader
private static PieceKind ParseKind(string kind) => kind.ToLowerInvariant() switch
{
"pawn" => PieceKind.Pawn,
"rook" => PieceKind.Rook,
"bishop" => PieceKind.Bishop,
"knight" => PieceKind.Knight,
@ -92,7 +93,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 +116,6 @@ public static class LevelLoader
{
public string Kind { get; set; } = "";
public int Count { get; set; }
public int Level { get; set; } = 1;
}
}

View file

@ -0,0 +1 @@
uid://bbajsqcri0wa1

View file

@ -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<PieceKind, int>(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);

View file

@ -0,0 +1 @@
uid://cnpyeaslw4mnb

View file

@ -10,7 +10,8 @@ public class BoardState
public Dictionary<Coords, ProductionDef> Productions { get; }
public Dictionary<Coords, DemandState> Demands { get; }
public List<PieceState> Pieces { get; }
public Dictionary<Coords, CargoType?> ProductionBuffers { get; }
public List<PieceState> DestroyedPieces { get; } = new();
public Dictionary<Coords, int> 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<Coords, ProductionDef>();
Demands = new Dictionary<Coords, DemandState>();
Pieces = new List<PieceState>();
ProductionBuffers = new Dictionary<Coords, CargoType?>();
ProductionBuffers = new Dictionary<Coords, int>();
RemainingStock = new Dictionary<PieceKind, int>();
OccupiedCells = new HashSet<Coords>();
@ -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)

View file

@ -0,0 +1 @@
uid://dq6vosmhy6ofr

View file

@ -0,0 +1 @@
uid://dmuxaq4gvi3v1

View file

@ -0,0 +1 @@
uid://dblei0vt3ulge

View file

@ -0,0 +1 @@
uid://cr4rqwl666m6i

View file

@ -0,0 +1 @@
uid://iv2oeeyht17f

View file

@ -0,0 +1 @@
uid://dwakj4uour8j0

View file

@ -0,0 +1 @@
uid://dxfk6kjtkl5th

View file

@ -0,0 +1 @@
uid://boa1klorcvwn7

View file

@ -2,6 +2,7 @@ namespace Chessistics.Engine.Model;
public enum PieceKind
{
Pawn,
Rook,
Bishop,
Knight

View file

@ -0,0 +1 @@
uid://b7ayb8mmmmvos

View file

@ -4,6 +4,7 @@ public static class PieceRules
{
public static int GetSocialStatus(PieceKind kind) => kind switch
{
PieceKind.Pawn => 1,
PieceKind.Rook => 5,
PieceKind.Bishop => 3,
PieceKind.Knight => 3,
@ -12,6 +13,7 @@ public static class PieceRules
public static int GetMaxRange(PieceKind kind) => kind switch
{
PieceKind.Pawn => 1,
PieceKind.Rook => 2,
PieceKind.Bishop => 2,
PieceKind.Knight => 0, // Knight uses L-shape, not range

View file

@ -0,0 +1 @@
uid://g4se3fjdvw40

View file

@ -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;

View file

@ -0,0 +1 @@
uid://cqcbtrhwqx1oq

View file

@ -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);

View file

@ -0,0 +1 @@
uid://iyhkgjgadvbq

View file

@ -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);

View file

@ -0,0 +1 @@
uid://3jsqpr5wfblc

View file

@ -5,7 +5,6 @@ public enum SimPhase
Edit,
Running,
Paused,
Collision,
Victory,
Defeat
}

View file

@ -0,0 +1 @@
uid://cmspgsp8mcvtd

View file

@ -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<PieceState> pieces)
{
var collisions = new List<(int, int, Coords)>();
var byCell = new Dictionary<Coords, List<PieceState>>();
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;
}
}

View file

@ -0,0 +1,55 @@
using Chessistics.Engine.Model;
namespace Chessistics.Engine.Rules;
public static class CollisionResolver
{
/// <summary>
/// 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.
/// </summary>
public static List<(PieceState? Survivor, List<PieceState> Destroyed, Coords Cell)> ResolveCollisions(
IReadOnlyList<PieceState> pieces)
{
var results = new List<(PieceState? Survivor, List<PieceState> Destroyed, Coords Cell)>();
var byCell = new Dictionary<Coords, List<PieceState>>();
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;
}
}

View file

@ -0,0 +1 @@
uid://ddl7yl8k7h4qp

View file

@ -19,6 +19,7 @@ public static class MoveValidator
return kind switch
{
PieceKind.Pawn => GetSlidingMoves(start, OrthogonalDirs, 1, board),
PieceKind.Rook => GetSlidingMoves(start, OrthogonalDirs, 2, board),
PieceKind.Bishop => GetSlidingMoves(start, DiagonalDirs, 2, board),
PieceKind.Knight => GetKnightMoves(start, board),

View file

@ -0,0 +1 @@
uid://danfmpxdyyc3w

View file

@ -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<PieceState> GetAdjacentPiecesWithoutCargo(
BoardState state, Coords position, HashSet<int> 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)
/// <summary>
/// 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°).
/// </summary>
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;
}
}

View file

@ -0,0 +1 @@
uid://c55a21x5tw5bo

View file

@ -0,0 +1 @@
uid://uh7qhohnsxpa

View file

@ -0,0 +1 @@
uid://c3aghlujtx44f

View file

@ -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
);

View file

@ -0,0 +1 @@
uid://c1bwhv57ykh2x

View file

@ -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;
}

View file

@ -0,0 +1 @@
uid://bowilag4t3fw7

View file

@ -0,0 +1 @@
uid://b6bmm28wyg8oq

View file

@ -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);

View file

@ -0,0 +1 @@
uid://c67yiwl47b1gq

View file

@ -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<PieceState>
{
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<PieceState>
{
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<PieceState>
{
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);
}
}

View file

@ -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<PieceState>
{
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<PieceState> { 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<PieceState>
{
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<PieceState> { 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<PieceState> { 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);
}
}

View file

@ -0,0 +1 @@
uid://dvjr7naqe8v28

View file

@ -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)

View file

@ -0,0 +1 @@
uid://rmx2djjvmcoj

View file

@ -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<CargoTransferredEvent>().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<CargoTransferredEvent>().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();

View file

@ -0,0 +1 @@
uid://7yqfkoottaie

View file

@ -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();

View file

@ -0,0 +1 @@
uid://bxu33tvxk3e0d

View file

@ -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<CargoProducedEvent>().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();

View file

@ -0,0 +1 @@
uid://dx5nefi0k4vpb

View file

@ -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();

View file

@ -0,0 +1 @@
uid://ciq3nnqbtumxg

View file

@ -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,22 @@ 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 :
4 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.
4 types :
#### Pion
```
X
X [Pion] X
X
```
- Se deplace de **1 case** en ligne droite (horizontal ou vertical)
- Ne peut pas traverser les murs ni les autres pieces
- Statut social : **1** (le plus bas — recoit et donne en dernier)
- Piece la moins chere, utile pour combler les maillons courts
#### Tour (niveau II)
@ -130,13 +145,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,21 +176,30 @@ 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) :
Tour 5
Fou 3
Cavalier 3
Pion 1
```
**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 +243,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
@ -406,7 +435,7 @@ Le joueur peut arreter la simulation a tout moment, reorganiser, et relancer.
---
## 8. Les 3 niveaux du prototype
## 8. Les 6 niveaux du prototype
### Niveau 1 — "Premier Convoi"
@ -559,6 +588,86 @@ Le Cavalier saute le mur en L. Il peut connecter les deux cotes du plateau la ou
---
### Niveau 4 — "Le Carrefour"
**Intention** : premier plateau 8x8, deux cargaisons en diagonale, un bloc de murs au centre force le contournement.
```
8 . . . . . . . .
7 [D2] . . . . . . . Forge — 3 Pierre en 40 coups
6 . . . . . . . .
5 . . . ## . . . .
4 . . . ## . . . .
3 . . . . . . . .
2 . . . . . . . .
1 [S1] . . . . . . [D1] Depot Royal — 3 Bois en 40 coups
a b c d e f g h
[S2] Carriere (h8)
```
- Plateau : **8x8**
- S1 = Scierie (a1, Bois), S2 = Carriere (h8, Pierre)
- D1 = Depot Royal (h1, 3 Bois/40c), D2 = Forge (a8, 3 Pierre/40c)
- Murs : bloc 2x2 au centre (d4, e5, d5, e4)
- Stock : 8 Pions, 4 Tours, 2 Fous, 2 Cavaliers
**L'enjeu** : les routes Bois (a1→h1) et Pierre (h8→a8) se croisent en diagonale. Le bloc central empeche la ligne droite. Le joueur decouvre le Pion comme maillon economique.
---
### Niveau 5 — "Le Labyrinthe"
**Intention** : des murs verticaux creent des couloirs etroits. Les Cavaliers sont essentiels pour enjamber les obstacles.
```
6 [S2] . # . # . # . Carriere (a6)
5 . . # . # . # .
4 . . # . # . . [D1] Depot Royal — 3 Bois en 50 coups
3 . . # . . . # .
2 . . . . # . # .
1 [S1] . . . # . . [D2] Forge — 3 Pierre en 50 coups
a b c d e f g h
```
- Plateau : **8x6**
- S1 = Scierie (a1, Bois), S2 = Carriere (a6, Pierre)
- D1 = Depot Royal (h6, 3 Bois/50c), D2 = Forge (h1, 3 Pierre/50c)
- Murs : 3 colonnes partielles formant un labyrinthe
- Stock : 10 Pions, 4 Tours, 2 Fous, 3 Cavaliers
**L'enjeu** : les murs fragmentent le plateau en couloirs. Seul le Cavalier saute par-dessus. Le joueur doit combiner Pions (relais courts dans les couloirs) et Cavaliers (ponts entre couloirs).
---
### Niveau 6 — "Trois Royaumes"
**Intention** : reseau a 3 productions et 3 demandes, plateau 10x8. Le joueur gere un vrai reseau logistique.
```
8 [S2] . . # . . # . . [D2] Forge — 3 Pierre en 50 coups
7 . . . # . . # . . .
6 . . . # ## . # . . .
5 . . . . . . . . . .
4 . . . # ## . # . . [S3] Scierie Est (j4, Bois)
3 . . . # . . # . . .
2 . . . . . . . . . .
1 [S1] . . . [D3] . . . . [D1] Depot Royal — 3 Bois en 50 coups
a b c d e f g h i j
```
- Plateau : **10x8**
- S1 = Scierie (a1, Bois), S2 = Carriere (a8, Pierre), S3 = Scierie Est (j4, Bois)
- D1 = Depot Royal (j1, 3 Bois/50c), D2 = Forge (j8, 3 Pierre/50c), D3 = Chantier (e8, 3 Bois/50c)
- Murs : deux colonnes avec pont horizontal
- Stock : 14 Pions, 6 Tours, 3 Fous, 4 Cavaliers
**L'enjeu** : le joueur doit decider comment repartir les productions entre les demandes. S1 et S3 produisent du Bois mais sont loin l'une de l'autre. Les murs creent trois "royaumes" et le joueur doit construire un reseau d'interconnexion.
---
## 9. Direction artistique (prototype)
Le prototype vise la lisibilite.
@ -673,7 +782,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.

View file

@ -12,9 +12,14 @@ config_version=5
config/name="Chessistics"
run/main_scene="res://Scenes/Main.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
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"