Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f96b520d6 | |||
| c3b53cef28 | |||
| ca38c2b16d | |||
| 09183cc139 | |||
| 1b704ca0b9 | |||
| 3c1b5c44cd | |||
| 8596cc74b1 | |||
| bd8458853b | |||
| 419debad74 | |||
| ed65770cc9 |
@@ -89,6 +89,6 @@ MonoBehaviour:
|
||||
SiegeSizeBase: 5
|
||||
SiegeSizePerResource: 0
|
||||
SiegeTimeoutTicks: 3600
|
||||
ScheduleEnabled: 1
|
||||
ScheduleEnabled: 0
|
||||
ScheduleIntervalTicks: 2700
|
||||
ScheduleSizePerWave: 1
|
||||
|
||||
@@ -1816,7 +1816,7 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
||||
MaxHealth: 45
|
||||
HitRadius: 0.8
|
||||
MoveSpeed: 2.6
|
||||
MoveSpeed: 3
|
||||
AttackRange: 1.7
|
||||
AttackDamage: 14
|
||||
AttackCooldownTicks: 48
|
||||
|
||||
@@ -873,7 +873,7 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
||||
MaxHealth: 28
|
||||
HitRadius: 1
|
||||
MoveSpeed: 2.8
|
||||
MoveSpeed: 3
|
||||
AttackRange: 1.8
|
||||
AttackDamage: 8
|
||||
AttackCooldownTicks: 66
|
||||
|
||||
@@ -927,7 +927,7 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
||||
MaxHealth: 30
|
||||
HitRadius: 0.7
|
||||
MoveSpeed: 3
|
||||
MoveSpeed: 4.2
|
||||
AttackRange: 1.6
|
||||
AttackDamage: 5
|
||||
AttackCooldownTicks: 48
|
||||
|
||||
@@ -102,7 +102,8 @@ GameObject:
|
||||
- component: {fileID: 9053853372340598254}
|
||||
- component: {fileID: 6834786618115927220}
|
||||
- component: {fileID: 7685488391646220227}
|
||||
m_Layer: 0
|
||||
- component: {fileID: 1225369404710843925}
|
||||
m_Layer: 9
|
||||
m_Name: Pylon
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
@@ -177,3 +178,24 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.StructureAuthoring
|
||||
Kind: 6
|
||||
MaxHp: 150
|
||||
--- !u!65 &1225369404710843925
|
||||
BoxCollider:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Material: {fileID: 0}
|
||||
m_IncludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_ExcludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_LayerOverridePriority: 0
|
||||
m_IsTrigger: 0
|
||||
m_ProvidesContacts: 0
|
||||
m_Enabled: 1
|
||||
serializedVersion: 3
|
||||
m_Size: {x: 1.6, y: 2, z: 1.6}
|
||||
m_Center: {x: 0, y: 1, z: 0}
|
||||
|
||||
@@ -28,7 +28,7 @@ Transform:
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: -0, z: -0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1.6, y: 1.6, z: 1.6}
|
||||
m_LocalScale: {x: 0.8, y: 0.8, z: 0.8}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 8624793677999475166}
|
||||
@@ -67,7 +67,7 @@ MeshRenderer:
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
||||
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
@@ -157,7 +157,7 @@ MeshRenderer:
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
||||
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
@@ -248,7 +248,7 @@ MeshRenderer:
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
||||
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
@@ -342,7 +342,7 @@ MeshRenderer:
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
||||
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
@@ -378,7 +378,8 @@ GameObject:
|
||||
- component: {fileID: 9053853372340598254}
|
||||
- component: {fileID: 6834786618115927220}
|
||||
- component: {fileID: 1794795016809289889}
|
||||
m_Layer: 0
|
||||
- component: {fileID: 9049467567705961987}
|
||||
m_Layer: 9
|
||||
m_Name: Turret
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
@@ -455,6 +456,27 @@ MonoBehaviour:
|
||||
CooldownTicks: 30
|
||||
Damage: 12
|
||||
MaxHp: 120
|
||||
--- !u!65 &9049467567705961987
|
||||
BoxCollider:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Material: {fileID: 0}
|
||||
m_IncludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_ExcludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_LayerOverridePriority: 0
|
||||
m_IsTrigger: 0
|
||||
m_ProvidesContacts: 0
|
||||
m_Enabled: 1
|
||||
serializedVersion: 3
|
||||
m_Size: {x: 0.8, y: 1.2, z: 0.8}
|
||||
m_Center: {x: 0, y: 0.6, z: 0}
|
||||
--- !u!1 &4051895978514069616
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -521,7 +543,7 @@ MeshRenderer:
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
||||
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
@@ -611,7 +633,7 @@ MeshRenderer:
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
||||
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
@@ -701,7 +723,7 @@ MeshRenderer:
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: 53c360a5b4f92c04caad3273e9bb5bef, type: 2}
|
||||
- {fileID: 2100000, guid: 71e41e43b459a244abaf5acca76b89ee, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
|
||||
@@ -103,7 +103,7 @@ GameObject:
|
||||
- component: {fileID: 6834786618115927220}
|
||||
- component: {fileID: 8793146551006314905}
|
||||
- component: {fileID: 7779358222264100756}
|
||||
m_Layer: 0
|
||||
m_Layer: 9
|
||||
m_Name: Wall
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace ProjectM.Authoring
|
||||
public GameObject TurretPrefab;
|
||||
|
||||
[Tooltip("Ore cost to build a turret.")]
|
||||
[Min(0)] public int TurretCostOre = 10;
|
||||
[Min(0)] public int TurretCostOre = 40; // DR-042 combat pass: was 10 (~1/3 node) -> 40 (~1.3 nodes); a real investment
|
||||
|
||||
[Tooltip("Wall structure ghost prefab (StructureAuthoring{Wall} + GhostAuthoring).")]
|
||||
public GameObject WallPrefab;
|
||||
@@ -68,7 +68,7 @@ namespace ProjectM.Authoring
|
||||
{
|
||||
Type = StructureType.Wall,
|
||||
Prefab = GetEntity(authoring.WallPrefab, TransformUsageFlags.Dynamic),
|
||||
CostResourceId = ResourceId.Ore,
|
||||
CostResourceId = ResourceId.Biomass, // DR-042 C6b: walls cost Biomass (the dead currency's only sink)
|
||||
CostAmount = authoring.WallCostOre,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ namespace ProjectM.Authoring
|
||||
|
||||
[Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")]
|
||||
public uint SiegeTimeoutTicks = 3600;
|
||||
[Header("Threat — scheduled base sieges")]
|
||||
[Tooltip("A timed cadence arms a base siege even without an expedition trip (keeps the base loop stakeful).")]
|
||||
public bool ScheduleEnabled = true;
|
||||
[Header("Threat — scheduled base sieges (DR-042: DISABLED — reserved/inert hook)")]
|
||||
[Tooltip("DR-042: OFF. A blind timed cadence was the AFK win path (auto-armed sieges the SiegeTimeout auto-cleared). The win-driver is now expedition clears; base sieges are post-expedition retaliation only. Code path kept as a config-inert reserved hook.")]
|
||||
public bool ScheduleEnabled = false;
|
||||
|
||||
[Tooltip("Server ticks (@60) between scheduled base sieges. First fire is one interval out (mine/build grace).")]
|
||||
public uint ScheduleIntervalTicks = 2700;
|
||||
@@ -59,7 +59,7 @@ namespace ProjectM.Authoring
|
||||
});
|
||||
AddComponent<ResourceLedger>(entity);
|
||||
AddBuffer<StorageEntry>(entity);
|
||||
AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // END-2: 4 survived sieges -> the final siege (the 5th)
|
||||
AddComponent(entity, new GoalProgress { Charge = 0, Target = 4 }); // DR-042: 4 expedition clears -> the climactic final siege
|
||||
// END-1: the losable Engine Core rides this GLOBAL ghost (no new ghost / no relevancy). Born full;
|
||||
// CycleDirectorSpawnSystem overrides Current with a persisted wounded value on Continue.
|
||||
AddComponent(entity, new CoreIntegrity
|
||||
@@ -73,6 +73,11 @@ namespace ProjectM.Authoring
|
||||
// CycleDirectorSpawnSystem overrides it with a persisted Victory/Loss on Continue.
|
||||
AddComponent(entity, new RunOutcome { Value = RunOutcomeId.InProgress });
|
||||
|
||||
// DR-042 C7b: replicated expedition-objective summary (the HUD 'enemies remaining / cleared' readout).
|
||||
// Born Idle; ZoneEnemyDirectorSystem is the sole writer. New [GhostField] component -> re-hashes the
|
||||
// runtime-spawned director ghost (server + client bake the same prefab -> hash matches), like CoreIntegrity.
|
||||
AddComponent(entity, new ExpeditionObjective { State = ExpeditionObjectiveState.Idle, Remaining = 0 });
|
||||
|
||||
|
||||
AddComponent(entity, new ThreatConfig
|
||||
{
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace ProjectM.Authoring
|
||||
{
|
||||
[Tooltip("Name of the Unity layer carrying the static world colliders (boundary ring + landmarks).")]
|
||||
public string EnvironmentLayerName = "Environment";
|
||||
[Tooltip("DR-042 C5: Unity layer carrying player-built structure colliders (Wall/Turret/Pylon) that block enemies.")]
|
||||
public string StructureLayerName = "Structure";
|
||||
|
||||
|
||||
private class WorldCollisionBaker : Baker<WorldCollisionAuthoring>
|
||||
{
|
||||
@@ -23,7 +26,9 @@ namespace ProjectM.Authoring
|
||||
int layer = LayerMask.NameToLayer(authoring.EnvironmentLayerName);
|
||||
uint mask = layer >= 0 ? 1u << layer : 0u;
|
||||
var entity = GetEntity(TransformUsageFlags.None);
|
||||
AddComponent(entity, new WorldCollisionConfig { EnvironmentMask = mask });
|
||||
int structLayer = LayerMask.NameToLayer(authoring.StructureLayerName);
|
||||
uint structMask = structLayer >= 0 ? 1u << structLayer : 0u;
|
||||
AddComponent(entity, new WorldCollisionConfig { EnvironmentMask = mask, StructureMask = structMask });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace ProjectM.Client
|
||||
/// rotates a conveyor's facing. Fire is suppressed while build mode is active (PlayerInputGatherSystem reads
|
||||
/// <see cref="BuildPaletteState.Active"/>), so the place-click never also fires. Build mode is suspended while
|
||||
/// the pause overlay is open, and the frame a palette button changes the selection never also places.
|
||||
/// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/N/H/F/C place at the local player's cell.
|
||||
/// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/F place at the local player's cell.
|
||||
/// Editor-only statics (PlaceStructure / PlaceHarvester / ...) drive the same RPC path from execute_code for
|
||||
/// headless validation. Managed SystemBase; UnityEngine.InputSystem types are fully qualified to avoid the
|
||||
/// ProjectM.Simulation.PlayerInput name collision. The server re-validates legality + cost authoritatively.
|
||||
@@ -30,10 +30,9 @@ namespace ProjectM.Client
|
||||
{
|
||||
(UnityEngine.InputSystem.Key.B, StructureType.Turret),
|
||||
(UnityEngine.InputSystem.Key.V, StructureType.Wall),
|
||||
(UnityEngine.InputSystem.Key.N, StructureType.Pylon),
|
||||
(UnityEngine.InputSystem.Key.H, StructureType.Harvester),
|
||||
(UnityEngine.InputSystem.Key.F, StructureType.Fabricator),
|
||||
(UnityEngine.InputSystem.Key.C, StructureType.Conveyor),
|
||||
// DR-042 C6d: Pylon/Harvester/Conveyor are dead (unwired automation) — dropped from the hotkey fallback
|
||||
// to match the hidden build palette; their PlaceStructure execute_code statics remain for dev.
|
||||
};
|
||||
|
||||
UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily)
|
||||
@@ -41,11 +40,16 @@ namespace ProjectM.Client
|
||||
Material _ghostMat;
|
||||
byte _lastSelected; // skip placing on the frame a palette click changes the selection
|
||||
|
||||
// DR-042 C6a: the ability-upgrade send is RUNTIME (the HUD Aether button calls UpgradeAbility); only the
|
||||
// execute_code PLACE statics stay editor-gated. Mirrors EquipSendSystem's unconditional queue + drain.
|
||||
static int s_PendingUpgrades = 0;
|
||||
/// <summary>Runtime hook (HUD Aether button) + execute_code: queue an ability-damage upgrade.</summary>
|
||||
public static void UpgradeAbility() => s_PendingUpgrades++;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; }
|
||||
static readonly System.Collections.Generic.Queue<PendingBuild> s_PendingBuild =
|
||||
new System.Collections.Generic.Queue<PendingBuild>();
|
||||
static int s_PendingUpgrades = 0;
|
||||
|
||||
/// <summary>EDITOR / execute_code hook: queue a structure placement at a specific cell.</summary>
|
||||
public static void PlaceStructure(byte type, int cellX, int cellZ, byte direction = 0) =>
|
||||
@@ -69,8 +73,6 @@ namespace ProjectM.Client
|
||||
/// <summary>EDITOR / execute_code hook: queue a conveyor placement facing direction (0=+X,1=-X,2=+Z,3=-Z).</summary>
|
||||
public static void PlaceConveyor(int cellX, int cellZ, byte direction) => PlaceStructure(StructureType.Conveyor, cellX, cellZ, direction);
|
||||
|
||||
/// <summary>EDITOR / execute_code hook: queue an ability-damage upgrade.</summary>
|
||||
public static void UpgradeAbility() => s_PendingUpgrades++;
|
||||
#endif
|
||||
|
||||
protected override void OnCreate()
|
||||
@@ -115,17 +117,19 @@ namespace ProjectM.Client
|
||||
SendUpgrade(connection);
|
||||
}
|
||||
|
||||
// DR-042 C6a: the ability-upgrade drain runs at RUNTIME (the HUD Aether button enqueues via UpgradeAbility);
|
||||
// only the execute_code PLACE drain stays editor-gated.
|
||||
while (s_PendingUpgrades > 0)
|
||||
{
|
||||
s_PendingUpgrades--;
|
||||
SendUpgrade(connection);
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
while (s_PendingBuild.Count > 0)
|
||||
{
|
||||
var b = s_PendingBuild.Dequeue();
|
||||
SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction);
|
||||
}
|
||||
while (s_PendingUpgrades > 0)
|
||||
{
|
||||
s_PendingUpgrades--;
|
||||
SendUpgrade(connection);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ namespace ProjectM.Client
|
||||
Color _slashTint;
|
||||
float _slashAge, _slashLife;
|
||||
bool _slashActive;
|
||||
float _slashRange, _slashHalf; // live cone geometry re-sampled each frame for the per-frame sweep rebuild
|
||||
int _slashSweepSign = 1; // alternate sweep direction per combo step (reads as alternating strikes)
|
||||
Material _dangerMat;
|
||||
readonly Dictionary<Entity, GameObject> _dangerZones = new();
|
||||
readonly HashSet<Entity> _dangerSeen = new();
|
||||
@@ -77,6 +79,21 @@ namespace ProjectM.Client
|
||||
Material _barBgMat, _barFillMat;
|
||||
// Telegraph scale-pulse (Slice 1, Feature C): per-enemy windup-onset time, folded into the danger cone.
|
||||
readonly Dictionary<Entity, float> _pulseStart = new();
|
||||
// Near-impact strike beep (deferred-items pass): entity -> the WindUpUntilTick it last beeped for (once/windup).
|
||||
readonly Dictionary<Entity, uint> _strikeBeeped = new();
|
||||
|
||||
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
|
||||
// player, edge-detected from the replicated MeleeCombo.SwingStartTick (the local player keeps _slashMr).
|
||||
class RemoteSlash
|
||||
{
|
||||
public GameObject Go; public Mesh Mesh; public MeshRenderer Mr; public Material Mat;
|
||||
public float Age, Life, Range, Half; public int SweepSign; public Color Tint;
|
||||
public bool Active; public uint LastSwingTick; public bool Init;
|
||||
}
|
||||
readonly Dictionary<Entity, RemoteSlash> _remoteSlashes = new();
|
||||
readonly HashSet<Entity> _remoteSeen = new();
|
||||
readonly List<Entity> _remoteStale = new();
|
||||
|
||||
|
||||
AudioClip _hitClip;
|
||||
AudioClip _deathClip;
|
||||
@@ -84,6 +101,8 @@ namespace ProjectM.Client
|
||||
AudioClip _telegraphClip;
|
||||
AudioClip _dashClip;
|
||||
AudioClip _swingClip;
|
||||
AudioClip _meleeConnectClip, _footstepClip, _strikeBeepClip; // combat feel pass: connect thunk / footstep / strike beep
|
||||
Vector3 _lastFootPos; float _footTimer; bool _footInit; // footstep edge-detect (local player locomotion)
|
||||
|
||||
Entity _localPlayer = Entity.Null;
|
||||
uint _lastLocalFireTick;
|
||||
@@ -106,6 +125,9 @@ namespace ProjectM.Client
|
||||
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
|
||||
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
|
||||
_swingClip = MakeClip("swing", 720f, 200f, 0.09f, 0.42f, noise: false);
|
||||
_meleeConnectClip = MakeClip("melee_thunk", 180f, 60f, 0.13f, 0.55f, noise: true); // meaty low connect
|
||||
_footstepClip = MakeClip("step", 200f, 110f, 0.06f, 0.18f, noise: true); // soft footfall
|
||||
_strikeBeepClip = MakeClip("strike", 1150f, 1500f, 0.05f, 0.30f, noise: false); // (reserved) near-impact beep
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
@@ -145,6 +167,14 @@ namespace ProjectM.Client
|
||||
if (kv.Value != null) { var mf = kv.Value.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); }
|
||||
foreach (var kv in _healthBars)
|
||||
if (kv.Value.CanvasGo != null) Object.Destroy(kv.Value.CanvasGo);
|
||||
foreach (var kv in _remoteSlashes)
|
||||
{
|
||||
if (kv.Value.Mesh != null) Object.Destroy(kv.Value.Mesh);
|
||||
if (kv.Value.Mat != null) Object.Destroy(kv.Value.Mat);
|
||||
if (kv.Value.Go != null) Object.Destroy(kv.Value.Go);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
@@ -217,6 +247,8 @@ namespace ProjectM.Client
|
||||
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
|
||||
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
||||
if (isLocalPlayer && FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.8f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||
if (isEnemy)
|
||||
{
|
||||
// MC-3: net-new player-dealt-hit camera punch — scales with the bite size
|
||||
@@ -225,6 +257,10 @@ namespace ProjectM.Client
|
||||
float hitMag = math.saturate((prev.Hp - cur) / math.max(1f, FeelConfig.HitStopRefDamage));
|
||||
PrototypeCameraRig.PunchFov(math.lerp(FeelConfig.HitStopFovKickMin, FeelConfig.HitStopFovKickMax, hitMag), FeelConfig.HitStopDurationMs);
|
||||
ShowHealthBar(entity); // Feature B: arm/refresh this enemy's bar on a damage edge
|
||||
// Hit-flash: a bright body-scaled puff in FeelConfig.HitFlashColor — the staple "I lit it up" read.
|
||||
EmitColored(_hitFx, (Vector3)p + Vector3.up * 0.7f, FeelConfig.HitFlashBurstCount, FeelConfig.HitFlashColor);
|
||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +302,9 @@ namespace ProjectM.Client
|
||||
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
|
||||
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
|
||||
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
|
||||
EmitColored(_hitFx, (Vector3)c.Pos + Vector3.up * 0.6f, FeelConfig.KillFlashBurstCount, FeelConfig.HitFlashColor); // kill pop
|
||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||
RumbleUtil.Pulse(FeelConfig.RumbleKill * 0.7f, FeelConfig.RumbleKill, FeelConfig.RumbleDurationSec);
|
||||
}
|
||||
_cache.Remove(_stale[i]);
|
||||
}
|
||||
@@ -329,18 +368,58 @@ namespace ProjectM.Client
|
||||
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
||||
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
||||
if (finisher) slashRange *= tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
|
||||
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, finisher); // the arc IS the range telegraph (MC-4 visual clarity)
|
||||
// MC-4 connect-vs-whiff: client-side cone overlap over the cached enemy snapshot gives an IMMEDIATE
|
||||
// "you bit" read (the authoritative server damage spark/number arrives a few ticks later).
|
||||
bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue;
|
||||
float cosHalf = Mathf.Cos(slashHalf);
|
||||
float2 fdir = new float2(face.x, face.z);
|
||||
foreach (var kv in _cache)
|
||||
{
|
||||
if (!kv.Value.IsEnemy) continue;
|
||||
if (MeleeConeMath.InCone(localPos, fdir, slashRange, cosHalf, kv.Value.Pos))
|
||||
{
|
||||
float d2 = math.distancesq(localPos, kv.Value.Pos);
|
||||
if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; }
|
||||
}
|
||||
}
|
||||
TriggerSlash((Vector3)localPos, new float2(face.x, face.z), slashRange, slashHalf, step, comboLen, connected); // sweeps + ramps + brightens on connect
|
||||
if (connected)
|
||||
{
|
||||
Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
|
||||
PlayClip(_meleeConnectClip, nearestHit, FeelConfig.MeleeConnectVolume);
|
||||
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
|
||||
if (FeelConfig.RumbleEnabled && AimPresentation.Scheme == 1)
|
||||
RumbleUtil.Pulse(FeelConfig.RumbleHit * 0.6f, FeelConfig.RumbleHit, FeelConfig.RumbleDurationSec);
|
||||
}
|
||||
if (finisher) PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick * 0.6f, FeelConfig.HitStopDurationMs);
|
||||
}
|
||||
_lastLocalSwingTick = mc.SwingStartTick;
|
||||
_swingTickInit = true;
|
||||
}
|
||||
|
||||
// Footsteps (combat feel): edge-detect local locomotion from the position delta; a soft step at a cadence.
|
||||
if (_localPlayer != Entity.Null)
|
||||
{
|
||||
Vector3 lp = (Vector3)localPos;
|
||||
if (_footInit)
|
||||
{
|
||||
float sp = dt > 1e-4f ? new Vector2(lp.x - _lastFootPos.x, lp.z - _lastFootPos.z).magnitude / dt : 0f;
|
||||
_footTimer -= dt;
|
||||
if (sp >= FeelConfig.FootstepMinSpeed && _footTimer <= 0f)
|
||||
{
|
||||
PlayClip(_footstepClip, lp, FeelConfig.FootstepVolume);
|
||||
_footTimer = FeelConfig.FootstepIntervalSec;
|
||||
}
|
||||
}
|
||||
_lastFootPos = lp; _footInit = true;
|
||||
}
|
||||
RumbleUtil.Tick(); // auto-stop any elapsed gamepad rumble pulse
|
||||
UpdateProjectileTrails(cfg);
|
||||
PruneVfx();
|
||||
AnimateNumbers(dt, cam);
|
||||
UpdateSlash(dt);
|
||||
UpdateEnemyDanger();
|
||||
UpdateEnemyDanger(localPos);
|
||||
UpdateRemoteSwings(dt);
|
||||
UpdateHealthBars(dt, cam, localPos);
|
||||
}
|
||||
|
||||
@@ -352,6 +431,15 @@ namespace ProjectM.Client
|
||||
return cfg.PlayerDeath != null ? cfg.PlayerDeath : cfg.EnemyDeath;
|
||||
}
|
||||
|
||||
// Emit a colored particle burst at a position (per-emit startColor) — used for the enemy hit-flash + kill pop
|
||||
// without a dedicated particle system (the unused FeelConfig.HitFlashColor finally lights enemies on a hit).
|
||||
void EmitColored(ParticleSystem ps, Vector3 pos, int count, Color color)
|
||||
{
|
||||
if (ps == null || count <= 0) return;
|
||||
var ep = new ParticleSystem.EmitParams { position = pos, startColor = color };
|
||||
ps.Emit(ep, count);
|
||||
}
|
||||
|
||||
void Burst(ParticleSystem fallback, GameObject prefab, Vector3 pos, int count)
|
||||
{
|
||||
if (prefab != null) SpawnVfx(prefab, pos, Quaternion.identity);
|
||||
@@ -495,12 +583,14 @@ namespace ProjectM.Client
|
||||
|
||||
fn.Active = true;
|
||||
fn.Age = 0f;
|
||||
fn.Life = 0.7f;
|
||||
float mag = Mathf.Clamp01(amount / Mathf.Max(1f, FeelConfig.HitStopRefDamage)); // big hits read bigger
|
||||
fn.Life = Mathf.Lerp(0.6f, 0.95f, mag);
|
||||
fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString();
|
||||
fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit)
|
||||
fn.Tm.color = fn.BaseColor;
|
||||
fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f);
|
||||
fn.Vel = new Vector3(0f, 2.2f, 0f);
|
||||
fn.Tr.localScale = Vector3.one * Mathf.Lerp(0.85f, 1.5f, mag);
|
||||
fn.Tr.gameObject.SetActive(true);
|
||||
if (cam != null) fn.Tr.rotation = cam.transform.rotation;
|
||||
}
|
||||
@@ -624,24 +714,28 @@ namespace ProjectM.Client
|
||||
}
|
||||
|
||||
// Rebuild the crescent (inner->outer arc) for the LIVE cone half-angle + range, in local +Z-forward space.
|
||||
void BuildSlashMesh(float range, float halfAngle)
|
||||
// `reveal` (0..1) sweeps the arc open from one edge (sweepSign) toward the other so the cleave reads directional.
|
||||
void BuildSlashInto(Mesh mesh, float range, float halfAngle, float reveal, int sweepSign)
|
||||
{
|
||||
const int seg = 16;
|
||||
float r1 = Mathf.Max(0.4f, range);
|
||||
float r0 = r1 * 0.45f;
|
||||
float aStart = sweepSign >= 0 ? -halfAngle : halfAngle; // trailing edge
|
||||
float aFull = sweepSign >= 0 ? halfAngle : -halfAngle; // far edge
|
||||
float aEnd = Mathf.Lerp(aStart, aFull, Mathf.Clamp01(reveal)); // current leading edge of the sweep
|
||||
var verts = new Vector3[(seg + 1) * 2];
|
||||
var cols = new Color[(seg + 1) * 2];
|
||||
var uvs = new Vector2[(seg + 1) * 2];
|
||||
var tris = new int[seg * 6];
|
||||
for (int i = 0; i <= seg; i++)
|
||||
{
|
||||
float a = Mathf.Lerp(-halfAngle, halfAngle, i / (float)seg);
|
||||
float a = Mathf.Lerp(aStart, aEnd, i / (float)seg);
|
||||
float sx = Mathf.Sin(a), cz = Mathf.Cos(a);
|
||||
verts[i * 2] = new Vector3(sx * r0, 0f, cz * r0);
|
||||
verts[i * 2 + 1] = new Vector3(sx * r1, 0f, cz * r1);
|
||||
float across = 1f - Mathf.Abs(i / (float)seg * 2f - 1f); // 0 at edges, 1 at centre
|
||||
cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.4f + 0.6f * across)); // inner brighter
|
||||
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
|
||||
float lead = i / (float)seg; // 0 trailing -> 1 leading edge (brightest at the travelling blade)
|
||||
cols[i * 2] = new Color(1f, 1f, 1f, 0.55f * (0.2f + 0.8f * lead)); // inner, brightest at the leading edge
|
||||
cols[i * 2 + 1] = new Color(1f, 1f, 1f, 0f); // outer rim fades out
|
||||
uvs[i * 2] = new Vector2(0.5f, 0.5f);
|
||||
uvs[i * 2 + 1] = new Vector2(0.5f, 0.5f);
|
||||
}
|
||||
@@ -651,27 +745,36 @@ namespace ProjectM.Client
|
||||
tris[i * 6 + 0] = b; tris[i * 6 + 1] = b + 1; tris[i * 6 + 2] = b + 2;
|
||||
tris[i * 6 + 3] = b + 1; tris[i * 6 + 4] = b + 3; tris[i * 6 + 5] = b + 2;
|
||||
}
|
||||
_slashMesh.Clear();
|
||||
_slashMesh.vertices = verts;
|
||||
_slashMesh.colors = cols;
|
||||
_slashMesh.uv = uvs;
|
||||
_slashMesh.triangles = tris;
|
||||
_slashMesh.RecalculateBounds();
|
||||
mesh.Clear();
|
||||
mesh.vertices = verts;
|
||||
mesh.colors = cols;
|
||||
mesh.uv = uvs;
|
||||
mesh.triangles = tris;
|
||||
mesh.RecalculateBounds();
|
||||
}
|
||||
|
||||
// Flash a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS the
|
||||
// range telegraph (MC-4 visual clarity): the player sees exactly how far + how wide the cleave reaches.
|
||||
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, bool finisher)
|
||||
// Trigger a cone-shaped slash matching the LIVE melee range + half-angle, oriented along facing. The arc IS
|
||||
// the range telegraph (MC-4 clarity) AND now SWEEPS across + ramps per combo step so the swing reads as a
|
||||
// directional, escalating cleave rather than a static flash.
|
||||
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected)
|
||||
{
|
||||
if (_slashMr == null || _slashMat == null) return;
|
||||
BuildSlashMesh(range, halfAngle);
|
||||
bool finisher = step >= comboLen;
|
||||
_slashRange = range; _slashHalf = halfAngle;
|
||||
_slashSweepSign = (step % 2 == 0) ? -1 : 1; // alternate L->R / R->L per swing -> reads as alternating strikes
|
||||
BuildSlashInto(_slashMesh, range, halfAngle, 0f, _slashSweepSign); // start closed; UpdateSlash sweeps it open
|
||||
Vector3 f = math.lengthsq(facing) > 1e-6f ? new Vector3(facing.x, 0f, facing.y).normalized : Vector3.forward;
|
||||
var tr = _slashMr.transform;
|
||||
tr.position = pos + Vector3.up * 0.12f;
|
||||
tr.rotation = Quaternion.LookRotation(f, Vector3.up);
|
||||
tr.localScale = Vector3.one;
|
||||
_slashTint = finisher ? new Color(3.2f, 2.3f, 0.7f) : new Color(1.6f, 2.4f, 3.2f); // finisher warm / light cool (HDR -> bloom)
|
||||
_slashLife = finisher ? 0.26f : 0.17f;
|
||||
// Per-step ramp so the chain visibly builds to the finisher (the steps were byte-identical before).
|
||||
float t = comboLen > 1 ? math.saturate((step - 1) / (float)(comboLen - 1)) : 1f;
|
||||
_slashTint = finisher
|
||||
? new Color(3.4f, 2.4f, 0.7f) // finisher: warm HDR flash
|
||||
: Color.Lerp(new Color(1.2f, 1.9f, 2.6f), new Color(1.9f, 2.8f, 3.4f), t); // cool, brighter per step
|
||||
if (connected) _slashTint *= 1.6f; // brighter arc on a confirmed bite (the immediate "you hit" read)
|
||||
_slashLife = finisher ? 0.28f : Mathf.Lerp(0.15f, 0.20f, t);
|
||||
_slashAge = 0f;
|
||||
_slashActive = true;
|
||||
_slashMat.color = _slashTint;
|
||||
@@ -684,14 +787,108 @@ namespace ProjectM.Client
|
||||
_slashAge += dt;
|
||||
float u = _slashAge / Mathf.Max(1e-4f, _slashLife);
|
||||
if (u >= 1f) { _slashActive = false; _slashMr.enabled = false; return; }
|
||||
var c = _slashTint; c.a = 1f - u; _slashMat.color = c;
|
||||
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.12f);
|
||||
// MC-4 clarity: SWEEP the crescent open across the arc over the first ~60% of life (reads as a blade
|
||||
// travelling through the cleave), then hold + fade — instead of popping the whole cone at once.
|
||||
float reveal = Mathf.Clamp01(u / 0.6f);
|
||||
BuildSlashInto(_slashMesh, _slashRange, _slashHalf, reveal, _slashSweepSign);
|
||||
var c = _slashTint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; _slashMat.color = c;
|
||||
_slashMr.transform.localScale = Vector3.one * (1f + u * 0.10f);
|
||||
}
|
||||
|
||||
// Remote teammates' melee cleave arcs (deferred-items pass, co-op readability): the local player's swing
|
||||
// renders via _slashMr; here each REMOTE player (interpolated, GhostOwnerIsLocal DISABLED) gets a pooled
|
||||
// slash arc edge-detected from its replicated MeleeCombo.SwingStartTick + PlayerFacing. Observe-only client
|
||||
// presentation; no sim, no new [GhostField]. Anchored to the moving teammate while it sweeps open + fades.
|
||||
void UpdateRemoteSwings(float dt)
|
||||
{
|
||||
if (!FeelConfig.RemoteSwingEnabled || _fxRoot == null) return;
|
||||
int comboLen = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
|
||||
float baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
||||
float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
||||
float finisherMult = tcfg.MeleeFinisherMult > 0f ? tcfg.MeleeFinisherMult : 1.8f;
|
||||
|
||||
_remoteSeen.Clear();
|
||||
foreach (var (xf, facing, mc, entity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>, RefRO<MeleeCombo>>()
|
||||
.WithAll<PlayerTag>().WithDisabled<GhostOwnerIsLocal>().WithEntityAccess())
|
||||
{
|
||||
_remoteSeen.Add(entity);
|
||||
if (!_remoteSlashes.TryGetValue(entity, out var rs)) { rs = CreateRemoteSlash(); _remoteSlashes[entity] = rs; }
|
||||
|
||||
uint swing = mc.ValueRO.SwingStartTick;
|
||||
if (rs.Init && swing != 0 && swing != rs.LastSwingTick)
|
||||
{
|
||||
int step = math.max(1, (int)mc.ValueRO.Step);
|
||||
bool finisher = step >= comboLen;
|
||||
rs.Range = finisher ? baseRange * finisherMult : baseRange;
|
||||
rs.Half = baseHalf;
|
||||
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
|
||||
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
|
||||
rs.Life = finisher ? 0.26f : 0.18f;
|
||||
rs.Age = 0f;
|
||||
rs.Active = true;
|
||||
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, 0f, rs.SweepSign);
|
||||
rs.Mat.color = rs.Tint;
|
||||
rs.Mr.enabled = true;
|
||||
}
|
||||
rs.LastSwingTick = swing;
|
||||
rs.Init = true;
|
||||
|
||||
if (rs.Active)
|
||||
{
|
||||
rs.Age += dt;
|
||||
float u = rs.Age / Mathf.Max(1e-4f, rs.Life);
|
||||
if (u >= 1f) { rs.Active = false; rs.Mr.enabled = false; }
|
||||
else
|
||||
{
|
||||
float2 fdir = facing.ValueRO.Direction;
|
||||
Vector3 f = math.lengthsq(fdir) > 1e-6f ? new Vector3(fdir.x, 0f, fdir.y).normalized : Vector3.forward;
|
||||
var tr = rs.Mr.transform;
|
||||
tr.position = (Vector3)xf.ValueRO.Position + Vector3.up * 0.12f;
|
||||
tr.rotation = Quaternion.LookRotation(f, Vector3.up);
|
||||
float reveal = Mathf.Clamp01(u / 0.6f);
|
||||
BuildSlashInto(rs.Mesh, rs.Range, rs.Half, reveal, rs.SweepSign);
|
||||
var c = rs.Tint; c.a = u < 0.6f ? 1f : 1f - (u - 0.6f) / 0.4f; rs.Mat.color = c;
|
||||
tr.localScale = Vector3.one * (1f + u * 0.10f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_remoteSlashes.Count != _remoteSeen.Count)
|
||||
{
|
||||
_remoteStale.Clear();
|
||||
foreach (var kv in _remoteSlashes) if (!_remoteSeen.Contains(kv.Key)) _remoteStale.Add(kv.Key);
|
||||
for (int i = 0; i < _remoteStale.Count; i++)
|
||||
{
|
||||
var rs = _remoteSlashes[_remoteStale[i]];
|
||||
if (rs.Mesh != null) Object.Destroy(rs.Mesh);
|
||||
if (rs.Mat != null) Object.Destroy(rs.Mat);
|
||||
if (rs.Go != null) Object.Destroy(rs.Go);
|
||||
_remoteSlashes.Remove(_remoteStale[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RemoteSlash CreateRemoteSlash()
|
||||
{
|
||||
var go = new GameObject("RemoteSlashArc");
|
||||
go.transform.SetParent(_fxRoot, false);
|
||||
var mesh = new Mesh { name = "RemoteSlashArc" };
|
||||
go.AddComponent<MeshFilter>().sharedMesh = mesh;
|
||||
var mr = go.AddComponent<MeshRenderer>();
|
||||
var mat = MakeParticleMaterial();
|
||||
mat.name = "RemoteSlashArc";
|
||||
mr.sharedMaterial = mat;
|
||||
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
mr.receiveShadows = false;
|
||||
mr.enabled = false;
|
||||
return new RemoteSlash { Go = go, Mesh = mesh, Mr = mr, Mat = mat, Active = false, Init = false };
|
||||
}
|
||||
|
||||
// Enemy attack TELEGRAPH (MC-4 clarity): while an enemy's AttackWindup counts down, paint a red ground danger
|
||||
// cone in its facing out to its reach, brightening + scaling as the strike nears -> the player reads WHERE +
|
||||
// WHEN to dodge. Client-only, observe-only; one pooled mesh per winding-up enemy, pruned each frame.
|
||||
void UpdateEnemyDanger()
|
||||
void UpdateEnemyDanger(float3 localPos)
|
||||
{
|
||||
if (_fxRoot == null || _dangerMat == null) return;
|
||||
Unity.NetCode.NetworkTick serverTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt) ? nt.ServerTick : default;
|
||||
@@ -721,6 +918,20 @@ namespace ProjectM.Client
|
||||
// any windup length (fixes the Charger plateauing early under the old hard-coded 22).
|
||||
float windupDur = math.max(1f, tele.ValueRO.WindupTicks);
|
||||
intensity = math.saturate(1f - remaining / windupDur);
|
||||
|
||||
// Near-impact strike beep (deferred-items pass): a "dodge NOW" cue once per windup, gated to
|
||||
// enemies near the local player (the danger cone already proves it's winding up to strike).
|
||||
if (FeelConfig.StrikeBeepEnabled && _localPlayer != Entity.Null && remaining <= FeelConfig.StrikeBeepLeadTicks
|
||||
&& (!_strikeBeeped.TryGetValue(entity, out var beepedUntil) || beepedUntil != until))
|
||||
{
|
||||
float3 ep = xf.ValueRO.Position;
|
||||
if (math.distancesq(ep, localPos) <= FeelConfig.StrikeBeepMaxDistSq)
|
||||
{
|
||||
PlayClip(_strikeBeepClip, (Vector3)ep, FeelConfig.StrikeBeepVolume);
|
||||
_strikeBeeped[entity] = until;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Feature C: a short anticipation scale-pulse folded into the client-owned cone (never the ghost).
|
||||
@@ -778,6 +989,8 @@ namespace ProjectM.Client
|
||||
if (g != null) { var mf = g.GetComponent<MeshFilter>(); if (mf != null && mf.sharedMesh != null) Object.Destroy(mf.sharedMesh); Object.Destroy(g); }
|
||||
_dangerZones.Remove(_dangerStale[i]);
|
||||
_pulseStart.Remove(_dangerStale[i]);
|
||||
_strikeBeeped.Remove(_dangerStale[i]);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.Rendering; // URPMaterialPropertyBaseColor, MaterialMeshInfo (Entities Graphics)
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Client-only TRUE BODY hit-flash for enemies (the focused follow-up DR-041 deferred as "its own ShaderGraph
|
||||
/// slice"). Enemies render via Rukhanka GPU deformation: the ghost ROOT holds the gameplay components
|
||||
/// (<see cref="Health"/>, <see cref="EnemyTag"/>) while the visible meshes are LinkedEntityGroup CHILD render
|
||||
/// entities (each with a <see cref="MaterialMeshInfo"/> + the AnimatedLitShader material, whose <c>_BaseColor</c>
|
||||
/// is white). <see cref="CombatFeedbackSystem"/>'s colored particle puff is the asset-free stand-in; THIS system
|
||||
/// flashes the actual body by driving the built-in Entities-Graphics per-instance override
|
||||
/// <see cref="URPMaterialPropertyBaseColor"/> (a registered <c>[MaterialProperty("_BaseColor")]</c>) on those
|
||||
/// render children: on an enemy <see cref="Health"/>-decrease edge it lerps <c>_BaseColor</c> toward
|
||||
/// <see cref="FeelConfig.BodyFlashColor"/> and decays back to white. No new component type, NO ShaderGraph edit,
|
||||
/// no server work, no <c>[GhostField]</c> — observe-only client presentation, so it is rollback-irrelevant.
|
||||
/// The render children gain <see cref="URPMaterialPropertyBaseColor"/> lazily (added once per enemy via ECB);
|
||||
/// white is the baked rest value (every enemy uses the same AnimatedLitShader/Synty-atlas convention), so
|
||||
/// <c>Flash==0</c> restores the untouched look and the override is never visible at rest.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class EnemyHitFlashSystem : SystemBase
|
||||
{
|
||||
class FlashEntry
|
||||
{
|
||||
public readonly List<Entity> RenderKids = new();
|
||||
public float LastHp;
|
||||
public float Flash; // 1 on a fresh hit, decays to 0
|
||||
public bool Settled; // wrote the final white frame after a flash ended (skips per-frame writes at rest)
|
||||
}
|
||||
|
||||
readonly Dictionary<Entity, FlashEntry> _tracked = new();
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
readonly List<Entity> _stale = new();
|
||||
|
||||
static readonly float4 White = new float4(1f, 1f, 1f, 1f);
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (!FeelConfig.BodyFlashEnabled) { RestoreAllToRest(); return; } // settle any mid-flash body back to white before bailing
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
EntityManager.CompleteDependencyBeforeRO<Health>();
|
||||
|
||||
// Pass 1: discover enemies, ensure each is tracked + its render children carry the override component.
|
||||
_seen.Clear();
|
||||
var ecb = new EntityCommandBuffer(Unity.Collections.Allocator.Temp);
|
||||
foreach (var (health, entity) in
|
||||
SystemAPI.Query<RefRO<Health>>().WithAll<EnemyTag, LinkedEntityGroup>().WithEntityAccess())
|
||||
{
|
||||
_seen.Add(entity);
|
||||
if (_tracked.ContainsKey(entity)) continue;
|
||||
|
||||
var entry = new FlashEntry { LastHp = health.ValueRO.Current, Flash = 0f, Settled = true };
|
||||
var leg = EntityManager.GetBuffer<LinkedEntityGroup>(entity);
|
||||
for (int i = 0; i < leg.Length; i++)
|
||||
{
|
||||
var c = leg[i].Value;
|
||||
if (!EntityManager.Exists(c) || !EntityManager.HasComponent<MaterialMeshInfo>(c)) continue;
|
||||
entry.RenderKids.Add(c);
|
||||
if (!EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
|
||||
ecb.AddComponent(c, new URPMaterialPropertyBaseColor { Value = White });
|
||||
}
|
||||
// Render children can lag ghost instantiation a frame; only finalize once we actually found them (else retry next frame).
|
||||
if (entry.RenderKids.Count > 0) _tracked[entity] = entry;
|
||||
}
|
||||
ecb.Playback(EntityManager);
|
||||
ecb.Dispose();
|
||||
|
||||
// Pass 2: edge-detect Health, drive + decay the flash, write _BaseColor to the render children.
|
||||
var bc = FeelConfig.BodyFlashColor;
|
||||
float4 peak = new float4(bc.r, bc.g, bc.b, bc.a);
|
||||
float decay = dt / math.max(0.01f, FeelConfig.BodyFlashDurationSec);
|
||||
foreach (var kv in _tracked)
|
||||
{
|
||||
var entity = kv.Key;
|
||||
var entry = kv.Value;
|
||||
if (!_seen.Contains(entity)) continue; // despawned -> pruned below
|
||||
|
||||
float cur = EntityManager.GetComponentData<Health>(entity).Current;
|
||||
if (cur < entry.LastHp - 0.001f) { entry.Flash = 1f; entry.Settled = false; }
|
||||
entry.LastHp = cur;
|
||||
|
||||
if (entry.Flash <= 0f)
|
||||
{
|
||||
if (!entry.Settled) { WriteColor(entry, White); entry.Settled = true; } // settle to baked white once
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.Flash = math.max(0f, entry.Flash - decay);
|
||||
WriteColor(entry, math.lerp(White, peak, entry.Flash));
|
||||
}
|
||||
|
||||
// Prune despawned enemies (their render children die with them; just drop the managed entry).
|
||||
if (_tracked.Count != _seen.Count)
|
||||
{
|
||||
_stale.Clear();
|
||||
foreach (var kv in _tracked) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||
for (int i = 0; i < _stale.Count; i++) _tracked.Remove(_stale[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Settle every tracked enemy's body back to its baked white rest color and stop tracking — invoked when
|
||||
// BodyFlashEnabled is toggled OFF mid-flash so no body is left frozen at an overdriven tint (re-tracked on re-enable).
|
||||
void RestoreAllToRest()
|
||||
{
|
||||
foreach (var kv in _tracked) WriteColor(kv.Value, White);
|
||||
_tracked.Clear();
|
||||
}
|
||||
|
||||
void WriteColor(FlashEntry entry, float4 col)
|
||||
{
|
||||
for (int i = 0; i < entry.RenderKids.Count; i++)
|
||||
{
|
||||
var c = entry.RenderKids[i];
|
||||
if (EntityManager.Exists(c) && EntityManager.HasComponent<URPMaterialPropertyBaseColor>(c))
|
||||
EntityManager.SetComponentData(c, new URPMaterialPropertyBaseColor { Value = col });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 03cbda5f0bedbe14fbf680d92db148fe
|
||||
@@ -109,6 +109,53 @@ namespace ProjectM.Client
|
||||
/// Health flicker on a clean dodge — the documented acceptable-not-a-bug interaction).</summary>
|
||||
public static bool DashHitSuppress;
|
||||
|
||||
// ---- Combat feel pass (2026-06): connect cue, hit-flash, kill pop, footsteps, rumble, telegraph beep ----
|
||||
/// <summary>Hit-flash puff density (a colored particle burst in HitFlashColor on an enemy damage edge).</summary>
|
||||
public static int HitFlashBurstCount;
|
||||
/// <summary>Extra FOV punch (deg) when the LOCAL melee cleave is confirmed to connect (vs a whiff).</summary>
|
||||
public static float MeleeConnectFovKick;
|
||||
/// <summary>Volume of the meaty melee connect "thunk".</summary>
|
||||
public static float MeleeConnectVolume;
|
||||
/// <summary>Kill-pop colored flash density on an enemy death.</summary>
|
||||
public static int KillFlashBurstCount;
|
||||
/// <summary>Soft footstep SFX volume.</summary>
|
||||
public static float FootstepVolume;
|
||||
/// <summary>Seconds between footsteps while the local player is moving.</summary>
|
||||
public static float FootstepIntervalSec;
|
||||
/// <summary>Local player speed (u/s) above which footsteps play.</summary>
|
||||
public static float FootstepMinSpeed;
|
||||
/// <summary>Master gate for gamepad rumble (no-op on KBM).</summary>
|
||||
public static bool RumbleEnabled;
|
||||
/// <summary>Rumble strength on a local hit taken / dealt.</summary>
|
||||
public static float RumbleHit;
|
||||
/// <summary>Rumble strength on a kill.</summary>
|
||||
public static float RumbleKill;
|
||||
/// <summary>Rumble strength on dash / local death (the heaviest).</summary>
|
||||
public static float RumbleHeavy;
|
||||
/// <summary>Seconds a rumble pulse lasts before it auto-stops.</summary>
|
||||
public static float RumbleDurationSec;
|
||||
|
||||
// ---- Deferred-items pass (2026-06): true body hit-flash, remote co-op swings, near-impact strike beep ----
|
||||
/// <summary>Master gate for the enemy material BODY hit-flash (drives URPMaterialPropertyBaseColor on render children).</summary>
|
||||
public static bool BodyFlashEnabled;
|
||||
/// <summary>Peak _BaseColor the enemy body flashes to on a hit (HDR; lerps from the baked white base and decays back).</summary>
|
||||
public static Color BodyFlashColor;
|
||||
/// <summary>Seconds the body flash decays from peak back to the baked white base.</summary>
|
||||
public static float BodyFlashDurationSec;
|
||||
/// <summary>Master gate for rendering REMOTE teammates' melee cleave arcs (co-op readability).</summary>
|
||||
public static bool RemoteSwingEnabled;
|
||||
/// <summary>Tint of a remote teammate's slash arc (cooler/friendlier than the local warm arc).</summary>
|
||||
public static Color RemoteSlashColor;
|
||||
/// <summary>Master gate for the near-impact \"dodge NOW\" strike beep on a winding-up enemy.</summary>
|
||||
public static bool StrikeBeepEnabled;
|
||||
/// <summary>Volume of the near-impact strike beep.</summary>
|
||||
public static float StrikeBeepVolume;
|
||||
/// <summary>Ticks before the strike lands that the beep fires (the dodge-reaction lead).</summary>
|
||||
public static int StrikeBeepLeadTicks;
|
||||
/// <summary>Squared world-distance from the local player beyond which the strike beep is suppressed (avoids a distant cacophony).</summary>
|
||||
public static float StrikeBeepMaxDistSq;
|
||||
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
public static void ResetDefaults()
|
||||
{
|
||||
@@ -161,6 +208,32 @@ namespace ProjectM.Client
|
||||
DashSfxVolume = 0.55f;
|
||||
DashShimmerPerFrame = 2;
|
||||
DashHitSuppress = true;
|
||||
|
||||
// Combat feel pass (2026-06)
|
||||
HitFlashBurstCount = 16;
|
||||
MeleeConnectFovKick = 0.8f;
|
||||
MeleeConnectVolume = 0.55f;
|
||||
KillFlashBurstCount = 20;
|
||||
FootstepVolume = 0.16f;
|
||||
FootstepIntervalSec = 0.32f;
|
||||
FootstepMinSpeed = 1.5f;
|
||||
RumbleEnabled = true;
|
||||
RumbleHit = 0.25f;
|
||||
RumbleKill = 0.45f;
|
||||
RumbleHeavy = 0.6f;
|
||||
RumbleDurationSec = 0.12f;
|
||||
|
||||
// Deferred-items pass (2026-06)
|
||||
BodyFlashEnabled = true;
|
||||
BodyFlashColor = new Color(3.2f, 2.8f, 2.2f, 1f); // hot near-white overdrive (multiplies the Synty atlas base map)
|
||||
BodyFlashDurationSec = 0.16f;
|
||||
RemoteSwingEnabled = true;
|
||||
RemoteSlashColor = new Color(1.4f, 2.2f, 2.8f, 1f); // cool teammate arc
|
||||
StrikeBeepEnabled = true;
|
||||
StrikeBeepVolume = 0.40f;
|
||||
StrikeBeepLeadTicks = 8;
|
||||
StrikeBeepMaxDistSq = 225f; // 15 m
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ namespace ProjectM.Client
|
||||
// END-2: terminal win/loss banner (observes the replicated RunOutcome; latched server-side).
|
||||
VisualElement _runBanner;
|
||||
Label _runBannerText, _runBannerSub;
|
||||
// DR-042 C6a: the Aether ability-upgrade button (was U-key only) + its live affordability tint.
|
||||
Button _upgradeBtn;
|
||||
|
||||
|
||||
readonly List<VisualElement> _pips = new();
|
||||
@@ -181,6 +183,28 @@ namespace ProjectM.Client
|
||||
_locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f)
|
||||
: finalSiege ? new Color(1f, 0.3f, 0.25f)
|
||||
: siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
|
||||
// DR-042 C7 (gate prompt) + C7b (objective readout): the expedition is the win-driver, so signpost it.
|
||||
// Reads the REPLICATED ExpeditionObjective summary (cross-region safe). Lower priority than the siege /
|
||||
// cold-turret / overrun overrides below, which still win.
|
||||
if (SystemAPI.TryGetSingleton<ExpeditionObjective>(out var obj))
|
||||
{
|
||||
if (onExpedition)
|
||||
{
|
||||
if (obj.State == ExpeditionObjectiveState.Cleared)
|
||||
{ _locationText.text = "ZONE CLEARED - return to base to claim"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); }
|
||||
else if (obj.State == ExpeditionObjectiveState.Active)
|
||||
{ _locationText.text = "CLEAR THE ZONE - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); }
|
||||
}
|
||||
else if (!siege)
|
||||
{
|
||||
if (obj.State == ExpeditionObjectiveState.Cleared)
|
||||
{ _locationText.text = "EXPEDITION CLEARED - return to claim your reward"; _locationText.style.color = new Color(0.5f, 1f, 0.6f); }
|
||||
else if (obj.State == ExpeditionObjectiveState.Active)
|
||||
{ _locationText.text = "EXPEDITION IN PROGRESS - " + obj.Remaining + " enemies remaining"; _locationText.style.color = new Color(1f, 0.8f, 0.4f); }
|
||||
else
|
||||
{ _locationText.text = "GO TO THE EXPEDITION GATE - clear a sortie to advance the Engine"; _locationText.style.color = new Color(0.55f, 0.85f, 1f); }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
|
||||
if (SystemAPI.TryGetSingleton<GoalProgress>(out var goal))
|
||||
@@ -230,6 +254,9 @@ namespace ProjectM.Client
|
||||
_oreNum.text = ore.ToString();
|
||||
_bioNum.text = bio.ToString();
|
||||
_chargeNum.text = charge.ToString();
|
||||
// DR-042 C6a: dim the Aether upgrade button when it isn't affordable (cost is a compile-time const).
|
||||
if (_upgradeBtn != null)
|
||||
_upgradeBtn.style.opacity = aether >= Tuning.AbilityUpgradeCostAmount ? 1f : 0.5f;
|
||||
// EB-2 quiet-turret cue (GLOBAL, not per-turret, so the deterministic Charge split never reads as one
|
||||
// broken turret): a dry base during a siege tells the player to build a Fabricator.
|
||||
if (siege && charge == 0 && !onExpedition)
|
||||
@@ -446,13 +473,19 @@ namespace ProjectM.Client
|
||||
}
|
||||
}
|
||||
|
||||
// DR-042 C6d: Harvester/Conveyor/Pylon are dead (unwired automation) -> hidden from the build palette
|
||||
// (catalog + prefabs stay baked, code-intact per DR-020). Only Turret/Wall/Fabricator are buildable in the UI.
|
||||
static bool IsPaletteType(byte type) =>
|
||||
type != StructureType.Pylon && type != StructureType.Harvester && type != StructureType.Conveyor;
|
||||
|
||||
void UpdatePalette(int aether, int ore, int bio, bool onExpedition)
|
||||
{
|
||||
if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity<StructureCatalog>(out var catE))
|
||||
{
|
||||
var cat = SystemAPI.GetBuffer<StructureCatalogEntry>(catE);
|
||||
for (int i = 0; i < cat.Length; i++)
|
||||
AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId);
|
||||
if (IsPaletteType(cat[i].Type))
|
||||
AddPaletteItem(cat[i].Type, cat[i].CostAmount, cat[i].CostResourceId);
|
||||
_paletteBuilt = true;
|
||||
}
|
||||
if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; }
|
||||
@@ -809,6 +842,11 @@ namespace ProjectM.Client
|
||||
strip.Add(ResourceChip(theme != null ? theme.OreIcon : null, OreAmber, "0", out _oreNum, 30, 22));
|
||||
strip.Add(ResourceChip(theme != null ? theme.BioIcon : null, BioGreen, "0", out _bioNum, 26, 20));
|
||||
strip.Add(ResourceChip(null, ChargeViolet, "0", out _chargeNum, 26, 20)); // EB-2 turret ammo (flat violet, no icon)
|
||||
// DR-042 C6a: the only Aether sink (ability-damage upgrade) gets a visible, clickable button (was U-key
|
||||
// only). The Button element handles its own picking even though the HUD root Ignores clicks.
|
||||
_upgradeBtn = MenuUi.Button("UPGRADE DMG (" + Tuning.AbilityUpgradeCostAmount + " AETHER)", BuildSendSystem.UpgradeAbility);
|
||||
_upgradeBtn.style.marginLeft = 18;
|
||||
strip.Add(_upgradeBtn);
|
||||
|
||||
root.Add(strip);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Gamepad rumble for combat feel — a static bridge (mirrors <see cref="FeelConfig"/> / <see cref="AimPresentation"/>).
|
||||
/// <see cref="Pulse"/> sets the motors and stamps a stop time; <see cref="Tick"/> (called once per frame from
|
||||
/// <c>CombatFeedbackSystem</c>) stops them when the pulse elapses OR the app loses focus, so a rumble never sticks.
|
||||
/// A no-op when no pad is connected; the CALLER gates to the Gamepad scheme. Statics survive fast-enter-playmode
|
||||
/// reloads, so <see cref="ResetState"/> re-arms clean on play-enter and stops any leaked motor (the AimPresentation
|
||||
/// reset idiom). Presentation-only, main-thread, never touches the simulation.
|
||||
/// </summary>
|
||||
public static class RumbleUtil
|
||||
{
|
||||
static float s_StopTime;
|
||||
static bool s_Active;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetState()
|
||||
{
|
||||
s_Active = false;
|
||||
s_StopTime = 0f;
|
||||
Stop();
|
||||
}
|
||||
|
||||
/// <summary>Pulse both motors at the given low/high strengths for durSec, then auto-stop. No-op without a pad.</summary>
|
||||
public static void Pulse(float low, float high, float durSec)
|
||||
{
|
||||
var pad = Gamepad.current;
|
||||
if (pad == null) return;
|
||||
pad.SetMotorSpeeds(Mathf.Clamp01(low), Mathf.Clamp01(high));
|
||||
s_StopTime = Time.unscaledTime + Mathf.Max(0.02f, durSec);
|
||||
s_Active = true;
|
||||
}
|
||||
|
||||
/// <summary>Call once per frame: stops the motors when the pulse elapses or focus is lost.</summary>
|
||||
public static void Tick()
|
||||
{
|
||||
if (!s_Active) return;
|
||||
if (!Application.isFocused || Time.unscaledTime >= s_StopTime)
|
||||
{
|
||||
Stop();
|
||||
s_Active = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void Stop()
|
||||
{
|
||||
var pad = Gamepad.current;
|
||||
if (pad != null) pad.ResetHaptics();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f7005e66cfc2ce4d825ebad3cdc9eac
|
||||
@@ -50,10 +50,14 @@ namespace ProjectM.Server
|
||||
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||
|
||||
// Derive occupancy from the live structure set (authoritative source of truth).
|
||||
// Derive occupancy from the live structure set (authoritative); also count turrets for the per-base cap.
|
||||
var occupied = new NativeHashSet<int2>(64, Allocator.Temp);
|
||||
int turretCount = 0;
|
||||
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
|
||||
{
|
||||
occupied.Add(ps.ValueRO.Cell);
|
||||
if (ps.ValueRO.Type == StructureType.Turret) turretCount++;
|
||||
}
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
@@ -67,7 +71,9 @@ namespace ProjectM.Server
|
||||
for (int i = 0; i < catalog.Length; i++)
|
||||
if (catalog[i].Type == req.StructureType) { entryIdx = i; break; }
|
||||
|
||||
if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null
|
||||
// DR-042 combat pass: cap turrets per base (server-authoritative) so they can't be spammed.
|
||||
bool turretCapOk = req.StructureType != StructureType.Turret || turretCount < Tuning.TurretCap;
|
||||
if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null && turretCapOk
|
||||
&& BuildPlacementMath.CanPlace(anchor, occupied, cell))
|
||||
{
|
||||
var entry = catalog[entryIdx];
|
||||
@@ -81,6 +87,7 @@ namespace ProjectM.Server
|
||||
// Commit IN-PLACE so a second same-tick request sees the spend + reservation.
|
||||
StorageMath.Withdraw(ledger, entry.CostResourceId, entry.CostAmount);
|
||||
occupied.Add(cell);
|
||||
if (req.StructureType == StructureType.Turret) turretCount++; // keep same-tick turret requests under the cap
|
||||
|
||||
var structure = ecb.Instantiate(entry.Prefab);
|
||||
var xform = m_TransformLookup[entry.Prefab];
|
||||
|
||||
@@ -100,8 +100,9 @@ namespace ProjectM.Server
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
|
||||
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
|
||||
var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = envMask, GroupIndex = 0 };
|
||||
bool sweep = havePhysics && envMask != 0u;
|
||||
uint sweepMask = envMask | worldCol.StructureMask; // DR-042 C5: also collide enemies against player-built walls
|
||||
var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = sweepMask, GroupIndex = 0 };
|
||||
bool sweep = havePhysics && sweepMask != 0u;
|
||||
const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement
|
||||
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, region) in
|
||||
|
||||
@@ -26,7 +26,6 @@ namespace ProjectM.Server
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<PlayerSpawner>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -37,14 +36,20 @@ namespace ProjectM.Server
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
var spawner = SystemAPI.GetSingleton<PlayerSpawner>();
|
||||
float3 center = spawner.SpawnPoint;
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor))
|
||||
// Resilient spawn reference: prefer the BaseAnchor plot center, fall back to the PlayerSpawner. NEVER
|
||||
// hard-require PlayerSpawner (a transiently-missing singleton must not strand dead players downed forever).
|
||||
bool haveSpawner = SystemAPI.TryGetSingleton<PlayerSpawner>(out var spawner);
|
||||
bool haveAnchor = SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor);
|
||||
if (!haveSpawner && !haveAnchor)
|
||||
return; // no spawn reference at all this tick — re-try next tick rather than mis-place
|
||||
float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint;
|
||||
float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f;
|
||||
int ringSlots = haveSpawner ? spawner.RingSlots : 4;
|
||||
center = BaseGridMath.PlotCenter(baseAnchor);
|
||||
|
||||
foreach (var (health, respawn, invuln, xform, owner, eff) in
|
||||
foreach (var (health, respawn, invuln, xform, region, owner, eff) in
|
||||
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
|
||||
RefRO<GhostOwner>, RefRO<EffectiveCharacterStats>>()
|
||||
RefRW<RegionTag>, RefRO<GhostOwner>, RefRO<EffectiveCharacterStats>>()
|
||||
.WithAll<PlayerTag>())
|
||||
{
|
||||
if (health.ValueRO.Current > 0f)
|
||||
@@ -66,8 +71,12 @@ namespace ProjectM.Server
|
||||
health.ValueRW.Current = maxHealth;
|
||||
|
||||
float3 pos = center + PlayerSpawnMath.SpawnOffset(
|
||||
owner.ValueRO.NetworkId, spawner.SpawnRingRadius, spawner.RingSlots);
|
||||
owner.ValueRO.NetworkId, ringRadius, ringSlots);
|
||||
xform.ValueRW.Position = pos;
|
||||
// Death fix: respawn is at BASE, so the player's server-only RegionTag MUST return to Base too
|
||||
// (every other mover flips RegionTag + Position together). Dying on an expedition otherwise leaves
|
||||
// you at base coords still tagged Expedition -> RegionRelevancy hides all base ghosts (soft-brick).
|
||||
region.ValueRW.Region = RegionId.Base;
|
||||
|
||||
// Grant brief post-respawn damage immunity so the swarm can't instantly re-kill.
|
||||
invuln.ValueRW.UntilTick = TickUtil.NonZero(now + (uint)math.max(0, respawn.ValueRO.InvulnTicks));
|
||||
|
||||
@@ -20,6 +20,10 @@ namespace ProjectM.Server
|
||||
/// wave is fully spawned and every zone enemy is dead, it marks <see cref="CycleRuntime.ClearedThisEpoch"/> once —
|
||||
/// the gate's once-per-epoch Ore reward reads that on the player's return.
|
||||
///
|
||||
/// DR-042 C7b: it ALSO writes the replicated <see cref="ExpeditionObjective"/> summary every tick, ABOVE the
|
||||
/// presence early-return (snapshot-above-early-return), so the client HUD's "enemies remaining / cleared" readout
|
||||
/// never freezes stale even when nobody is out.
|
||||
///
|
||||
/// Ordering: <c>[UpdateAfter(ExpeditionFieldSystem)]</c> ONLY. ExpeditionFieldSystem is itself
|
||||
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>, so ALSO declaring <c>[UpdateBefore(CyclePhaseSystem)]</c> here (as the
|
||||
/// v1 plan first sketched) would close a CyclePhase->Field->Zone->CyclePhase sort cycle that throws at Play
|
||||
@@ -51,26 +55,55 @@ namespace ProjectM.Server
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
// Per-player presence: only run while someone is OUT in the expedition (mirrors ExpeditionFieldSystem).
|
||||
// Per-player presence: the SPAWNER only runs while someone is OUT in the expedition (mirrors
|
||||
// ExpeditionFieldSystem). The objective readout below is written FIRST, every tick, even when nobody's out.
|
||||
int expeditionPlayers = 0;
|
||||
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||
if (region.ValueRO.Region == RegionId.Expedition)
|
||||
expeditionPlayers++;
|
||||
if (expeditionPlayers == 0)
|
||||
return; // nobody out there: the field manager owns teardown, we do nothing
|
||||
|
||||
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
|
||||
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
|
||||
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
|
||||
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
|
||||
if (prefabs.Length == 0)
|
||||
return;
|
||||
|
||||
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
||||
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||
int epoch = runtime.ExpeditionEpoch;
|
||||
|
||||
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
|
||||
|
||||
// DR-042 C7b: write the REPLICATED objective summary FIRST, above the early-returns (snapshot-above-
|
||||
// early-return) so the HUD never freezes stale. Rides the untagged CycleDirector ghost (cross-region safe).
|
||||
if (SystemAPI.HasComponent<ExpeditionObjective>(cycleEntity))
|
||||
{
|
||||
byte objState;
|
||||
short objRemaining;
|
||||
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
|
||||
{
|
||||
objState = ExpeditionObjectiveState.Cleared; // cleared but not yet claimed -> "return to claim"
|
||||
objRemaining = 0;
|
||||
}
|
||||
else if (expeditionPlayers > 0 && (aliveZone > 0 || zs.RemainingToSpawn > 0))
|
||||
{
|
||||
objState = ExpeditionObjectiveState.Active;
|
||||
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
objState = ExpeditionObjectiveState.Idle;
|
||||
objRemaining = 0;
|
||||
}
|
||||
SystemAPI.SetComponent(cycleEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
|
||||
}
|
||||
|
||||
if (expeditionPlayers == 0)
|
||||
return; // nobody out there: the field manager owns teardown, the spawner does nothing
|
||||
|
||||
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
|
||||
if (prefabs.Length == 0)
|
||||
return;
|
||||
|
||||
// MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
|
||||
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
|
||||
var bands = new MixBands
|
||||
@@ -94,8 +127,6 @@ namespace ProjectM.Server
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
|
||||
}
|
||||
|
||||
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
|
||||
|
||||
if (zs.RemainingToSpawn > 0)
|
||||
{
|
||||
// Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
|
||||
|
||||
@@ -63,6 +63,8 @@ namespace ProjectM.Server
|
||||
ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal });
|
||||
|
||||
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
||||
// DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger.
|
||||
bool restoredLedger = false;
|
||||
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
|
||||
if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity))
|
||||
{
|
||||
@@ -79,6 +81,7 @@ namespace ProjectM.Server
|
||||
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
|
||||
var destLedger = ecb.SetBuffer<StorageEntry>(director);
|
||||
SaveApply.WriteLedger(srcLedger, destLedger);
|
||||
restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore (C6c)
|
||||
|
||||
// END-1: born-correct the Engine Core. Max comes from the BAKED prefab (never the save); a
|
||||
// persisted wounded Current (>0) restores clamped to Max, else (0 = pre-v4 save) born full.
|
||||
@@ -99,6 +102,12 @@ namespace ProjectM.Server
|
||||
ecb.DestroyEntity(pendingEntity);
|
||||
}
|
||||
|
||||
// DR-042 C6c: NEW game only (no restored ledger) -> seed a little Ore so the build loop isn't a cold
|
||||
// deadlock (a turret needs Charge from a Fabricator that costs Ore you haven't mined yet). Appended
|
||||
// BEFORE Playback so the ghost first-serializes WITH the seed (no empty-ledger replication flicker).
|
||||
if (!restoredLedger)
|
||||
ecb.AppendToBuffer(director, new StorageEntry { ItemId = ResourceId.Ore, Count = Tuning.StartingOre });
|
||||
|
||||
// Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint.
|
||||
ecb.AddComponent(director, new SaveRequest { Pending = 0 });
|
||||
}
|
||||
|
||||
@@ -159,18 +159,12 @@ namespace ProjectM.Server
|
||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||
}
|
||||
else if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
||||
{
|
||||
// Long-arc goal: +1 per siege survived, CLAMPED to Target (single writer). Clamping at the
|
||||
// increment site keeps the persisted Charge bounded regardless of system order; GoalReachedSystem
|
||||
// only READS this edge to arm the final siege.
|
||||
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
||||
goal.Charge = math.min(goal.Charge + 1, goal.Target);
|
||||
SystemAPI.SetComponent(cycleEntity, goal);
|
||||
// Autosave checkpoint: a survived siege is a natural save point (host-only writer consumes the flag).
|
||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||
}
|
||||
// DR-042: a SURVIVED base siege no longer advances the win meter — that was the AFK/passive win
|
||||
// path (scheduled sieges auto-armed + auto-collapsed on timeout, so standing still won). The win-
|
||||
// driver moved to EXPEDITION CLEARS: GoalProgress.Charge is now credited per cleared expedition by
|
||||
// ExpeditionGateSystem on the player's RETURN. Surviving a normal siege is still its own reward
|
||||
// (resources kept, Core intact) but is not progress toward Victory. The final-siege Victory latch
|
||||
// above is unchanged — GoalReachedSystem still arms the climactic final siege once Charge hits Target.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,19 +83,35 @@ namespace ProjectM.Server
|
||||
SystemAPI.SetComponent(threatEntity, threat);
|
||||
}
|
||||
|
||||
// Once-per-epoch zone-clear reward: a returner banks flat Ore IFF this epoch's expedition wave was
|
||||
// actually cleared and not yet rewarded. Resolved ONCE here (not per-returner) so two same-tick co-op
|
||||
// returns pay exactly once (DR-040 BLOCKER 4) and gate re-entry before a clear can't farm (MINOR 2).
|
||||
if (SystemAPI.HasSingleton<CycleState>()
|
||||
&& SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
|
||||
&& SystemAPI.HasSingleton<ResourceLedger>())
|
||||
// Once-per-epoch zone-clear reward: a returner BANKS flat Ore to the shared ledger AND advances the
|
||||
// long-arc win meter (DR-042 — EXPEDITION CLEARS, not survived base sieges, are the win-driver:
|
||||
// CyclePhaseSystem no longer credits Charge, so this is the sole PRODUCTION writer of GoalProgress.Charge).
|
||||
// Resolved ONCE here (not per-returner) so two same-tick co-op returns pay exactly once (DR-040 BLOCKER 4)
|
||||
// and gate re-entry before a clear can't farm (MINOR 2). Ore + Charge share the SAME LastRewardedEpoch
|
||||
// latch so they always share fate (never one without the other). The Charge credit is guarded
|
||||
// independently of the ledger so it still lands in ledger-less worlds.
|
||||
if (SystemAPI.HasSingleton<CycleState>())
|
||||
{
|
||||
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
|
||||
if (SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
|
||||
&& SystemAPI.HasSingleton<ResourceLedger>())
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
|
||||
}
|
||||
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
||||
{
|
||||
// +1 toward the goal per cleared expedition, CLAMPED to Target (single production writer).
|
||||
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
||||
goal.Charge = math.min(goal.Charge + 1, goal.Target);
|
||||
SystemAPI.SetComponent(cycleEntity, goal);
|
||||
}
|
||||
// Checkpoint the hard-won clear (replaces the deleted survived-siege autosave in CyclePhaseSystem).
|
||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||
runtime.LastRewardedEpoch = runtime.ExpeditionEpoch;
|
||||
SystemAPI.SetComponent(cycleEntity, runtime);
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ namespace ProjectM.Simulation
|
||||
public static TuningConfig Defaults() => new TuningConfig
|
||||
{
|
||||
DashDistance = 4.0f,
|
||||
IFrameWindowTicks = 12f,
|
||||
IFrameWindowTicks = 14f, // tune: was 12 (0.20s) -> 0.23s, i-frames better cover a reacted telegraph
|
||||
RecoverTailTicks = 9f,
|
||||
DashCooldownTicks = 45f,
|
||||
DashCooldownTicks = 36f, // tune: was 45 (0.75s) -> 0.60s, snappier horde-kiter cadence
|
||||
DashSharpness = 200f,
|
||||
ChargerWindupTicks = 30f,
|
||||
ChargerLungeSpeed = 16f,
|
||||
|
||||
@@ -63,6 +63,17 @@ namespace ProjectM.Simulation
|
||||
/// Ore. Operator feel-fork: keep generous so turrets stay fed while you keep mining.</summary>
|
||||
public const int TurretChargeCostPerShot = 1;
|
||||
|
||||
/// <summary>Max turrets buildable per base (server-enforced in BuildPlaceSystem; the client preview goes red at
|
||||
/// the cap). Turrets are a deliberate fortress investment, not spammable — paired with the 40-Ore build cost.</summary>
|
||||
public const int TurretCap = 6;
|
||||
|
||||
// ---- Cold start (CycleDirectorSpawnSystem seeds the shared ledger on a NEW game) ----
|
||||
|
||||
/// <summary>DR-042 C6c: Ore deposited into the shared ledger at spawn on a NEW game ONLY (a restored save keeps
|
||||
/// its persisted ledger). Bootstraps the Fabricator(30)->Charge->Turret(10) chain so a turret placed before any
|
||||
/// mining isn't a silent cold deadlock. Ore-only so the 'build a Fabricator to arm turrets' lesson survives.</summary>
|
||||
public const int StartingOre = 50;
|
||||
|
||||
// ---- Inventory (per-player bag; InventoryMath / ResourceHarvestSystem / InventoryDepositSystem) ----
|
||||
|
||||
/// <summary>Max stacks a player can carry; InventoryMath rejects deposits past this and the harvest remainder spills to the global ledger.</summary>
|
||||
|
||||
@@ -83,4 +83,29 @@ namespace ProjectM.Simulation
|
||||
/// <summary>1 once the current epoch's expedition wave has FULLY spawned and been cleared to zero live zone enemies; reset to 0 on the empty->occupied epoch bump. The reward fires only on a REAL clear.</summary>
|
||||
public byte ClearedThisEpoch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an
|
||||
/// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED CycleDirector ghost
|
||||
/// (alongside <see cref="CycleState"/> / GoalProgress) so GhostRelevancy.SetIsIrrelevant never hides it
|
||||
/// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy
|
||||
/// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns
|
||||
/// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]).
|
||||
/// </summary>
|
||||
public struct ExpeditionObjective : IComponentData
|
||||
{
|
||||
/// <summary>0 = Idle (no sortie active), 1 = Active (wave in progress), 2 = Cleared (return to claim).</summary>
|
||||
[GhostField] public byte State;
|
||||
|
||||
/// <summary>Live zone enemies remaining (alive + not-yet-spawned) while Active; 0 when Idle/Cleared.</summary>
|
||||
[GhostField] public short Remaining;
|
||||
}
|
||||
|
||||
/// <summary>State constants for <see cref="ExpeditionObjective.State"/> (byte, not enum — Burst/serialization).</summary>
|
||||
public static class ExpeditionObjectiveState
|
||||
{
|
||||
public const byte Idle = 0;
|
||||
public const byte Active = 1;
|
||||
public const byte Cleared = 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ namespace ProjectM.Simulation
|
||||
/// <summary>
|
||||
/// Long-arc progress toward the goal ("reach THEM"). Lives on the GLOBAL CycleDirector ghost (relevant in
|
||||
/// every region, alongside CycleState + the resource ledger), so it is visible to all players regardless
|
||||
/// of region. SINGLE writer: <c>CyclePhaseSystem</c> increments <see cref="Charge"/> on each completed
|
||||
/// cycle (Build -> Expedition). The HUD observes it for a progress bar.
|
||||
/// of region. Sole PRODUCTION writer (DR-042): <c>ExpeditionGateSystem</c> increments <see cref="Charge"/> by
|
||||
/// one per cleared EXPEDITION (on the player's return). <c>GoalReachedSystem</c> only READS the Charge==Target
|
||||
/// edge to arm the climactic final siege. (<c>DebugCommandReceiveSystem</c> is a manual dev-op writer.) The HUD
|
||||
/// observes it for a progress bar.
|
||||
/// </summary>
|
||||
public struct GoalProgress : IComponentData
|
||||
{
|
||||
|
||||
@@ -11,7 +11,14 @@ namespace ProjectM.Simulation
|
||||
/// </summary>
|
||||
public struct WorldCollisionConfig : IComponentData
|
||||
{
|
||||
/// <summary>BelongsTo bitmask of the Environment physics layer (<c>1u << layerIndex</c>).</summary>
|
||||
/// <summary>BelongsTo bitmask of the Environment physics layer (<c>1u << layerIndex</c>).</summary>
|
||||
public uint EnvironmentMask;
|
||||
|
||||
/// <summary>DR-042 C5: BelongsTo bitmask of the "Structure" physics layer (player-built Wall/Turret/Pylon
|
||||
/// colliders). OR'd into the enemy-movement sweep filter so husks collide-and-slide against walls. 0 if the
|
||||
/// layer is absent (feature inert). The layer matrix excludes Structure×the player layer so the player CC
|
||||
/// passes through its own walls.</summary>
|
||||
public uint StructureMask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,7 +894,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.StructureCatalogAuthoring
|
||||
TurretPrefab: {fileID: 3885353946372160549, guid: 5459c9edea89bd94fa6f5043ae00eb40, type: 3}
|
||||
TurretCostOre: 10
|
||||
TurretCostOre: 40
|
||||
WallPrefab: {fileID: 3885353946372160549, guid: 1e321aea244cc484f99c1cdd68cb01c4, type: 3}
|
||||
WallCostOre: 4
|
||||
PylonPrefab: {fileID: 3885353946372160549, guid: 7d0637ef90f120a4c9e2ba637dfc00af, type: 3}
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ProjectM.Tests
|
||||
/// carrying CycleState + CycleRuntime (+ optionally ThreatState / WaveState / GoalProgress). The global phase
|
||||
/// is only ever Calm or Siege — being out on an expedition is per-player presence, NOT a global phase — so
|
||||
/// these pin: Calm holds with no pending siege; an armed ThreatState.PendingSiegeSize enters Siege and seeds
|
||||
/// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm and charges the goal once;
|
||||
/// WaveState's Spawning entry at the EXACT size; a cleared Siege returns to Calm WITHOUT charging the goal (DR-042: expedition clears drive the win);
|
||||
/// and split co-op presence never produces a non-Calm phase. All timing is wrap-safe NetworkTick math.
|
||||
/// </summary>
|
||||
public class CyclePhaseSystemTests
|
||||
@@ -99,7 +99,7 @@ namespace ProjectM.Tests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Siege_Exits_To_Calm_On_DefendCleared_And_Charges_Goal_Once()
|
||||
public void Siege_Exits_To_Calm_On_DefendCleared_Does_Not_Charge_Goal()
|
||||
{
|
||||
var (world, group) = MakeWorld("SiegeClears", serverTick: 200);
|
||||
using (world)
|
||||
@@ -114,8 +114,8 @@ namespace ProjectM.Tests
|
||||
|
||||
Assert.AreEqual(CyclePhase.Calm, em.GetComponentData<CycleState>(cycle).Phase,
|
||||
"A cleared siege returns to Calm.");
|
||||
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cycle).Charge,
|
||||
"One goal charge accrues per siege survived (single writer).");
|
||||
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cycle).Charge,
|
||||
"DR-042: surviving a base siege does NOT charge the goal (the AFK win path is closed).");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ namespace ProjectM.Tests
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
// Dash speed derived from the baked knobs: 4.0 units / (12 ticks / 60) = 20 units/s.
|
||||
const float ExpectedDashSpeed = 20f;
|
||||
// Dash speed derived from the live knobs: DashDistance / (IFrameWindowTicks/60). Tracks TuningConfig.Defaults().
|
||||
static readonly float ExpectedDashSpeed = TuningConfig.Defaults().DashDistance / (TuningConfig.Defaults().IFrameWindowTicks / 60f);
|
||||
|
||||
static Entity MakeDasher(EntityManager em, float2 facing)
|
||||
{
|
||||
@@ -69,7 +69,7 @@ namespace ProjectM.Tests
|
||||
var ctrl = em.GetComponentData<CharacterControl>(e).MoveVelocity;
|
||||
Assert.AreEqual(0f, ctrl.x, 1e-3f, "Dash heading (0,1) overrides X to 0.");
|
||||
Assert.AreEqual(0f, ctrl.y, 1e-3f, "Planar dash keeps Y at 0.");
|
||||
Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (20).");
|
||||
Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (derived from the dash knobs).");
|
||||
Assert.AreEqual(200f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"i-frame window raises GroundedMovementSharpness to ~200 (the blink).");
|
||||
}
|
||||
@@ -133,9 +133,9 @@ namespace ProjectM.Tests
|
||||
|
||||
var ds = em.GetComponentData<DashState>(e);
|
||||
Assert.AreEqual(100u, ds.StartTick, "StartTick = now.");
|
||||
Assert.AreEqual(112u, ds.IFrameUntilTick, "IFrameUntilTick = now + 12.");
|
||||
Assert.AreEqual(121u, ds.RecoverUntilTick, "RecoverUntilTick = now + 12 + 9.");
|
||||
Assert.AreEqual(145u, em.GetComponentData<DashCooldown>(e).NextTick, "Cooldown = now + 45.");
|
||||
Assert.AreEqual(114u, ds.IFrameUntilTick, "IFrameUntilTick = now + 14.");
|
||||
Assert.AreEqual(123u, ds.RecoverUntilTick, "RecoverUntilTick = now + 14 + 9.");
|
||||
Assert.AreEqual(136u, em.GetComponentData<DashCooldown>(e).NextTick, "Cooldown = now + 36.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace ProjectM.Tests
|
||||
/// <see cref="ThreatDirectorSystem"/> SiegeTimeout guard. A bare world is seeded with a NetworkTime singleton and a
|
||||
/// CycleDirector entity carrying the full run-state set (CycleState/CycleRuntime/ThreatState/ThreatConfig/
|
||||
/// GoalProgress/CoreIntegrity/RunPhase/RunOutcome/SaveRequest + a ledger). These pin: the goal cap arms a bigger
|
||||
/// final siege EXACTLY once and CyclePhaseSystem enters it once; Charge clamps to Target; a survived final siege
|
||||
/// final siege EXACTLY once and CyclePhaseSystem enters it once; a survived NORMAL siege no longer charges the goal (DR-042); a survived final siege
|
||||
/// latches Victory (no extra charge); a Core breach during the final siege latches Loss with NONE of the END-1
|
||||
/// soft-loss side effects (no ledger drain, no OverrunTick); a NORMAL-phase overrun STILL takes the END-1 soft
|
||||
/// path (the key regression); a restored Victory does not re-arm; and the SiegeTimeout cull is disabled during the
|
||||
@@ -127,21 +127,25 @@ namespace ProjectM.Tests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Charge_Clamps_To_Target_On_Survived_Siege()
|
||||
public void Survived_Normal_Siege_Neither_Charges_Goal_Nor_Arms_Final()
|
||||
{
|
||||
var (world, group) = MakeWorld("End2Clamp", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
// Charge already AT Target, a NORMAL (non-final) siege is survived -> Charge must not exceed Target.
|
||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4,
|
||||
// DR-042: surviving a NORMAL siege one short of the cap must neither charge the goal nor arm the final.
|
||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4,
|
||||
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
||||
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge,
|
||||
"Charge clamps at Target on a survived siege (min(Charge+1, Target)); it never runs away.");
|
||||
Assert.AreEqual(3, em.GetComponentData<GoalProgress>(dir).Charge,
|
||||
"a survived normal siege does NOT charge the goal (DR-042: base-siege survival is not win-progress).");
|
||||
Assert.AreEqual(RunPhaseId.Normal, em.GetComponentData<RunPhase>(dir).Value,
|
||||
"the final siege is NOT armed by a survived siege near the cap.");
|
||||
Assert.AreEqual(0, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||
"nothing is armed (the cap is only crossed by an expedition clear).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,13 +318,13 @@ namespace ProjectM.Tests
|
||||
public void Final_Siege_Arms_On_Goal_Edge_Through_Pipeline_Not_Stomped_By_Scheduler()
|
||||
{
|
||||
// M-3 + N-4: drive the REAL cross-system handoff (ThreatDirector -> CyclePhase -> GoalReached) over the
|
||||
// Charge edge (3 -> 4 via a survived siege), then prove a DUE scheduled source can't stomp the armed final
|
||||
// Charge edge (now crossed by an EXPEDITION CLEAR in production; PRE-SEEDED at Target here), then prove a DUE scheduled source can't stomp the armed final
|
||||
// siege (the FinalDefense + PendingSiegeSize!=0 guards) and the FINAL size flows through to the wave.
|
||||
var (world, group) = MakeFullWorld("End2Pipeline", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 3, target: 4,
|
||||
var dir = MakeDirector(em, CyclePhase.Siege, defendStartWave: 5, charge: 4, target: 4,
|
||||
core: 100, RunPhaseId.Normal, RunOutcomeId.InProgress);
|
||||
var cfg = Cfg(); cfg.ScheduleEnabled = 1; cfg.ScheduleIntervalTicks = 100;
|
||||
em.SetComponentData(dir, cfg);
|
||||
@@ -328,9 +332,10 @@ namespace ProjectM.Tests
|
||||
var wave = MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); // DefendCleared this tick
|
||||
int expected = ExpectedFinalSize(5, 1, 6); // (5 + 6) * 2.5 = 27
|
||||
|
||||
// Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Charge 3->4, Calm) -> GoalReached (arm).
|
||||
// Tick 1: ThreatDirector (Siege -> no arm) -> CyclePhase (survive -> Calm, Charge stays at cap) -> GoalReached (arm).
|
||||
group.Update();
|
||||
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge, "the survived siege reaches the cap.");
|
||||
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(dir).Charge,
|
||||
"Charge sits at the cap (crossed by an expedition clear in production; survived sieges no longer credit — DR-042).");
|
||||
Assert.AreEqual(RunPhaseId.FinalDefense, em.GetComponentData<RunPhase>(dir).Value,
|
||||
"GoalReached flips FinalDefense the same tick the Charge edge is crossed.");
|
||||
Assert.AreEqual(expected, em.GetComponentData<ThreatState>(dir).PendingSiegeSize,
|
||||
|
||||
@@ -10,9 +10,11 @@ namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into
|
||||
/// <see cref="ExpeditionGateSystem"/> (DR-040 BLOCKER 4). A returning player banks flat Ore to the shared ledger
|
||||
/// IFF this epoch's expedition wave was actually cleared and not yet rewarded — and never twice for the same
|
||||
/// epoch (the co-op same-tick / gate-re-entry de-dup).
|
||||
/// <see cref="ExpeditionGateSystem"/> (DR-040 BLOCKER 4 + DR-042). A returning player banks flat Ore to the
|
||||
/// shared ledger AND advances the long-arc win meter (GoalProgress.Charge — DR-042: EXPEDITION CLEARS, not
|
||||
/// survived sieges, are the win-driver) IFF this epoch's expedition wave was actually cleared and not yet
|
||||
/// rewarded — and never twice for the same epoch (the co-op same-tick / gate-re-entry de-dup; Ore + Charge
|
||||
/// share the one LastRewardedEpoch latch so they always share fate).
|
||||
/// </summary>
|
||||
public class ExpeditionGateRewardTests
|
||||
{
|
||||
@@ -26,13 +28,15 @@ namespace ProjectM.Tests
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
var em = world.EntityManager;
|
||||
|
||||
// CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state.
|
||||
var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger), typeof(ThreatState));
|
||||
// CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state + goal meter.
|
||||
var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger),
|
||||
typeof(ThreatState), typeof(GoalProgress));
|
||||
em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm });
|
||||
em.SetComponentData(cyc, new CycleRuntime
|
||||
{
|
||||
ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch,
|
||||
});
|
||||
em.SetComponentData(cyc, new GoalProgress { Charge = 0, Target = 4 });
|
||||
em.AddBuffer<StorageEntry>(cyc);
|
||||
|
||||
// Zone-enemy director singleton (only RewardOre matters to the reward fold).
|
||||
@@ -68,7 +72,7 @@ namespace ProjectM.Tests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cleared_Return_Banks_Ore_Once()
|
||||
public void Cleared_Return_Banks_Ore_And_Charge_Once()
|
||||
{
|
||||
var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
|
||||
using (world)
|
||||
@@ -79,6 +83,8 @@ namespace ProjectM.Tests
|
||||
group.Update(); // player walks the gate back to base -> reward
|
||||
|
||||
Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger");
|
||||
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cyc).Charge,
|
||||
"DR-042: a cleared return also advances the win meter by one (the new win-driver).");
|
||||
Assert.AreEqual(1, em.GetComponentData<CycleRuntime>(cyc).LastRewardedEpoch, "the epoch is marked rewarded");
|
||||
|
||||
// Force a second same-epoch return (the player is back in the expedition at the gate).
|
||||
@@ -88,6 +94,26 @@ namespace ProjectM.Tests
|
||||
group.Update(); // returns again, but the epoch was already rewarded
|
||||
|
||||
Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)");
|
||||
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cyc).Charge,
|
||||
"the same epoch never double-credits the win meter either (shared LastRewardedEpoch latch).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cleared_Return_Clamps_Charge_At_Target()
|
||||
{
|
||||
// DR-042: the win credit clamps at Target (min(Charge+1, Target)) — a cleared return at the cap never overshoots.
|
||||
var (world, group, cyc) = MakeWorld("GateRewardClamp", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
em.SetComponentData(cyc, new GoalProgress { Charge = 4, Target = 4 }); // already at the cap
|
||||
MakeExpeditionPlayerAtGate(em);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(4, em.GetComponentData<GoalProgress>(cyc).Charge,
|
||||
"a cleared return at the cap clamps at Target (never overshoots).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +129,8 @@ namespace ProjectM.Tests
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)");
|
||||
Assert.AreEqual(0, em.GetComponentData<GoalProgress>(cyc).Charge,
|
||||
"an uncleared return advances neither Ore nor the win meter.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace ProjectM.Tests
|
||||
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
|
||||
em.AddComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHealth });
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
||||
return e;
|
||||
}
|
||||
|
||||
@@ -103,5 +104,27 @@ namespace ProjectM.Tests
|
||||
Assert.AreEqual(50f, em.GetComponentData<Health>(player).Current, 1e-4f, "Alive health is untouched.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Expedition_Death_Respawns_At_Base_And_Resets_RegionTag()
|
||||
{
|
||||
// Death-fix regression: a player who dies ON an expedition (RegionTag=Expedition) must respawn at base
|
||||
// AND have its RegionTag reset to Base, or it soft-bricks (RegionRelevancy hides all base ghosts).
|
||||
var (world, group) = MakeWorld("RespawnExpedition", serverTick: 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var player = MakePlayer(em, health: 0f, maxHealth: 100f, respawnTick: 160,
|
||||
delayTicks: 60, invulnTicks: 120, pos: new float3(1005, 1, 3), networkId: 1);
|
||||
em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); // died out on the sortie
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region,
|
||||
"Death fix: respawn resets RegionTag to Base (else the player is stranded tagged Expedition).");
|
||||
Assert.Less(em.GetComponentData<LocalTransform>(player).Position.x, 100f,
|
||||
"And is repositioned from the expedition (x~1005) back to the base ring.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ namespace ProjectM.Tests
|
||||
{
|
||||
var d = TuningConfig.Defaults();
|
||||
Assert.AreEqual(4.0f, d.DashDistance, 1e-6f, "DashDistance");
|
||||
Assert.AreEqual(12f, d.IFrameWindowTicks, 1e-6f, "IFrameWindowTicks");
|
||||
Assert.AreEqual(14f, d.IFrameWindowTicks, 1e-6f, "IFrameWindowTicks");
|
||||
Assert.AreEqual(9f, d.RecoverTailTicks, 1e-6f, "RecoverTailTicks");
|
||||
Assert.AreEqual(45f, d.DashCooldownTicks, 1e-6f, "DashCooldownTicks");
|
||||
Assert.AreEqual(36f, d.DashCooldownTicks, 1e-6f, "DashCooldownTicks");
|
||||
Assert.AreEqual(200f, d.DashSharpness, 1e-6f, "DashSharpness");
|
||||
Assert.AreEqual(30f, d.ChargerWindupTicks, 1e-6f, "ChargerWindupTicks");
|
||||
Assert.AreEqual(16f, d.ChargerLungeSpeed, 1e-6f, "ChargerLungeSpeed");
|
||||
|
||||
@@ -143,7 +143,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
|
||||
|
||||
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||
- **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
|
||||
- **Core loop is base-local ★ (DR-031):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner` singleton; `SetComponent`-override Region+Ore — Add throws; Ore-only). Scheduled base sieges via `ThreatDirectorSystem`'s reserved **Schedule** source (`ScheduleEnabled`/`Interval`/`SizePerWave` on `CycleDirectorAuthoring`) need NO expedition trip; `ExpeditionFieldSystem` teardown region-filtered Expedition-only (else it wipes the base field). The now-dormant expedition still lives at `base+(1000,0,0)`, hidden per-connection via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]].
|
||||
- **Core loop is base-local ★ (DR-031 · DR-042):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner`; `SetComponent`-override Region+Ore — Add throws; Ore-only). **Win = expedition CLEARS (DR-042):** `ExpeditionGateSystem` is sole writer of `GoalProgress.Charge` (+1/clear on RETURN); base sieges = **retaliation only**, blind `ScheduleEnabled` baked OFF (★ a serialized prefab bool ignores the C# initializer — flip `CycleDirector.prefab`, not the authoring default). `ExpeditionFieldSystem` teardown Expedition-filtered (else wipes base field); dormant expedition at `base+(1000,0,0)` hidden via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]] · [[DR-042_Loop_Reshape_Expedition_Driven]].
|
||||
|
||||
## DOTS / ECS conventions (authoritative summary)
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ Last decluttered 2026-06-08 (removed all shipped `[x]` items; their context is p
|
||||
|
||||
A 5-subsystem loop evaluation found the loop has **two conflicting win-models bolted together**: the only path to victory is "survive 4 base sieges" (passively/AFK-reachable — scheduled sieges auto-arm + a 60 s timeout auto-clears them), while the **expedition (the stated combat spine, where all the new enemy variety lives) advances nothing toward winning.** Root cause: the END-1/END-2 base-siege win is a leftover from the superseded jam slice ([[DR-035_End_Of_Month_Slice_Adoption]]/[[DR-036_END2_Final_Siege_Win_Lose]]) that DR-037 never retired. **Operator chose (2026-06-24): commit to the expedition-driven vision.** Build order:
|
||||
|
||||
- **A — Coherence core (first):** move the win-driver from *base sieges* → *expedition clears* (`GoalProgress.Charge` +1 on a cleared sortie); **kill the AFK win** (disable the blind scheduled siege as a progression source); final beat reached through the spine. *Netcode-touching → design-review first.*
|
||||
- **A — Coherence core — ✅ BUILT + validated (2026-06-25):** win-driver moved *base sieges → expedition clears* — `ExpeditionGateSystem` is now the sole production writer of `GoalProgress.Charge` (+1/clear, credited **on RETURN** — the review overturned credit-on-clear, which would arm the undefended final siege → uncontestable Loss; credit-on-return keeps the player home). **AFK win killed** (`ScheduleEnabled` baked OFF, retaliation-only; survived sieges no longer credit). Final beat = the END-2 final **base** siege (operator-locked). 389/389 EditMode + clean Play smoke (no sort-cycle, `ScheduleEnabled=0`). Design-review-gated (`wf_ebef4e81-dba`). Fun-gate playtest pending. *Next: **C**.*
|
||||
- **B — Retaliation connect:** post-expedition siege becomes THE base-siege source (fix the interference) — defending what you built becomes a *consequence* of sortieing, not the goal. *Netcode-touching → design-review.*
|
||||
- **C — Legibility fixes:** walls actually block (structures on the enemy collision filter); Aether-upgrade HUD button + cost; Biomass sink (or cut); cold-start ledger seed; hide dead Harvester/Conveyor/Pylon; expedition objective UI + gate prompt; reward scales with depth.
|
||||
- **C — Legibility fixes — ✅ BUILT + validated (2026-06-25):** walls now block enemies (dedicated `Structure` physics layer; player passes own walls — Play-verified baked filters); Aether-upgrade HUD button + affordability tint; Biomass sink (Wall cost → Biomass, operator fork); cold-start Ore seed (kills the turret-before-fabricator deadlock); dead Harvester/Conveyor/Pylon hidden from palette + hotkeys; **expedition objective readout** (new replicated `ExpeditionObjective` GhostField) + gate prompt. Reward-depth scaling deferred (operator fork). Scoping/design-gated (`wf_7c5a555e-136`); 389/389 EditMode + Play smokes. *Visual fun-gate pending. Next: B (retaliation polish) → D.*
|
||||
- **D — Persistent meta (= Slice 4):** SaveData v6 + between-runs growth (above).
|
||||
|
||||
**Consolidation:** [[DR-036_END2_Final_Siege_Win_Lose]]'s "survive-4-base-sieges" win is **superseded** (win-driver → expedition clears; the charge-cadence "siege-survived-only" lock is reversed); [[DR-034_END1_Losable_Core]]'s Core stays but as a **consequence of the retaliation siege**, not the win-gate. The health-bar fill bug-fix (sprite-less `Image.Filled` ignores `fillAmount` → size the RectTransform) rides the first commit.
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: Deferred Combat-Feel Items — Body Hit-Flash + Remote Co-op Swings + Strike Beep — Build
|
||||
date: 2026-06-27
|
||||
tags: [session, combat, juice, presentation, rukhanka, entities-graphics, netcode, co-op]
|
||||
permalink: gamevault/07-sessions/2026/2026-06-27-deferred-feel-items
|
||||
---
|
||||
|
||||
# Deferred combat-feel items — Build session
|
||||
|
||||
Cleared the three focused follow-ups that the combat-overhaul pass had explicitly deferred (named in commit `c3b53cef2` + the [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]] "needs its own ShaderGraph slice" note). All three are **client-only, observe-only presentation** (PresentationSystemGroup; no sim mutation, no `[GhostField]`, no server work, rollback-irrelevant).
|
||||
|
||||
## What shipped
|
||||
- **Item 1 — TRUE body hit-flash (resolves the DR-041 deferral).** New `EnemyHitFlashSystem` (client `SystemBase`, PresentationSystemGroup). Enemies render via Rukhanka GPU deformation: the ghost ROOT holds gameplay components (`Health`, `EnemyTag`); the visible meshes are `LinkedEntityGroup` CHILD render entities (each with `Unity.Rendering.MaterialMeshInfo` + the `Shader Graphs/AnimatedLitShader` material, `_BaseColor` baked white). The system drives the **built-in Entities-Graphics per-instance override `Unity.Rendering.URPMaterialPropertyBaseColor`** (`[MaterialProperty("_BaseColor")]`) on those children: a `Health`-decrease edge lerps `_BaseColor` toward `FeelConfig.BodyFlashColor` (HDR near-white) and decays back to white. **No ShaderGraph edit, no new component type** — the original deferral assumed a custom `_Flash*` graph property was required; the reusable shortcut is the stock EG `URP*MaterialProperty*` components, which the deformation shader already honors.
|
||||
- **Item 2 — co-op REMOTE swing arcs.** `CombatFeedbackSystem` now renders each remote teammate's melee cleave: a per-remote-player pooled `RemoteSlash` (Mesh+Material+GO), edge-detected from the **replicated `MeleeCombo.SwingStartTick`** on interpolated teammates (`.WithAll<PlayerTag>().WithDisabled<GhostOwnerIsLocal>()`), reusing the refactored `BuildSlashInto(mesh, …)` sweep. The local player keeps its dedicated `_slashMr`. (`MeleeCombo` replicates to non-owners — `PlayerAnimationDriveSystem.RemoteDriveJob` already relies on this for teammate attack anim.)
|
||||
- **Item 3 — near-impact strike beep.** Folded into the existing enemy danger-cone loop (which already computes `remaining` ticks to impact): a once-per-windup `_strikeBeepClip` "dodge NOW" cue at `StrikeBeepLeadTicks` (default 8 ≈ 130 ms) before the strike lands, distance-gated to the local player.
|
||||
- 9 new `FeelConfig` knobs (+ `ResetDefaults`) covering all three.
|
||||
|
||||
## How it went (verify ladder)
|
||||
- Drove the whole thing off an **empirical Play probe** of a live Husk: confirmed render entities are LEG children (not the root), shader = `AnimatedLitShader`, `_BaseColor` = white, and the children do **not** ship `URPMaterialPropertyBaseColor` until added.
|
||||
- **Override-works proof:** the unfocused editor kept disposing the netcode worlds mid-Play (known hazard) and server-spawned test husks were culled (spawned outside the director's bookkeeping), so I proved the mechanism on the **local player** (same `AnimatedLitShader`, persistent, centered): tinted its render children via the override → captured the Game view → **body rendered red**. Same shader ⇒ the enemy flash works. Separately confirmed `EnemyHitFlashSystem` attaches the override to all 4 enemy render children at runtime.
|
||||
- **390/390 EditMode**, clean compile, zero Play exceptions with all three paths live.
|
||||
|
||||
## Post-impl adversarial review (`wf_8a998c6c-af9`)
|
||||
3 lenses (ECS/Entities-Graphics correctness · lifecycle/leaks/rollback · netcode-read edge cases). **No critical/major.** Fixed 4 real minors:
|
||||
- **[FIXED] Strike beep could fire spuriously at base origin** — the "no local player ⇒ `localPos`=`float3.zero` ⇒ distance gate suppresses" assumption is FALSE: origin IS the base, so base-siege enemies within 15 m would beep before the local player ghost resolves (co-op join / save-load mid-siege). Gated the beep on `_localPlayer != Entity.Null`.
|
||||
- **[FIXED] `BodyFlashEnabled` toggled off mid-flash froze an enemy tinted** (reachable via the FeelConfig tuning toggle). Added `RestoreAllToRest()` on the disable edge (settle white + clear; re-tracked on re-enable).
|
||||
- **[FIXED] `RenderKids` captured once with no recovery** — don't finalize tracking until render children exist (retry next frame if Rukhanka child setup lags ghost instantiation).
|
||||
- **[FIXED] `OnDestroy` symmetry** — also destroy the pooled remote-slash `GO`.
|
||||
- **[NOTED, no change]** hardcoded white-restore (moot — every enemy uses the Synty-atlas white-`_BaseColor` convention; documented in the system); runtime `AddComponent` fragmentation (bounded, once/child); committed-Charger lunge relies on the cone not the beep (intended).
|
||||
|
||||
## Gotchas worth remembering
|
||||
- **Material-driven body flash on Rukhanka/EG = drive a stock `URP*MaterialProperty*` component on the render-entity CHILDREN, not a custom ShaderGraph property.** The render entities are `LinkedEntityGroup` children with `MaterialMeshInfo` (the root has none); `_BaseColor` bakes white, so flash-toward-color / decay-to-white needs no per-material rest capture. Add the override at runtime (once/child) from a client observe-only system; settle to white at rest so the override is invisible when idle.
|
||||
- **To prove an EG per-instance override is honored by a deformation shader without a stable enemy:** tint the **local player** (same material) and screenshot — the player is persistent + centered, unlike server-spawned test enemies which get culled and unlike the worlds which the unfocused editor disposes.
|
||||
|
||||
See [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]] (item-1 origin) · [[DR-022_Animation_Pipeline_Rukhanka_Synty]] (render-entity structure) · [[DR-038_Slice1_Combat_Readability_HUD_Declutter]] (danger-cone the beep folds into).
|
||||
@@ -75,10 +75,34 @@ The game has **two disconnected win-models that fight each other**:
|
||||
- **[[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]]:** this DR is the concrete loop the redirect implied. Slice 4 (Persistent Meta) is **re-framed**: the loop-coherence work (A–C) comes first; the meta/SaveData-v6 (D) is the final phase.
|
||||
- **Roadmap:** [[Path_to_Fun]] (Path A/B) was already historical post-DR-037; this is the current operative loop plan. The committed list is in [[Backlog]] under the Co-op Roguelite Redirect.
|
||||
|
||||
## Open forks (operator, lock before/at the design review)
|
||||
- **Final beat:** a **final expedition** (the climax IS a deep sortie — purest expression of "expedition = spine") vs a **final base siege** (a defense climax, reuses END-2's final-siege machinery). *Recommend present both at the A-phase review.*
|
||||
- **Do base sieges stay in v1 at all,** or does retaliation become a later layer (B) so the first coherent build is purely sortie→reward→escalate? *(The recommendation keeps a light retaliation siege so the build pillar isn't orphaned.)*
|
||||
- Expedition reward shape + depth scaling; whether a soft-loss should cost `GoalProgress` (give the run real downside).
|
||||
## Open forks — RESOLVED (operator, 2026-06-25)
|
||||
- **Final beat:** **final base siege** (defense climax, reuses END-2's final-siege machinery) — *operator-chosen*. With credit-on-RETURN the capping return arms the climactic siege while the player is freshly home, so the build pillar pays off at the finish. (A *final expedition* was the alternative; deferred.)
|
||||
- **Base sieges stay in v1** as **post-expedition retaliation only** (the build pillar isn't orphaned). The blind scheduled source is disabled.
|
||||
- Expedition reward shape + depth scaling, and whether a soft-loss costs `GoalProgress` — deferred to phase C/tuning.
|
||||
|
||||
## Status
|
||||
Accepted + locked (direction). Build pending — phase A first, design-review-gated. Loop evaluation transcript: `wf_4cebbc74-216`. Combat substrate it gives purpose to: [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]].
|
||||
**Phase A — BUILT + validated (2026-06-25).** Design-review-gated (`wf_ebef4e81-dba`, GREEN-WITH-CHANGES). Loop evaluation transcript: `wf_4cebbc74-216`. Combat substrate it gives purpose to: [[DR-041_Slice_Combat_Depth_Enemy_Variety_Impact]].
|
||||
|
||||
### Phase A build record — what shipped (and the design correction)
|
||||
The review overturned the literal A1 sketch. **The win credit fires on the player's RETURN, not at the expedition-clear edge.** Crediting at the clear edge (while the player is still out in the expedition) would arm the climactic final *base* siege with nobody home — and `SiegeTimeout` is disabled in the final — so the undefended Core would breach into an **uncontestable terminal Loss**. Credit-on-return guarantees the player is teleported home the same tick the final arms.
|
||||
|
||||
The implementation **relocates** the single writer of `GoalProgress.Charge` (it does not add a second writer):
|
||||
- **`ExpeditionGateSystem`** is now the sole *production* writer of `GoalProgress.Charge`: folded a clamped `+1` (`math.min(Charge+1, Target)`) + a `SaveRequest` checkpoint into the **existing** once-per-epoch reward block, reusing the same `LastRewardedEpoch` latch that gates the Ore reward (Ore + Charge share fate; co-op/re-entry de-dup is free). No new latch field, **no new `[GhostField]`**, no ghost re-hash. The credit is guarded independently of the ledger so it still lands in ledger-less worlds.
|
||||
- **`CyclePhaseSystem`** no longer credits Charge on a survived base siege (the AFK win path deleted); the final-siege Victory latch is unchanged. `GoalReachedSystem` still arms the climactic final siege at `Charge>=Target`.
|
||||
- **`ScheduleEnabled` baked OFF** (both the `CycleDirectorAuthoring` code default *and* the serialized `CycleDirector.prefab` value — the code default alone does not flip an already-serialized prefab field; Play-verified `ScheduleEnabled=0` at runtime). The schedule code path is kept as a config-inert reserved hook; `ScheduleSizePerWave` left non-zero (the final-siege size formula still reads it). Retaliation (`PostExpeditionEnabled`) stays on.
|
||||
- **No system-ordering change** → no sort-cycle risk (Gate is already `[UpdateBefore(CyclePhaseSystem)]`, so the credit lands before `GoalReachedSystem` reads the edge same-tick). SaveData stays **v5**; legacy v5 saves load as-is (their old siege-era Charge reads as clears — accepted for single-slot dev saves).
|
||||
- **Validation:** 389/389 EditMode (re-pointed `CyclePhaseSystemTests` + `EndgameWinLoseTests` survived-siege assertions; extended `ExpeditionGateRewardTests` with the +1, no-double-credit, and clamp cases) + a clean netcode Play smoke (world boots, no sort-cycle, `ScheduleEnabled=0`, no exceptions). **Fun-gate playtest still pending** (the base reads as inert until you walk to the gate — the gate-prompt UI is phase C, accepted caveat).
|
||||
|
||||
### Phase C build record — BUILT + validated (2026-06-25)
|
||||
Scoping/design-gated (`wf_7c5a555e-136`). Legibility cleanup that makes the loop self-explain. Shipped in two commits:
|
||||
- **C7b objective readout** — new replicated `ExpeditionObjective{[GhostField] byte State, short Remaining}` on the untagged CycleDirector ghost (cross-region safe). Sole writer `ZoneEnemyDirectorSystem`, written ABOVE its early-returns (snapshot-above-early-return). Play-verified it replicates server→client.
|
||||
- **C7a gate prompt + HUD readout** — `HudSystem` shows "GO TO THE EXPEDITION GATE" / "EXPEDITION IN PROGRESS — N remaining" / "CLEARED — return to claim" (below the siege/overrun overrides).
|
||||
- **C6a Aether upgrade button** — un-gated `BuildSendSystem.UpgradeAbility`; `HudSystem` button + affordability tint (was U-key only).
|
||||
- **C6c cold-start seed** — `CycleDirectorSpawnSystem` seeds `Tuning.StartingOre`(50) on a NEW game only (born-correct, pre-Playback). Kills the silent turret-before-fabricator deadlock. Play-verified `seededOre=50`.
|
||||
- **C6b Biomass sink** — Wall cost Ore→Biomass (the dead currency now has a home). Play-verified.
|
||||
- **C6d palette declutter** — dead Pylon/Harvester/Conveyor hidden from the palette + dev hotkeys (code-intact).
|
||||
- **C5 walls block enemies** — dedicated `Structure` physics layer (slot 9) + `WorldCollisionConfig.StructureMask`; `EnemyAISystem` ORs it into the sweep filter; Wall/Turret/Pylon get cell-sized colliders on the Structure layer; matrix `Default×Structure` unchecked so the player CC passes its own walls while enemies are stopped. **Play-verified baked filters:** `StructMask=512`, structure `BelongsTo=512 CollidesWith=0xFFFFFFFE` (excludes the player/Default bit). Server-only/static → deterministic, despawn frees collision for free.
|
||||
|
||||
389/389 EditMode + clean Play smokes throughout. SaveData stays **v5**. The Biomass-for-walls choice + defer-reward-scaling were operator forks (2026-06-25). Open: a **visual fun-gate** (husk stops at wall / player walks through; objective readout reads clearly) and the optional gate-direction arrow (deferred).
|
||||
|
||||
### Next phases: **B** (retaliation polish — mostly satisfied by A2 already; tune the post-expedition siege feel) → **D** (Slice 4 persistent meta, SaveData v6).
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
--- !u!55 &1
|
||||
PhysicsManager:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 13
|
||||
serializedVersion: 23
|
||||
m_Gravity: {x: 0, y: -9.81, z: 0}
|
||||
m_DefaultMaterial: {fileID: 0}
|
||||
m_BounceThreshold: 2
|
||||
m_DefaultMaxDepenetrationVelocity: 10
|
||||
m_SleepThreshold: 0.005
|
||||
m_DefaultContactOffset: 0.01
|
||||
m_DefaultSolverIterations: 6
|
||||
@@ -16,11 +17,11 @@ PhysicsManager:
|
||||
m_EnableAdaptiveForce: 0
|
||||
m_ClothInterCollisionDistance: 0.1
|
||||
m_ClothInterCollisionStiffness: 0.2
|
||||
m_ContactsGeneration: 1
|
||||
m_LayerCollisionMatrix: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
m_AutoSimulation: 1
|
||||
m_LayerCollisionMatrix: fffdfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
|
||||
m_SimulationMode: 0
|
||||
m_AutoSyncTransforms: 0
|
||||
m_ReuseCollisionCallbacks: 1
|
||||
m_InvokeCollisionCallbacks: 1
|
||||
m_ClothInterCollisionSettingsToggle: 0
|
||||
m_ClothGravity: {x: 0, y: -9.81, z: 0}
|
||||
m_ContactPairsMode: 0
|
||||
@@ -31,6 +32,14 @@ PhysicsManager:
|
||||
m_WorldSubdivisions: 8
|
||||
m_FrictionType: 0
|
||||
m_EnableEnhancedDeterminism: 0
|
||||
m_EnableUnifiedHeightmaps: 1
|
||||
m_ImprovedPatchFriction: 0
|
||||
m_GenerateOnTriggerStayEvents: 1
|
||||
m_SolverType: 0
|
||||
m_DefaultMaxAngularSpeed: 50
|
||||
m_ScratchBufferChunkCount: 4
|
||||
m_CurrentBackendId: 4072204805
|
||||
m_FastMotionThreshold: 3.4028235e+38
|
||||
m_SceneBuffersReleaseInterval: 0
|
||||
m_ReleaseSceneBuffers: 0
|
||||
m_LogVerbosity: 3
|
||||
m_IncrementalStaticBroadphase: 1
|
||||
|
||||
@@ -14,7 +14,7 @@ TagManager:
|
||||
-
|
||||
-
|
||||
- Environment
|
||||
-
|
||||
- Structure
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
Reference in New Issue
Block a user