Chessistics/Scripts/Presentation/EventAnimator.cs
Samuel Bouchet c4f6ecbf44 Add collision camera pan/zoom and toast notification
EventAnimator now emits CollisionOccurred at the end of the collision
phase, carrying the struck cell and victim/destroyer identity. Main pans
and zooms the camera onto the cell over 0.45s and shows a fading toast
("Pion détruit par Tour — retourné au stock", or "collision mutuelle"
for same-status ties). The toast fades out after 3s and leaves the
camera framing the collision so the player can inspect the aftermath
before resuming.
2026-04-17 22:21:36 +02:00

486 lines
18 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 static readonly Color ToolsCargoColor = new("#C87533");
private static readonly Color ArmsCargoColor = new("#8B0000");
private static readonly Color GoldCargoColor = new("#FFD700");
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();
[Signal]
public delegate void MissionAdvancedEventHandler();
[Signal]
public delegate void CollisionOccurredEventHandler(int col, int row, string victimKind, int destroyerId);
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<PieceReturnedToStockEvent>();
// Pre-scan: if MissionStartedEvent follows MissionCompleteEvent, it's an auto-advance (not last mission)
bool hasAutoAdvance = events.Any(e => e is MissionStartedEvent);
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 CargoConvertedEvent converted:
// Visual flash on transformer cell (treat like a produce event for animation)
produceEvents.Add(new CargoProducedEvent(converted.TurnNumber, converted.TransformerCell, converted.OutputCargo));
break;
case CargoTransferredEvent:
case DemandProgressEvent:
transferEvents.Add(evt);
break;
case PieceMovedEvent moved:
moveEvents.Add(moved);
break;
case PieceReturnedToStockEvent returned:
collisionEvents.Add(returned);
break;
case MissionCompleteEvent:
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
tween.TweenCallback(Callable.From(() =>
{
SfxManager.Instance?.PlayVictory();
SpawnConfetti();
if (!hasAutoAdvance)
EmitSignal(SignalName.VictoryReached);
}));
break;
case MissionStartedEvent:
FlushPhases(tween, produceEvents, transferEvents, moveEvents, collisionEvents);
tween.TweenCallback(Callable.From(() =>
{
EmitSignal(SignalName.MissionAdvanced);
}));
break;
case SimulationPausedEvent:
// Auto-pause from collision — handled by FlushPhases
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<PieceReturnedToStockEvent> 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 — piece returned to stock (shrink + spin + particles)
if (collisionEvents.Count > 0)
{
var captured = collisionEvents.ToList();
tween.TweenCallback(Callable.From(() =>
{
SfxManager.Instance?.PlayDestroy();
foreach (var returned in captured)
{
if (_pieceViews.TryGetValue(returned.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);
}
}
// Emit signal for Main to pan camera + show toast
var first = captured[0];
EmitSignal(SignalName.CollisionOccurred,
first.Cell.Col, first.Cell.Row, first.Kind.ToString(),
first.DestroyerPieceId ?? -1);
}));
tween.TweenInterval(DestroyDuration);
tween.TweenCallback(Callable.From(() =>
{
foreach (var returned in captured)
UnregisterPiece(returned.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,
CargoType.Tools => ToolsCargoColor,
CargoType.Arms => ArmsCargoColor,
CargoType.Gold => GoldCargoColor,
_ => 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();
}
}