Chessistics/Scripts/Presentation/EventAnimator.cs
Samuel Bouchet 450c069854 Juice pass: procedural SFX, particles, polished visuals
Sound (SfxManager.cs):
- Procedural audio synthesis via AudioStreamWav — no external files
- Distinct tones for place, produce, transfer, deliver, move, destroy, victory
- Simple ADSR envelope, sine/triangle waveforms, filtered noise for swooshes

Pieces (PieceView.cs):
- Warm earthy palette: sage green, deep teal, dusty rose, burnt sienna
- Drop shadow under each piece for depth
- 3-stop radial gradient (bright center → main → dark rim)
- Scale bounce on placement (0 → 1.15 → 1.0 with back-out easing)
- Cargo indicator pulses gently when carrying

Trajectories (TrajectView.cs):
- Arrowhead at endpoint showing movement direction
- Antialiased lines with piece-matched colors

Cells (CellView.cs):
- Warmer palette: parchment/walnut board, deep forest production, aged gold demand
- Production flash uses warm golden glow instead of white
- Subtle inner shadow for visual depth

Animations (EventAnimator.cs):
- Production: golden particles burst from production cells
- Transfer: cargo slides with 2-particle trail + back-out whip easing
- Destruction: pieces shrink + spin + red particle explosion
- Victory: 40 confetti particles rain across the screen
- All phases trigger appropriate SFX

UI polish:
- ControlBar: styled buttons with rounded corners, disabled states
- MetricsOverlay: fade-in + scale animation, sequential metric reveals
- ObjectivePanel: animated progress bars, styled fills, green flash on completion
- Main: fade-in/out transitions between level select and gameplay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:05:55 +02:00

450 lines
16 KiB
C#

