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