Port retail portal viewport projection and reveal behavior, preserve outbound combat style, drive remote and local grounded movement from authored CSequence root frames, and reuse the local prepared pose so animation hooks advance once. User-verified portal, observer movement, combat stance, and short-tap locomotion gates. Release build passed with 5,767 tests and five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
274 lines
11 KiB
C#
274 lines
11 KiB
C#
namespace AcDream.Core.World;
|
|
|
|
// acclient.h:6871 — TeleportAnimState enum, verbatim order.
|
|
public enum TeleportAnimState
|
|
{
|
|
Off = 0,
|
|
WorldFadeOut = 1,
|
|
TunnelFadeIn = 2,
|
|
Tunnel = 3,
|
|
TunnelContinue = 4,
|
|
TunnelFadeOut = 5,
|
|
WorldFadeIn = 6,
|
|
}
|
|
|
|
/// <summary>Why the teleport was triggered — drives per-entry start state (spec §2.5).</summary>
|
|
public enum TeleportEntryKind { Portal, Login, Death, Logout }
|
|
|
|
/// <summary>
|
|
/// Edge-triggered events the sequencer emits on the tick they first occur.
|
|
/// Consumers drive audio / placement / LoginComplete from these; the sequencer
|
|
/// has no dependency on any of those systems.
|
|
/// </summary>
|
|
public enum TeleportAnimEvent
|
|
{
|
|
PlayEnterSound, // Begin(): sound_ui_enter_portal
|
|
EnterTunnel, // First tunnel-family frame: portal viewport replaces world
|
|
Place, // Tunnel -> TunnelContinue: world loaded; place the player
|
|
PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
|
|
FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
|
|
}
|
|
|
|
/// <summary>Immutable per-frame snapshot from the sequencer.</summary>
|
|
public readonly record struct TeleportAnimSnapshot(
|
|
TeleportAnimState State,
|
|
float ViewPlaneBlend, // 0 = game view distance, 1 = transition distance
|
|
bool ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut
|
|
bool ShowPleaseWait); // true during TunnelContinue only
|
|
|
|
/// <summary>
|
|
/// Pure 7-state teleport animation machine (spec §2.1, acclient gmSmartBoxUI::UseTime 0x004d6e30).
|
|
/// No GL / dat / network dependency — fully unit-testable.
|
|
/// </summary>
|
|
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;
|
|
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;
|
|
private const short LastVisibleOutgoingAnimationLevel = 1022;
|
|
|
|
// 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
|
|
private float _continueElapsed = 0f; // tracks time inside TunnelContinue
|
|
private bool _enterSoundPending = false;
|
|
private bool _enterTunnelPending = false;
|
|
|
|
public bool IsActive => _state != TeleportAnimState.Off;
|
|
public TeleportAnimState State => _state;
|
|
|
|
/// <summary>
|
|
/// Start the animation. Portal/Login/Death enter at Tunnel (skipping world-fade-out);
|
|
/// Logout enters at WorldFadeOut (spec §2.5).
|
|
/// </summary>
|
|
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancel the current transition and discard every pending edge event.
|
|
/// Session teardown and a replacement teleport both require a clean
|
|
/// lifetime; no event from the abandoned transition may reach the next
|
|
/// session or destination.
|
|
/// </summary>
|
|
public void Reset()
|
|
{
|
|
_state = TeleportAnimState.Off;
|
|
_elapsed = 0f;
|
|
_continueElapsed = 0f;
|
|
_enterSoundPending = false;
|
|
_enterTunnelPending = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advance the machine by <paramref name="dt"/> seconds.
|
|
/// <paramref name="worldReady"/> = the complete destination-load barrier is
|
|
/// satisfied (Near-tier render meshes/textures plus collision residency).
|
|
/// Returns the current snapshot + edge-triggered events fired THIS tick.
|
|
/// </summary>
|
|
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
|
|
Tick(float dt, bool worldReady, int tunnelAnimationFrame = 72)
|
|
{
|
|
var evts = new List<TeleportAnimEvent>();
|
|
|
|
// Edge events queued by Begin or a prior transition (enter-sound + enter-tunnel only;
|
|
// exit-sound and login-complete are emitted inline at their transitions below):
|
|
if (_enterSoundPending) { evts.Add(TeleportAnimEvent.PlayEnterSound); _enterSoundPending = false; }
|
|
if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; }
|
|
|
|
_elapsed += dt;
|
|
|
|
// State-machine transitions:
|
|
switch (_state)
|
|
{
|
|
case TeleportAnimState.WorldFadeOut:
|
|
if (OutgoingViewportReachedTerminalProjection())
|
|
// UseTime's viewport-visibility block precedes this state
|
|
// transition, so retail shows portal space on the next tick.
|
|
Advance(TeleportAnimState.TunnelFadeIn, enterTunnel: true);
|
|
break;
|
|
|
|
case TeleportAnimState.TunnelFadeIn:
|
|
if (_elapsed >= FadeTime)
|
|
Advance(TeleportAnimState.Tunnel, enterTunnel: false);
|
|
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;
|
|
// 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);
|
|
break;
|
|
|
|
case TeleportAnimState.TunnelFadeOut:
|
|
if (OutgoingViewportReachedTerminalProjection())
|
|
{
|
|
Advance(TeleportAnimState.WorldFadeIn, enterTunnel: false);
|
|
evts.Add(TeleportAnimEvent.PlayExitSound);
|
|
}
|
|
break;
|
|
|
|
case TeleportAnimState.WorldFadeIn:
|
|
if (_elapsed >= FadeTime)
|
|
{
|
|
Advance(TeleportAnimState.Off, enterTunnel: false);
|
|
evts.Add(TeleportAnimEvent.FireLoginComplete);
|
|
}
|
|
break;
|
|
|
|
case TeleportAnimState.Off:
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return (BuildSnapshot(), evts);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retire an outgoing viewport before the first quantized projection that
|
|
/// exposes the finite tunnel boundary. Retail's whole-frame black clear and
|
|
/// frame-paced D3D presentation show level 1022 as the last tunnel sample;
|
|
/// paired captures show no 1023/1024 portal frame before the world swap.
|
|
/// An uncapped modern loop can otherwise publish those sub-20 ms samples
|
|
/// and let the desktop compositor hold one for a complete display refresh.
|
|
/// </summary>
|
|
private bool OutgoingViewportReachedTerminalProjection()
|
|
=> GetRetailAnimationLevel(_elapsed / FadeTime) > LastVisibleOutgoingAnimationLevel;
|
|
|
|
private void Advance(TeleportAnimState next, bool enterTunnel)
|
|
{
|
|
_state = next;
|
|
_elapsed = 0f;
|
|
if (enterTunnel) _enterTunnelPending = true;
|
|
}
|
|
|
|
private TeleportAnimSnapshot BuildSnapshot()
|
|
{
|
|
float viewBlend = ComputeViewPlaneBlend(_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, viewBlend, showTunnel, pleaseWait);
|
|
}
|
|
|
|
/// <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);
|
|
// 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];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's four states named <c>*Fade*</c> do not drive an alpha cover.
|
|
/// <c>gmSmartBoxUI::UseTime</c> (0x004D7133-0x004D725F) uses this eased
|
|
/// level only to interpolate SmartBox's view-plane distance.
|
|
/// </summary>
|
|
private static float ComputeViewPlaneBlend(TeleportAnimState state, float elapsed)
|
|
{
|
|
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
|
|
float level = GetRetailAnimationLevel(t) / 1024f;
|
|
return state switch
|
|
{
|
|
// Normal game view distance -> transition distance.
|
|
TeleportAnimState.WorldFadeOut => level,
|
|
TeleportAnimState.TunnelFadeOut => level,
|
|
// Transition distance -> normal game view distance.
|
|
TeleportAnimState.TunnelFadeIn => 1f - level,
|
|
TeleportAnimState.WorldFadeIn => 1f - level,
|
|
// Stable viewport states use the ordinary game projection.
|
|
TeleportAnimState.Tunnel => 0f,
|
|
TeleportAnimState.TunnelContinue => 0f,
|
|
_ => 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;
|
|
}
|
|
}
|