using Godot;
using System;
using System.Collections.Generic;
using System.Linq;
using Chessistics.Engine.Events;
using Chessistics.Engine.Model;
using Chessistics.Scripts.Board;
using Chessistics.Scripts.Pieces;
using Chessistics.Scripts.UI;
namespace Chessistics.Scripts.Presentation;
public partial class EventAnimator : Node
{
private BoardView _boardView = null!;
private ObjectivePanel _objectivePanel = null!;
private ControlBar _controlBar = null!;
private MetricsOverlay _metricsOverlay = null!;
private readonly Dictionary<int, PieceView> _pieceViews = new();
private readonly Dictionary<int, TrajectView> _trajectViews = new();
private bool _animating;
public bool IsAnimating => _animating;
private static readonly Color WoodCargoColor = new("#A67C32");
private static readonly Color StoneCargoColor = new("#7A7A7A");
private const float ProduceDuration = 0.35f;
private const float TransferDuration = 0.28f;
private const float MoveDuration = 0.32f;
private const float KnightMoveDuration = 0.42f;
private const float DestroyDuration = 0.45f;
[Signal]
public delegate void TurnAnimationCompletedEventHandler();
[Signal]
public delegate void VictoryReachedEventHandler();
public void Initialize(BoardView boardView, ObjectivePanel objectivePanel,
ControlBar controlBar, MetricsOverlay metricsOverlay)
{
_boardView = boardView;
_objectivePanel = objectivePanel;
_controlBar = controlBar;
_metricsOverlay = metricsOverlay;
}
public void RegisterPiece(int pieceId, PieceView pieceView, TrajectView trajectView)
{
_pieceViews[pieceId] = pieceView;
_trajectViews[pieceId] = trajectView;
}
public void UnregisterPiece(int pieceId)
{
if (_pieceViews.TryGetValue(pieceId, out var pv))
{
pv.QueueFree();
_pieceViews.Remove(pieceId);
}
if (_trajectViews.TryGetValue(pieceId, out var tv))
{
tv.QueueFree();
_trajectViews.Remove(pieceId);
}
}
public void ProcessEvents(IReadOnlyList<IWorldEvent> events)
{
_animating = true;
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:
moveEvents.Add(moved);
break;
case PieceDestroyedEvent destroyed:
collisionEvents.Add(destroyed);
break;
case VictoryEvent victory:
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
tween.TweenCallback(Callable.From(() =>
{
SfxManager.Instance?.PlayVictory();
SpawnConfetti();
_metricsOverlay.ShowMetrics(victory.Metrics);
EmitSignal(SignalName.VictoryReached);
}));
break;
case TurnEndedEvent:
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
break;
default:
break;
}
}
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
tween.TweenCallback(Callable.From(() =>
{
_animating = false;
EmitSignal(SignalName.TurnAnimationCompleted);
}));
}
private void FlushPhases(
Tween tween,
List<CargoProducedEvent> produceEvents,
List<IWorldEvent> transferEvents,
List<PieceMovedEvent> moveEvents,
List<PieceDestroyedEvent> collisionEvents)
{
// Phase 1: Produce — warm golden flash + particle burst
if (produceEvents.Count > 0)
{
var captured = produceEvents.ToList();
tween.TweenCallback(Callable.From(() =>
{
SfxManager.Instance?.PlayProduce();
foreach (var evt in captured)
{
var cell = _boardView.GetCellView(evt.ProductionCell);
cell?.FlashProduce(ProduceDuration);
SpawnProduceParticles(evt.ProductionCell, evt.Type);
}
}));
tween.TweenInterval(ProduceDuration);
produceEvents.Clear();
}
// Phase 2: Transfers — cargo slides with trail particles
if (transferEvents.Count > 0)
{
var eventsToAnimate = transferEvents.ToList();
tween.TweenCallback(Callable.From(() =>
{
bool hasDelivery = false;
foreach (var evt in eventsToAnimate)
{
if (evt is CargoTransferredEvent transfer)
{
if (transfer.GivingPieceId != null && _pieceViews.ContainsKey(transfer.GivingPieceId.Value))
_pieceViews[transfer.GivingPieceId.Value].SetCargo(null);
SpawnCargoSlide(transfer);
if (transfer.ReceivingPieceId == null) hasDelivery = true;
}
}
if (hasDelivery)
SfxManager.Instance?.PlayDeliver();
else
SfxManager.Instance?.PlayTransfer();
}));
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 — simultaneous, with sfx
if (moveEvents.Count > 0)
{
tween.TweenCallback(Callable.From(() => SfxManager.Instance?.PlayMove()));
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)
.SetEase(Tween.EaseType.InOut).SetTrans(Tween.TransitionType.Sine);
}
}
tween.SetParallel(false);
moveEvents.Clear();
}
// Phase 4: Collision/Destruction — shrink + spin + particles
if (collisionEvents.Count > 0)
{
var captured = collisionEvents.ToList();
tween.TweenCallback(Callable.From(() =>
{
SfxManager.Instance?.PlayDestroy();
foreach (var destroyed in captured)
{
if (_pieceViews.TryGetValue(destroyed.PieceId, out var pv))
{
SpawnDestroyParticles(pv.Position);
var dt = pv.CreateTween();
dt.SetParallel(true);
dt.TweenProperty(pv, "scale", Vector2.Zero, DestroyDuration)
.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Back);
dt.TweenProperty(pv, "rotation", Mathf.Pi * 2f, DestroyDuration);
dt.TweenProperty(pv, "modulate:a", 0f, DestroyDuration);
}
}
}));
tween.TweenInterval(DestroyDuration);
tween.TweenCallback(Callable.From(() =>
{
foreach (var destroyed in captured)
UnregisterPiece(destroyed.PieceId);
}));
collisionEvents.Clear();
}
}
// --- Visual Effects ---
private void SpawnCargoSlide(CargoTransferredEvent transfer)
{
var from = _boardView.CoordsToPixel(transfer.From);
var to = _boardView.CoordsToPixel(transfer.To);
var color = GetCargoColor(transfer.Type);
var container = new Node2D { Position = from };
_boardView.AddChild(container);
// Main cargo square
var main = new ColorRect
{
Size = new Vector2(12, 12),
Position = new Vector2(-6, -6),
Color = color,
MouseFilter = Control.MouseFilterEnum.Ignore
};
container.AddChild(main);
// Trail particles (2 smaller squares that follow with delay)
for (int i = 1; i <= 2; i++)
{
float trailDelay = i * 0.04f;
float trailSize = 12f - i * 3f;
float trailAlpha = 0.6f - i * 0.2f;
var trail = new ColorRect
{
Size = new Vector2(trailSize, trailSize),
Position = new Vector2(-trailSize / 2f, -trailSize / 2f),
Color = new Color(color, trailAlpha),
MouseFilter = Control.MouseFilterEnum.Ignore
};
var trailContainer = new Node2D { Position = from };
trailContainer.AddChild(trail);
_boardView.AddChild(trailContainer);
var trailTween = trailContainer.CreateTween();
trailTween.TweenInterval(trailDelay);
trailTween.TweenProperty(trailContainer, "position", to, TransferDuration - trailDelay)
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);
trailTween.TweenCallback(Callable.From(() => trailContainer.QueueFree()));
}
// Main slide with whip easing
var slideTween = container.CreateTween();
slideTween.TweenProperty(container, "position", to, TransferDuration)
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Back);
slideTween.TweenCallback(Callable.From(() => container.QueueFree()));
}
private void SpawnProduceParticles(Coords cell, CargoType type)
{
var center = _boardView.CoordsToPixel(cell);
var color = GetCargoColor(type);
var rng = new Random();
for (int i = 0; i < 6; i++)
{
float angle = i * Mathf.Pi * 2f / 6f + (float)rng.NextDouble() * 0.5f;
float dist = 25f + (float)rng.NextDouble() * 15f;
float size = 4f + (float)rng.NextDouble() * 4f;
var particle = new ColorRect
{
Size = new Vector2(size, size),
Position = new Vector2(-size / 2f, -size / 2f),
Color = new Color(color, 0.8f),
MouseFilter = Control.MouseFilterEnum.Ignore
};
var pNode = new Node2D { Position = center };
pNode.AddChild(particle);
_boardView.AddChild(pNode);
var target = center + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * dist;
var pt = pNode.CreateTween();
pt.SetParallel(true);
pt.TweenProperty(pNode, "position", target, ProduceDuration * 0.8f)
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
pt.TweenProperty(pNode, "modulate:a", 0f, ProduceDuration);
pt.SetParallel(false);
pt.TweenCallback(Callable.From(() => pNode.QueueFree()));
}
}
private void SpawnDestroyParticles(Vector2 position)
{
var rng = new Random();
var red = new Color(0.9f, 0.25f, 0.2f);
for (int i = 0; i < 10; i++)
{
float angle = (float)rng.NextDouble() * Mathf.Pi * 2f;
float dist = 20f + (float)rng.NextDouble() * 30f;
float size = 3f + (float)rng.NextDouble() * 5f;
var particle = new ColorRect
{
Size = new Vector2(size, size),
Position = new Vector2(-size / 2f, -size / 2f),
Color = new Color(red, 0.9f),
MouseFilter = Control.MouseFilterEnum.Ignore
};
var pNode = new Node2D { Position = position };
pNode.AddChild(particle);
_boardView.AddChild(pNode);
var target = position + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * dist;
var pt = pNode.CreateTween();
pt.SetParallel(true);
pt.TweenProperty(pNode, "position", target, DestroyDuration * 0.7f)
.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Cubic);
pt.TweenProperty(pNode, "modulate:a", 0f, DestroyDuration);
pt.TweenProperty(pNode, "scale", Vector2.Zero, DestroyDuration)
.SetEase(Tween.EaseType.In);
pt.SetParallel(false);
pt.TweenCallback(Callable.From(() => pNode.QueueFree()));
}
}
private void SpawnConfetti()
{
var rng = new Random();
var viewport = GetViewport().GetVisibleRect().Size;
Color[] confettiColors = [
new("#FFD700"), new("#FF6B35"), new("#3D8E5A"),
new("#4A7AB5"), new("#B54A8E"), new("#E8D5A8")
];
for (int i = 0; i < 40; i++)
{
float x = (float)rng.NextDouble() * viewport.X;
float startY = -20f;
float endY = viewport.Y + 50f;
float size = 4f + (float)rng.NextDouble() * 6f;
float duration = 1.5f + (float)rng.NextDouble() * 1.5f;
float delay = (float)rng.NextDouble() * 0.5f;
float drift = ((float)rng.NextDouble() - 0.5f) * 80f;
var confetti = new ColorRect
{
Size = new Vector2(size, size * 0.5f),
Color = confettiColors[rng.Next(confettiColors.Length)],
MouseFilter = Control.MouseFilterEnum.Ignore
};
// Confetti needs to be in screen space (CanvasLayer)
var parent = GetTree().Root;
var cNode = new Node2D { Position = new Vector2(x, startY) };
cNode.AddChild(confetti);
parent.AddChild(cNode);
var ct = cNode.CreateTween();
ct.TweenInterval(delay);
ct.SetParallel(true);
ct.TweenProperty(cNode, "position", new Vector2(x + drift, endY), duration)
.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Sine);
ct.TweenProperty(cNode, "rotation", (float)rng.NextDouble() * Mathf.Pi * 4f, duration);
ct.SetParallel(false);
ct.TweenCallback(Callable.From(() => cNode.QueueFree()));
}
}
private static Color GetCargoColor(CargoType type) => type switch
{
CargoType.Wood => WoodCargoColor,
CargoType.Stone => StoneCargoColor,
_ => Colors.White
};
public void ResetPiecePositions(BoardSnapshot snapshot)
{
foreach (var ps in snapshot.Pieces)
{
if (_pieceViews.TryGetValue(ps.Id, out var pv))
{
pv.Position = _boardView.CoordsToPixel(ps.StartCell);
pv.SetCargo(null);
}
}
}
public void ClearAll()
{
foreach (var pv in _pieceViews.Values)
pv.QueueFree();
foreach (var tv in _trajectViews.Values)
tv.QueueFree();
_pieceViews.Clear();
_trajectViews.Clear();
}
}