feat(world): port retail portal-space viewport

Replace the opaque teleport cover with the DAT-authored CreatureMode tunnel, exact retail camera/light/animation timing and easing, and animation-hook audio routing. Compose portal and fade passes below retained UI so gameplay windows and input remain active through F751 travel.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 21:59:23 +02:00
parent 0eab7497c1
commit eab23cbdd1
14 changed files with 983 additions and 45 deletions

View file

@ -49,6 +49,14 @@ public sealed class TeleportAnimSequencer
public const float FadeTime = 1.0f;
public const float MinContinue = 2.0f;
public const float MaxContinue = 5.0f;
public const float TunnelFramesPerSecond = 40.0f;
public const int TunnelEndFrame = 120;
public const float ExitWindowLow = FadeTime + 0.1f;
public const float ExitWindowHigh = FadeTime + 0.3f;
// UIGlobals::Init @ 0x004EE470. Retail integrates 100 integer sine
// samples into a 0..1024 easing table.
private static readonly short[] RetailAnimationLevels = BuildRetailAnimationLevels();
private TeleportAnimState _state = TeleportAnimState.Off;
private float _elapsed = 0f; // time in current state
@ -80,7 +88,7 @@ public sealed class TeleportAnimSequencer
/// Returns the current snapshot + edge-triggered events fired THIS tick.
/// </summary>
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
Tick(float dt, bool worldReady)
Tick(float dt, bool worldReady, int tunnelAnimationFrame = 72)
{
var evts = new List<TeleportAnimEvent>();
@ -116,10 +124,15 @@ public sealed class TeleportAnimSequencer
case TeleportAnimState.TunnelContinue:
_continueElapsed += dt;
// worldReady stays true once the landblock loads, so this advances at the 2s
// minimum in the normal case; gating min-advance on worldReady keeps the
// MaxContinue safety net authoritative if readiness ever regresses mid-continue.
bool minMet = worldReady && _continueElapsed >= MinContinue;
// gmSmartBoxUI::UseTime @ 0x004D7264: after the two-second
// minimum, start the one-second fade only while the portal
// animation has 1.1..1.3 seconds left. Retail performs the
// frame subtraction as unsigned arithmetic.
uint remainingFrames = unchecked((uint)TunnelEndFrame - (uint)tunnelAnimationFrame);
float remainingSeconds = remainingFrames / TunnelFramesPerSecond;
bool minMet = _continueElapsed >= MinContinue
&& remainingSeconds > ExitWindowLow
&& remainingSeconds < ExitWindowHigh;
bool maxForce = _continueElapsed >= MaxContinue;
if (minMet || maxForce)
Advance(TeleportAnimState.TunnelFadeOut, enterTunnel: false);
@ -167,27 +180,60 @@ public sealed class TeleportAnimSequencer
return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait);
}
private static float Smoothstep(float t)
/// <summary>
/// Retail <c>UIGlobals::GetAnimLevel</c> (<c>0x004EE540</c>), returning
/// an integer level in the inclusive range 0..1024.
/// </summary>
public static short GetRetailAnimationLevel(float t)
{
t = Math.Clamp(t, 0f, 1f);
return t * t * (3f - 2f * t);
// x87 _ftol2 truncates -99*t toward zero, then retail subtracts the
// resulting negative word offset from the table base.
int negativeIndex = (int)Math.Truncate(-99.0 * t);
return RetailAnimationLevels[-negativeIndex];
}
private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed)
{
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
float level = GetRetailAnimationLevel(t) / 1024f;
return state switch
{
// Fading TO black (alpha 0→1):
TeleportAnimState.WorldFadeOut => Smoothstep(t),
TeleportAnimState.TunnelFadeIn => 1f - Smoothstep(t), // tunnel fades IN: overlay goes clear
TeleportAnimState.WorldFadeOut => level,
TeleportAnimState.TunnelFadeIn => 1f - level, // 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
TeleportAnimState.TunnelFadeOut => level, // tunnel fades out: overlay goes black
TeleportAnimState.WorldFadeIn => 1f - level, // world fades in: overlay clears
_ => 0f,
};
}
private static short[] BuildRetailAnimationLevels()
{
const double retailPi = 3.1415920000000002d;
const double reciprocal99 = 0.010101010101010102d;
const double scale = 1024.0d;
var samples = new short[100];
int total = 0;
for (int i = 0; i < samples.Length; i++)
{
short sample = (short)Math.Truncate(Math.Sin(i * retailPi * reciprocal99) * scale);
samples[i] = sample;
total += sample;
}
int running = 0;
for (int i = 0; i < samples.Length; i++)
{
running += samples[i];
samples[i] = (short)((running << 10) / total);
}
return samples;
}
}