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>
This commit is contained in:
parent
9618c66813
commit
7b9a66c9ea
5 changed files with 350 additions and 0 deletions
92
src/AcDream.Core/Lighting/LightInfoLoader.cs
Normal file
92
src/AcDream.Core/Lighting/LightInfoLoader.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="Setup"/>'s <c>Lights</c> dictionary (dat-level
|
||||
/// <see cref="LightInfo"/> records) into runtime <see cref="LightSource"/>
|
||||
/// instances the <see cref="LightManager"/> can consume.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail <see cref="LightInfo"/> fields (r13 §1):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>ViewSpaceLocation</c>: local Frame relative to the owning part.</description></item>
|
||||
/// <item><description><c>Color</c>: packed ARGB. Alpha is ignored; channels go through <c>/255</c>.</description></item>
|
||||
/// <item><description><c>Intensity</c>: multiplies color for final diffuse.</description></item>
|
||||
/// <item><description><c>Falloff</c>: world metres — acts as the <see cref="LightSource.Range"/> hard cutoff.</description></item>
|
||||
/// <item><description><c>ConeAngle</c>: radians; 0 = point, >0 = spot cone.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class LightInfoLoader
|
||||
{
|
||||
/// <summary>
|
||||
/// Extract all lights from a Setup, positioned in the entity's
|
||||
/// world frame (via <paramref name="entityPosition"/> +
|
||||
/// <paramref name="entityRotation"/>). The dat's per-light Frame is
|
||||
/// treated as a local offset relative to the entity root; acdream
|
||||
/// doesn't yet transform through the animated part chain (retail's
|
||||
/// hand-held torches), so held lights render at the entity root
|
||||
/// until the animation hook layer handles per-part placement.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<LightSource> Load(
|
||||
Setup setup,
|
||||
uint ownerId,
|
||||
Vector3 entityPosition,
|
||||
Quaternion entityRotation)
|
||||
{
|
||||
var results = new List<LightSource>();
|
||||
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
|
||||
|
||||
foreach (var kvp in setup.Lights)
|
||||
{
|
||||
var info = kvp.Value;
|
||||
if (info is null) continue;
|
||||
|
||||
// Local Frame offset into world space.
|
||||
Vector3 localOffset = Vector3.Zero;
|
||||
Quaternion localRot = Quaternion.Identity;
|
||||
if (info.ViewSpaceLocation is not null)
|
||||
{
|
||||
localOffset = new Vector3(
|
||||
info.ViewSpaceLocation.Origin.X,
|
||||
info.ViewSpaceLocation.Origin.Y,
|
||||
info.ViewSpaceLocation.Origin.Z);
|
||||
localRot = new Quaternion(
|
||||
info.ViewSpaceLocation.Orientation.X,
|
||||
info.ViewSpaceLocation.Orientation.Y,
|
||||
info.ViewSpaceLocation.Orientation.Z,
|
||||
info.ViewSpaceLocation.Orientation.W);
|
||||
}
|
||||
|
||||
// Transform local offset into world space via the entity's
|
||||
// rotation + translation. No per-part chain yet — held
|
||||
// torches track the entity's root for now.
|
||||
Vector3 worldPos = entityPosition + Vector3.Transform(localOffset, entityRotation);
|
||||
Quaternion worldRot = entityRotation * localRot;
|
||||
Vector3 forward = Vector3.Transform(Vector3.UnitY, worldRot);
|
||||
|
||||
var light = new LightSource
|
||||
{
|
||||
Kind = info.ConeAngle > 0f ? LightKind.Spot : LightKind.Point,
|
||||
WorldPosition = worldPos,
|
||||
WorldForward = forward,
|
||||
ColorLinear = new Vector3(
|
||||
(info.Color?.Red ?? 255) / 255f,
|
||||
(info.Color?.Green ?? 255) / 255f,
|
||||
(info.Color?.Blue ?? 255) / 255f),
|
||||
Intensity = info.Intensity,
|
||||
Range = info.Falloff,
|
||||
ConeAngle = info.ConeAngle,
|
||||
OwnerId = ownerId,
|
||||
IsLit = true,
|
||||
};
|
||||
results.Add(light);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
78
src/AcDream.Core/Lighting/LightingHookSink.cs
Normal file
78
src/AcDream.Core/Lighting/LightingHookSink.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue