Hazard: Blight geyser — permanent periodic both-sides AoE (Phase 1.5b bundle 3)
Review-first netcode slice (design review wf_900e9965-8f0 -> 11 folded; post-impl wf_5c8299f8-299 clean). A PERMANENT periodic telegraphed AoE in Blight-biome rooms only; one new [GhostField] uint NextEruptTick. - Geyser component + GeyserEruptSystem (server): inverted invalid-tick guard (a baked-0 tick must NEVER erupt -> lazy-stamps born-correct instead of the party-wiping per-tick barrage), inline both-sides gather (SourceNetworkId=-1), reschedule = now + period (never +=), never destroyed. - GeyserTelegraphSystem (client): absolute-tick growing warning disc + erupt burst on the >0->=<0 crossing, latched per NextEruptTick + arm-guard (never edge-detects the replicated field -> no phantom on 0->stamp / relevancy re-entry). Reuses ScorchDecalSystem + DynamicLightSystem. - Seeded in RoomFieldSystem (Blight-gated on plan.Biome, born-correct staggered stamp from live ServerTick), GeyserFieldSpawner + authoring wired into the Gameplay subscene. Geyser.prefab duplicated from ResourceNode (no collider). - BuildDisc promoted to FeedbackFx (shared with the barrel fuse ring). - 474/474 EditMode incl. the unstamped-storm regression; live no-storm end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user