using System;
using System.Collections.Generic;
namespace AcDream.Core.Rendering;
///
/// #188 — per-(entity, Setup-part) translucency ramp state. Ports retail's
/// FPHook TRANSLUCENCY / PART_TRANSLUCENCY interpolation
/// (CPhysicsObj::SetPartTranslucency 0x00511730,
/// FPHook::Execute 0x0051baa0, CPhysicsObj::process_fp_hook
/// 0x005135c0): a linear ramp from Start to End over
/// Time seconds, ticked every frame rather than retail's per-object
/// physics tick (behaviorally identical — both re-evaluate the same
/// elapsed/duration ratio every simulated step).
///
///
/// Retail's translucency scale is the OPPOSITE of "opacity": 0 = fully
/// solid/opaque, 1 = fully invisible (CMaterial::SetTranslucencySimple
/// 0x005396f0: alpha = 1 - translucency). Values here are stored on
/// that same [0,1] translucency scale — callers convert to an alpha/opacity
/// multiplier at the point of use.
///
///
///
/// Retail's instant-apply shortcut: if the hook's Time is at or below
/// ~0.2 ms, the target value is applied immediately with no ramp
/// (SetPartTranslucency's epsilon check).
/// is that exact retail constant.
///
///
public sealed class TranslucencyFadeManager
{
///
/// Retail's exact epsilon (CPhysicsObj::SetPartTranslucency
/// 0x00511730): a hook Time at or below this is applied
/// instantly, no ramp.
///
public const float TranslucencyInstantEpsilon = 0.000199999995f;
private sealed class Fade
{
public float Elapsed;
public float Duration;
public float Start;
public float End;
}
// entityId -> partIndex -> in-flight ramp.
private readonly Dictionary> _activeFades = new();
// entityId -> partIndex -> current committed translucency value
// ([0,1], retail scale). Persists after a fade completes so later
// frames keep reading the settled value.
private readonly Dictionary> _committed = new();
///
/// Start (or replace) a translucency ramp for one Setup part of one
/// entity. Mirrors CPhysicsObj::SetPartTranslucency: a
/// near-zero applies
/// immediately; otherwise the value ramps from
/// to across
/// seconds, advanced by .
///
public void StartPartFade(uint entityId, uint partIndex, float start, float end, float time)
{
if (time <= TranslucencyInstantEpsilon)
{
if (_activeFades.TryGetValue(entityId, out var activeForEntity))
activeForEntity.Remove(partIndex);
Commit(entityId, partIndex, end);
return;
}
if (!_activeFades.TryGetValue(entityId, out var fades))
{
fades = new Dictionary();
_activeFades[entityId] = fades;
}
fades[partIndex] = new Fade { Elapsed = 0f, Duration = time, Start = start, End = end };
// FPHook's first Execute call (at elapsed=0) already reports
// value=start — commit it now so a reader on the same frame the
// hook fired sees the ramp's starting value, not the stale prior one.
Commit(entityId, partIndex, start);
}
///
/// Advance every in-flight fade by seconds
/// (plain linear interpolation, no easing — matches
/// FPHook::Execute). A fade that reaches its duration commits
/// the bitwise-exact End value and stops advancing (retail
/// deletes the FPHook at that point); the committed value stays
/// readable via .
///
public void AdvanceAll(float dt)
{
if (_activeFades.Count == 0) return;
List? emptyEntities = null;
foreach (var (entityId, fades) in _activeFades)
{
List? finished = null;
foreach (var (partIndex, fade) in fades)
{
fade.Elapsed += dt;
float t = fade.Duration <= TranslucencyInstantEpsilon
? 1f
: Math.Clamp(fade.Elapsed / fade.Duration, 0f, 1f);
float value = t >= 1f ? fade.End : fade.Start + (fade.End - fade.Start) * t;
Commit(entityId, partIndex, value);
if (t >= 1f)
{
finished ??= new List();
finished.Add(partIndex);
}
}
if (finished is not null)
{
foreach (var partIndex in finished)
fades.Remove(partIndex);
if (fades.Count == 0)
{
emptyEntities ??= new List();
emptyEntities.Add(entityId);
}
}
}
if (emptyEntities is not null)
foreach (var entityId in emptyEntities)
_activeFades.Remove(entityId);
}
///
/// Read the current committed translucency value ([0,1], retail
/// scale — 0 opaque, 1 invisible) for one entity part. Returns false
/// if this part has never had a fade start (caller should fall back
/// to the dat-authored default — a no-op).
///
public bool TryGetCurrentValue(uint entityId, uint partIndex, out float value)
{
if (_committed.TryGetValue(entityId, out var parts) && parts.TryGetValue(partIndex, out value))
return true;
value = 0f;
return false;
}
/// Drop all fade state for an entity (despawn / unload).
public void ClearEntity(uint entityId)
{
_activeFades.Remove(entityId);
_committed.Remove(entityId);
}
private void Commit(uint entityId, uint partIndex, float value)
{
if (!_committed.TryGetValue(entityId, out var parts))
{
parts = new Dictionary();
_committed[entityId] = parts;
}
parts[partIndex] = value;
}
}