cd607ae156
Phase 1.5 environmental hazard v1 (design-review wf_2cb10454-fdf: 13 findings confirmed + folded; Build Spec in the vault). ~25% of room clutter seeds as EXPLOSIVE (Variant 3 on the existing replicated byte - zero new GhostFields/prefabs/RPCs). The FUSE is the review's fold - one mechanism closes both HIGHs: - Pop sites (projectile sweep + isServer-gated melee harvest) do NOT destroy an explosive: they zero the replicated Remaining + add a server-only BarrelFuse (~36 ticks). The client sees Remaining=0 on a still-alive barrel across snapshots -> unambiguous fuse cue, and booms at despawn ONLY when cached Remaining<=0 - a teardown despawn carries Remaining>0, so portal-advance teardowns can never fire false booms (HIGH #1). - HazardExplosionSystem (server, plain group, presence-gated on BarrelFuse, never lifecycle-gated) detonates at ExplodeTick: radius damage to LIVING enemies AND players (boss-slam player filter verbatim; friendly fire = the bait mechanic), SourceTick stamped at detonation = the authoring moment (HIGH #2: the authored-tick contract dash i-frames negate against), SourceNetworkId=-1 (the environment convention - a player id would consume Charger whiff-punish windows). Fuse rides the RoomTag'd barrel -> teardown cleans lit barrels free. - Lit barrels are unhittable at both snapshot sites (no double-pop). - Client: WorldFeedback caches Variant -> boom-vs-puff split (big burst + light flash + boom SFX vs the old puff); DynamicLightSystem gives Variant-3 barrels a red danger glow that STROBES once fused. Verified: 470/470 EditMode (4 new: fused pop instead of destroy + unhittable, both-sides damage w/ -1 source + authored tick, dead-player + radius filters, unelapsed-fuse inert); live smoke - seeded 3/8 explosive, real-sweep pop, CLIENT observed the fuse cue on the live ghost, pinned bait enemy took exactly 26 (30->4), walk-out escape confirmed; console clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
174 lines
8.5 KiB
C#
174 lines
8.5 KiB
C#
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
|
|
{
|
|
/// <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; public byte Variant; }
|
|
|
|
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;
|
|
AudioClip _boomClip;
|
|
|
|
protected override void OnCreate()
|
|
{
|
|
_chipClip = MakeClip("harvest_chip", 900f, 1400f, 0.06f, 0.30f);
|
|
_clearClip = MakeClip("clutter_clear", 420f, 90f, 0.22f, 0.50f);
|
|
_boomClip = MakeClip("barrel_boom", 140f, 40f, 0.45f, 0.65f, noise: true, decay: 6f);
|
|
}
|
|
|
|
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<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), 0, 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<BlightClutter>, RefRO<LocalTransform>>().WithEntityAccess())
|
|
{
|
|
_seen.Add(e);
|
|
Observe(e, clutter.ValueRO.Remaining, xf.ValueRO.Position, true, WorldFeelConfig.WildTint, clutter.ValueRO.Variant, 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)
|
|
{
|
|
if (c.IsClutter && c.Variant == 3 && c.Remaining <= 0)
|
|
{
|
|
// EXPLOSIVE detonation: only a hit-popped barrel reaches despawn with a cached
|
|
// Remaining of 0 (a teardown despawn still carries Remaining>0) — the review-hardened
|
|
// boom-vs-puff split, so portal-advance teardowns can never fire false booms.
|
|
EmitTinted(_clearFx, (Vector3)c.Pos + Vector3.up * 0.6f, WorldFeelConfig.ClearBurstCount * 4, new Color(3.4f, 1.4f, 0.3f));
|
|
DynamicLightSystem.RequestFlash((Vector3)c.Pos, new Color(1f, 0.62f, 0.25f), 1.6f);
|
|
PlayClip(_boomClip, (Vector3)c.Pos, 0.8f);
|
|
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick * 2.2f, 90f);
|
|
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake * 2.5f);
|
|
}
|
|
else
|
|
{
|
|
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, byte variant, 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, Variant = variant };
|
|
}
|
|
|
|
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) ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
}
|