feat(core/slice-1): TeleportAnimSequencer — full 7-state Tick() with timed transitions and edge events
TunnelContinue exit gate: minMet requires worldReady (min-continue hold); maxForce fires unconditionally at MaxContinue (safety-net fallback when world never loads). This matches spec §3.4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0468df21f5
commit
c2fc7ce1ef
2 changed files with 169 additions and 1 deletions
|
|
@ -121,7 +121,7 @@ public sealed class TeleportAnimSequencer
|
||||||
|
|
||||||
case TeleportAnimState.TunnelContinue:
|
case TeleportAnimState.TunnelContinue:
|
||||||
_continueElapsed += dt;
|
_continueElapsed += dt;
|
||||||
bool minMet = _continueElapsed >= MinContinue;
|
bool minMet = worldReady && _continueElapsed >= MinContinue;
|
||||||
bool maxForce = _continueElapsed >= MaxContinue;
|
bool maxForce = _continueElapsed >= MaxContinue;
|
||||||
if (minMet || maxForce)
|
if (minMet || maxForce)
|
||||||
Advance(TeleportAnimState.TunnelFadeOut, enterTunnel: false);
|
Advance(TeleportAnimState.TunnelFadeOut, enterTunnel: false);
|
||||||
|
|
|
||||||
|
|
@ -68,4 +68,172 @@ public sealed class TeleportAnimSequencerTests
|
||||||
var (_, events) = seq.Tick(dt: 0f, worldReady: false);
|
var (_, events) = seq.Tick(dt: 0f, worldReady: false);
|
||||||
Assert.Contains(TeleportAnimEvent.PlayEnterSound, events);
|
Assert.Contains(TeleportAnimEvent.PlayEnterSound, events);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Task 1.3: State transitions — timing-correct Tick() ---
|
||||||
|
|
||||||
|
// Helper: drive the sequencer forward by total elapsed time using small fixed steps.
|
||||||
|
private static (TeleportAnimSnapshot snap, List<TeleportAnimEvent> allEvents)
|
||||||
|
DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f)
|
||||||
|
{
|
||||||
|
var allEvts = new List<TeleportAnimEvent>();
|
||||||
|
float remaining = seconds;
|
||||||
|
TeleportAnimSnapshot last = default;
|
||||||
|
while (remaining > 0f)
|
||||||
|
{
|
||||||
|
float dt = Math.Min(step, remaining);
|
||||||
|
var (snap, evts) = seq.Tick(dt, worldReady);
|
||||||
|
last = snap;
|
||||||
|
allEvts.AddRange(evts);
|
||||||
|
remaining -= dt;
|
||||||
|
}
|
||||||
|
return (last, allEvts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Logout_AfterFadeTime_TransitionsToTunnelFadeIn()
|
||||||
|
{
|
||||||
|
var seq = new TeleportAnimSequencer();
|
||||||
|
seq.Begin(TeleportEntryKind.Logout);
|
||||||
|
|
||||||
|
// Consume the enter-sound tick at dt=0
|
||||||
|
seq.Tick(0f, worldReady: false);
|
||||||
|
|
||||||
|
// Drive just past FadeTime (1.0s)
|
||||||
|
DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);
|
||||||
|
|
||||||
|
Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Logout_After2xFadeTime_TransitionsToTunnel()
|
||||||
|
{
|
||||||
|
var seq = new TeleportAnimSequencer();
|
||||||
|
seq.Begin(TeleportEntryKind.Logout);
|
||||||
|
seq.Tick(0f, worldReady: false); // consume enter-sound
|
||||||
|
|
||||||
|
DriveSeconds(seq, 2f * TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);
|
||||||
|
|
||||||
|
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Portal/Login/Death path: enters at Tunnel ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Portal_StartsInTunnel_HoldsWhileNotReady()
|
||||||
|
{
|
||||||
|
var seq = new TeleportAnimSequencer();
|
||||||
|
seq.Begin(TeleportEntryKind.Portal);
|
||||||
|
seq.Tick(0f, worldReady: false); // consume enter-sound
|
||||||
|
|
||||||
|
// Drive 10s — should not advance past Tunnel while !worldReady
|
||||||
|
DriveSeconds(seq, 10f, worldReady: false);
|
||||||
|
|
||||||
|
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Portal_WhenWorldReady_TransitionsToTunnelContinue_EmitsEnterTunnelAndPlace()
|
||||||
|
{
|
||||||
|
var seq = new TeleportAnimSequencer();
|
||||||
|
seq.Begin(TeleportEntryKind.Portal);
|
||||||
|
seq.Tick(0f, worldReady: false); // consume enter-sound
|
||||||
|
|
||||||
|
// The very first tick with worldReady=true should advance Tunnel→TunnelContinue and emit Place.
|
||||||
|
var (_, evts) = seq.Tick(0.016f, worldReady: true);
|
||||||
|
|
||||||
|
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
||||||
|
Assert.Contains(TeleportAnimEvent.Place, evts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TunnelContinue_DoesNotAdvance_BeforeMinContinue()
|
||||||
|
{
|
||||||
|
var seq = new TeleportAnimSequencer();
|
||||||
|
seq.Begin(TeleportEntryKind.Portal);
|
||||||
|
seq.Tick(0f, worldReady: false);
|
||||||
|
// Enter TunnelContinue
|
||||||
|
seq.Tick(0.016f, worldReady: true);
|
||||||
|
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
||||||
|
|
||||||
|
// Drive MinContinue - ε — should still be TunnelContinue
|
||||||
|
DriveSeconds(seq, TeleportAnimSequencer.MinContinue - 0.1f, worldReady: true);
|
||||||
|
|
||||||
|
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TunnelContinue_AdvancesToTunnelFadeOut_AfterMinContinue()
|
||||||
|
{
|
||||||
|
var seq = new TeleportAnimSequencer();
|
||||||
|
seq.Begin(TeleportEntryKind.Portal);
|
||||||
|
seq.Tick(0f, worldReady: false);
|
||||||
|
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
||||||
|
|
||||||
|
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
|
||||||
|
|
||||||
|
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MAX_CONTINUE forces progress even when !worldReady ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TunnelContinue_ForcesAdvance_AtMaxContinue_EvenIfNotReady()
|
||||||
|
{
|
||||||
|
// Simulate: world never becomes fully ready but MAX_CONTINUE forces fade-out
|
||||||
|
// This path exercises the safety-net from spec §3.4.
|
||||||
|
var seq = new TeleportAnimSequencer();
|
||||||
|
seq.Begin(TeleportEntryKind.Portal);
|
||||||
|
seq.Tick(0f, worldReady: false);
|
||||||
|
// Manually push the state to TunnelContinue by passing worldReady=true for one tick,
|
||||||
|
// then simulate worldReady toggling back to false.
|
||||||
|
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
||||||
|
|
||||||
|
// Drive MaxContinue + ε with worldReady=false (simulating "world never loaded")
|
||||||
|
DriveSeconds(seq, TeleportAnimSequencer.MaxContinue + 0.05f, worldReady: false);
|
||||||
|
|
||||||
|
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TunnelFadeOut_EmitsPlayExitSound_OnTransitionToWorldFadeIn()
|
||||||
|
{
|
||||||
|
var seq = new TeleportAnimSequencer();
|
||||||
|
seq.Begin(TeleportEntryKind.Portal);
|
||||||
|
seq.Tick(0f, worldReady: false);
|
||||||
|
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
||||||
|
|
||||||
|
// Drive through MinContinue -> TunnelFadeOut
|
||||||
|
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
|
||||||
|
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
|
||||||
|
|
||||||
|
// Drive through FadeTime -> WorldFadeIn; PlayExitSound should fire on that edge
|
||||||
|
var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
|
||||||
|
Assert.Equal(TeleportAnimState.WorldFadeIn, seq.State);
|
||||||
|
Assert.Contains(TeleportAnimEvent.PlayExitSound, evts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WorldFadeIn -> Off: FireLoginComplete edge event ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorldFadeIn_EmitsFireLoginComplete_OnTransitionToOff()
|
||||||
|
{
|
||||||
|
var seq = new TeleportAnimSequencer();
|
||||||
|
seq.Begin(TeleportEntryKind.Portal);
|
||||||
|
seq.Tick(0f, worldReady: false);
|
||||||
|
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
|
||||||
|
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
|
||||||
|
DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); // -> WorldFadeIn
|
||||||
|
|
||||||
|
var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
|
||||||
|
|
||||||
|
Assert.Equal(TeleportAnimState.Off, seq.State);
|
||||||
|
Assert.False(seq.IsActive);
|
||||||
|
Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue