diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index de78d13..a1f9e54 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -164,6 +164,9 @@ public sealed class GameWindow : IDisposable AcDream.Core.World.SkyStateProvider.Default()); public readonly AcDream.Core.Lighting.LightManager Lighting = new(); public readonly AcDream.Core.World.WeatherSystem Weather = new(); + // Wired into the hook router in OnLoad so SetLightHook fires + // from the animation pipeline flip the matching LightSource.IsLit. + private AcDream.Core.Lighting.LightingHookSink? _lightingSink; // Phase G.1 sky renderer + shared UBO. Created once the GL context // exists in OnLoad; shared across every other renderer via @@ -629,6 +632,12 @@ public sealed class GameWindow : IDisposable _particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem); _hookRouter.Register(_particleSink); + // Phase G.2 lighting hooks: SetLightHook flips IsLit on + // owner-tagged lights so ignite-torch animations light up, + // extinguish-torch animations go dark. + _lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting); + _hookRouter.Register(_lightingSink); + // Phase E.2 audio: init OpenAL + hook sink. Suppressible via // ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers. if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1") @@ -767,6 +776,16 @@ public sealed class GameWindow : IDisposable radius: _streamingRadius, removeTerrain: id => { + // Phase G.2: release any LightSources attached to entities + // in this landblock before their records disappear from + // _worldState — otherwise the LightManager accumulates + // stale entries for every walk across a landblock boundary. + if (_lightingSink is not null && + _worldState.TryGetLandblock(id, out var lb)) + { + foreach (var ent in lb!.Entities) + _lightingSink.UnregisterOwner(ent.Id); + } _terrain?.RemoveLandblock(id); _physicsEngine.RemoveLandblock(id); _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); @@ -2308,6 +2327,32 @@ public sealed class GameWindow : IDisposable int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0; foreach (var entity in lb.Entities) { + // Phase G.2: if the entity's Setup has baked-in LightInfos, + // register them with the LightManager so torches, braziers, + // and lifestones cast real light on nearby geometry. Hooked + // via the LightingHookSink so per-entity owner tracking + + // SetLightHook IsLit toggles all go through one codepath. + // Only applies to Setup-sourced entities (0x02xxxxxx) — raw + // GfxObjs don't carry Lights dictionaries. + if (_lightingSink is not null && _dats is not null) + { + uint src = entity.SourceGfxObjOrSetupId; + if ((src & 0xFF000000u) == 0x02000000u) + { + var datSetup = _dats.Get(src); + if (datSetup is not null && datSetup.Lights.Count > 0) + { + var loaded = AcDream.Core.Lighting.LightInfoLoader.Load( + datSetup, + ownerId: entity.Id, + entityPosition: entity.Position, + entityRotation: entity.Rotation); + foreach (var ls in loaded) + _lightingSink.RegisterOwnedLight(ls); + } + } + } + int entityBsp = 0, entityCyl = 0; // Treat both procedural scenery (0x80000000+) AND LandBlockInfo // stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index f1fb421..f3448ef 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -65,6 +65,22 @@ public sealed class GpuWorldState public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); + /// + /// Try to grab the loaded record for a landblock — useful for callers + /// that need to enumerate entities before the landblock is dropped + /// (e.g. unregistering dynamic lights on a RemoveLandblock). + /// + public bool TryGetLandblock(uint landblockId, out LoadedLandblock? lb) + { + if (_loaded.TryGetValue(landblockId, out var found)) + { + lb = found; + return true; + } + lb = null; + return false; + } + /// /// Store the axis-aligned bounding box for a loaded landblock. Called from /// the render thread after the terrain mesh is built and uploaded. diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs new file mode 100644 index 0000000..63a250f --- /dev/null +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Lighting; + +/// +/// Converts a 's Lights dictionary (dat-level +/// records) into runtime +/// instances the can consume. +/// +/// +/// Retail fields (r13 §1): +/// +/// ViewSpaceLocation: local Frame relative to the owning part. +/// Color: packed ARGB. Alpha is ignored; channels go through /255. +/// Intensity: multiplies color for final diffuse. +/// Falloff: world metres — acts as the hard cutoff. +/// ConeAngle: radians; 0 = point, >0 = spot cone. +/// +/// +/// +public static class LightInfoLoader +{ + /// + /// Extract all lights from a Setup, positioned in the entity's + /// world frame (via + + /// ). 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. + /// + public static IReadOnlyList Load( + Setup setup, + uint ownerId, + Vector3 entityPosition, + Quaternion entityRotation) + { + var results = new List(); + 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; + } +} diff --git a/src/AcDream.Core/Lighting/LightingHookSink.cs b/src/AcDream.Core/Lighting/LightingHookSink.cs new file mode 100644 index 0000000..9a052d2 --- /dev/null +++ b/src/AcDream.Core/Lighting/LightingHookSink.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Types; + +namespace AcDream.Core.Lighting; + +/// +/// Routes animation hooks to the +/// — when a torch lights / extinguishes via +/// an animation frame, flip the corresponding +/// latch. Per r13 §2 the hook is AC's +/// way of saying "this Setup's baked-in LightInfo is now active". +/// +/// +/// Registration: at entity spawn time the caller walks the Setup's +/// Lights dictionary and registers a +/// per LightInfo, 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). +/// +/// +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> _byOwner = new(); + + public LightingHookSink(LightManager lights) + { + _lights = lights ?? throw new System.ArgumentNullException(nameof(lights)); + } + + /// + /// Register a light with the manager + track it by owner so later + /// SetLightHook / Unregister calls can reach it. + /// + public void RegisterOwnedLight(LightSource light) + { + System.ArgumentNullException.ThrowIfNull(light); + _lights.Register(light); + if (!_byOwner.TryGetValue(light.OwnerId, out var list)) + { + list = new List(); + _byOwner[light.OwnerId] = list; + } + list.Add(light); + } + + /// Drop every light tagged to this owner (despawn / unload). + public void UnregisterOwner(uint ownerId) + { + if (!_byOwner.TryGetValue(ownerId, out var list)) return; + foreach (var l in list) _lights.Unregister(l); + _byOwner.Remove(ownerId); + } + + /// + /// Get the set of registered lights for an owner — exposed so + /// callers can reposition them (torch on hand follows hand part). + /// + public IReadOnlyList? 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; + } +} diff --git a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs new file mode 100644 index 0000000..c3884a6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs @@ -0,0 +1,119 @@ +using System.Numerics; +using AcDream.Core.Lighting; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +public sealed class LightingHookSinkTests +{ + [Fact] + public void SetLightHook_FlipsOwnedLights() + { + var mgr = new LightManager(); + var sink = new LightingHookSink(mgr); + + var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true }; + var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true }; + var other = new LightSource { Kind = LightKind.Point, OwnerId = 99, IsLit = true }; + sink.RegisterOwnedLight(light1); + sink.RegisterOwnedLight(light2); + sink.RegisterOwnedLight(other); + + var hook = new SetLightHook { LightsOn = false }; + sink.OnHook(entityId: 42, entityWorldPosition: Vector3.Zero, hook: hook); + + Assert.False(light1.IsLit); + Assert.False(light2.IsLit); + Assert.True(other.IsLit); // owner 99 untouched + } + + [Fact] + public void UnregisterOwner_RemovesAllOwnedLights() + { + var mgr = new LightManager(); + var sink = new LightingHookSink(mgr); + + sink.RegisterOwnedLight(new LightSource { OwnerId = 7 }); + sink.RegisterOwnedLight(new LightSource { OwnerId = 7 }); + Assert.Equal(2, mgr.RegisteredCount); + + sink.UnregisterOwner(7); + Assert.Equal(0, mgr.RegisteredCount); + } + + [Fact] + public void UnrelatedHook_Ignored() + { + var mgr = new LightManager(); + var sink = new LightingHookSink(mgr); + var light = new LightSource { OwnerId = 1, IsLit = true }; + sink.RegisterOwnedLight(light); + + // Should not crash or change state for non-SetLight hooks. + var noise = new SoundHook(); + sink.OnHook(entityId: 1, entityWorldPosition: Vector3.Zero, hook: noise); + + Assert.True(light.IsLit); + } +} + +public sealed class LightInfoLoaderTests +{ + [Fact] + public void Load_EmptyLights_ReturnsEmpty() + { + var setup = new DatReaderWriter.DBObjs.Setup(); + var result = LightInfoLoader.Load(setup, 1u, Vector3.Zero, Quaternion.Identity); + Assert.Empty(result); + } + + [Fact] + public void Load_PointLight_ProducesCorrectSource() + { + var setup = new DatReaderWriter.DBObjs.Setup(); + setup.Lights[0] = new LightInfo + { + ViewSpaceLocation = new Frame + { + Origin = new Vector3(1, 2, 3), + Orientation = Quaternion.Identity, + }, + Color = new ColorARGB { Red = 255, Green = 200, Blue = 50, Alpha = 255 }, + Intensity = 0.8f, + Falloff = 8f, + ConeAngle = 0f, // point + }; + + var result = LightInfoLoader.Load(setup, ownerId: 77, + entityPosition: new Vector3(100, 200, 300), + entityRotation: Quaternion.Identity); + + Assert.Single(result); + var light = result[0]; + Assert.Equal(LightKind.Point, light.Kind); + Assert.Equal(77u, light.OwnerId); + Assert.Equal(8f, light.Range); + Assert.Equal(0.8f, light.Intensity); + Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition); + Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f); + } + + [Fact] + public void Load_NonZeroConeAngle_ProducesSpot() + { + var setup = new DatReaderWriter.DBObjs.Setup(); + setup.Lights[0] = new LightInfo + { + ViewSpaceLocation = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + Color = new ColorARGB { Red = 255, Green = 255, Blue = 255, Alpha = 255 }, + Intensity = 1f, + Falloff = 5f, + ConeAngle = 0.5f, + }; + + var result = LightInfoLoader.Load(setup, ownerId: 1, entityPosition: Vector3.Zero, entityRotation: Quaternion.Identity); + Assert.Equal(LightKind.Spot, result[0].Kind); + Assert.Equal(0.5f, result[0].ConeAngle); + } +}