acdream/src/AcDream.Core/Lighting/LightingHookSink.cs
Erik 7b9a66c9ea feat(lighting): Phase G.2 — Setup.Lights + SetLightHook wiring
Register dat-defined LightInfos as runtime LightSources when entities
stream in. Every Setup (0x02xxxxxx) with a non-empty Lights dictionary
gets its per-part lights pulled via LightInfoLoader, which converts
the local Frame + ColorARGB + Intensity + Falloff + ConeAngle fields
into world-space LightSource records owned by the entity id.

Wire the LightingHookSink into the animation-hook router so retail's
SetLightHook animations (ignite-torch, extinguish-lamp) flip the
matching LightSource.IsLit latches. One hook may own multiple lights
(lamp-posts with two LightInfo entries) — the sink maintains an
owner-indexed map so all get toggled together.

Unregister on landblock unload: the streaming controller's
removeTerrain callback grabs the loaded landblock's entity list (new
GpuWorldState.TryGetLandblock helper) and drops every owner from the
sink before the entities disappear — otherwise walking across
landblocks accumulates stale LightSources.

9 new tests (LightingHookSink routing + LightInfoLoader conversion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:46:49 +02:00

78 lines
2.8 KiB
C#

using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.Core.Lighting;
/// <summary>
/// Routes <see cref="SetLightHook"/> animation hooks to the
/// <see cref="LightManager"/> — when a torch lights / extinguishes via
/// an animation frame, flip the corresponding
/// <see cref="LightSource.IsLit"/> latch. Per r13 §2 the hook is AC's
/// way of saying "this Setup's baked-in LightInfo is now active".
///
/// <para>
/// Registration: at entity spawn time the caller walks the Setup's
/// <c>Lights</c> dictionary and registers a <see cref="LightSource"/>
/// per <c>LightInfo</c>, tagging it with the owning entity id. When a
/// hook fires later, we look up every light tagged to that owner and
/// flip them all together (retail's SetLightHook is a per-setup
/// boolean, not per-light).
/// </para>
/// </summary>
public sealed class LightingHookSink : IAnimationHookSink
{
private readonly LightManager _lights;
// Index owner → the set of LightSource instances they registered.
// Maintained lazily — populated on first RegisterLight for that owner.
private readonly Dictionary<uint, List<LightSource>> _byOwner = new();
public LightingHookSink(LightManager lights)
{
_lights = lights ?? throw new System.ArgumentNullException(nameof(lights));
}
/// <summary>
/// Register a light with the manager + track it by owner so later
/// SetLightHook / Unregister calls can reach it.
/// </summary>
public void RegisterOwnedLight(LightSource light)
{
System.ArgumentNullException.ThrowIfNull(light);
_lights.Register(light);
if (!_byOwner.TryGetValue(light.OwnerId, out var list))
{
list = new List<LightSource>();
_byOwner[light.OwnerId] = list;
}
list.Add(light);
}
/// <summary>Drop every light tagged to this owner (despawn / unload).</summary>
public void UnregisterOwner(uint ownerId)
{
if (!_byOwner.TryGetValue(ownerId, out var list)) return;
foreach (var l in list) _lights.Unregister(l);
_byOwner.Remove(ownerId);
}
/// <summary>
/// Get the set of registered lights for an owner — exposed so
/// callers can reposition them (torch on hand follows hand part).
/// </summary>
public IReadOnlyList<LightSource>? GetOwnedLights(uint ownerId)
{
return _byOwner.TryGetValue(ownerId, out var list) ? list : null;
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
if (hook is not SetLightHook slh) return;
if (!_byOwner.TryGetValue(entityId, out var list)) return;
foreach (var light in list)
light.IsLit = slh.LightsOn;
}
}