using System.Collections.Generic;
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;
using static ProjectM.Client.FeedbackFx;
namespace ProjectM.Client
{
///
/// Client-only WORLD JUICE for harvesting resource nodes + smashing Blight clutter. A managed presentation
/// system (SystemBase, main thread, NO Burst) in that REACTS to
/// replicated state — it never runs simulation. Each frame it edge-detects every node/clutter ghost's
/// replicated Remaining: 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
/// . Never destroys a ghost — GhostDespawnSystem owns despawn; we only OBSERVE.
///
[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 _cache = new();
readonly HashSet _seen = new();
readonly List _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(_fxRoot, "HarvestChips", mat, new Color(2.6f, 1.9f, 0.7f), 0.10f, 5f, 0.30f, 256, 0.25f, 0.10f, 0.2f);
_clearFx = MakeBurst(_fxRoot, "ClutterClear", mat, new Color(3.0f, 1.1f, 0.25f), 0.16f, 7f, 0.45f, 512, 0.25f, 0.10f, 0.2f);
}
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();
EntityManager.CompleteDependencyBeforeRO();
EntityManager.CompleteDependencyBeforeRO();
// Local player position (for the proximity gate).
bool haveLocal = false;
float3 localPos = default;
foreach (var xf in SystemAPI.Query>().WithAll())
{
localPos = xf.ValueRO.Position;
haveLocal = true;
}
_seen.Clear();
// Resource nodes — chip on depletion.
foreach (var (node, xf, e) in
SystemAPI.Query, RefRO>().WithEntityAccess())
{
_seen.Add(e);
Observe(e, node.ValueRO.Remaining, xf.ValueRO.Position, false, TintForResource(node.ValueRO.ResourceId), haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange);
}
// Blight clutter — chip on damage.
foreach (var (clutter, xf, e) in
SystemAPI.Query, RefRO>().WithEntityAccess())
{
_seen.Add(e);
Observe(e, clutter.ValueRO.Remaining, xf.ValueRO.Position, true, WorldFeelConfig.WildTint, haveLocal && math.distancesq(xf.ValueRO.Position, localPos) <= WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange);
}
// 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, bool nearLocal)
{
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);
if (nearLocal) // tiny per-hit camera impact, only for harvesting near the local player (not a teammate's far node)
{
if (WorldFeelConfig.ChipFovKick > 0f) PrototypeCameraRig.PunchFov(WorldFeelConfig.ChipFovKick, 90f);
if (WorldFeelConfig.ChipShake > 0f) PrototypeCameraRig.AddShake(WorldFeelConfig.ChipShake);
}
}
_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) ----
}
}