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);
+ }
}