Compare commits
4 Commits
8da1402e25
...
4febf3dfe2
| Author | SHA1 | Date | |
|---|---|---|---|
| 4febf3dfe2 | |||
| f271d5f47f | |||
| 649f656833 | |||
| b3ef846fb8 |
@@ -0,0 +1,177 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &2427603878820464664
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 8054832990839364730}
|
||||
- component: {fileID: 7899507094755352317}
|
||||
- component: {fileID: 4642700531704847346}
|
||||
m_Layer: 0
|
||||
m_Name: Model
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &8054832990839364730
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2427603878820464664}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: -0, z: -0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 2.2, y: 2.2, z: 2.2}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 3572766465862231365}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!33 &7899507094755352317
|
||||
MeshFilter:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2427603878820464664}
|
||||
m_Mesh: {fileID: 4300000, guid: a52e2d101bf32694d9012060774e97fd, type: 3}
|
||||
--- !u!23 &4642700531704847346
|
||||
MeshRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 2427603878820464664}
|
||||
m_Enabled: 1
|
||||
m_CastShadows: 1
|
||||
m_ReceiveShadows: 1
|
||||
m_DynamicOccludee: 1
|
||||
m_StaticShadowCaster: 0
|
||||
m_MotionVectors: 1
|
||||
m_LightProbeUsage: 1
|
||||
m_ReflectionProbeUsage: 1
|
||||
m_RayTracingMode: 2
|
||||
m_RayTraceProcedural: 0
|
||||
m_RayTracingAccelStructBuildFlagsOverride: 0
|
||||
m_RayTracingAccelStructBuildFlags: 1
|
||||
m_SmallMeshCulling: 1
|
||||
m_ForceMeshLod: -1
|
||||
m_MeshLodSelectionBias: 0
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 1da6f4366b5f0ed40b87287df95b5a81, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
m_StaticBatchRoot: {fileID: 0}
|
||||
m_ProbeAnchor: {fileID: 0}
|
||||
m_LightProbeVolumeOverride: {fileID: 0}
|
||||
m_ScaleInLightmap: 1
|
||||
m_ReceiveGI: 1
|
||||
m_PreserveUVs: 0
|
||||
m_IgnoreNormalsForChartDetection: 0
|
||||
m_ImportantGI: 0
|
||||
m_StitchLightmapSeams: 1
|
||||
m_SelectedEditorRenderState: 3
|
||||
m_MinimumChartSize: 4
|
||||
m_AutoUVMaxDistance: 0.5
|
||||
m_AutoUVMaxAngle: 89
|
||||
m_LightmapParameters: {fileID: 0}
|
||||
m_GlobalIlluminationMeshLod: 0
|
||||
m_SortingLayerID: 0
|
||||
m_SortingLayer: 0
|
||||
m_SortingOrder: 0
|
||||
m_MaskInteraction: 0
|
||||
m_AdditionalVertexStreams: {fileID: 0}
|
||||
--- !u!1 &3885353946372160549
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 3572766465862231365}
|
||||
- component: {fileID: 9053853372340598254}
|
||||
- component: {fileID: 6834786618115927220}
|
||||
- component: {fileID: 5589603174154228860}
|
||||
m_Layer: 0
|
||||
m_Name: Geyser
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &3572766465862231365
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 8054832990839364730}
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &9053853372340598254
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring
|
||||
--- !u!114 &6834786618115927220
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent
|
||||
HasOwner: 0
|
||||
SupportAutoCommandTarget: 1
|
||||
TrackInterpolationDelay: 0
|
||||
GhostGroup: 0
|
||||
UsePreSerialization: 0
|
||||
UseSingleBaseline: 0
|
||||
RollbackPredictedSpawnedGhostState: 0
|
||||
RollbackPredictionOnStructuralChanges: 1
|
||||
DefaultGhostMode: 0
|
||||
SupportedGhostModes: 3
|
||||
OptimizationMode: 0
|
||||
Importance: 1
|
||||
MaxSendRate: 0
|
||||
prefabId: 8565e5eb00679fb45b8b7dac1e2ae9f3
|
||||
--- !u!114 &5589603174154228860
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: e45c85bf47692824eacc671035712f38, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.GeyserAuthoring
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8425e38ad7271e44693e65e8d574a993
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,27 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for a Blight-geyser ghost prefab (ownerless interpolated — duplicate from ResourceNode.prefab so the
|
||||
/// GhostAuthoringComponent + LinkedEntityGroup come free; keep a cosmetic vent mesh, NO physics collider — the
|
||||
/// hazard is the AoE, you walk over it). Bakes <see cref="Geyser"/> (NextEruptTick left 0 — the server stamps it
|
||||
/// born-correct from the live ServerTick at spawn in RoomFieldSystem; a 0 must NEVER erupt) +
|
||||
/// <see cref="RegionTag"/>{Expedition} so GhostRelevancy scopes it to expedition players. The field spawner
|
||||
/// overrides Position + the born-correct NextEruptTick per instance.
|
||||
/// </summary>
|
||||
public class GeyserAuthoring : MonoBehaviour
|
||||
{
|
||||
private class GeyserBaker : Baker<GeyserAuthoring>
|
||||
{
|
||||
public override void Bake(GeyserAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent(entity, new Geyser { NextEruptTick = 0u }); // 0 = unstamped; server stamps born-correct at spawn
|
||||
AddComponent(entity, new RegionTag { Region = RegionId.Expedition });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e45c85bf47692824eacc671035712f38
|
||||
@@ -0,0 +1,35 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Authoring for the baked <see cref="GeyserFieldSpawner"/> singleton (mirrors ClutterFieldSpawnerAuthoring /
|
||||
/// CoverFieldSpawnerAuthoring). Place once in the gameplay subscene and assign the geyser ghost prefab;
|
||||
/// RoomFieldSystem scatters it in Blight-biome rooms (never Boss). Carries no transform.
|
||||
/// </summary>
|
||||
public class GeyserFieldSpawnerAuthoring : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Geyser ghost prefab. Must carry GeyserAuthoring + a GhostAuthoringComponent (ownerless, interpolated).")]
|
||||
public GameObject GeyserPrefab;
|
||||
|
||||
[Tooltip("Number of geysers per Blight-biome room (Boss rooms get none).")]
|
||||
[Min(0)] public int Count = 2;
|
||||
|
||||
private class GeyserFieldSpawnerBaker : Baker<GeyserFieldSpawnerAuthoring>
|
||||
{
|
||||
public override void Bake(GeyserFieldSpawnerAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.None);
|
||||
AddComponent(entity, new GeyserFieldSpawner
|
||||
{
|
||||
Prefab = authoring.GeyserPrefab != null
|
||||
? GetEntity(authoring.GeyserPrefab, TransformUsageFlags.Dynamic)
|
||||
: Entity.Null,
|
||||
Count = authoring.Count,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e5d1544d6afe18245b563de599240493
|
||||
@@ -0,0 +1,177 @@
|
||||
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 COVER DAMAGE decals (Phase 1.5b bundle 2 — decals). Destructible cover = a
|
||||
/// <see cref="BlightClutter"/> ghost (Variant 4, Remaining 8 → 0; see the Destructible_Cover_Build_Spec). This
|
||||
/// observe-only <see cref="PresentationSystemGroup"/> <see cref="SystemBase"/> edge-detects the replicated
|
||||
/// <see cref="BlightClutter.Remaining"/> on cover ghosts and accretes jagged CRACK decals on the ground around
|
||||
/// the rock as it is carved (<see cref="WorldFeedbackSystem"/> already fires the chip puff / SFX / camera-punch
|
||||
/// on the same edge — we only add the persistent cracks). On despawn (the rock shattered NEAR the local player)
|
||||
/// it drops a small ground scorch via <see cref="ScorchDecalSystem.RequestScorch"/> and releases the piece's
|
||||
/// cracks. The despawn scorch is PROXIMITY-GATED (mirrors WorldFeedbackSystem) so a room-teardown / region-transit
|
||||
/// despawn storm — every cover ghost dropped at once, far off-camera — can't spray scorches or exhaust the pool.
|
||||
/// <para>
|
||||
/// Pure procedural decal quads — NO material/shader dependency. This is deliberate: cover's Synty prop shader
|
||||
/// declares <c>_BaseColor</c> as Unity-Per-Material (NOT Hybrid-Per-Instance), so the
|
||||
/// <c>URPMaterialPropertyBaseColor</c> darken that <see cref="EnemyHitFlashSystem"/> uses on the DOTS-authored
|
||||
/// AnimatedLitShader would silently no-op here. Decals sidestep that entirely and read as literal damage.
|
||||
/// </para>
|
||||
/// No new component / [GhostField], no server work. Knobs live in <see cref="DecalConfig"/>.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class CoverDamageSystem : SystemBase
|
||||
{
|
||||
const byte CoverVariant = 4;
|
||||
|
||||
class Piece
|
||||
{
|
||||
public int LastRemaining;
|
||||
public int MaxRemaining;
|
||||
public float3 Pos;
|
||||
public readonly List<GameObject> Cracks = new();
|
||||
}
|
||||
|
||||
readonly Dictionary<Entity, Piece> _tracked = new();
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
readonly List<Entity> _stale = new();
|
||||
|
||||
GameObject _root;
|
||||
Material _mat;
|
||||
Mesh[] _crackMeshes; // pre-built variants, reused across all cracks (no per-crack Mesh alloc/leak)
|
||||
MaterialPropertyBlock _mpb;
|
||||
static readonly int ColorId = Shader.PropertyToID("_Color");
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
_crackMeshes = new Mesh[6];
|
||||
for (int i = 0; i < _crackMeshes.Length; i++) _crackMeshes[i] = BuildCrackMesh(2 + (i % 2), i + 1);
|
||||
_mpb = new MaterialPropertyBlock();
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_root != null) Object.Destroy(_root);
|
||||
if (_mat != null) Object.Destroy(_mat);
|
||||
if (_crackMeshes != null)
|
||||
for (int i = 0; i < _crackMeshes.Length; i++)
|
||||
if (_crackMeshes[i] != null) Object.Destroy(_crackMeshes[i]);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") return;
|
||||
if (!DecalConfig.Enabled) { if (_tracked.Count > 0) ClearAll(); return; }
|
||||
|
||||
EntityManager.CompleteDependencyBeforeRO<BlightClutter>();
|
||||
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
||||
|
||||
if (_root == null)
|
||||
{
|
||||
_root = new GameObject("~CoverDecals");
|
||||
Object.DontDestroyOnLoad(_root);
|
||||
_mat = MakeDecalMaterial("CoverCrackDecal");
|
||||
}
|
||||
|
||||
// Local player position for the shatter-scorch proximity gate (mirrors WorldFeedbackSystem).
|
||||
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();
|
||||
foreach (var (clutter, xf, e) in
|
||||
SystemAPI.Query<RefRO<BlightClutter>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
if (clutter.ValueRO.Variant != CoverVariant) continue; // cover only
|
||||
_seen.Add(e);
|
||||
int remaining = clutter.ValueRO.Remaining;
|
||||
float3 pos = xf.ValueRO.Position;
|
||||
if (!_tracked.TryGetValue(e, out var piece))
|
||||
{
|
||||
// First sight: cache the baseline; a joiner mid-carve keeps its already-damaged look implicitly.
|
||||
_tracked[e] = new Piece { LastRemaining = remaining, MaxRemaining = math.max(1, remaining), Pos = pos };
|
||||
continue;
|
||||
}
|
||||
piece.Pos = pos;
|
||||
if (remaining < piece.LastRemaining)
|
||||
{
|
||||
// accrete cracks proportional to damage taken; cap at CoverCrackMaxCount.
|
||||
float dmgFrac = 1f - remaining / (float)piece.MaxRemaining;
|
||||
int want = math.clamp((int)math.round(DecalConfig.CoverCrackMaxCount * dmgFrac), 0, DecalConfig.CoverCrackMaxCount);
|
||||
while (piece.Cracks.Count < want) SpawnCrack(piece, piece.Cracks.Count);
|
||||
piece.LastRemaining = remaining;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- prune shattered cover ----
|
||||
if (_tracked.Count != _seen.Count)
|
||||
{
|
||||
float rangeSq = WorldFeelConfig.ProximityRange * WorldFeelConfig.ProximityRange;
|
||||
_stale.Clear();
|
||||
foreach (var kv in _tracked) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||
for (int i = 0; i < _stale.Count; i++)
|
||||
{
|
||||
var piece = _tracked[_stale[i]];
|
||||
// Only a real shatter NEAR the local player leaves a scorch; a teardown / region-transit despawn
|
||||
// storm (all cover dropped at once, off-camera) stays silent — no spray, no pool exhaustion.
|
||||
if (DecalConfig.CoverShatterScorch && haveLocal && math.distancesq(piece.Pos, localPos) <= rangeSq)
|
||||
ScorchDecalSystem.RequestScorch((Vector3)piece.Pos, 1.6f);
|
||||
DestroyCracks(piece);
|
||||
_tracked.Remove(_stale[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SpawnCrack(Piece piece, int index)
|
||||
{
|
||||
uint seed = (uint)math.max(1, index * 131 + (int)(math.abs(piece.Pos.x) * 7f + math.abs(piece.Pos.z) * 13f));
|
||||
var rng = new Unity.Mathematics.Random(seed);
|
||||
var go = new GameObject("Crack");
|
||||
go.transform.SetParent(_root.transform, false);
|
||||
go.AddComponent<MeshFilter>().sharedMesh = _crackMeshes[(index + (int)(seed % 3u)) % _crackMeshes.Length];
|
||||
var mr = go.AddComponent<MeshRenderer>();
|
||||
mr.sharedMaterial = _mat;
|
||||
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
mr.receiveShadows = false;
|
||||
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
|
||||
_mpb.SetColor(ColorId, DecalConfig.CoverCrackColor);
|
||||
mr.SetPropertyBlock(_mpb);
|
||||
|
||||
// scatter as GROUND cracks radiating from the rock's footprint (avoids z-fighting the rock mesh)
|
||||
float ang = rng.NextFloat(0f, math.PI * 2f);
|
||||
float rad = rng.NextFloat(0.8f, 1.8f);
|
||||
float scale = rng.NextFloat(0.9f, 1.6f);
|
||||
go.transform.SetPositionAndRotation(
|
||||
new Vector3(piece.Pos.x + math.cos(ang) * rad, 0.06f + 0.003f * index, piece.Pos.z + math.sin(ang) * rad),
|
||||
Quaternion.Euler(0f, rng.NextFloat(0f, 360f), 0f));
|
||||
go.transform.localScale = new Vector3(scale, 1f, scale);
|
||||
piece.Cracks.Add(go);
|
||||
}
|
||||
|
||||
void DestroyCracks(Piece piece)
|
||||
{
|
||||
for (int c = 0; c < piece.Cracks.Count; c++)
|
||||
if (piece.Cracks[c] != null) Object.Destroy(piece.Cracks[c]);
|
||||
piece.Cracks.Clear();
|
||||
}
|
||||
|
||||
void ClearAll()
|
||||
{
|
||||
foreach (var kv in _tracked) DestroyCracks(kv.Value);
|
||||
_tracked.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 63769d219acf0b248b321ef76bdf4189
|
||||
@@ -0,0 +1,68 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Live-tunable knobs for the client-only DECALS slice (Phase 1.5b bundle 2: explosion scorch pool, cover
|
||||
/// damage cracks, room-arena scars). A static bridge (mirrors <see cref="WorldFeelConfig"/> / <see cref="FeelConfig"/>)
|
||||
/// so values can be poked at runtime via MCP <c>execute_code</c> without a recompile
|
||||
/// (e.g. <c>ProjectM.Client.DecalConfig.ScorchColor = ...;</c>). Read ONLY by the decal presentation systems
|
||||
/// (<see cref="ScorchDecalSystem"/>, <see cref="CoverDamageSystem"/>) and <see cref="RoomDressingSystem"/> — all
|
||||
/// managed, main-thread. NEVER read from a [BurstCompile] system (managed-static + Color-in-Burst hazards).
|
||||
/// <see cref="ResetDefaults"/> re-stamps on play-enter via [RuntimeInitializeOnLoadMethod] because statics survive
|
||||
/// fast-enter-playmode reloads (else a poked value leaks across play-enters).
|
||||
/// </summary>
|
||||
public static class DecalConfig
|
||||
{
|
||||
/// <summary>Master gate for every decal (scorch pool + cover cracks + room scars).</summary>
|
||||
public static bool Enabled;
|
||||
|
||||
// ---- explosion scorch pool (ScorchDecalSystem) ----
|
||||
/// <summary>Scorch disc radius as a multiple of the explosion radius passed to RequestScorch.</summary>
|
||||
public static float ScorchRadiusMul;
|
||||
/// <summary>Seconds a fresh scorch holds at full strength before it starts fading.</summary>
|
||||
public static float ScorchHoldSec;
|
||||
/// <summary>Seconds a scorch fades from full to gone (then returns to the pool).</summary>
|
||||
public static float ScorchFadeSec;
|
||||
/// <summary>Peak scorch tint (dark char); the alpha is the peak opacity it fades from.</summary>
|
||||
public static Color ScorchColor;
|
||||
/// <summary>Max live scorch decals; the oldest is recycled past this.</summary>
|
||||
public static int ScorchPoolCap;
|
||||
|
||||
// ---- cover damage cracks (CoverDamageSystem) ----
|
||||
/// <summary>Crack-decal tint (dark).</summary>
|
||||
public static Color CoverCrackColor;
|
||||
/// <summary>Max crack decals accreted around one cover rock as it is carved to 0.</summary>
|
||||
public static int CoverCrackMaxCount;
|
||||
/// <summary>Drop a small ground scorch/debris mark where a cover rock shattered.</summary>
|
||||
public static bool CoverShatterScorch;
|
||||
|
||||
// ---- room-arena scars (RoomDressingSystem) ----
|
||||
/// <summary>Persistent crack/scorch detail decals laid at the room arena centre (0 = off).</summary>
|
||||
public static int RoomArenaDecalCount;
|
||||
/// <summary>Room-arena scar tint (dark, subtle).</summary>
|
||||
public static Color RoomDecalColor;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
public static void ResetDefaults()
|
||||
{
|
||||
Enabled = true;
|
||||
|
||||
// scorch pool — slightly inside the blast radius so it reads as the burn, not the danger ring
|
||||
ScorchRadiusMul = 0.85f;
|
||||
ScorchHoldSec = 4f;
|
||||
ScorchFadeSec = 6f;
|
||||
ScorchColor = new Color(0.05f, 0.04f, 0.03f, 0.85f); // near-black char, mostly opaque at peak
|
||||
ScorchPoolCap = 24;
|
||||
|
||||
// cover cracks
|
||||
CoverCrackColor = new Color(0.06f, 0.05f, 0.05f, 0.8f);
|
||||
CoverCrackMaxCount = 3;
|
||||
CoverShatterScorch = true;
|
||||
|
||||
// room-arena scars
|
||||
RoomArenaDecalCount = 3;
|
||||
RoomDecalColor = new Color(0.08f, 0.07f, 0.06f, 0.5f); // subtle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95c95df641b3d1a4cbce24348a716d66
|
||||
@@ -109,5 +109,100 @@ namespace ProjectM.Client
|
||||
if (clip == null) return;
|
||||
AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx);
|
||||
}
|
||||
|
||||
// ---- ground DECAL primitives (Bundle 2 — explosion scorch, cover cracks, room scars) ----
|
||||
|
||||
// Transparent unlit ground-decal material: Sprites/Default honours vertex colour × the per-renderer _Color
|
||||
// MPB, blends alpha, is double-sided (Cull Off) and writes no depth (ZWrite Off) — so flat, overlapping
|
||||
// ground decals never z-fight and never cast/receive shadows. Same shader family as the fuse disc.
|
||||
public static Material MakeDecalMaterial(string name = "GroundDecal")
|
||||
{
|
||||
Shader sh = Shader.Find("Sprites/Default");
|
||||
if (sh == null) sh = Shader.Find("Universal Render Pipeline/Particles/Unlit");
|
||||
if (sh == null) sh = Shader.Find("Unlit/Transparent");
|
||||
return new Material(sh) { name = name, renderQueue = 3000 }; // Transparent
|
||||
}
|
||||
|
||||
// Irregular soft radial disc on the XZ plane (already flat — scale x/z, spin about Y): vertex alpha 1 at the
|
||||
// centre fading to 0 at a JITTERED rim, so it reads as an organic scorch blob with NO hard rectangle edge
|
||||
// (the feathered rim is what keeps it from ghosting over fog the way an opaque quad did). White vertex colour
|
||||
// so the caller's material/MPB tint (dark char) shows through.
|
||||
public static Mesh BuildScorchMesh(int segments = 24, float jitter = 0.3f, int seed = 1)
|
||||
{
|
||||
if (segments < 6) segments = 6;
|
||||
var rng = new System.Random(seed * 6151 + 13);
|
||||
var m = new Mesh { name = "ScorchDecal" };
|
||||
var v = new Vector3[segments + 1];
|
||||
var col = new Color[segments + 1];
|
||||
var tris = new int[segments * 3];
|
||||
v[0] = Vector3.zero;
|
||||
col[0] = new Color(1f, 1f, 1f, 1f);
|
||||
for (int i = 0; i < segments; i++)
|
||||
{
|
||||
float a = i / (float)segments * Mathf.PI * 2f;
|
||||
float r = 1f - jitter * (float)rng.NextDouble();
|
||||
v[i + 1] = new Vector3(Mathf.Cos(a) * r, 0f, Mathf.Sin(a) * r);
|
||||
col[i + 1] = new Color(1f, 1f, 1f, 0f); // transparent feathered rim
|
||||
int n = (i + 1) % segments;
|
||||
tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
|
||||
}
|
||||
m.vertices = v; m.colors = col; m.triangles = tris;
|
||||
m.RecalculateBounds();
|
||||
return m;
|
||||
}
|
||||
|
||||
// A jagged crack star on the XZ plane: `spokes` thin tapered triangles radiating from the centre at random
|
||||
// angles/lengths, opaque at the base and feathered to nothing at the tip. Reads as surface cracking, visually
|
||||
// distinct from the scorch blob. White vertex colour; caller tints dark and scales it to the piece.
|
||||
public static Mesh BuildCrackMesh(int spokes = 3, int seed = 1)
|
||||
{
|
||||
if (spokes < 1) spokes = 1;
|
||||
var rng = new System.Random(seed * 9277 + 5);
|
||||
var m = new Mesh { name = "CrackDecal" };
|
||||
var v = new Vector3[spokes * 3];
|
||||
var col = new Color[spokes * 3];
|
||||
var tris = new int[spokes * 3];
|
||||
for (int s = 0; s < spokes; s++)
|
||||
{
|
||||
float a = (s / (float)spokes) * Mathf.PI * 2f + (float)(rng.NextDouble() - 0.5) * 1.2f;
|
||||
float len = 0.6f + 0.4f * (float)rng.NextDouble();
|
||||
float w = 0.06f + 0.05f * (float)rng.NextDouble();
|
||||
var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
|
||||
var perp = new Vector3(-dir.z, 0f, dir.x) * w;
|
||||
int b = s * 3;
|
||||
v[b] = perp; v[b + 1] = -perp; v[b + 2] = dir * len;
|
||||
col[b] = new Color(1f, 1f, 1f, 1f);
|
||||
col[b + 1] = new Color(1f, 1f, 1f, 1f);
|
||||
col[b + 2] = new Color(1f, 1f, 1f, 0f); // feathered tip
|
||||
tris[b] = b; tris[b + 1] = b + 2; tris[b + 2] = b + 1;
|
||||
}
|
||||
m.vertices = v; m.colors = col; m.triangles = tris;
|
||||
m.RecalculateBounds();
|
||||
return m;
|
||||
}
|
||||
|
||||
// Unit-radius upward-facing disc fan on the XZ plane (centre vertex + rim) for ground telegraph / blast-radius
|
||||
// rings — scale x/z to the radius. No vertex colours (white default) so the material or a per-renderer _Color
|
||||
// MPB fully tints + fades it. Promoted from WorldFeedbackSystem so the barrel fuse ring + the geyser telegraph
|
||||
// share ONE primitive (review M6).
|
||||
public static Mesh BuildDisc(int segments)
|
||||
{
|
||||
if (segments < 6) segments = 6;
|
||||
var m = new Mesh { name = "Disc" };
|
||||
var v = new Vector3[segments + 1];
|
||||
var tris = new int[segments * 3];
|
||||
v[0] = Vector3.zero;
|
||||
for (int i = 0; i < segments; i++)
|
||||
{
|
||||
float a = i / (float)segments * Mathf.PI * 2f;
|
||||
v[i + 1] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
|
||||
int n = (i + 1) % segments;
|
||||
tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
|
||||
}
|
||||
m.vertices = v;
|
||||
m.triangles = tris;
|
||||
m.RecalculateBounds();
|
||||
return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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 BLIGHT-GEYSER telegraph + erupt VFX (Phase 1.5b bundle 3; Geyser_Build_Spec). Observe-only
|
||||
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/> — sibling of
|
||||
/// <see cref="EnemyDangerTelegraphSystem"/>, never mutates the sim. Reads each geyser's replicated
|
||||
/// <see cref="Geyser.NextEruptTick"/> against the client's PREDICTED <c>NetworkTime.ServerTick</c> and derives
|
||||
/// everything from that ABSOLUTE tick (never an edge on the field — so it survives snapshot loss + relevancy
|
||||
/// re-entry; the design-review-hardened contract, review wf_900e9965-8f0 H1):
|
||||
/// <list type="bullet">
|
||||
/// <item>a growing/pulsing warning DISC at <see cref="Tuning.GeyserEruptRadius"/> over the last
|
||||
/// <see cref="Tuning.GeyserTelegraphTicks"/> before the eruption (the absolute-tick countdown, exactly the
|
||||
/// enemy-telegraph idiom);</item>
|
||||
/// <item>the erupt BURST fired when the countdown CROSSES <c>>0 → <=0</c>, LATCHED once per
|
||||
/// <c>NextEruptTick</c> value + an ARM-guard (only if a prior frame saw it counting down) — immune to the
|
||||
/// <c>0→stamp</c> phantom, relevancy re-entry mid-period, and snapshot loss.</item>
|
||||
/// </list>
|
||||
/// Erupt VFX reuse the shipped statics — <see cref="FeedbackFx"/> burst + <see cref="DynamicLightSystem.RequestFlash"/>
|
||||
/// + <see cref="ScorchDecalSystem.RequestScorch"/> (bundle 2, built for this). Pooled disc GameObjects under a
|
||||
/// private root, pruned every frame; a pruned geyser (teardown / relevancy drop) is SILENT.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class GeyserTelegraphSystem : SystemBase
|
||||
{
|
||||
static readonly Color DiscColor = new Color(2.6f, 0.35f, 2.4f); // hot blight magenta-purple (danger + biome id)
|
||||
static readonly Color EruptColor = new Color(2.9f, 0.5f, 3.2f); // brighter burst
|
||||
static readonly int ColorId = Shader.PropertyToID("_Color");
|
||||
|
||||
Transform _fxRoot;
|
||||
Material _discMat;
|
||||
Material _burstMat;
|
||||
Mesh _discMesh;
|
||||
ParticleSystem _eruptFx;
|
||||
AudioClip _eruptClip;
|
||||
MaterialPropertyBlock _mpb;
|
||||
|
||||
readonly Dictionary<Entity, GameObject> _discs = new();
|
||||
readonly Dictionary<Entity, uint> _armed = new(); // NextEruptTick we've seen counting down for
|
||||
readonly Dictionary<Entity, uint> _lastFired = new(); // NextEruptTick we last fired the burst for (latch)
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
readonly List<Entity> _stale = new();
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
_eruptClip = MakeClip("geyser_erupt", 90f, 300f, 0.30f, 0.55f, noise: true, decay: 5f);
|
||||
_mpb = new MaterialPropertyBlock();
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
if (_fxRoot != null) return;
|
||||
_fxRoot = new GameObject("~GeyserTelegraphFX").transform;
|
||||
_discMat = MakeParticleMaterial("GeyserDisc");
|
||||
_discMat.color = DiscColor;
|
||||
_discMesh = BuildDisc(40);
|
||||
_burstMat = MakeParticleMaterial("GeyserErupt");
|
||||
_eruptFx = MakeBurst(_fxRoot, "GeyserErupt", _burstMat, EruptColor, 0.18f, 8f, 0.5f, 256, -0.15f, 0.25f, 0.25f);
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
|
||||
if (_discMat != null) Object.Destroy(_discMat);
|
||||
if (_burstMat != null) Object.Destroy(_burstMat);
|
||||
if (_discMesh != null) Object.Destroy(_discMesh);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (_fxRoot == null || _discMat == null) return;
|
||||
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var nt) || !nt.ServerTick.IsValid) return;
|
||||
var serverTick = nt.ServerTick;
|
||||
|
||||
EntityManager.CompleteDependencyBeforeRO<Geyser>();
|
||||
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
||||
|
||||
_seen.Clear();
|
||||
foreach (var (geyser, xf, e) in
|
||||
SystemAPI.Query<RefRO<Geyser>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
uint next = geyser.ValueRO.NextEruptTick;
|
||||
if (next == 0u) continue; // unstamped -> no telegraph yet (born-correct pending)
|
||||
var untilTick = new NetworkTick(next);
|
||||
if (!untilTick.IsValid) continue;
|
||||
_seen.Add(e);
|
||||
|
||||
int lead = untilTick.TicksSince(serverTick); // >0 = erupt in the future, <=0 = erupt now/past
|
||||
float3 pos = xf.ValueRO.Position;
|
||||
|
||||
if (lead > 0)
|
||||
{
|
||||
_armed[e] = next; // arm this eruption while it counts down
|
||||
}
|
||||
else // lead <= 0: the erupt tick has arrived / passed
|
||||
{
|
||||
bool armedForThis = _armed.TryGetValue(e, out var av) && av == next;
|
||||
bool alreadyFired = _lastFired.TryGetValue(e, out var lf) && lf == next;
|
||||
if (armedForThis && !alreadyFired)
|
||||
{
|
||||
// fire ONCE per eruption, on the ServerTick crossing (co-located with the disc completion)
|
||||
EmitTinted(_eruptFx, (Vector3)pos + Vector3.up * 0.3f, 44, EruptColor);
|
||||
DynamicLightSystem.RequestFlash((Vector3)pos, new Color(0.85f, 0.35f, 1f), 1.4f);
|
||||
ScorchDecalSystem.RequestScorch((Vector3)pos, Tuning.GeyserEruptRadius);
|
||||
PlayClip(_eruptClip, (Vector3)pos, 0.7f);
|
||||
_lastFired[e] = next;
|
||||
}
|
||||
}
|
||||
|
||||
// Warning disc: grow + pulse over the telegraph window; hidden when dormant or erupted.
|
||||
bool showing = lead > 0 && lead <= Tuning.GeyserTelegraphTicks;
|
||||
if (showing)
|
||||
{
|
||||
float charge = 1f - lead / (float)Tuning.GeyserTelegraphTicks; // 0 at window open -> 1 at erupt
|
||||
if (!_discs.TryGetValue(e, out var go) || go == null)
|
||||
{
|
||||
go = new GameObject("GeyserWarning");
|
||||
go.transform.SetParent(_fxRoot, false);
|
||||
go.AddComponent<MeshFilter>().sharedMesh = _discMesh;
|
||||
var mr = go.AddComponent<MeshRenderer>();
|
||||
mr.sharedMaterial = _discMat;
|
||||
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
mr.receiveShadows = false;
|
||||
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
|
||||
_discs[e] = go;
|
||||
}
|
||||
if (!go.activeSelf) go.SetActive(true);
|
||||
float radius = Tuning.GeyserEruptRadius * (0.55f + 0.45f * charge);
|
||||
go.transform.position = new Vector3(pos.x, 0.055f, pos.z);
|
||||
go.transform.localScale = new Vector3(radius, 1f, radius);
|
||||
float alpha = 0.14f + 0.5f * charge + 0.12f * Mathf.Abs(Mathf.Sin(UnityEngine.Time.time * 10f));
|
||||
_mpb.SetColor(ColorId, new Color(DiscColor.r, DiscColor.g, DiscColor.b, alpha));
|
||||
go.GetComponent<MeshRenderer>().SetPropertyBlock(_mpb);
|
||||
}
|
||||
else if (_discs.TryGetValue(e, out var go) && go != null && go.activeSelf)
|
||||
{
|
||||
go.SetActive(false); // dormant (lead > window) or just erupted (lead <= 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Prune despawned geysers (teardown / relevancy drop) — destroy the disc, drop tracking, emit nothing.
|
||||
if (_discs.Count > 0)
|
||||
{
|
||||
_stale.Clear();
|
||||
foreach (var kv in _discs) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||
for (int i = 0; i < _stale.Count; i++)
|
||||
{
|
||||
if (_discs[_stale[i]] != null) Object.Destroy(_discs[_stale[i]]);
|
||||
_discs.Remove(_stale[i]);
|
||||
}
|
||||
}
|
||||
PruneMap(_armed);
|
||||
PruneMap(_lastFired);
|
||||
}
|
||||
|
||||
void PruneMap(Dictionary<Entity, uint> dict)
|
||||
{
|
||||
if (dict.Count == 0) return;
|
||||
_stale.Clear();
|
||||
foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||
for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9720b14cfcb422046a945abddddd8cfc
|
||||
@@ -14,6 +14,11 @@ namespace ProjectM.Client
|
||||
/// (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))]
|
||||
@@ -22,6 +27,13 @@ namespace ProjectM.Client
|
||||
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;
|
||||
@@ -30,6 +42,10 @@ namespace ProjectM.Client
|
||||
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()
|
||||
@@ -106,6 +122,51 @@ namespace ProjectM.Client
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Collections.Generic;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
using static ProjectM.Client.FeedbackFx;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-only EXPLOSION SCORCH pool (Phase 1.5b bundle 2 — decals). A fading pool of flat ground scorch discs
|
||||
/// dropped at boom sites via the static <see cref="RequestScorch"/> queue — the exact request-drain idiom of
|
||||
/// <see cref="DynamicLightSystem.RequestFlash"/>: <see cref="WorldFeedbackSystem"/> enqueues at a barrel
|
||||
/// detonation (same thread, same <see cref="PresentationSystemGroup"/>), we drain once per frame. Each decal
|
||||
/// HOLDS then FADES its alpha through a single reused <see cref="MaterialPropertyBlock"/> (per-renderer copy →
|
||||
/// independent lifetimes, no shared-material bleed) and returns to the pool. Pooled GameObjects live under a
|
||||
/// private DontDestroyOnLoad root (never scene-saved), scene-gated to "Game", null-guarded across scene reloads.
|
||||
/// Observe-only — never touches the sim, no netcode, no [GhostField]. Reusable by the future Blight-geyser hazard.
|
||||
/// Knobs live in <see cref="DecalConfig"/>.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class ScorchDecalSystem : SystemBase
|
||||
{
|
||||
struct ScorchRequest { public Vector3 Pos; public float Radius; }
|
||||
class Active { public GameObject Go; public MeshRenderer Renderer; public float SpawnTime; public float PeakAlpha; }
|
||||
|
||||
// Static request queue: WorldFeedbackSystem (same thread, PresentationSystemGroup) enqueues, we drain.
|
||||
static readonly List<ScorchRequest> Pending = new List<ScorchRequest>();
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetStatics() { Pending.Clear(); }
|
||||
|
||||
/// <summary>Queue a ground scorch at a detonation. <paramref name="radius"/> is the explosion radius; the
|
||||
/// decal is scaled by <see cref="DecalConfig.ScorchRadiusMul"/>.</summary>
|
||||
public static void RequestScorch(Vector3 pos, float radius)
|
||||
{
|
||||
if (Pending.Count < 64) Pending.Add(new ScorchRequest { Pos = pos, Radius = radius });
|
||||
}
|
||||
|
||||
GameObject _root;
|
||||
Material _mat;
|
||||
Mesh[] _meshes; // a few pre-jittered variants for organic variety (reused, no per-decal alloc)
|
||||
MaterialPropertyBlock _mpb;
|
||||
readonly List<Active> _active = new List<Active>();
|
||||
readonly Stack<GameObject> _pool = new Stack<GameObject>();
|
||||
static readonly int ColorId = Shader.PropertyToID("_Color");
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
_meshes = new Mesh[4];
|
||||
for (int i = 0; i < _meshes.Length; i++) _meshes[i] = BuildScorchMesh(24, 0.3f, i + 1);
|
||||
_mpb = new MaterialPropertyBlock();
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_root != null) Object.Destroy(_root);
|
||||
if (_mat != null) Object.Destroy(_mat);
|
||||
if (_meshes != null)
|
||||
for (int i = 0; i < _meshes.Length; i++)
|
||||
if (_meshes[i] != null) Object.Destroy(_meshes[i]);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != "Game") { Pending.Clear(); return; }
|
||||
if (!DecalConfig.Enabled)
|
||||
{
|
||||
Pending.Clear();
|
||||
if (_root != null && _root.activeSelf) _root.SetActive(false);
|
||||
return;
|
||||
}
|
||||
if (_root == null)
|
||||
{
|
||||
_root = new GameObject("~ScorchDecals");
|
||||
Object.DontDestroyOnLoad(_root);
|
||||
_mat = MakeDecalMaterial("ScorchDecal");
|
||||
}
|
||||
if (!_root.activeSelf) _root.SetActive(true);
|
||||
|
||||
float now = UnityEngine.Time.time;
|
||||
|
||||
// ---- drain new scorch requests ----
|
||||
for (int i = 0; i < Pending.Count; i++)
|
||||
{
|
||||
if (_active.Count >= Mathf.Max(1, DecalConfig.ScorchPoolCap)) RecycleOldest();
|
||||
var req = Pending[i];
|
||||
var go = Rent();
|
||||
float scale = Mathf.Max(0.5f, req.Radius * DecalConfig.ScorchRadiusMul);
|
||||
// small per-decal y-stagger avoids coplanar transparent z-fight between overlapping scorches
|
||||
float y = 0.045f + 0.002f * (_active.Count % 8);
|
||||
float yaw = Mathf.Repeat(req.Pos.x * 53f + req.Pos.z * 31f, 360f);
|
||||
go.transform.SetPositionAndRotation(new Vector3(req.Pos.x, y, req.Pos.z), Quaternion.Euler(0f, yaw, 0f));
|
||||
go.transform.localScale = new Vector3(scale, 1f, scale);
|
||||
int mi = Mathf.Abs(Mathf.RoundToInt(req.Pos.x + req.Pos.z)) % _meshes.Length;
|
||||
go.GetComponent<MeshFilter>().sharedMesh = _meshes[mi];
|
||||
_active.Add(new Active { Go = go, Renderer = go.GetComponent<MeshRenderer>(), SpawnTime = now, PeakAlpha = DecalConfig.ScorchColor.a });
|
||||
}
|
||||
Pending.Clear();
|
||||
|
||||
// ---- hold + fade + recycle ----
|
||||
float hold = Mathf.Max(0f, DecalConfig.ScorchHoldSec);
|
||||
float fade = Mathf.Max(0.01f, DecalConfig.ScorchFadeSec);
|
||||
var baseCol = DecalConfig.ScorchColor;
|
||||
for (int i = _active.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var a = _active[i];
|
||||
if (a.Go == null) { _active.RemoveAt(i); continue; } // scene reload can null pooled objects
|
||||
float age = now - a.SpawnTime;
|
||||
float alpha = age <= hold ? a.PeakAlpha : a.PeakAlpha * Mathf.Clamp01(1f - (age - hold) / fade);
|
||||
if (alpha <= 0.001f) { Return(a.Go); _active.RemoveAt(i); continue; }
|
||||
_mpb.SetColor(ColorId, new Color(baseCol.r, baseCol.g, baseCol.b, alpha));
|
||||
a.Renderer.SetPropertyBlock(_mpb);
|
||||
}
|
||||
}
|
||||
|
||||
GameObject Rent()
|
||||
{
|
||||
GameObject go = null;
|
||||
while (_pool.Count > 0) { go = _pool.Pop(); if (go != null) break; }
|
||||
if (go == null)
|
||||
{
|
||||
go = new GameObject("Scorch");
|
||||
go.transform.SetParent(_root.transform, false);
|
||||
go.AddComponent<MeshFilter>();
|
||||
var mr = go.AddComponent<MeshRenderer>();
|
||||
mr.sharedMaterial = _mat;
|
||||
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
mr.receiveShadows = false;
|
||||
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
|
||||
}
|
||||
go.SetActive(true);
|
||||
return go;
|
||||
}
|
||||
|
||||
void Return(GameObject go) { if (go != null) { go.SetActive(false); _pool.Push(go); } }
|
||||
|
||||
void RecycleOldest()
|
||||
{
|
||||
if (_active.Count == 0) return;
|
||||
Return(_active[0].Go);
|
||||
_active.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 225e2db8aab51ad47847beba0f4e0d7f
|
||||
@@ -139,6 +139,7 @@ namespace ProjectM.Client
|
||||
PlayClip(_boomClip, (Vector3)c.Pos, 0.8f);
|
||||
PrototypeCameraRig.PunchFov(WorldFeelConfig.ClearFovKick * 2.2f, 90f);
|
||||
PrototypeCameraRig.AddShake(WorldFeelConfig.ClearShake * 2.5f);
|
||||
ScorchDecalSystem.RequestScorch((Vector3)c.Pos, Tuning.BarrelExplodeRadius); // bundle 2: charred ground mark at the boom
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -198,27 +199,6 @@ void Observe(Entity e, int remaining, float3 pos, bool isClutter, Color tint, by
|
||||
go.transform.position = new Vector3(pos.x, 0.06f, pos.z);
|
||||
}
|
||||
|
||||
// Unit-radius upward-facing disc fan; scaled per ring to the blast radius.
|
||||
static Mesh BuildDisc(int segments)
|
||||
{
|
||||
var m = new Mesh { name = "FuseDisc" };
|
||||
var v = new Vector3[segments + 1];
|
||||
var tris = new int[segments * 3];
|
||||
v[0] = Vector3.zero;
|
||||
for (int i = 0; i < segments; i++)
|
||||
{
|
||||
float a = i / (float)segments * Mathf.PI * 2f;
|
||||
v[i + 1] = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
|
||||
int n = (i + 1) % segments;
|
||||
tris[i * 3] = 0; tris[i * 3 + 1] = n + 1; tris[i * 3 + 2] = i + 1;
|
||||
}
|
||||
m.vertices = v;
|
||||
m.triangles = tris;
|
||||
m.RecalculateBounds();
|
||||
return m;
|
||||
}
|
||||
|
||||
|
||||
static Color TintForResource(byte resourceId)
|
||||
{
|
||||
if (resourceId == ResourceId.Ore) return WorldFeelConfig.OreTint;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Erupts PERMANENT Blight <see cref="Geyser"/>s (Geyser_Build_Spec, design-review wf_900e9965-8f0): when a
|
||||
/// geyser's replicated <see cref="Geyser.NextEruptTick"/> elapses, radius-damage LIVING enemies AND players
|
||||
/// (friendly fire — the bait/lure mechanic, exactly like the exploding-barrel hazard), then RESCHEDULE
|
||||
/// NextEruptTick one period ahead of NOW and leave the geyser alive (it is permanent — <see cref="RoomTag"/>
|
||||
/// teardown is the only removal; NEVER DestroyEntity here). Player + enemy gather mirrors
|
||||
/// <see cref="HazardExplosionSystem"/> VERBATIM (Health > 0; players also RegionTag == Expedition;
|
||||
/// <see cref="DamageEvent.SourceNetworkId"/> = -1 environment convention; SourceTick stamped at NOW, the
|
||||
/// authoring tick, so dash i-frames negate against it).
|
||||
/// <para>
|
||||
/// ★ The invalid-tick guard is INVERTED from the barrel (review HIGH H2): an unstamped/invalid NextEruptTick
|
||||
/// (0, the "ready" sentinel) is NOT-READY and is SKIPPED — it must NEVER erupt. Copying the barrel's
|
||||
/// "invalid -> detonate" fall-through onto a born-0 [GhostField] would erupt every tick and wipe the party.
|
||||
/// The reschedule is <c>= now + period</c> (NEVER <c>+=</c> — an anchored accumulator catch-up-storms after any
|
||||
/// multi-period skip; every tick-sentinel in the project is <c>= now + delay</c>). Plain server
|
||||
/// <see cref="SimulationSystemGroup"/>, NO ordering edges; presence-gated on <see cref="Geyser"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct GeyserEruptSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<Geyser>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) || !netTime.ServerTick.IsValid)
|
||||
return;
|
||||
var serverTick = netTime.ServerTick;
|
||||
uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); // SourceTick = NOW (authoring tick)
|
||||
uint reschedule = TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks); // = now + period
|
||||
|
||||
var erupts = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (geyser, lt) in SystemAPI.Query<RefRW<Geyser>, RefRO<LocalTransform>>())
|
||||
{
|
||||
uint next = geyser.ValueRO.NextEruptTick;
|
||||
var until = new NetworkTick(next);
|
||||
// INVERTED guard (review H2): an unstamped/invalid NextEruptTick (0) is NOT-READY and must NEVER
|
||||
// erupt. LAZY-STAMP it born-correct (now + period) instead — self-heals the rare case where the
|
||||
// seed-time stamp missed (NetworkTime invalid at seed), so a 0 can neither storm nor stay inert.
|
||||
if (next == 0u || !until.IsValid) { geyser.ValueRW.NextEruptTick = reschedule; continue; }
|
||||
if (until.IsNewerThan(serverTick)) continue; // still counting down
|
||||
erupts.Add(lt.ValueRO.Position);
|
||||
geyser.ValueRW.NextEruptTick = reschedule; // reschedule in place; permanent, never destroyed
|
||||
}
|
||||
|
||||
if (erupts.Length > 0)
|
||||
{
|
||||
float radiusSq = Tuning.GeyserEruptRadius * Tuning.GeyserEruptRadius;
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
// Players: living + expedition only (the HazardExplosionSystem gather verbatim).
|
||||
foreach (var (hp, region, plt, player) in
|
||||
SystemAPI.Query<RefRO<Health>, RefRO<RegionTag>, RefRO<LocalTransform>>()
|
||||
.WithAll<PlayerTag>().WithEntityAccess())
|
||||
{
|
||||
if (hp.ValueRO.Current <= 0f || region.ValueRO.Region != RegionId.Expedition) continue;
|
||||
for (int i = 0; i < erupts.Length; i++)
|
||||
if (math.distancesq(plt.ValueRO.Position.xz, erupts[i].xz) <= radiusSq)
|
||||
ecb.AppendToBuffer(player, new DamageEvent
|
||||
{
|
||||
Amount = Tuning.GeyserEruptDamage,
|
||||
SourceNetworkId = -1,
|
||||
SourceTick = stamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Enemies: living only (Health > 0 also excludes Dying corpses, the established convention).
|
||||
foreach (var (hp, elt, enemy) in
|
||||
SystemAPI.Query<RefRO<Health>, RefRO<LocalTransform>>()
|
||||
.WithAll<EnemyTag>().WithEntityAccess())
|
||||
{
|
||||
if (hp.ValueRO.Current <= 0f) continue;
|
||||
for (int i = 0; i < erupts.Length; i++)
|
||||
if (math.distancesq(elt.ValueRO.Position.xz, erupts[i].xz) <= radiusSq)
|
||||
ecb.AppendToBuffer(enemy, new DamageEvent
|
||||
{
|
||||
Amount = Tuning.GeyserEruptDamage,
|
||||
SourceNetworkId = -1,
|
||||
SourceTick = stamp,
|
||||
});
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
erupts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2e98116af8019a488692870c6f09985
|
||||
@@ -3,6 +3,7 @@ using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
@@ -155,6 +156,45 @@ namespace ProjectM.Server
|
||||
}
|
||||
}
|
||||
|
||||
// BLIGHT GEYSER (OPTIONAL singleton; Geyser_Build_Spec / review wf_900e9965-8f0): a PERMANENT
|
||||
// periodic BOTH-SIDES AoE hazard, ONLY in Blight-biome rooms (gate on the LOCAL plan, like the
|
||||
// cover block). Never Boss rooms — a DESIGN choice (the geyser has no collider, so unlike cover it
|
||||
// is NOT the depenetration concern). Distinct hash sub-stream 0x6E7; keep-out ring around origin.
|
||||
// BORN-CORRECT: stamp NextEruptTick from the LIVE ServerTick (staggered per instance so eruptions
|
||||
// desync) so the first snapshot never carries the 0 sentinel; if NetworkTime is invalid this tick
|
||||
// the geyser ships 0 and GeyserEruptSystem lazy-stamps it born-correct instead (never a storm).
|
||||
if (plan.Biome == RoomBiomeId.Blight
|
||||
&& plan.RoomType != RoomTypeId.Boss
|
||||
&& SystemAPI.TryGetSingleton<GeyserFieldSpawner>(out var geyser)
|
||||
&& geyser.Prefab != Entity.Null)
|
||||
{
|
||||
uint eruptStamp = 0u;
|
||||
if (SystemAPI.TryGetSingleton<NetworkTime>(out var gnt) && gnt.ServerTick.IsValid)
|
||||
eruptStamp = gnt.ServerTick.TickIndexForValidTick;
|
||||
var gBaked = SystemAPI.GetComponent<LocalTransform>(geyser.Prefab);
|
||||
var grng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x6E7u) | 1u);
|
||||
int gCount = math.clamp(geyser.Count, 0, 4);
|
||||
const float GeyserKeepOut = 7f;
|
||||
for (int i = 0; i < gCount; i++)
|
||||
{
|
||||
float3 pos = origin;
|
||||
for (int attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, gCount, ref grng);
|
||||
if (math.distance(pos.xz, origin.xz) >= GeyserKeepOut) break;
|
||||
}
|
||||
if (math.distance(pos.xz, origin.xz) < GeyserKeepOut) continue; // unlucky draws: drop the piece
|
||||
var e = ecb.Instantiate(geyser.Prefab);
|
||||
ecb.SetComponent(e, gBaked.WithPosition(pos));
|
||||
// born-correct + per-instance stagger so geysers desync; 0 only if NetworkTime was invalid.
|
||||
uint next = eruptStamp != 0u
|
||||
? TickUtil.NonZero(eruptStamp + Tuning.GeyserPeriodTicks + (uint)i * 60u)
|
||||
: 0u;
|
||||
ecb.SetComponent(e, new Geyser { NextEruptTick = next });
|
||||
ecb.AddComponent(e, new RoomTag { Room = room });
|
||||
}
|
||||
}
|
||||
|
||||
rf.LastSpawnedRoomEpoch = run.RoomEpoch;
|
||||
SystemAPI.SetComponent(spawnerEntity, rf);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// A PERMANENT periodic BLIGHT hazard fixture (Phase 1.5b bundle 3; see Geyser_Build_Spec). An ownerless
|
||||
/// INTERPOLATED ghost placed only in Blight-biome expedition rooms. <see cref="NextEruptTick"/> is the ABSOLUTE
|
||||
/// server tick of the next eruption — the ONLY replicated field; the client derives its telegraph countdown and
|
||||
/// erupt-fire from it against its own predicted ServerTick (absolute-tick, NEVER an edge on the field — so it
|
||||
/// survives snapshot loss + relevancy re-entry; the design-review-hardened contract, see Geyser_Build_Spec §7).
|
||||
/// Server-only <see cref="ProjectM.Server"/> GeyserEruptSystem radius-damages LIVING players AND enemies (friendly
|
||||
/// fire, like exploding barrels — also a tactical lure) when it elapses, then reschedules one period ahead of NOW
|
||||
/// (never destroyed; RoomTag teardown removes it). Born-correct: RoomFieldSystem stamps NextEruptTick from the
|
||||
/// live ServerTick at spawn, so the first snapshot never carries the 0 "ready/unstamped" sentinel (a baked 0 must
|
||||
/// be treated as not-ready and NEVER erupted — the inverted invalid-tick guard).
|
||||
/// </summary>
|
||||
public struct Geyser : IComponentData
|
||||
{
|
||||
/// <summary>Absolute server tick of the next eruption (via TickUtil.NonZero; 0 = unstamped = never erupt).</summary>
|
||||
[GhostField] public uint NextEruptTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 686fc215cf5ebd84ab06f560f9a16d9e
|
||||
@@ -0,0 +1,20 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Baked singleton holding the BLIGHT-GEYSER ghost prefab (Geyser_Build_Spec; a ResourceNode sibling — a
|
||||
/// <see cref="Geyser"/> carrier with RegionTag{Expedition} and NO collider). RoomFieldSystem scatters
|
||||
/// <see cref="Count"/> geysers per room epoch ONLY in Blight-biome rooms (plan.Biome == Blight) and NEVER in
|
||||
/// Boss rooms, with a keep-out ring around the room origin (player landing + portal). OPTIONAL: absent
|
||||
/// singleton = no geysers. Mirrors <see cref="CoverFieldSpawner"/>; carries no transform.
|
||||
/// </summary>
|
||||
public struct GeyserFieldSpawner : IComponentData
|
||||
{
|
||||
/// <summary>Baked geyser ghost prefab to instantiate.</summary>
|
||||
public Entity Prefab;
|
||||
|
||||
/// <summary>Geysers per Blight-biome room (Boss rooms get none).</summary>
|
||||
public int Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e7f72ba05b64124fbe572dbadb2a05f
|
||||
@@ -121,6 +121,22 @@ namespace ProjectM.Simulation
|
||||
/// <summary>Detonation damage — hits LIVING enemies AND players alike (friendly fire = the bait mechanic).</summary>
|
||||
public const float BarrelExplodeDamage = 26f;
|
||||
|
||||
// ---- Blight geyser (Phase 1.5b bundle-3 hazard; periodic BOTH-SIDES AoE — see Geyser_Build_Spec) ----
|
||||
|
||||
/// <summary>Ticks between eruptions (~5 s @60). Server reschedules NextEruptTick = now + this each erupt
|
||||
/// (never +=, per the review — anchored += catch-up-storms after a skip).</summary>
|
||||
public const uint GeyserPeriodTicks = 300;
|
||||
|
||||
/// <summary>Telegraph window (ticks, ~1.3 s @60): the client grows the warning disc over the last this-many
|
||||
/// ticks before an eruption (int so the client lead comparison stays signed).</summary>
|
||||
public const int GeyserTelegraphTicks = 78;
|
||||
|
||||
/// <summary>Eruption AoE radius (world units, XZ) — hits LIVING players AND enemies (friendly fire = lure bait).</summary>
|
||||
public const float GeyserEruptRadius = 3.0f;
|
||||
|
||||
/// <summary>Eruption damage per hit.</summary>
|
||||
public const float GeyserEruptDamage = 22f;
|
||||
|
||||
// ---- Expedition BOSS (a scaled Charger given a real kit by BossAISystem; server-only feel consts) ----
|
||||
|
||||
/// <summary>Radial SLAM AoE radius (world units): a player inside this ring at wind-up elapse eats the hit
|
||||
|
||||
@@ -344,6 +344,7 @@ GameObject:
|
||||
- component: {fileID: 17637047}
|
||||
- component: {fileID: 17637046}
|
||||
- component: {fileID: 17637048}
|
||||
- component: {fileID: 17637049}
|
||||
m_Layer: 0
|
||||
m_Name: ResourceFieldSpawner
|
||||
m_TagString: Untagged
|
||||
@@ -396,6 +397,20 @@ MonoBehaviour:
|
||||
ClutterPrefab: {fileID: 3885353946372160549, guid: 6ffeddcc4482c0f44880dc9a555884dd, type: 3}
|
||||
Count: 14
|
||||
Radius: 14
|
||||
--- !u!114 &17637049
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 17637045}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: e5d1544d6afe18245b563de599240493, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.GeyserFieldSpawnerAuthoring
|
||||
GeyserPrefab: {fileID: 3885353946372160549, guid: 8425e38ad7271e44693e65e8d574a993, type: 3}
|
||||
Count: 2
|
||||
--- !u!1 &25399149
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for <see cref="GeyserEruptSystem"/> (Geyser_Build_Spec; design-review
|
||||
/// wf_900e9965-8f0). Pins the review-hardened contract: an ELAPSED geyser radius-damages LIVING players AND
|
||||
/// enemies (friendly fire) with SourceNetworkId = -1, RESCHEDULES NextEruptTick = now + period (never +=, never
|
||||
/// the 0 sentinel), and is NEVER destroyed (opposite of the barrel). The load-bearing HIGH regression: a geyser
|
||||
/// that ships an UNSTAMPED NextEruptTick (0) must NEVER erupt — the inverted guard lazy-stamps it born-correct
|
||||
/// instead of the party-wiping per-tick barrage that copying the barrel's invalid-guard verbatim would cause.
|
||||
/// </summary>
|
||||
public class GeyserEruptSystemTests
|
||||
{
|
||||
const uint T0 = 1000;
|
||||
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick = T0)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<GeyserEruptSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var nt = world.EntityManager.CreateEntity(typeof(NetworkTime));
|
||||
world.EntityManager.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakeGeyser(EntityManager em, float3 pos, uint nextEruptTick)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new Geyser { NextEruptTick = nextEruptTick });
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em, float3 pos, float health, byte region)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new PlayerTag());
|
||||
em.AddComponentData(e, new Health { Current = health, Max = 130f });
|
||||
em.AddComponentData(e, new RegionTag { Region = region });
|
||||
em.AddBuffer<DamageEvent>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeEnemy(EntityManager em, float3 pos, float health)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new EnemyTag());
|
||||
em.AddComponentData(e, new Health { Current = health, Max = 30f });
|
||||
em.AddBuffer<DamageEvent>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Elapsed_Geyser_Damages_Living_Both_Sides_Reschedules_And_Survives()
|
||||
{
|
||||
var (world, group) = MakeWorld("EruptBoth");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var geyser = MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: T0); // due exactly now
|
||||
var player = MakePlayer(em, new float3(11, 0, 10), health: 100f, region: RegionId.Expedition);
|
||||
var enemy = MakeEnemy(em, new float3(10, 0, 12), health: 30f);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsTrue(em.Exists(geyser), "A geyser is PERMANENT — it is never destroyed on erupt.");
|
||||
var pd = em.GetBuffer<DamageEvent>(player);
|
||||
Assert.AreEqual(1, pd.Length, "Friendly fire: the living expedition player in radius is hit.");
|
||||
Assert.AreEqual(Tuning.GeyserEruptDamage, pd[0].Amount);
|
||||
Assert.AreEqual(-1, pd[0].SourceNetworkId, "Environment convention: never a player id.");
|
||||
Assert.AreNotEqual(0u, pd[0].SourceTick, "SourceTick stamped at NOW (the authored-tick contract).");
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "The living enemy in radius is hit too (bait mechanic).");
|
||||
|
||||
Assert.AreEqual(TickUtil.NonZero(T0 + Tuning.GeyserPeriodTicks), em.GetComponentData<Geyser>(geyser).NextEruptTick,
|
||||
"Reschedule = now + period (never += ; never the 0 sentinel).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Unstamped_Geyser_Never_Erupts_And_Is_Lazy_Stamped()
|
||||
{
|
||||
// The HIGH regression: a baked-0 NextEruptTick at a high serverTick must NOT storm (the inverted guard).
|
||||
var (world, group) = MakeWorld("UnstampedNoStorm", serverTick: 5000);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var geyser = MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: 0u); // unstamped
|
||||
var player = MakePlayer(em, new float3(10, 0, 10), health: 130f, region: RegionId.Expedition);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length,
|
||||
"An unstamped (0) geyser must NEVER erupt — no per-tick barrage.");
|
||||
Assert.AreEqual(TickUtil.NonZero(5000u + Tuning.GeyserPeriodTicks), em.GetComponentData<Geyser>(geyser).NextEruptTick,
|
||||
"0 is lazy-stamped born-correct (now + period) instead of erupting.");
|
||||
Assert.IsTrue(em.Exists(geyser));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dead_Player_And_Out_Of_Radius_Get_Nothing()
|
||||
{
|
||||
var (world, group) = MakeWorld("EruptFilters");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: T0);
|
||||
var deadPlayer = MakePlayer(em, new float3(10.5f, 0, 10), health: 0f, region: RegionId.Expedition);
|
||||
var basePlayer = MakePlayer(em, new float3(10.5f, 0, 10), health: 100f, region: RegionId.Base);
|
||||
var farEnemy = MakeEnemy(em, new float3(10 + Tuning.GeyserEruptRadius + 1f, 0, 10), health: 30f);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(deadPlayer).Length, "A dead player in radius gets NOTHING.");
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(basePlayer).Length, "A non-expedition player gets NOTHING.");
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(farEnemy).Length, "Out of radius gets nothing.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Unelapsed_Geyser_Does_Nothing_And_Keeps_Counting_Down()
|
||||
{
|
||||
var (world, group) = MakeWorld("EruptWait");
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var geyser = MakeGeyser(em, new float3(10, 0, 10), nextEruptTick: T0 + 120); // still counting down
|
||||
var player = MakePlayer(em, new float3(10, 0, 10), health: 100f, region: RegionId.Expedition);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "No damage before the eruption tick.");
|
||||
Assert.AreEqual(T0 + 120, em.GetComponentData<Geyser>(geyser).NextEruptTick, "An un-elapsed geyser is not rescheduled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a02ba4950608fb347a8491783ebc4746
|
||||
@@ -72,7 +72,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
- **A system-ordering CYCLE is INVISIBLE to plain-Entities EditMode tests** (they register systems individually, unsorted) — it only throws `ComponentSystemSorter` "circular dependency cycle" at **world creation (Play)**. When you add cross-system `[UpdateBefore/After]`, re-audit the EXISTING `[Update*]` attributes of the systems you order around and **always Play-validate**. [[DR-017_Persistent_Base_Player_Driven_Pacing]]
|
||||
- **A dev/debug `IRpcCommand` wire TYPE must be UNCONDITIONAL (no `#if`)** — the reflection-built RpcCollection hash must match across release/dev peers or the handshake refuses; `#if UNITY_EDITOR`-gate only the send/receive SYSTEMS, never the request struct. **Re-mean bytes, don't rename**: unchanged byte VALUES keep the `[GhostField]` serializer identical → re-bake-free (only authoring *default-value* edits re-bake the subscene).
|
||||
- **Derive enableable gates instead of replicating them.** e.g. player `Dead` = a LOCAL enableable derived every predicted tick from replicated `Health<=0` (rollback-correct, no `[GhostEnabledBit]`). To write the bit on a disabled entity the query must visit it (`.WithPresent<Dead>()`); **bake the enableable DISABLED** so instances spawn off. Respawn/death *timing* is server-only.
|
||||
- **Cooldown/spawn "next tick" sentinels:** route every stored tick through **`TickUtil.NonZero(...)`** (a computed `ServerTick+delay` can wrap to 0, the "ready" sentinel) and compare with `NetworkTick.IsNewerThan` / `.TicksSince`, **never** raw `uint <` / subtraction.
|
||||
- **Cooldown/spawn "next tick" sentinels:** route every stored tick through **`TickUtil.NonZero(...)`** (a computed `ServerTick+delay` can wrap to 0, the "ready" sentinel) and compare with `NetworkTick.IsNewerThan` / `.TicksSince`, **never** raw `uint <` / subtraction. **★ A BAKED `[GhostField]` scheduled-tick defaults to 0 → the "invalid-tick ⇒ fire" guard that's safe for a runtime-ADDED fuse STORMS it (0 fails `IsValid`, falls through, fires every tick); for a baked/periodic tick INVERT it (0 = not-ready → skip/lazy-stamp) + stamp born-correct at spawn. Client cues off a periodic tick ride the ABSOLUTE tick + a value-latch + a was-counting-down arm-guard — never edge-detect the field increment (phantom-fires on `0→stamp` + relevancy re-entry).** See [[Geyser_Build_Spec]].
|
||||
- **`GhostRelevancy` for region splits:** use `GhostRelevancyMode.SetIsIrrelevant` (not `SetIsRelevant`) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide. `RegionTag{byte Region}` is **server-only, NOT a `[GhostField]`**. **★ A 2nd region sharing an EXISTING tag (`EnemyTag`) → re-audit every query/cull over it: once-safe global despawns/cleared-checks then wipe or block cross-region (DR-031, DR-040).** `RelevantGhostForConnection` = `{int Connection=NetworkId.Value; int Ghost=ghostId}`. See [[DR-013_M6_Aether_Cycle_Region_Split]].
|
||||
- **Shared GLOBAL state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost**, never a region-tagged one (`SetIsIrrelevant` would hide it cross-region). Resolve the ledger via its DISTINCT `ResourceLedger` tag (the multi-`StorageEntry` "multiple instances" rule — EB-2 line).
|
||||
- **Frontend world lifecycle (menu → on-demand worlds) ★:** use `CreateClientWorld`/`CreateServerWorld` (they register the `ServerWorld`/`ClientWorld` statics the UI reads; `CreateLocalWorld` was internal pre-6.5, PUBLIC on 6.5.0); menu world via `DefaultWorldInitialization.Initialize(name, false)`. **Never dispose/create worlds inside an ECS system** — do it on a frame-boundary coroutine (`SessionRunner`, `DontDestroyOnLoad`). The gameplay subscene streams in ONLY if a netcode world is the `DefaultGameObjectInjectionWorld` at `LoadScene` time. See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||
@@ -105,6 +105,7 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
||||
- BefourStudios HDRP art: convert via `EnvArtTools.cs`, never switch pipelines (HDRP breaks EG); Synty = URP-native → archive 2026-07-06.
|
||||
- **World = cosmetic Synty nature biomes ★ (DR-025):** `Game.unity` roots `BaseBiome`(Meadow_Forest)@origin + `ExpeditionBiome`(Arid_Desert)@+1000; ground = stock URP/Lit (NOT prop-atlas `S_General`). Per-region fog/ambient cross-fade via client `WorldAtmosphereSystem` (camera X>500). **PNB fog/cloud-ring prefabs = white torus — don't place.** See [[DR-025_World_Environment_Redo_Natural_Frontier]].
|
||||
- **A dark-lit screenshot MASKS material bugs — verify material *values*.** `shader.GetPropertyType(idx)`-guard before `GetColor`/`GetFloat`/`GetTexture` (`S_General`'s `_BaseColorMultiply` is a float → `GetColor` returns black). Gate emission on the `_Emissive` flag + a fixture name; keep converted env metallic low (0.1–0.2).
|
||||
- **An EG per-instance tint (`URPMaterialPropertyBaseColor`) darkens a ghost ONLY if the shader's `_BaseColor` is Hybrid-Per-Instance** (ShaderGraph `overrideHLSLDeclaration:true`+`hlslDeclarationOverride:2`). Enemies flash because `AnimatedLitShader` is DOTS-authored; a **stock Synty prop graph (`Generic_Basic`) is Unity-Per-Material → the override RENDERS but silently no-ops.** Check the graph before planning a per-instance tint on a baked/prop mesh; else use procedural decal quads (07-12 cover damage-cracks, Part P).
|
||||
- **VolumeProfile.Add persistence + the URP `m_AssetVersion` build blocker** → archive 2026-07-06 (+ native memory `urp-global-settings-version-blocks-build`).
|
||||
- **`LocalTransform.FromPosition()` resets Scale=1** — server spawners read the prefab's baked `LocalTransform`, override only Position (Scale is a `[GhostField]` → consistent-but-wrong).
|
||||
- **Static decor → gameplay subscene** (EG renders only baked entities); **strip colliders from cosmetic props** + no `GhostAuthoring` on scenery (classic-URP colliders are inert to the DOTS PhysicsWorld). **World collision = subscene-only ★:** `Environment`-layer boundary ring + landmark colliders (player blocked via the layer matrix); enemies slide via a server `CollisionWorld.SphereCast` in `EnemyAISystem`. **★ enemy slide has NO pathfinding — a near-vertical wall normal or an embedded spawn FROZE Husks on cover rocks (soft-locks a room on one leftover); fixed 07-07 via `EnemyMoveUtil.Depenetrate` + tangent-slide + an `EnemyNavState` nudge backstop. Re-validate movers aren't frozen when adding Environment cover. 07-10: the nudge is COVER-AWARE — a live destructible-cover ghost (`BlightClutter` carrier) SUPPRESSES the phase-through (breakable ⇒ no soft-lock); static wedges still nudge.** Boundary = `SM_Env_Rock_Cliff` rim. See [[2026-06-08_World_Collision_HUD_Scaling]].
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: Geyser_Build_Spec
|
||||
type: note
|
||||
permalink: gamevault/03-design/geyser-build-spec
|
||||
---
|
||||
|
||||
# Blight Geyser — Build Spec (Phase 1.5b bundle 3, design-review-hardened)
|
||||
|
||||
Review: `wf_900e9965-8f0` (3 lenses — netcode/relevancy · determinism/prediction · reuse/scope — 23 agents,
|
||||
0 errors, 19 findings → **11 CONFIRMED / 8 refuted**; the [[workflow-agent-quota-failures-look-clean]] check
|
||||
passed — no agents died). This spec is the fold; it supersedes the pre-review draft. Mirrors the two shipped
|
||||
hazards [[Exploding_Barrels_Build_Spec]] + [[Destructible_Cover_Build_Spec]].
|
||||
|
||||
**Locked (operator, do not revisit):** Blight-biome rooms ONLY · PERMANENT (never destroyed; `RoomTag`
|
||||
teardown is the only removal) · both-sides telegraphed AoE (friendly fire vs living players AND enemies, like
|
||||
the shipped exploding barrels) · also a tactical lure tool.
|
||||
|
||||
## 1. Core mechanism
|
||||
|
||||
A `Geyser` is an ownerless **interpolated** ghost placed only in Blight rooms. One replicated field —
|
||||
`[GhostField] uint NextEruptTick`, the absolute server tick of its next eruption. Server-only
|
||||
`GeyserEruptSystem` (plain `SimulationSystemGroup`) fires when `ServerTick` reaches `NextEruptTick`:
|
||||
radius-gathers living players + living enemies in XZ, appends `DamageEvent`s (drained next tick by
|
||||
`HealthApplyDamageSystem`, exactly the barrel path), then reschedules `NextEruptTick` one period ahead of
|
||||
**now**. NEVER destroys itself. The client telegraph derives the countdown from the *absolute* `NextEruptTick`
|
||||
vs its own predicted `ServerTick` (never an edge on the replicated value) to grow a warning disc, and fires
|
||||
the erupt burst on the tick-crossing, latched once per eruption.
|
||||
|
||||
## 2. Server
|
||||
|
||||
- **Component** `Geyser { [GhostField] uint NextEruptTick }` in `ProjectM.Simulation`.
|
||||
- **`GeyserEruptSystem`** — `ProjectM.Server`, `[WorldSystemFilter(ServerSimulation)]` +
|
||||
`[UpdateInGroup(typeof(SimulationSystemGroup))]`, **NO `[UpdateBefore/After]` edges**,
|
||||
`RequireForUpdate<Geyser>`. Mirror `HazardExplosionSystem.cs:25-27` (`[BurstCompile]` fine — no enums, no
|
||||
lifted `SystemAPI.Query`). Each tick: `TryGetSingleton<NetworkTime>`; if `!ServerTick.IsValid` → skip.
|
||||
Iterate `(RefRW<Geyser>, LocalTransform, Entity)`:
|
||||
- **Erupt condition — INVERTED from the barrel (H2):** `until = new NetworkTick(g.NextEruptTick)`. If
|
||||
`g.NextEruptTick == 0u || !until.IsValid` → **skip** (unstamped = not-ready; never erupt). If
|
||||
`until.IsNewerThan(serverTick)` → not yet, skip. Otherwise erupt.
|
||||
- **Gather — inline, do NOT extract a helper (M5):** duplicate the ~12-line both-sides gather verbatim from
|
||||
`HazardExplosionSystem.cs:59-89`, parameterized only by this geyser's position + `Tuning.GeyserEruptRadius`
|
||||
+ `Tuning.GeyserEruptDamage`. Players `Health>0 && RegionTag==Expedition`, XZ within radius; enemies
|
||||
`Health>0`, XZ within radius. `AppendToBuffer(DamageEvent{ Amount=GeyserEruptDamage, SourceNetworkId=-1,
|
||||
SourceTick=stamp })`.
|
||||
- **SourceTick = NOW, not the scheduled tick (M8):** `uint stamp =
|
||||
TickUtil.NonZero(serverTick.TickIndexForValidTick);` — verbatim from `HazardExplosionSystem.cs:57`.
|
||||
- **Reschedule = now + period, NOT `+=` (M3):** `g.ValueRW.NextEruptTick =
|
||||
TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks);` via `RefRW<Geyser>.ValueRW`.
|
||||
- **Never** `DestroyEntity`.
|
||||
|
||||
## 3. Client
|
||||
|
||||
- **New sibling `GeyserTelegraphSystem`** — `ProjectM.Client`, observe-only `SystemBase`,
|
||||
`[WorldSystemFilter(ClientSimulation)]` + `[UpdateInGroup(typeof(PresentationSystemGroup))]` (sibling of
|
||||
`EnemyDangerTelegraphSystem`). `CompleteDependencyBeforeRO<Geyser>()` before reading. Read the client
|
||||
`NetworkTime.ServerTick` (the **predicted** ServerTick — matches shipped enemy/HUD countdowns; NOT
|
||||
InterpolationTick). Per-geyser cache keyed by `Entity`, pruned every frame (pruned = silent removal; emit
|
||||
nothing on prune).
|
||||
- **Disc (absolute-tick derived — the sound half of the original design):** guard `g.NextEruptTick != 0u &&
|
||||
new NetworkTick(g.NextEruptTick).IsValid`. `int lead = new NetworkTick(g.NextEruptTick).TicksSince(serverTick)`
|
||||
(negative once passed); clamp to `[0, GeyserTelegraphTicks]`. Grow the disc at `GeyserEruptRadius` scaled by
|
||||
`(1 - lead/(float)GeyserTelegraphTicks)` while `0 < lead <= GeyserTelegraphTicks`.
|
||||
- **Erupt burst — fire on the ServerTick CROSSING, latched (H1):** fire when `lead` crosses `>0 → <=0`
|
||||
(`serverTick` first reaches/exceeds `NextEruptTick`). Store `_lastFired[entity] = g.NextEruptTick`; refire
|
||||
ONLY when `NextEruptTick` differs (the `EnemyDangerTelegraphSystem.cs:177-185` `_strikeBeeped` latch). Plus a
|
||||
**"was counting down" arm-guard** — only fire if a prior frame observed `lead>0` for this entity
|
||||
(`EnemyDangerTelegraphSystem.cs:139-140`). Immune to: the `0→stamp` phantom, relevancy re-entry mid-period
|
||||
(a freshly re-added ghost has `lead>0` → no crossing on seed), and snapshot loss (absolute tick). Co-locates
|
||||
the burst with the disc completion on the ServerTick timeline (also fixes the boom-trails-disc split).
|
||||
- **Erupt VFX — reuse shipped statics (no new plumbing):** `FeedbackFx.EmitTinted`/`MakeBurst` particles +
|
||||
`DynamicLightSystem.RequestFlash` + `ScorchDecalSystem.RequestScorch(pos, GeyserEruptRadius)` (bundle 2,
|
||||
documented "Reusable by the future Blight-geyser hazard").
|
||||
- **Promote the disc primitive (M6):** move `BuildDisc` (+ a disc/ring material factory) out of
|
||||
`WorldFeedbackSystem` (private `:203`) into `FeedbackFx` so the barrel fuse ring and the geyser telegraph
|
||||
share ONE primitive. Do NOT re-implement a second private disc-builder.
|
||||
|
||||
## 4. Seeding / authoring
|
||||
|
||||
- **Ghost prefab `Geyser.prefab`:** duplicate **`ResourceNode.prefab`** (or `BlightClutter.prefab`) — NOT
|
||||
`CoverRock.prefab` (its Environment-layer collider would block the walk-over hazard). Swap authoring →
|
||||
`GeyserAuthoring` (bakes `Geyser` + `RegionTag{Expedition}`); cosmetic vent mesh; NO collider. Ownerless/
|
||||
interpolated `GhostAuthoringComponent` + `LinkedEntityGroup` come free. An inherited `HitRadius` is inert
|
||||
(server scans by `Tuning.GeyserEruptRadius`) — no strip needed.
|
||||
- **`GeyserFieldSpawnerAuthoring` → baked `GeyserFieldSpawner { Entity Prefab; int Count }`** singleton,
|
||||
mirroring `ClutterFieldSpawnerAuthoring`. Author into `Gameplay.unity`'s subscene; assign the prefab.
|
||||
- **Seeding in `RoomFieldSystem.OnUpdate` InRoom block**, sibling of cover/clutter:
|
||||
- **Gate on `plan.Biome == RoomBiomeId.Blight`** (local plan, NOT `info.CurrentBiome`) — matches the cover
|
||||
block's `plan.RoomType != RoomTypeId.Boss`. `RoomBiomeId.Blight == 3`.
|
||||
- **Never Boss rooms** — an explicit, **commented** design choice (geyser has no collider → NOT the cover
|
||||
depenetration concern; it's a boss-fight decision).
|
||||
- `TryGetSingleton<GeyserFieldSpawner>` + `Prefab != Entity.Null` (optional singleton; absent → skip).
|
||||
- Distinct hash sub-stream `0x6E7`; keep-out ring around the room origin (like cover's `KeepOutFromOrigin=7`).
|
||||
- Stamp `RoomTag{room}` with the SAME `room` local every sibling uses → free `RoomTeardown` removal.
|
||||
- **Born-correct spawn-stamp (H2):** the block has no `NetworkTime` today — add `TryGetSingleton<NetworkTime>`;
|
||||
if `!ServerTick.IsValid` this tick, do NOT seed geysers (the once-per-`RoomEpoch` guard retries). Otherwise
|
||||
per instance `ecb.SetComponent(e, new Geyser{ NextEruptTick =
|
||||
TickUtil.NonZero(serverTick.TickIndexForValidTick + Tuning.GeyserPeriodTicks) })` in the deferred loop → the
|
||||
entity is fully formed before ECB playback / `GhostSendSystem` serialization → the first snapshot never
|
||||
carries 0.
|
||||
- **Tuning consts** in `Simulation/Tuning.cs` beside the barrel block: `GeyserPeriodTicks = 300` (5s@60),
|
||||
`GeyserTelegraphTicks = 78` (~1.3s), `GeyserEruptRadius = 3.0f`, `GeyserEruptDamage = 22`. Single-source only
|
||||
the constants (no shared gather helper).
|
||||
|
||||
## 5. Tests
|
||||
|
||||
`Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs`, plain-Entities, mirroring
|
||||
`HazardExplosionSystemTests.cs` (reuse `MakeWorld`/`MakePlayer`/`MakeEnemy`):
|
||||
1. **Both-sides erupt:** living player + living enemy in radius each get exactly one `DamageEvent`;
|
||||
`SourceNetworkId == -1`.
|
||||
2. **Reschedule:** after eruption `NextEruptTick` advances by exactly `GeyserPeriodTicks` from now, never the
|
||||
`0` sentinel.
|
||||
3. **Never destroyed:** the geyser survives the eruption (opposite of the barrel destroy-once assertion).
|
||||
4. **Unstamped-storm regression (H2):** seed a geyser at `serverTick=5000` with `NextEruptTick=0`; assert
|
||||
**zero** `DamageEvent`s on the first ticks (the inverted guard skips — no per-tick barrage).
|
||||
5. **Out-of-radius / dead filters:** dead or out-of-radius get no `DamageEvent`.
|
||||
|
||||
## 6. Wire / bake classification
|
||||
|
||||
- **New ghost type + prefab** `Geyser.prefab` → genuinely NEW ghost hash (NOT a byte re-mean). ONE new
|
||||
`[GhostField]` (`Geyser.NextEruptTick`).
|
||||
- **New systems:** `GeyserEruptSystem` (server) + `GeyserTelegraphSystem` (client).
|
||||
- **New baked singleton** `GeyserFieldSpawner` authored into `Gameplay.unity` subscene (load-bearing — unwired
|
||||
= zero geysers, silent).
|
||||
- **Modified:** `RoomFieldSystem` (Blight seed block + `NetworkTime` spawn-stamp), `Tuning.cs` (4 consts),
|
||||
`FeedbackFx` (promote `BuildDisc` + material factory), `WorldFeedbackSystem` (fuse ring calls the promoted
|
||||
primitive).
|
||||
- **Relevancy:** free via prefab-baked `RegionTag{Expedition}` → existing `RegionRelevancySystem`
|
||||
`SetIsIrrelevant` hides it cross-region. No relevancy-system change.
|
||||
- **Ordering:** edge-free server system → no new sort constraint, no Play-only cycle (confirmed).
|
||||
- Re-bake: new prefab + subscene spawner + authoring defaults; full asmdef recompile for the new types.
|
||||
|
||||
## 7. Changes the review FORCED vs the original design
|
||||
|
||||
**HIGH** — H1 erupt VFX must NOT edge-detect the `NextEruptTick` jump (phantom-fires on `0→stamp` + relevancy
|
||||
re-entry + snapshot loss) → absolute-tick `TicksSince` disc + latched `>0→<=0` crossing + arm-guard (mirror
|
||||
`EnemyDangerTelegraphSystem`; `AttackWindup.cs:12` "absolute, not an edge"). Also fixes the boom-trails-disc
|
||||
timeline split. · H2 INVERT the barrel's invalid-tick guard (unstamped `0` → SKIP, never erupt) + add
|
||||
`NetworkTime` to `RoomFieldSystem` and stamp `NextEruptTick` born-correct at spawn (else a baked-0 geyser
|
||||
erupts every tick = a party-wiping barrage).
|
||||
|
||||
**MED** — M3 reschedule `= now + period`, not `+=` (catch-up storm; every in-repo sentinel is `= now + delay`).
|
||||
· M5 do NOT extract a shared `HazardDamage` gather helper (`SystemAPI.Query` can't be lifted to a static;
|
||||
verbatim-copy is the shipped convention; Burst-ICE risk) — duplicate inline. · M6 promote `BuildDisc` into
|
||||
`FeedbackFx`. · M7 gate on `plan.Biome`, not `info.CurrentBiome`. · M8 stamp `SourceTick` at NOW, not the
|
||||
scheduled `eruptTick` (tick-batching mis-negates dash i-frames).
|
||||
|
||||
**LOW** — duplicate `ResourceNode`/`BlightClutter` (no collider), not `CoverRock`. · add `GeyserEruptSystemTests`.
|
||||
|
||||
**Confirmed correct, no change:** plain-`SimulationSystemGroup` placement · next-tick `DamageEvent` drain ·
|
||||
single-apply · `SourceNetworkId=-1` attribution · dash-i-frame negation · edge-free ordering ·
|
||||
`RegionTag{Expedition}` relevancy scoping.
|
||||
@@ -168,10 +168,21 @@ plain"** — expand 1.5 before 1.7. Locked bundles, sequenced ground-first:
|
||||
per room inside the room's ACTUAL shape (hash stream 0xD2E55; 47 build-safe prefab refs), room-torn-down;
|
||||
live-verified incl. teardown + exact Meadow tint convergence; (c) worn paths storage→warpgate/base-gate;
|
||||
(d) 357 base flora re-enabled (buildings/pond/FX stay parked).
|
||||
2. **Decals**: dynamic explosion scorch marks (fading quad pool at boom sites), static POI scorch/cracks,
|
||||
cover damage-crack states (URPMaterialPropertyBaseColor darkening keyed on replicated Remaining).
|
||||
3. **Hazard variant**: Blight geyser (periodic both-sides AoE on the HazardExplosion + telegraph-disc
|
||||
chassis) — mini design review first (new replicated erupt-phase surface).
|
||||
2. ✅ **Decals** (SHIPPED, Part P — all procedural, client-only, zero netcode): explosion scorch pool
|
||||
(fading disc pool via `ScorchDecalSystem.RequestScorch`, wired from the barrel boom; reusable by the
|
||||
geyser) · cover damage-CRACKS keyed on replicated `BlightClutter.Remaining` (Variant 4) + proximity-gated
|
||||
shatter-scorch · light room-arena crack/scorch scars (via `RoomDressingSystem`). ★ The specified
|
||||
`URPMaterialPropertyBaseColor` darken would have **silently no-op'd** — cover's Synty `Generic_Basic`
|
||||
shadergraph declares `_BaseColor` Unity-Per-Material, NOT Hybrid-Per-Instance (only the DOTS-authored
|
||||
`AnimatedLitShader` honours the EG per-instance override); operator fork → decal quads instead. Base POIs
|
||||
skipped (Part O baked their wear). Knobs in `DecalConfig`.
|
||||
3. ✅ **Hazard variant — Blight geyser** (SHIPPED, Part Q; review-FIRST `wf_900e9965-8f0` → 11 folded,
|
||||
post-impl `wf_5c8299f8-299` clean): a PERMANENT periodic both-sides telegraphed AoE, Blight-biome rooms only
|
||||
(operator forks). ONE new `[GhostField] uint NextEruptTick`; server `GeyserEruptSystem` (INVERTED invalid-guard
|
||||
+ lazy-stamp — a baked-0 tick must NEVER erupt; reschedule `=now+period`), client `GeyserTelegraphSystem`
|
||||
(absolute-tick disc + latched/armed crossing burst — NEVER edge-detect the field; reuses bundle-2 scorch).
|
||||
Seeded in `RoomFieldSystem` (born-correct staggered), spawner wired into the subscene. 474/474 + live
|
||||
no-storm end-to-end. See [[Geyser_Build_Spec]].
|
||||
4. **Critters + weather beats**: fleeing bugs/birds; cloud-shadow drifts; Blight lightning flicker.
|
||||
|
||||
Gate: the same "no longer static" lap, now with the ground judged explicitly.
|
||||
|
||||
@@ -319,14 +319,90 @@ normals untouched. Ground luminance now 0.30/0.33 in edit ambient (tints stay th
|
||||
for the play lap). Recipe note: bake pattern = readback-blit sources to linear Color[] → composite →
|
||||
LinearToGammaSpace → PNG sRGB; HP detail imports LINEAR.
|
||||
|
||||
## Part P — Phase 1.5b bundle 2 (DECALS) shipped
|
||||
|
||||
Three locked sub-features, all **client-only presentation, procedural, zero netcode surface** (they read
|
||||
*existing* replicated state): (a) explosion scorch pool, (b) cover damage-cracks, (c) light room-arena scars.
|
||||
|
||||
- **Pivotal grounding finding (a fork the roadmap didn't foresee):** the roadmap specified cover cracks as
|
||||
`URPMaterialPropertyBaseColor` darkening — but that would **silently no-op**. Cover's render child uses the
|
||||
Synty `Generic_Basic.shadergraph`, whose `_BaseColor` is declared **Unity-Per-Material**
|
||||
(`overrideHLSLDeclaration:false`, `hlslDeclarationOverride:0`), **NOT Hybrid-Per-Instance** — so the
|
||||
Entities-Graphics per-instance override that `EnemyHitFlashSystem` drives (enemies use the DOTS-authored
|
||||
`AnimatedLitShader`, which IS hybrid-per-instance) writes to a slot the shader never reads. Exactly the
|
||||
"dark render masks a material bug" trap. **Operator fork → crack-decal QUADS** (guaranteed to render,
|
||||
on-theme for a decals bundle, most legible as damage) + **light room-arena cracks** (skip base POIs — Part O
|
||||
already baked their wear; the deleted-WornPath double-darken lesson).
|
||||
- **Architecture — one procedural decal primitive:** `FeedbackFx` gained `MakeDecalMaterial` (Sprites/Default,
|
||||
ZWrite off, transparent, vertex×MPB `_Color`), `BuildScorchMesh` (irregular soft radial disc, vertex α1→0
|
||||
jittered rim — the feathered edge is what stops it ghosting over fog like the old rectangle quad) and
|
||||
`BuildCrackMesh` (jagged radial spokes, feathered tips). `ScorchDecalSystem` = static `RequestScorch(pos,
|
||||
radius)` queue → pooled fading discs under a `DontDestroyOnLoad` root, scene-gated "Game", per-decal
|
||||
hold→fade via a reused MPB (mirrors `DynamicLightSystem.RequestFlash`); wired from `WorldFeedbackSystem`'s
|
||||
barrel-boom branch; **reusable by the bundle-3 geyser**. `CoverDamageSystem` accretes cracks keyed on
|
||||
replicated `BlightClutter.Remaining` (Variant 4, 8→0) + shatter-scorch on despawn. `RoomDressingSystem` lays
|
||||
arena scars on a distinct hash sub-stream (0xD2E56), torn down with dressing. `DecalConfig` = live knobs.
|
||||
- **Self-review caught + fixed (stood in for the diff-review — client-only, no netcode):** the shatter-scorch
|
||||
is now **proximity-gated** (mirrors WorldFeedbackSystem) — else a room-teardown / region-transit despawn
|
||||
storm (every cover ghost dropped at once, off-camera) would spray scorches and exhaust the 24-cap pool.
|
||||
- **Verified:** 470/470 EditMode; console clean (only pre-existing `WorldAtmosphereSystem` CS0618 warnings —
|
||||
not this bundle). L3 headless — all 3 systems exception-free in ClientWorld, `DecalConfig` reset correct,
|
||||
and the flagship scorch path proven end-to-end via VALUE inspection (mesh 25 verts + feathered vColors,
|
||||
y=0.05, scale 2.76 = 3.25×0.85 exact, Sprites/Default queue 3000, shadows off, `_Color` dark-char @ α0.85)
|
||||
**plus** the full spawn→hold→fade→recycle→pooled-reuse lifecycle (no leak). Commit `<pending>`.
|
||||
- **Deferred to the operator's gate lap (documented, per precedent):** the in-run VISUAL judgment — cover
|
||||
cracks accreting during real carving, arena scars in a real room, and whether the dark scorch reads well
|
||||
over the dark baked ground — needs a launched expedition + a focused editor (unfocused blocks live-frame
|
||||
verify). Knobs to taste on the lap: `ScorchColor`/alpha, `CoverCrackMaxCount`, `RoomArenaDecalCount`.
|
||||
|
||||
## Part Q — Phase 1.5b bundle 3 (BLIGHT GEYSER) shipped — review-first netcode slice
|
||||
|
||||
A PERMANENT periodic hazard (operator forks: **Blight-biome rooms only · permanent · both-sides telegraphed
|
||||
AoE**). The genuinely-new replicated surface is ONE `[GhostField] uint NextEruptTick`; everything else reuses
|
||||
the barrel/cover chassis + bundle-2's scorch pool.
|
||||
|
||||
- **Design-review FIRST (`wf_900e9965-8f0`, 3 lenses / 23 agents / 0 errors → 11 CONFIRMED, 8 refuted; the
|
||||
[[workflow-agent-quota-failures-look-clean]] check passed).** It caught **two HIGH defects the naive design
|
||||
would have shipped**: (H1) firing the erupt VFX by EDGE-DETECTING the replicated `NextEruptTick` jump —
|
||||
phantom-fires on the `0→stamp` transition + on every relevancy re-entry (death→respawn→re-enter, region
|
||||
transit) + drops under snapshot loss; **fix = absolute-tick `TicksSince(predicted ServerTick)` disc + a
|
||||
`>0→<=0` crossing burst LATCHED per NextEruptTick + a was-counting-down arm-guard** (the
|
||||
`EnemyDangerTelegraphSystem`/`AttackWindup` "absolute, not an edge" idiom). (H2) copying the barrel's
|
||||
`invalid-tick → detonate` guard VERBATIM onto a **baked [GhostField] that defaults to 0** → the 0 fails
|
||||
`IsValid`, falls through, and erupts EVERY tick — a party-wiping AoE barrage; **fix = INVERT the guard (0 =
|
||||
not-ready → skip) + born-correct stamp from the live ServerTick at seed**, hardened further with a
|
||||
**lazy-stamp** in the erupt system so a stray 0 can neither storm nor stay inert-forever. Plus MEDs: reschedule
|
||||
`= now + period` not `+=` (catch-up storm); do NOT extract a shared gather helper (`SystemAPI.Query` can't be
|
||||
a static — verbatim-duplicate, the shipped convention); promote `BuildDisc` to `FeedbackFx` (shared with the
|
||||
fuse ring); gate on `plan.Biome` not `info.CurrentBiome`; `SourceTick` at NOW not the scheduled tick. Durable
|
||||
fold: [[Geyser_Build_Spec]].
|
||||
- **Shipped:** `Geyser` component · server `GeyserEruptSystem` (inverted+lazy guard, inline both-sides gather,
|
||||
`SourceNetworkId=-1`, reschedule `=now+period`, never destroyed) · client `GeyserTelegraphSystem` (absolute-tick
|
||||
growing disc + latched/armed crossing burst; erupt VFX reuse `ScorchDecalSystem.RequestScorch` + `RequestFlash`)
|
||||
· `GeyserFieldSpawner` + authoring · `Geyser.prefab` (duplicated `ResourceNode.prefab` — NOT `CoverRock`, which
|
||||
has an Environment collider that'd block the walk-over hazard — authoring swapped to `GeyserAuthoring`) ·
|
||||
`RoomFieldSystem` Blight-gated born-correct staggered seed (+`NetworkTime`) · `GeyserFieldSpawnerAuthoring`
|
||||
wired into the `Gameplay.unity` subscene (`ResourceFieldSpawner` hub, Count 2). `BuildDisc` promoted to
|
||||
`FeedbackFx`; `WorldFeedbackSystem` fuse ring now calls it.
|
||||
- **Verified:** 474/474 EditMode (incl. the unstamped-storm regression asserting ZERO DamageEvents on a baked-0
|
||||
geyser at a high tick) · console clean · **live end-to-end** (server-spawned geyser: periodic erupt +
|
||||
single-clean reschedule `=now+period` across ~7 eruptions, **no storm**; ghost replicated to the client;
|
||||
telegraph disc GOs created; erupt scorch fired). **Post-impl review clean** (`wf_5c8299f8-299`, 3 lenses, 0
|
||||
findings — genuine, journal-verified). ⚠ Editing hazard hit + fixed mid-build: a `script_apply_edits` delete of
|
||||
`WorldFeedbackSystem.BuildDisc` mangled the file (duplicated an `UpdateFuseRing` fragment, orphan brace) — a
|
||||
brace-balanced whole-span `apply_text_edits` repaired it; prefer balanced-span replaces over structured deletes.
|
||||
- **Deferred to the operator's gate lap:** the in-run VISUAL feel — telegraph disc read + erupt over the dark
|
||||
Blight ground, and whether a distinct geyser mesh (interim = the ResourceNode mesh) is wanted. Knobs:
|
||||
`Tuning.Geyser*` (period/telegraph/radius/damage) + `GeyserFieldSpawner.Count`.
|
||||
|
||||
## Next-session intent
|
||||
|
||||
**Phase 1.5b bundle 1 (ground) is SHIPPED**; remaining locked bundles in order: **2) decals** (explosion
|
||||
scorch pool · POI grime · cover damage-cracks) → **3) Blight geyser** (mini design review first — new
|
||||
replicated erupt-phase surface) → **4) critters + weather beats**. Then the operator's 1.5 gate lap ("no
|
||||
longer static", ground judged explicitly) → Phase 1.7 boon overhaul (design-review-FIRST) → Phase 2
|
||||
hub-ification. Taste queue: arid brightness · craters call · B5 split-panels smoke · barrel explosion VFX
|
||||
polish · flower density judgment (base may read dense — operator call on the lap).
|
||||
**Phase 1.5b bundles 1 (ground) + 2 (decals) + 3 (Blight geyser) are SHIPPED.** Last locked bundle: **4)
|
||||
critters + weather beats** (fleeing bugs/birds · cloud-shadow drifts · Blight lightning flicker — client-only,
|
||||
likely no design review). Then the operator's 1.5 gate lap ("no longer static"; ground + decals + geyser judged
|
||||
explicitly) → Phase 1.7 boon overhaul (design-review-FIRST) → Phase 2 hub-ification. Taste queue: **geyser feel
|
||||
lap (telegraph/erupt tuning · distinct geyser mesh?)** · decals feel lap · arid brightness · craters call · B5
|
||||
split-panels smoke · barrel explosion VFX polish · flower density.
|
||||
|
||||
Related: [[Iteration_2026-07_CoopHades]] · [[2026-07-07_Workflow_Consolidation]] ·
|
||||
[[2026-07-07_Expedition_Enemy_Stuck_Harvest_Feedback]]
|
||||
|
||||
Reference in New Issue
Block a user