diff --git a/Assets/_Project/Prefabs/Geyser.prefab b/Assets/_Project/Prefabs/Geyser.prefab new file mode 100644 index 000000000..529439b9f --- /dev/null +++ b/Assets/_Project/Prefabs/Geyser.prefab @@ -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 diff --git a/Assets/_Project/Prefabs/Geyser.prefab.meta b/Assets/_Project/Prefabs/Geyser.prefab.meta new file mode 100644 index 000000000..0efbe488a --- /dev/null +++ b/Assets/_Project/Prefabs/Geyser.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8425e38ad7271e44693e65e8d574a993 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs new file mode 100644 index 000000000..02b2d362f --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs @@ -0,0 +1,27 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// 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 (NextEruptTick left 0 — the server stamps it + /// born-correct from the live ServerTick at spawn in RoomFieldSystem; a 0 must NEVER erupt) + + /// {Expedition} so GhostRelevancy scopes it to expedition players. The field spawner + /// overrides Position + the born-correct NextEruptTick per instance. + /// + public class GeyserAuthoring : MonoBehaviour + { + private class GeyserBaker : Baker + { + 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 }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs.meta new file mode 100644 index 000000000..5ca7c014c --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/GeyserAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e45c85bf47692824eacc671035712f38 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs new file mode 100644 index 000000000..409853a1c --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs @@ -0,0 +1,35 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// Authoring for the baked 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. + /// + 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 + { + 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, + }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs.meta new file mode 100644 index 000000000..cee5e5177 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Economy/GeyserFieldSpawnerAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e5d1544d6afe18245b563de599240493 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs index 8ea5dc44e..74c7373a0 100644 --- a/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs +++ b/Assets/_Project/Scripts/Client/Presentation/FeedbackFx.cs @@ -180,5 +180,29 @@ namespace ProjectM.Client 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; + } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs b/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs new file mode 100644 index 000000000..909bc42f7 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs @@ -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 +{ + /// + /// Client-only BLIGHT-GEYSER telegraph + erupt VFX (Phase 1.5b bundle 3; Geyser_Build_Spec). Observe-only + /// in — sibling of + /// , never mutates the sim. Reads each geyser's replicated + /// against the client's PREDICTED NetworkTime.ServerTick 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): + /// + /// a growing/pulsing warning DISC at over the last + /// before the eruption (the absolute-tick countdown, exactly the + /// enemy-telegraph idiom); + /// the erupt BURST fired when the countdown CROSSES >0 → <=0, LATCHED once per + /// NextEruptTick value + an ARM-guard (only if a prior frame saw it counting down) — immune to the + /// 0→stamp phantom, relevancy re-entry mid-period, and snapshot loss. + /// + /// Erupt VFX reuse the shipped statics — burst + + /// + (bundle 2, built for this). Pooled disc GameObjects under a + /// private root, pruned every frame; a pruned geyser (teardown / relevancy drop) is SILENT. + /// + [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 _discs = new(); + readonly Dictionary _armed = new(); // NextEruptTick we've seen counting down for + readonly Dictionary _lastFired = new(); // NextEruptTick we last fired the burst for (latch) + readonly HashSet _seen = new(); + readonly List _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(out var nt) || !nt.ServerTick.IsValid) return; + var serverTick = nt.ServerTick; + + EntityManager.CompleteDependencyBeforeRO(); + EntityManager.CompleteDependencyBeforeRO(); + + _seen.Clear(); + foreach (var (geyser, xf, e) in + SystemAPI.Query, RefRO>().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().sharedMesh = _discMesh; + var mr = go.AddComponent(); + 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().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 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]); + } + } +} diff --git a/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs.meta b/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs.meta new file mode 100644 index 000000000..876a63930 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Presentation/GeyserTelegraphSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9720b14cfcb422046a945abddddd8cfc \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs index 350508f0f..f03dc7194 100644 --- a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs @@ -199,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; diff --git a/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs b/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs new file mode 100644 index 000000000..c1af9e606 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs @@ -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 +{ + /// + /// Erupts PERMANENT Blight s (Geyser_Build_Spec, design-review wf_900e9965-8f0): when a + /// geyser's replicated 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 — + /// teardown is the only removal; NEVER DestroyEntity here). Player + enemy gather mirrors + /// VERBATIM (Health > 0; players also RegionTag == Expedition; + /// = -1 environment convention; SourceTick stamped at NOW, the + /// authoring tick, so dash i-frames negate against it). + /// + /// ★ 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 = now + period (NEVER += — an anchored accumulator catch-up-storms after any + /// multi-period skip; every tick-sentinel in the project is = now + delay). Plain server + /// , NO ordering edges; presence-gated on . + /// + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + public partial struct GeyserEruptSystem : ISystem + { + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + if (!SystemAPI.TryGetSingleton(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(Allocator.Temp); + foreach (var (geyser, lt) in SystemAPI.Query, RefRO>()) + { + 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, RefRO>() + .WithAll().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>() + .WithAll().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(); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs.meta new file mode 100644 index 000000000..b27104e27 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/GeyserEruptSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2e98116af8019a488692870c6f09985 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs index 270020e87..49757abad 100644 --- a/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs +++ b/Assets/_Project/Scripts/Server/Economy/RoomFieldSystem.cs @@ -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(out var geyser) + && geyser.Prefab != Entity.Null) + { + uint eruptStamp = 0u; + if (SystemAPI.TryGetSingleton(out var gnt) && gnt.ServerTick.IsValid) + eruptStamp = gnt.ServerTick.TickIndexForValidTick; + var gBaked = SystemAPI.GetComponent(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); } diff --git a/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs b/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs new file mode 100644 index 000000000..8003c9117 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs @@ -0,0 +1,23 @@ +using Unity.Entities; +using Unity.NetCode; + +namespace ProjectM.Simulation +{ + /// + /// 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. 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 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). + /// + public struct Geyser : IComponentData + { + /// Absolute server tick of the next eruption (via TickUtil.NonZero; 0 = unstamped = never erupt). + [GhostField] public uint NextEruptTick; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs.meta new file mode 100644 index 000000000..01455dd0f --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/Geyser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 686fc215cf5ebd84ab06f560f9a16d9e \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs b/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs new file mode 100644 index 000000000..11ce2f162 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs @@ -0,0 +1,20 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Baked singleton holding the BLIGHT-GEYSER ghost prefab (Geyser_Build_Spec; a ResourceNode sibling — a + /// carrier with RegionTag{Expedition} and NO collider). RoomFieldSystem scatters + /// 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 ; carries no transform. + /// + public struct GeyserFieldSpawner : IComponentData + { + /// Baked geyser ghost prefab to instantiate. + public Entity Prefab; + + /// Geysers per Blight-biome room (Boss rooms get none). + public int Count; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs.meta b/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs.meta new file mode 100644 index 000000000..5af4b3efb --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Economy/GeyserFieldSpawner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3e7f72ba05b64124fbe572dbadb2a05f \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 3eed64daf..1be5f2015 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -121,6 +121,22 @@ namespace ProjectM.Simulation /// Detonation damage — hits LIVING enemies AND players alike (friendly fire = the bait mechanic). public const float BarrelExplodeDamage = 26f; + // ---- Blight geyser (Phase 1.5b bundle-3 hazard; periodic BOTH-SIDES AoE — see Geyser_Build_Spec) ---- + + /// Ticks between eruptions (~5 s @60). Server reschedules NextEruptTick = now + this each erupt + /// (never +=, per the review — anchored += catch-up-storms after a skip). + public const uint GeyserPeriodTicks = 300; + + /// 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). + public const int GeyserTelegraphTicks = 78; + + /// Eruption AoE radius (world units, XZ) — hits LIVING players AND enemies (friendly fire = lure bait). + public const float GeyserEruptRadius = 3.0f; + + /// Eruption damage per hit. + public const float GeyserEruptDamage = 22f; + // ---- Expedition BOSS (a scaled Charger given a real kit by BossAISystem; server-only feel consts) ---- /// Radial SLAM AoE radius (world units): a player inside this ring at wind-up elapse eats the hit diff --git a/Assets/_Project/Subscenes/Gameplay.unity b/Assets/_Project/Subscenes/Gameplay.unity index cfe6c941b..b52205c58 100644 --- a/Assets/_Project/Subscenes/Gameplay.unity +++ b/Assets/_Project/Subscenes/Gameplay.unity @@ -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 diff --git a/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs b/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs new file mode 100644 index 000000000..5147c446d --- /dev/null +++ b/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs @@ -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 +{ + /// + /// Plain-Entities EditMode tests for (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. + /// + 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(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + 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(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(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(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(enemy).Length, "The living enemy in radius is hit too (bait mechanic)."); + + Assert.AreEqual(TickUtil.NonZero(T0 + Tuning.GeyserPeriodTicks), em.GetComponentData(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(player).Length, + "An unstamped (0) geyser must NEVER erupt — no per-tick barrage."); + Assert.AreEqual(TickUtil.NonZero(5000u + Tuning.GeyserPeriodTicks), em.GetComponentData(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(deadPlayer).Length, "A dead player in radius gets NOTHING."); + Assert.AreEqual(0, em.GetBuffer(basePlayer).Length, "A non-expedition player gets NOTHING."); + Assert.AreEqual(0, em.GetBuffer(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(player).Length, "No damage before the eruption tick."); + Assert.AreEqual(T0 + 120, em.GetComponentData(geyser).NextEruptTick, "An un-elapsed geyser is not rescheduled."); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs.meta new file mode 100644 index 000000000..8a16625fc --- /dev/null +++ b/Assets/_Project/Tests/EditMode/GeyserEruptSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a02ba4950608fb347a8491783ebc4746 \ No newline at end of file