Compare commits

..

10 Commits

Author SHA1 Message Date
kronic 8f96b520d6 Deferred feel items: true enemy body hit-flash, co-op remote swing arcs, near-impact strike beep
Clears the three follow-ups deferred by the combat-overhaul pass (c3b53cef2) + the
DR-041 "needs its own ShaderGraph slice" note. All client-only, observe-only presentation
(PresentationSystemGroup; no sim mutation, no [GhostField], no server work).

- Item 1: EnemyHitFlashSystem flashes the ACTUAL enemy body by driving the stock
  Entities-Graphics URPMaterialPropertyBaseColor override on the Rukhanka render-entity
  LEG children (root has no MaterialMeshInfo) -- NO ShaderGraph edit, no new component type.
  Lerp white->BodyFlashColor on a Health-decrease edge, decay back to white. Verified on
  screen (the AnimatedLitShader honors the per-instance _BaseColor override).
- Item 2: per-remote-player slash-arc pool in CombatFeedbackSystem, edge-detected from the
  replicated MeleeCombo on interpolated teammates (.WithDisabled<GhostOwnerIsLocal>());
  BuildSlashMesh -> BuildSlashInto(mesh,...) refactor; local player keeps _slashMr.
- Item 3: once-per-windup near-impact strike beep folded into the danger-cone loop, gated
  to a resolved local player.
- 9 new FeelConfig knobs (+ ResetDefaults).

390/390 EditMode, clean compile, zero Play exceptions. 3-lens adversarial review
(wf_8a998c6c-af9) -- no critical/major; fixed 4 minors: spurious beep at base origin before
the local player resolves, frozen tint if BodyFlashEnabled toggles off mid-flash, render-child
capture with no recovery, OnDestroy GO symmetry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 10:51:58 -07:00
kronic c3b53cef28 Combat feel pass: enemy hit-flash, melee connect cue, kill pop, gamepad rumble, footsteps, damage-number tiering
Implements the "what's missing" feel backlog from the combat overhaul investigation (wf_c6c87dc5-9c3). All
client-only, observe-only (PresentationSystemGroup), no sim/netcode change, rollback-safe; new FeelConfig knobs
default-stamped on play-enter.

