diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs index d7980a78..7836e879 100644 --- a/src/AcDream.Core/World/TeleportAnimSequencer.cs +++ b/src/AcDream.Core/World/TeleportAnimSequencer.cs @@ -35,3 +35,164 @@ public readonly record struct TeleportAnimSnapshot( float FadeAlpha, // 0 = clear world, 1 = full black bool ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut bool ShowPleaseWait); // true during TunnelContinue only + +/// +/// Pure 7-state teleport animation machine (spec §2.1, acclient gmSmartBoxUI::UseTime 0x004d6e30). +/// No GL / dat / network dependency — fully unit-testable. +/// +public sealed class TeleportAnimSequencer +{ + // Golden constants (spec §2.1, verified VAs): + // TELEPORT_ANIM_FADE_TIME 0x007BD278 = 1.0s + // TELEPORT_ANIM_MIN_CONTINUE_TIME 0x007BD268 = 2.0s + // TELEPORT_ANIM_MAX_CONTINUE_TIME 0x007BD270 = 5.0s + public const float FadeTime = 1.0f; + public const float MinContinue = 2.0f; + public const float MaxContinue = 5.0f; + + private TeleportAnimState _state = TeleportAnimState.Off; + private float _elapsed = 0f; // time in current state + private float _continueElapsed = 0f; // tracks time inside TunnelContinue + private bool _enterSoundPending = false; + private bool _enterTunnelPending = false; + private bool _exitSoundPending = false; + private bool _loginCompletePending = false; + + public bool IsActive => _state != TeleportAnimState.Off; + public TeleportAnimState State => _state; + + /// + /// Start the animation. Portal/Login/Death enter at Tunnel (skipping world-fade-out); + /// Logout enters at WorldFadeOut (spec §2.5). + /// + public void Begin(TeleportEntryKind kind) + { + _state = kind == TeleportEntryKind.Logout + ? TeleportAnimState.WorldFadeOut + : TeleportAnimState.Tunnel; + _elapsed = 0f; + _continueElapsed = 0f; + _enterSoundPending = true; + _enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death + _exitSoundPending = false; + _loginCompletePending = false; + } + + /// + /// Advance the machine by seconds. + /// = destination collision landblock is resident. + /// Returns the current snapshot + edge-triggered events fired THIS tick. + /// + public (TeleportAnimSnapshot snapshot, IReadOnlyList events) + Tick(float dt, bool worldReady) + { + var evts = new List(); + + // Edge events queued by Begin or a prior transition: + if (_enterSoundPending) { evts.Add(TeleportAnimEvent.PlayEnterSound); _enterSoundPending = false; } + if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; } + if (_exitSoundPending) { evts.Add(TeleportAnimEvent.PlayExitSound); _exitSoundPending = false; } + if (_loginCompletePending) { evts.Add(TeleportAnimEvent.FireLoginComplete); _loginCompletePending = false; } + + _elapsed += dt; + + // State-machine transitions: + switch (_state) + { + case TeleportAnimState.WorldFadeOut: + if (_elapsed >= FadeTime) + Advance(TeleportAnimState.TunnelFadeIn, enterTunnel: false); + break; + + case TeleportAnimState.TunnelFadeIn: + if (_elapsed >= FadeTime) + Advance(TeleportAnimState.Tunnel, enterTunnel: true); + break; + + case TeleportAnimState.Tunnel: + // Hold here until worldReady (EndTeleportAnimation analogue). + if (worldReady) + { + evts.Add(TeleportAnimEvent.Place); + Advance(TeleportAnimState.TunnelContinue, enterTunnel: false); + _continueElapsed = 0f; + } + break; + + case TeleportAnimState.TunnelContinue: + _continueElapsed += dt; + bool minMet = _continueElapsed >= MinContinue; + bool maxForce = _continueElapsed >= MaxContinue; + if (minMet || maxForce) + Advance(TeleportAnimState.TunnelFadeOut, enterTunnel: false); + break; + + case TeleportAnimState.TunnelFadeOut: + if (_elapsed >= FadeTime) + { + Advance(TeleportAnimState.WorldFadeIn, enterTunnel: false); + // Flush exit sound in the same tick: + evts.Add(TeleportAnimEvent.PlayExitSound); + _exitSoundPending = false; + } + break; + + case TeleportAnimState.WorldFadeIn: + if (_elapsed >= FadeTime) + { + Advance(TeleportAnimState.Off, enterTunnel: false); + evts.Add(TeleportAnimEvent.FireLoginComplete); + _loginCompletePending = false; + } + break; + + case TeleportAnimState.Off: + default: + break; + } + + return (BuildSnapshot(), evts); + } + + private void Advance(TeleportAnimState next, bool enterTunnel) + { + _state = next; + _elapsed = 0f; + if (enterTunnel) _enterTunnelPending = true; + } + + private TeleportAnimSnapshot BuildSnapshot() + { + float alpha = ComputeFadeAlpha(_state, _elapsed); + bool showTunnel = _state is TeleportAnimState.TunnelFadeIn + or TeleportAnimState.Tunnel + or TeleportAnimState.TunnelContinue + or TeleportAnimState.TunnelFadeOut; + bool pleaseWait = _state == TeleportAnimState.TunnelContinue; + return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait); + } + + private static float Smoothstep(float t) + { + t = Math.Clamp(t, 0f, 1f); + return t * t * (3f - 2f * t); + } + + private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed) + { + float t = Math.Clamp(elapsed / FadeTime, 0f, 1f); + return state switch + { + // Fading TO black (alpha 0→1): + TeleportAnimState.WorldFadeOut => Smoothstep(t), + TeleportAnimState.TunnelFadeIn => 1f - Smoothstep(t), // tunnel fades IN: overlay goes clear + // Full black / fully clear inside tunnel states: + TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed + TeleportAnimState.TunnelContinue => 0f, + // Fading back out of tunnel: + TeleportAnimState.TunnelFadeOut => Smoothstep(t), // tunnel fades out: overlay goes black + TeleportAnimState.WorldFadeIn => 1f - Smoothstep(t), // world fades in: overlay clears + _ => 0f, + }; + } +} diff --git a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs index 355b22f7..425af9b2 100644 --- a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs +++ b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs @@ -40,4 +40,32 @@ public sealed class TeleportAnimSequencerTests Assert.False(snap.ShowTunnel); Assert.False(snap.ShowPleaseWait); } + + // --- Task 1.2: Begin(), IsActive, initial state --- + + [Theory] + [InlineData(TeleportEntryKind.Portal, TeleportAnimState.Tunnel)] + [InlineData(TeleportEntryKind.Login, TeleportAnimState.Tunnel)] + [InlineData(TeleportEntryKind.Death, TeleportAnimState.Tunnel)] + [InlineData(TeleportEntryKind.Logout, TeleportAnimState.WorldFadeOut)] + public void Begin_SetsCorrectStartState(TeleportEntryKind kind, TeleportAnimState expectedStart) + { + var seq = new TeleportAnimSequencer(); + Assert.False(seq.IsActive); + + seq.Begin(kind); + + Assert.True(seq.IsActive); + Assert.Equal(expectedStart, seq.State); + } + + [Fact] + public void Begin_EmitsPlayEnterSoundOnFirstTick() + { + // PlayEnterSound is edge-triggered on the first Tick after Begin. + var seq = new TeleportAnimSequencer(); + seq.Begin(TeleportEntryKind.Portal); + var (_, events) = seq.Tick(dt: 0f, worldReady: false); + Assert.Contains(TeleportAnimEvent.PlayEnterSound, events); + } }