Files
Project-M/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs
T
kronic a4edf7a03b UITK HUD rework + build palette (click-to-place ghost)
Rebuild the in-game HUD on UI Toolkit (HudUi/HudSystem, Aether palette) consistent with the menu; build-palette bar (BuildPaletteState) drives cursor->cell ground-ghost preview (green/red via BuildPreviewMath), left-click place / right-click cancel / rotate; fire suppressed in build mode; combat juice restyle. +4 BuildPreviewMath EditMode tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:05:49 -07:00

227 lines
9.7 KiB
C#

using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only WORLD JUICE for harvesting resource nodes + smashing Blight clutter. A managed presentation
/// system (SystemBase, main thread, NO Burst) in <see cref="PresentationSystemGroup"/> that REACTS to
/// replicated state — it never runs simulation. Each frame it edge-detects every node/clutter ghost's
/// replicated <c>Remaining</c>: a decrease spawns a small tinted chip burst + soft SFX; a despawn (the server
/// destroyed it — depletion or shatter) spawns a clear burst + SFX, and clutter adds a camera punch (the
/// "carve through the frontier" smash). A PROXIMITY GATE suppresses the prune VFX unless the despawned
/// entity's last position was near the local player, so the region-transit despawn storm at +1000 X stays
/// silent off-camera (GhostRelevancy drops every expedition ghost at once when the player walks home).
/// Procedural particles + procedural SFX (mirrors CombatFeedbackSystem; self-contained); knobs live in
/// <see cref="WorldFeelConfig"/>. Never destroys a ghost — GhostDespawnSystem owns despawn; we only OBSERVE.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class WorldFeedbackSystem : SystemBase
{
struct Cache { public int Remaining; public float3 Pos; public bool IsClutter; public Color Tint; }
readonly Dictionary<Entity, Cache> _cache = new();
readonly HashSet<Entity> _seen = new();
readonly List<Entity> _stale = new();
Transform _fxRoot;
ParticleSystem _chipFx;
ParticleSystem _clearFx;
AudioClip _chipClip;
AudioClip _clearClip;
protected override void OnCreate()
{
_chipClip = MakeClip("harvest_chip", 900f, 1400f, 0.06f, 0.30f);
_clearClip = MakeClip("clutter_clear", 420f, 90f, 0.22f, 0.50f);
}
protected override void OnStartRunning()
{
if (_fxRoot != null) return;
_fxRoot = new GameObject("~WorldFeedbackFX").transform;
var mat = MakeParticleMaterial();
_chipFx = MakeBurst("HarvestChips", mat, new Color(2.6f, 1.9f, 0.7f), 0.10f, 5f, 0.30f, 256);
_clearFx = MakeBurst("ClutterClear", mat, new Color(3.0f, 1.1f, 0.25f), 0.16f, 7f, 0.45f, 512);
}
protected override void OnDestroy()
{
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
}
protected override void OnUpdate()
{
if (!WorldFeelConfig.Enabled) { _cache.Clear(); return; }
// Complete predicted/interpolation jobs writing these before the main-thread reads.
EntityManager.CompleteDependencyBeforeRO<ResourceNode>();
EntityManager.CompleteDependencyBeforeRO<BlightClutter>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
// Local player position (for the proximity gate).
bool haveLocal = false;
float3 localPos = default;
foreach (var xf in SystemAPI.Query<RefRO<LocalTransform>>().WithAll<GhostOwnerIsLocal, PlayerTag>())
{
localPos = xf.ValueRO.Position;
haveLocal = true;
}
_seen.Clear();
// Resource nodes — chip on depletion.
foreach (var (node, xf, e) in
SystemAPI.Query<RefRO<ResourceNode>, RefRO<LocalTransform>>().WithEntityAccess())
{
_seen.Add(e);
Observe(e, node.ValueRO.Remaining, xf.ValueRO.Position, false, TintForResource(node.ValueRO.ResourceId));
}
// Blight clutter — chip on damage.
foreach (var (clutter, xf, e) in
SystemAPI.Query<RefRO<BlightClutter>, RefRO<LocalTransform>>().WithEntityAccess())
{
_seen.Add(e);
Observe(e, clutter.ValueRO.Remaining, xf.ValueRO.Position, true, WorldFeelConfig.WildTint);
}
// Prune: a despawn = the server destroyed it (node depleted / clutter shattered). Gate on proximity so
// a region-transit despawn storm (every expedition ghost dropped at once, far at +1000 X) stays silent.
if (_cache.Count != _seen.Count)
{
_stale.Clear();
foreach (var kv in _cache)
if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
float rangeSq = WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange;
for (int i = 0; i < _stale.Count; i++)
{
var c = _cache[_stale[i]];
if (haveLocal && math.distancesq(c.Pos, localPos) <= rangeSq)
{
EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount, c.Tint);
PlayClip(_clearClip, (Vector3)c.Pos, WorldFeelConfig.ClearSfxVolume);
if (c.IsClutter)
{
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick, 90f);
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake);
}
}
_cache.Remove(_stale[i]);
}
}
}
void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint)
{
if (_cache.TryGetValue(e, out var prev) && remaining < prev.Remaining)
{
EmitTinted(_chipFx, (Vector3)pos + Vector3.up * 0.6f, WorldFeelConfig.ChipBurstCount, tint);
PlayClip(_chipClip, (Vector3)pos, WorldFeelConfig.ChipSfxVolume);
}
_cache[e] = new Cache { Remaining = remaining, Pos = pos, IsClutter = isClutter, Tint = tint };
}
static Color TintForResource(byte resourceId)
{
if (resourceId == ResourceId.Ore) return WorldFeelConfig.OreTint;
if (resourceId == ResourceId.Biomass) return WorldFeelConfig.BiomassTint;
return WorldFeelConfig.WildTint; // Aether + default
}
// ---- procedural particles + SFX (mirrors CombatFeedbackSystem; self-contained) ----
static void EmitTinted(ParticleSystem ps, Vector3 pos, int count, Color tint)
{
if (ps == null) return;
var main = ps.main;
main.startColor = tint;
ps.transform.position = pos;
ps.Emit(count);
}
static Material MakeParticleMaterial()
{
Shader sh = Shader.Find("Sprites/Default");
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Color");
return new Material(sh) { name = "WorldFeedbackParticle" };
}
ParticleSystem MakeBurst(string name, Material mat, Color color, float size, float speed, float life, int max)
{
var go = new GameObject(name);
go.transform.SetParent(_fxRoot, false);
var ps = go.AddComponent<ParticleSystem>();
var main = ps.main;
main.loop = false;
main.playOnAwake = false;
main.startLifetime = life;
main.startSpeed = speed;
main.startSize = size;
main.startColor = color;
main.maxParticles = max;
main.gravityModifier = 0.25f;
main.simulationSpace = ParticleSystemSimulationSpace.World;
var emission = ps.emission;
emission.enabled = false; // manual Emit(count)
var shape = ps.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 0.10f;
var colOverLife = ps.colorOverLifetime;
colOverLife.enabled = true;
var grad = new Gradient();
grad.SetKeys(
new[] { new GradientColorKey(Color.white, 0f), new GradientColorKey(Color.white, 1f) },
new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(0f, 1f) });
colOverLife.color = new ParticleSystem.MinMaxGradient(grad);
var sizeOverLife = ps.sizeOverLifetime;
sizeOverLife.enabled = true;
sizeOverLife.size = new ParticleSystem.MinMaxCurve(1f, AnimationCurve.Linear(0f, 1f, 1f, 0.2f));
var renderer = ps.GetComponent<ParticleSystemRenderer>();
renderer.material = mat;
renderer.renderMode = ParticleSystemRenderMode.Billboard;
return ps;
}
static AudioClip MakeClip(string name, float f0, float f1, float dur, float vol)
{
const int rate = 44100;
int len = Mathf.Max(16, (int)(dur * rate));
var clip = AudioClip.Create(name, len, 1, rate, false);
var data = new float[len];
float phase = 0f;
for (int i = 0; i < len; i++)
{
float t = i / (float)len;
float env = Mathf.Exp(-5f * t);
float freq = Mathf.Lerp(f0, f1, t);
phase += 2f * Mathf.PI * freq / rate;
data[i] = Mathf.Sin(phase) * env * vol;
}
clip.SetData(data, 0);
return clip;
}
static void PlayClip(AudioClip clip, Vector3 pos, float vol)
{
if (clip == null) return;
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
}
}
}