- ENEMY HIT-FLASH (#1 missing): on the enemy Health-decrease edge, a bright body-scaled particle puff in the
  previously-unused FeelConfig.HitFlashColor (via a new EmitColored helper using per-emit startColor) -- the staple
  "I connected" read. (A true material body-flash needs an AnimatedLitShader emission property + Entities Graphics
  MaterialProperty; the puff is the asset-free version.)
- MELEE CONNECT-vs-WHIFF: on the local swing, a client-side MeleeConeMath.InCone overlap over the cached enemy
  snapshot -> immediate connect read (brightens the slash arc, hit-spark at the nearest enemy, a meaty low "thunk"
  SFX, extra FOV punch) before the authoritative server spark arrives. Whiffs stay dim.
- KILL POP: a colored flash burst + rumble on enemy death (on top of the existing burst/shake/FOV).
- GAMEPAD RUMBLE: new RumbleUtil (auto-stopping pulse, gamepad-only, stops on focus-loss, reset on play-enter)
  pulsed on local hit-taken / hit-dealt / kill, gated on AimPresentation.Scheme==Gamepad.
- FOOTSTEPS: edge-detect local locomotion from the position delta -> soft step SFX at a cadence while moving.
- DAMAGE-NUMBER TIERING: SpawnNumber scales font + life by hit magnitude (vs HitStopRefDamage) so heavy hits read.

390/390 EditMode, clean compile + Play (no exceptions; per-frame footstep/rumble-tick paths verified). On-screen
feel is the operator's eyes. Deferred (focused follow-ups): true material body hit-flash (ShaderGraph), co-op
remote-swing rendering, near-impact telegraph beep (clip reserved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:09:07 -07:00
kronic ca38c2b16d Melee clarity: the swing-arc now SWEEPS across + ramps per combo step (reads as a directional escalating cleave)
Operator: "the melee ability needs visual clarity, on what it does." The cleave is instant (kept — operator chose
instant + VFX), and the arc already matched the exact cone range/half-angle — but it popped whole then faded
(read as a flash) and all 3 combo steps looked byte-identical.

Client-only, observe-only (PresentationSystemGroup), no sim/netcode change, rollback-safe by construction:
- SWEEP: BuildSlashMesh takes a reveal fraction + sweep sign; UpdateSlash rebuilds the crescent each frame so the
  blade wipes across the arc over the first ~60% of life, then holds + fades. Leading edge is brightest. Sweep
  direction alternates per combo step -> reads as alternating strikes.
- PER-STEP RAMP: TriggerSlash takes step + comboLen; tint/brightness/life ramp per link so the chain visibly
  builds to the warm-HDR finisher (steps were indistinguishable before). Facing is already snapped at the swing
  edge.

Compiles clean, no runtime exceptions (per-frame 33-vert rebuild is negligible). The on-screen feel is the
operator's eyes. Deferred to a feel follow-up (they share the enemy-cache / damage-edge code path): connect-vs-
whiff flash, co-op remote-swing rendering, and enemy hit-flash. Investigation: wf_c6c87dc5-9c3 (melee lane).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:13:17 -07:00
kronic 09183cc139 Turrets: 40 Ore + per-base cap of 6 + fit-one-cell textured model (fix cheap/spam/massive/untextured)
Operator: turrets were "super duper cheap", spammable "unlimited", "spaced weirdly", "massive and not textured".
All four were real and independently rooted (placement grid was actually fine).

- Cost: TurretCostOre 10 -> 40 (authoring default + the serialized Gameplay subscene value, which overrides the
  code default). A node yields 30 Ore, so a turret is now ~1.3 nodes instead of 1/3 of one.
- Cap: new Tuning.TurretCap=6, enforced server-authoritatively in BuildPlaceSystem (count live Base turrets while
  building the occupancy set; reject placement at the cap, same-tick-safe). Was unlimited.
- Model: the 1.6x Synty ballista (~5m on a 1m cell, clipping neighbours) scaled to 0.8 to fit one cell; the C5
  BoxCollider shrunk to match (0.8x1.2x0.8, center y 0.6); all 6 sub-renderers swapped off the flat untextured
  teal Mat_StructureOwned_Cyan to the Synty atlas PolygonFantasyKingdom_Mat_01_A (textured). Play-verified
  TurretCost=40 Ore / cap=6 baked; no exceptions.

Also fixes 3 EditMode tests that pinned the old dash knobs (the prior tuning commit changed iframe 12->14 /
cooldown 45->36 but I committed it without re-running tests): DashSystemTests now derives the expected dash speed
from TuningConfig.Defaults() (robust to future tuning) + asserts now+14/+36; TuningConfigTests pins the new
defaults. 390/390 EditMode green.

Investigation: wf_c6c87dc5-9c3 (turret lane). Operator fork: 40 Ore + cap 6 (stricter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:07:57 -07:00
kronic 1b704ca0b9 Combat tuning: lift sluggish chasers toward kiteable band + snappier dash (research-backed)
Deep-research-backed ratios (GDKeys, gamedeveloper.com twin-stick study, Hades/RoR, critpoints i-frames):
enemies should sit ~0.5-0.85x of player move speed (kiteable but pressing), telegraphs >= ~250ms.

- Enemy move speed (on the RIGGED prefabs the directors actually spawn - verified the live ZoneEnemy/Wave
  rosters at runtime: Grunt=EnemyWerewolf, Charger=EnemyChargerMuscle, Spitter=EnemySpitter, Swarmer=
  EnemySwarmerUndead): Grunt 3.0->4.2 (0.70x base), Charger walk 2.6->3.0, Spitter 2.8->3.0. The 0.43-0.50x
  cluster was trivially out-walked by the 6.9 Ranger; lifted to credible pressure while still kiteable.
  Swarmer kept at 6.5 (intentional surround/rush). Telegraph windups unchanged (already research-aligned).
- Dash (live TuningConfig defaults): IFrameWindowTicks 12->14 (0.20->0.23s, covers a reacted telegraph),
  DashCooldownTicks 45->36 (0.75->0.60s, horde-kiter cadence). Dash distance/arc unchanged.

Play-verified the baked rosters: Grunt 4.2 / Charger 3 / Spitter 3 / Swarmer 6.5; dash 14/36. 390/390 EditMode.
All are live-tunable (dash) or one re-bake (enemy speeds). Investigation: wf_c6c87dc5-9c3 (tuning lane).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:55:42 -07:00
kronic 3c1b5c44cd Fix: dying on an expedition soft-bricked the player — respawn now resets RegionTag to Base
PlayerRespawnSystem teleported a recovered player to base coords but never reset its server-only RegionTag
(every other region-mover flips RegionTag + Position together). So dying ON an expedition left you at base
still tagged Expedition: GhostRelevancy hid all base ghosts from you, base enemies ignored you, and the
expedition field/zone-director kept counting you as "still out there" (waves never stopped). No self-recovery.

- PlayerRespawnSystem: add RefRW<RegionTag> to the recovery query + set Region=Base alongside the reposition.
- Harden: drop the hard RequireForUpdate<PlayerSpawner> (a transiently-missing spawner could strand dead
  players downed forever) -> TryGetSingleton with a BaseAnchor fallback, early-return only if both are absent.
- PlayerRespawnSystemTests: add RegionTag to the harness + a regression (expedition death -> respawn at base
  with RegionTag reset to Base). 390/390 EditMode.

Investigation: combat-overhaul workflow wf_c6c87dc5-9c3 (death lane). Base-death case was already correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:44:33 -07:00
kronic 8596cc74b1 Docs: DR-042 Phase C build record (legibility C5-C7 complete) + Backlog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:27:55 -07:00
kronic bd8458853b DR-042 Phase C (legibility, part 2): walls block enemies (C5) — restore the fortress fantasy
Player-built structures now physically block enemies (husks walked straight through walls before). Dedicated
"Structure" physics layer (slot 9) so the player passes its own walls while enemies are stopped:

- New WorldCollisionConfig.StructureMask, baked from the "Structure" layer in WorldCollisionAuthoring (mirrors
  EnvironmentMask). EnemyAISystem ORs it into the movement sweep filter (CollidesWith = envMask | structMask) —
  no new system, same 1-2 SphereCasts per enemy.
- Wall/Turret/Pylon prefabs get a cell-sized BoxCollider on the Structure layer (Wall's existing one relayered).
- Physics matrix: Default x Structure unchecked, so the kinematic player CC (Default) passes its own walls while
  the enemy's explicit cast still hits them. Despawn frees collision for free (collider dies with the entity).

Play-verified baked filters: StructMask=512; structure colliders BelongsTo=512, CollidesWith=0xFFFFFFFE
(includes Environment for the enemy cast, EXCLUDES bit 0 so the player passes). 389/389 EditMode, no exceptions.
Server-only/static colliders -> deterministic, no client divergence. SaveData stays v5.

Phase C complete (C5-C7). A visual fun-gate (husk stops at wall, player walks through) is the operator's eyes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:26:56 -07:00
kronic 419debad74 DR-042 Phase C (legibility, part 1): expedition objective HUD, Aether button, cold-start seed, Biomass sink, palette declutter
Scoping/design-gated (wf_7c5a555e-136). Fixes "the base reads as inert after Phase A":

- 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) so the HUD never freezes stale. Play-verified it replicates
  server->client.
- C7a gate prompt + C7b 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 (was #if UNITY_EDITOR); HudSystem adds a
  MenuUi.Button with live affordability tint (the only Aether sink was U-key only).
- C6c cold-start seed: CycleDirectorSpawnSystem seeds Tuning.StartingOre (50) into the ledger on a NEW game only
  (born-correct, pre-Playback), killing 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 WallCostRes=Biomass.
- C6d palette declutter: hide dead Pylon/Harvester/Conveyor from the build palette + trimmed their dev hotkeys
  (catalog/prefabs stay baked, code-intact per DR-020).

389/389 EditMode + clean netcode Play smoke (ghost re-hash OK, no exceptions). SaveData stays v5.
C5 (walls block enemies) is the remaining Phase C item, sequenced separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:18:17 -07:00
kronic ed65770cc9 DR-042 Phase A: expedition-driven win — move win-driver off base-siege survival, kill the AFK path
Design-review-gated (wf_ebef4e81-dba, GREEN-WITH-CHANGES). The win-driver moves
from "survive N base sieges" to "clear N expeditions". The review overturned the
literal plan: credit on RETURN, not at the clear edge (clear-edge crediting arms
the undefended final base siege -> uncontestable terminal Loss).

- ExpeditionGateSystem: now the sole production writer of GoalProgress.Charge —
  a clamped +1 per cleared expedition folded into the existing once-per-epoch
  reward block, reusing the LastRewardedEpoch latch (Ore + Charge share fate) +
  a SaveRequest checkpoint. No new latch, no new GhostField, no ordering change.
- CyclePhaseSystem: deleted the survived-siege +1 (the AFK win path). Victory
  latch unchanged; GoalReached still arms the final base siege at cap.
- CycleDirectorAuthoring + CycleDirector.prefab: ScheduleEnabled baked OFF
  (retaliation-only). A serialized prefab bool ignores the C# field initializer,
  so the value is flipped in the prefab, not just the code default.
- Tests: re-pointed CyclePhaseSystemTests + EndgameWinLoseTests survived-siege
  assertions; extended ExpeditionGateRewardTests (+1, no-double-credit, clamp).
  389/389 EditMode green; clean netcode Play smoke (no sort-cycle, Schedule=0).

SaveData stays v5. Docs: DR-042 build record + forks resolved, CLAUDE.md
base-loop line, Backlog (A done).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:49:27 -07:00
43 changed files with 945 additions and 144 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+23 -1
View File
@@ -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}
+31 -9
View File
@@ -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
+1 -1
View File
@@ -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-&gt;Field-&gt;Zone-&gt;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-&gt;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 -&gt; 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 &lt;&lt; layerIndex</c>).</summary>
/// <summary>BelongsTo bitmask of the Environment physics layer (<c>1u &lt;&lt; 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&times;the player layer so the player CC
/// passes through its own walls.</summary>
public uint StructureMask;
}
}
+1 -1
View File
@@ -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");
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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 siegesexpedition 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.
@@ -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 (AC) 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).
+14 -5
View File
@@ -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
+1 -1
View File
@@ -14,7 +14,7 @@ TagManager:
-
-
- Environment
-
- Structure
-
-
-