649f656833
All procedural, client-only, observe-only in PresentationSystemGroup, zero netcode surface (read existing replicated state). Shared FeedbackFx decal primitives (scorch blob + crack star meshes + transparent decal material). ScorchDecalSystem: static RequestScorch queue -> pooled fading discs (mirrors DynamicLightSystem), wired from the barrel boom, reusable by the geyser. CoverDamageSystem: crack accretion keyed on BlightClutter.Remaining (Variant 4) + proximity-gated shatter-scorch. RoomDressingSystem: persistent arena scars on a distinct hash sub-stream, torn down with dressing. Knobs in DecalConfig. Cover cracks use decals (not URPMaterialPropertyBaseColor) because the Synty prop shader is not Hybrid-Per-Instance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
8.5 KiB
C#
173 lines
8.5 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using UnityEngine;
|
|
|
|
namespace ProjectM.Client
|
|
{
|
|
/// <summary>
|
|
/// Phase 1.5b (ground bundle) — client-only, observe-only PER-ROOM DRESSING SCATTER. A managed
|
|
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> that OBSERVES replicated
|
|
/// <see cref="RunInfo"/> and instantiates biome-flavoured cosmetic ground props (pebbles, tufts, bones,
|
|
/// mushrooms) inside the room's ACTUAL shape — the same <see cref="RoomLayoutMath"/> authority the server
|
|
/// scatter uses, on a DISTINCT hash stream so dressing never co-locates with nodes (0x0DE) or clutter
|
|
/// (0xC17). Deterministic from the replicated run seed: every co-op client (and a late-joiner) sees the
|
|
/// same floor. Torn down when the room changes or the run ends. Colliders + rigidbodies are stripped on
|
|
/// spawn — the aim-reticle raycast and the physics world must never see dressing. Never mutates the sim.
|
|
/// <para>
|
|
/// Bundle 2 (decals): also lays a few persistent feathered crack/scorch DETAIL scars at the arena centre
|
|
/// (higher-frequency detail the baked ground splat can't resolve), on its own hash sub-stream, parented to
|
|
/// the same dressing root so they tear down together. Base POIs are skipped — Part O baked their wear in.
|
|
/// </para>
|
|
/// </summary>
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
|
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
|
public partial class RoomDressingSystem : SystemBase
|
|
{
|
|
Transform _root;
|
|
uint _activeKey;
|
|
|
|
// Bundle 2 arena-scar decal assets (reused across rebuilds; the scar GameObjects are children of _root and
|
|
// torn down each room change, these shared assets persist until OnDestroy).
|
|
Material _decalMat;
|
|
Mesh[] _decalMeshes;
|
|
MaterialPropertyBlock _decalMpb;
|
|
static readonly int DecalColorId = Shader.PropertyToID("_Color");
|
|
|
|
protected override void OnStartRunning()
|
|
{
|
|
if (_root == null) _root = new GameObject("~RoomDressing").transform;
|
|
}
|
|
|
|
protected override void OnDestroy()
|
|
{
|
|
if (_root != null) Object.Destroy(_root.gameObject);
|
|
if (_decalMat != null) Object.Destroy(_decalMat);
|
|
if (_decalMeshes != null)
|
|
for (int i = 0; i < _decalMeshes.Length; i++)
|
|
if (_decalMeshes[i] != null) Object.Destroy(_decalMeshes[i]);
|
|
}
|
|
|
|
protected override void OnUpdate()
|
|
{
|
|
if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") return;
|
|
if (_root == null) return;
|
|
|
|
var cfg = RoomDressingConfig.Instance;
|
|
bool inRoom = SystemAPI.TryGetSingleton<RunInfo>(out var ri)
|
|
&& (ri.Lifecycle == RunLifecycle.InRoom
|
|
|| ri.Lifecycle == RunLifecycle.RoomReward
|
|
|| ri.Lifecycle == RunLifecycle.RoomExplore);
|
|
bool on = cfg != null && cfg.Enabled && cfg.CountPerRoom > 0;
|
|
if (!inRoom || !on || !SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
|
{
|
|
Clear();
|
|
return;
|
|
}
|
|
|
|
uint key = RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E5u);
|
|
if (key == _activeKey) return;
|
|
Clear();
|
|
Build(in ri, in anchor, cfg);
|
|
_activeKey = key;
|
|
}
|
|
|
|
void Clear()
|
|
{
|
|
if (_activeKey == 0 || _root == null) { _activeKey = 0; return; }
|
|
for (int i = _root.childCount - 1; i >= 0; i--)
|
|
Object.Destroy(_root.GetChild(i).gameObject);
|
|
_activeKey = 0;
|
|
}
|
|
|
|
void Build(in RunInfo ri, in BaseAnchor anchor, RoomDressingConfig cfg)
|
|
{
|
|
GameObject[] set = ri.CurrentBiome switch
|
|
{
|
|
RoomBiomeId.Meadow => cfg.Meadow,
|
|
RoomBiomeId.Cavern => cfg.Cavern,
|
|
RoomBiomeId.Blight => cfg.Blight,
|
|
_ => cfg.Arid,
|
|
};
|
|
if (set == null || set.Length == 0) return;
|
|
|
|
float3 baseCenter = BaseGridMath.PlotCenter(anchor);
|
|
byte subSlot = (byte)(ri.CurrentRoom & 1);
|
|
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, subSlot);
|
|
float3 portal = RegionMath.ExpeditionPortalPos(baseCenter, subSlot);
|
|
|
|
var map = RunMapMath.Generate(ri.RunSeed);
|
|
var node = map.NodeAt(RunMap.NodeId(ri.CurrentRoom, ri.CurrentCol));
|
|
var plan = RoomLayoutMath.Plan(node, ri.CurrentRoom, ri.RoomCount);
|
|
var rng = new Unity.Mathematics.Random(RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E55u) | 1u);
|
|
|
|
int count = cfg.CountPerRoom;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng);
|
|
for (int attempt = 0; attempt < 4; attempt++)
|
|
{
|
|
bool nearLanding = math.distancesq(pos.xz, origin.xz) < 9f; // party lands at the origin
|
|
bool nearPortal = math.distancesq(pos.xz, portal.xz) < 6.25f; // keep the exit beacon clear
|
|
if (!nearLanding && !nearPortal) break;
|
|
pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng);
|
|
}
|
|
var prefab = set[rng.NextInt(0, set.Length)];
|
|
if (prefab == null) continue;
|
|
var go = Object.Instantiate(prefab, _root, false);
|
|
go.transform.SetPositionAndRotation(
|
|
new Vector3(pos.x, 0f, pos.z), // terrain y=0 (origin.y is the CC capsule plane, 1 u up)
|
|
Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
|
|
go.transform.localScale = Vector3.one * rng.NextFloat(cfg.ScaleMin, cfg.ScaleMax);
|
|
foreach (var col in go.GetComponentsInChildren<Collider>(true)) Object.Destroy(col);
|
|
foreach (var rb in go.GetComponentsInChildren<Rigidbody>(true)) Object.Destroy(rb);
|
|
}
|
|
|
|
// Bundle 2 arena scars: distinct hash sub-stream (0xD2E56) so they never co-locate with the prop
|
|
// scatter (0xD2E55); clustered around the arena centre. Persistent (no fade); torn down with dressing.
|
|
if (DecalConfig.Enabled && DecalConfig.RoomArenaDecalCount > 0)
|
|
{
|
|
EnsureDecalAssets();
|
|
var srng = new Unity.Mathematics.Random(RunMapMath.Hash(ri.RunSeed, (uint)(ri.CurrentRoom + 1), 0xD2E56u) | 1u);
|
|
for (int i = 0; i < DecalConfig.RoomArenaDecalCount; i++)
|
|
SpawnArenaScar(origin, i, ref srng);
|
|
}
|
|
}
|
|
|
|
void EnsureDecalAssets()
|
|
{
|
|
if (_decalMat == null) _decalMat = FeedbackFx.MakeDecalMaterial("RoomScarDecal");
|
|
if (_decalMpb == null) _decalMpb = new MaterialPropertyBlock();
|
|
if (_decalMeshes == null)
|
|
{
|
|
_decalMeshes = new Mesh[4];
|
|
_decalMeshes[0] = FeedbackFx.BuildScorchMesh(20, 0.32f, 11);
|
|
_decalMeshes[1] = FeedbackFx.BuildScorchMesh(20, 0.34f, 23);
|
|
_decalMeshes[2] = FeedbackFx.BuildCrackMesh(3, 31);
|
|
_decalMeshes[3] = FeedbackFx.BuildCrackMesh(2, 47);
|
|
}
|
|
}
|
|
|
|
void SpawnArenaScar(float3 origin, int index, ref Unity.Mathematics.Random rng)
|
|
{
|
|
var go = new GameObject("ArenaScar");
|
|
go.transform.SetParent(_root, false);
|
|
go.AddComponent<MeshFilter>().sharedMesh = _decalMeshes[rng.NextInt(0, _decalMeshes.Length)];
|
|
var mr = go.AddComponent<MeshRenderer>();
|
|
mr.sharedMaterial = _decalMat;
|
|
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
|
mr.receiveShadows = false;
|
|
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
|
|
_decalMpb.SetColor(DecalColorId, DecalConfig.RoomDecalColor);
|
|
mr.SetPropertyBlock(_decalMpb);
|
|
float ang = rng.NextFloat(0f, math.PI * 2f);
|
|
float rad = rng.NextFloat(0f, 4.5f); // clustered around the arena centre where the fight happens
|
|
float scale = rng.NextFloat(1.4f, 3.0f);
|
|
go.transform.SetPositionAndRotation(
|
|
new Vector3(origin.x + math.cos(ang) * rad, 0.05f + 0.003f * index, origin.z + math.sin(ang) * rad),
|
|
Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
|
|
go.transform.localScale = new Vector3(scale, 1f, scale);
|
|
}
|
|
}
|
|
}
|