414f9ff62a
Dark-ambient, dynamic-light-first look (RoR2/Death Scourges reference),
built filter-agnostic so the pixel style composes on top:
- New DynamicLightSystem (client-only, PresentationSystemGroup): pooled
point lights follow projectile ghosts; pulsing portal light during
RoomExplore (same ExpeditionPortalPos authority as the beacon); soft
resource-node glow that fades with harvest-shrink; short impact
FLASHES fed by CombatFeedbackSystem's burst funnel (EmitColored +
SpawnVfx -> RequestFlash). Knobs in DynamicLightConfig (scene object,
code defaults when absent).
- Atmosphere darkened: WorldAtmosphereConfig/System palettes moved to
the dark set (base cool dusk, arid burnt dusk, per-room biome consts
darkened); scene fog switched Linear -> ExponentialSquared (the
system's per-region DENSITY writes were dead in Linear mode);
trilight ambient lowered; directional 1.9 -> 1.05 slightly warm.
- 6 landmark mood lights (warpgate cyan, artefact violet x2, survey
camp warm x2, cabin warm); URP additional-lights-per-object 4 -> 8
(was starving the existing Aether lights).
- Art-look gate material: Assets/Screenshots/artlook_{base,room}_
pixel{ON,OFF}.png over the lit scene.
Verified: lights live in Play (20 static at base incl. new landmarks;
pooled dynamics active in-room: node glows + impact flashes), fog/
ambient values confirmed live, 466/466 EditMode, console clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
207 lines
9.7 KiB
C#
207 lines
9.7 KiB
C#
using System.Collections.Generic;
|
|
using ProjectM.Simulation;
|
|
using Unity.Entities;
|
|
using Unity.Transforms;
|
|
using UnityEngine;
|
|
|
|
namespace ProjectM.Client
|
|
{
|
|
/// <summary>
|
|
/// Client-only DYNAMIC LIGHT layer (Phase 1.5 lighting pass — dark ambient, dynamic-light-first): pooled
|
|
/// point lights that follow projectile ghosts, a pulsing portal light during the RoomExplore loot window
|
|
/// (same <see cref="RegionMath.ExpeditionPortalPos"/> authority the beacon + HUD prompt use), a soft glow
|
|
/// on resource nodes, and short impact FLASHES fed by CombatFeedbackSystem's burst funnel via
|
|
/// <see cref="RequestFlash"/>. Observe-only <see cref="SystemBase"/> in
|
|
/// <see cref="PresentationSystemGroup"/> (once per frame, no rollback double-fire); never mutates the sim;
|
|
/// Entity keys are pruned every frame (a pruned ghost = despawn); lights are pooled GameObjects under a
|
|
/// private root, NEVER scene-saved. Knobs live in <see cref="DynamicLightConfig"/> (defaults when absent).
|
|
/// Wall-clock time is fine here (presentation only). URP: point lights, shadows off.
|
|
/// </summary>
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
|
public partial class DynamicLightSystem : SystemBase
|
|
{
|
|
struct FlashRequest { public Vector3 Pos; public Color Color; public float Scale; }
|
|
struct ActiveFlash { public Light Light; public float EndTime; public float BaseIntensity; public float Duration; }
|
|
|
|
// Static request queue: CombatFeedbackSystem (same thread, PresentationSystemGroup) enqueues, we drain.
|
|
static readonly List<FlashRequest> Pending = new List<FlashRequest>();
|
|
|
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
|
static void ResetStatics() { Pending.Clear(); }
|
|
|
|
/// <summary>Queue a short impact flash. <paramref name="color"/> with a==0 means "use the config
|
|
/// default colour"; <paramref name="scale"/> scales intensity+range (clamped by the drainer).</summary>
|
|
public static void RequestFlash(Vector3 pos, Color color, float scale)
|
|
{
|
|
if (Pending.Count < 64) Pending.Add(new FlashRequest { Pos = pos, Color = color, Scale = scale });
|
|
}
|
|
|
|
GameObject _root;
|
|
readonly Dictionary<Entity, Light> _projectileLights = new Dictionary<Entity, Light>();
|
|
readonly Dictionary<Entity, Light> _nodeLights = new Dictionary<Entity, Light>();
|
|
readonly HashSet<Entity> _seen = new HashSet<Entity>();
|
|
readonly List<Entity> _dead = new List<Entity>();
|
|
readonly Stack<Light> _pool = new Stack<Light>();
|
|
readonly List<ActiveFlash> _flashes = new List<ActiveFlash>();
|
|
Light _portalLight;
|
|
|
|
protected override void OnDestroy()
|
|
{
|
|
if (_root != null) Object.Destroy(_root);
|
|
}
|
|
|
|
protected override void OnUpdate()
|
|
{
|
|
if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") return;
|
|
var cfg = DynamicLightConfig.Instance;
|
|
bool enabled = cfg == null || cfg.Enabled;
|
|
if (!enabled)
|
|
{
|
|
Pending.Clear();
|
|
if (_root != null && _root.activeSelf) _root.SetActive(false);
|
|
return;
|
|
}
|
|
if (_root == null)
|
|
{
|
|
_root = new GameObject("~DynamicLights");
|
|
Object.DontDestroyOnLoad(_root);
|
|
}
|
|
if (!_root.activeSelf) _root.SetActive(true);
|
|
|
|
// ---- projectile lights (pooled follow-lights) ----
|
|
int projCap = cfg != null ? cfg.MaxProjectileLights : 24;
|
|
Color projColor = cfg != null ? cfg.ProjectileColor : new Color(0.45f, 0.85f, 1f, 1f);
|
|
float projIntensity = cfg != null ? cfg.ProjectileIntensity : 2.6f;
|
|
float projRange = cfg != null ? cfg.ProjectileRange : 7f;
|
|
_seen.Clear();
|
|
foreach (var (lt, entity) in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<Projectile>().WithEntityAccess())
|
|
{
|
|
_seen.Add(entity);
|
|
if (!_projectileLights.TryGetValue(entity, out var light))
|
|
{
|
|
if (_projectileLights.Count >= projCap) continue;
|
|
light = Rent();
|
|
_projectileLights[entity] = light;
|
|
}
|
|
light.color = projColor;
|
|
light.intensity = projIntensity;
|
|
light.range = projRange;
|
|
var p = lt.ValueRO.Position;
|
|
light.transform.position = new Vector3(p.x, p.y + 0.4f, p.z);
|
|
}
|
|
PruneUnseen(_projectileLights);
|
|
|
|
// ---- resource-node glow ----
|
|
bool nodeGlow = cfg == null || cfg.NodeGlow;
|
|
int nodeCap = cfg != null ? cfg.MaxNodeLights : 12;
|
|
Color nodeColor = cfg != null ? cfg.NodeColor : new Color(0.40f, 1f, 0.75f, 1f);
|
|
float nodeIntensity = cfg != null ? cfg.NodeIntensity : 1.3f;
|
|
float nodeRange = cfg != null ? cfg.NodeRange : 4.5f;
|
|
_seen.Clear();
|
|
if (nodeGlow)
|
|
{
|
|
foreach (var (lt, entity) in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<ResourceNode>().WithEntityAccess())
|
|
{
|
|
_seen.Add(entity);
|
|
if (!_nodeLights.TryGetValue(entity, out var light))
|
|
{
|
|
if (_nodeLights.Count >= nodeCap) continue;
|
|
light = Rent();
|
|
_nodeLights[entity] = light;
|
|
}
|
|
light.color = nodeColor;
|
|
// scale the glow with the node's harvest-shrink so it fades as the node depletes
|
|
light.intensity = nodeIntensity * Mathf.Clamp01(lt.ValueRO.Scale);
|
|
light.range = nodeRange;
|
|
var p = lt.ValueRO.Position;
|
|
light.transform.position = new Vector3(p.x, p.y + 1.1f, p.z);
|
|
}
|
|
}
|
|
PruneUnseen(_nodeLights);
|
|
|
|
// ---- portal light (RoomExplore only) ----
|
|
bool portalOn = false;
|
|
if (SystemAPI.TryGetSingleton<RunInfo>(out var runInfo)
|
|
&& runInfo.Lifecycle == RunLifecycle.RoomExplore
|
|
&& SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
|
{
|
|
portalOn = true;
|
|
var pos = RegionMath.ExpeditionPortalPos(BaseGridMath.PlotCenter(anchor), (byte)(runInfo.CurrentRoom & 1));
|
|
if (_portalLight == null) _portalLight = Rent();
|
|
float baseI = cfg != null ? cfg.PortalIntensity : 3.5f;
|
|
float amp = cfg != null ? cfg.PortalPulseAmp : 1.2f;
|
|
_portalLight.color = cfg != null ? cfg.PortalColor : new Color(0.55f, 0.95f, 1f, 1f);
|
|
_portalLight.range = cfg != null ? cfg.PortalRange : 13f;
|
|
_portalLight.intensity = baseI + amp * Mathf.Sin(UnityEngine.Time.time * 4f);
|
|
_portalLight.transform.position = new Vector3(pos.x, pos.y + 2.2f, pos.z);
|
|
}
|
|
if (!portalOn && _portalLight != null) { Return(_portalLight); _portalLight = null; }
|
|
|
|
// ---- impact flashes ----
|
|
float now = UnityEngine.Time.time;
|
|
int flashCap = cfg != null ? cfg.MaxFlashLights : 8;
|
|
Color flashDefault = cfg != null ? cfg.FlashDefaultColor : new Color(1f, 0.85f, 0.55f, 1f);
|
|
float flashIntensity = cfg != null ? cfg.FlashIntensity : 3.2f;
|
|
float flashRange = cfg != null ? cfg.FlashRange : 6.5f;
|
|
float flashDur = cfg != null ? cfg.FlashDuration : 0.18f;
|
|
for (int i = 0; i < Pending.Count; i++)
|
|
{
|
|
if (_flashes.Count >= flashCap) break;
|
|
var req = Pending[i];
|
|
float scale = Mathf.Clamp(req.Scale, 0.3f, 1.6f);
|
|
var light = Rent();
|
|
light.color = req.Color.a > 0f ? req.Color : flashDefault;
|
|
light.intensity = flashIntensity * scale;
|
|
light.range = flashRange * Mathf.Sqrt(scale);
|
|
light.transform.position = req.Pos + new Vector3(0f, 0.6f, 0f);
|
|
_flashes.Add(new ActiveFlash { Light = light, EndTime = now + flashDur, BaseIntensity = light.intensity, Duration = flashDur });
|
|
}
|
|
Pending.Clear();
|
|
for (int i = _flashes.Count - 1; i >= 0; i--)
|
|
{
|
|
var f = _flashes[i];
|
|
float remain = f.EndTime - now;
|
|
if (remain <= 0f) { Return(f.Light); _flashes.RemoveAt(i); continue; }
|
|
f.Light.intensity = f.BaseIntensity * (remain / f.Duration);
|
|
}
|
|
}
|
|
|
|
void PruneUnseen(Dictionary<Entity, Light> map)
|
|
{
|
|
_dead.Clear();
|
|
foreach (var kv in map)
|
|
if (!_seen.Contains(kv.Key)) _dead.Add(kv.Key);
|
|
for (int i = 0; i < _dead.Count; i++)
|
|
{
|
|
Return(map[_dead[i]]);
|
|
map.Remove(_dead[i]);
|
|
}
|
|
}
|
|
|
|
Light Rent()
|
|
{
|
|
while (_pool.Count > 0)
|
|
{
|
|
var pooled = _pool.Pop();
|
|
if (pooled == null) continue; // scene reload can null pooled objects
|
|
pooled.gameObject.SetActive(true);
|
|
return pooled;
|
|
}
|
|
var go = new GameObject("PointLight");
|
|
go.transform.SetParent(_root.transform, false);
|
|
var light = go.AddComponent<Light>();
|
|
light.type = LightType.Point;
|
|
light.shadows = LightShadows.None;
|
|
return light;
|
|
}
|
|
|
|
void Return(Light light)
|
|
{
|
|
if (light == null) return;
|
|
light.gameObject.SetActive(false);
|
|
_pool.Push(light);
|
|
}
|
|
}
|
|
}
|