Further Tests & Progress

This commit is contained in:
2026-06-04 11:35:57 -07:00
parent 5c11ff4fad
commit 51401d2c2b
65 changed files with 2784 additions and 45 deletions
@@ -50,6 +50,8 @@ namespace ProjectM.Authoring
AttackCooldownTicks = authoring.AttackCooldownTicks,
});
AddComponent(entity, new EnemyAttackCooldown { NextAttackTick = 0 });
AddComponent<KnockbackState>(entity); // server-only recoil state (zero = not knocked)
AddComponent<AttackWindup>(entity); // replicated telegraph signal (zero = not winding up)
}
}
}
@@ -67,6 +67,8 @@ namespace ProjectM.Authoring
// Empty replicated modifier stack (grown by upgrades/pickups/debug hook, server-authoritative).
AddBuffer<StatModifier>(entity);
// Server-only expiry tracker for timed buffs (paired with a StatModifier by SourceId; not replicated).
AddBuffer<TimedModifier>(entity);
// Combat: server-authoritative health (Current replicated for display), the player's
// damageable hit radius, predicted cooldown state, and the per-tick damage inbox.
@@ -38,7 +38,10 @@ namespace ProjectM.Client
float3 _lastKbmGroundPoint; // last valid cursor ground point (held when the projection misses)
bool _haveKbmPoint; // true after the first valid KBM ground hit
bool _cursorHidden; // tracks the applied Cursor.visible state (avoid per-frame churn)
bool _cursorTouched; // we changed the OS cursor at least once -> restore on destroy
bool _cursorTouched;
GameObject _tether;
LineRenderer _tetherLine;
Material _tetherMat; // we changed the OS cursor at least once -> restore on destroy
protected override void OnStartRunning()
{
@@ -52,9 +55,12 @@ namespace ProjectM.Client
bool haveTarget = false;
float3 ringPos = default;
float3 lpPos = default;
float2 lpFacing = default;
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
EntityManager.CompleteDependencyBeforeRO<PlayerFacing>();
EntityManager.CompleteDependencyBeforeRO<Health>();
foreach (var (xform, facing) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
@@ -87,6 +93,8 @@ namespace ProjectM.Client
}
ringPos.y += ReticleLiftY;
lpPos = playerPos;
lpFacing = facing.ValueRO.Direction;
haveTarget = true;
break;
}
@@ -97,6 +105,43 @@ namespace ProjectM.Client
if (_reticle.activeSelf != haveTarget) _reticle.SetActive(haveTarget);
}
// Lock-on tether (cosmetic aim HINT - the client computes nearest enemy itself; the server's actual
// gamepad auto-target cone may differ, so divergence is acceptable, not a bug).
bool tetherShown = false;
if (_tetherLine != null && haveTarget && FeelConfig.LockOnEnabled
&& (!FeelConfig.LockOnGamepadOnly || scheme == InputSchemeId.Gamepad))
{
float2 fdir = lpFacing;
if (math.lengthsq(fdir) < 1e-6f) fdir = new float2(0f, 1f);
fdir = math.normalize(fdir);
float rangeSq = FeelConfig.LockOnRange * FeelConfig.LockOnRange;
float cone = math.cos(math.radians(FeelConfig.LockOnArcDegrees));
float bestSq = float.MaxValue;
float3 bestPos = default;
bool found = false;
foreach (var (hx, hh) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>().WithAll<EnemyTag>())
{
if (hh.ValueRO.Current <= 0f) continue;
float3 hp = hx.ValueRO.Position;
float2 to = hp.xz - lpPos.xz;
float sq = math.lengthsq(to);
if (sq > rangeSq || sq < 1e-6f) continue;
if (math.dot(fdir, math.normalize(to)) < cone) continue;
if (sq < bestSq) { bestSq = sq; bestPos = hp; found = true; }
}
if (found)
{
_tetherLine.startColor = FeelConfig.LockOnLineColor;
_tetherLine.endColor = FeelConfig.LockOnLineColor;
_tetherLine.widthMultiplier = FeelConfig.LockOnLineWidth;
_tetherLine.SetPosition(0, (Vector3)ringPos);
_tetherLine.SetPosition(1, new Vector3(bestPos.x, ringPos.y, bestPos.z));
tetherShown = true;
}
}
if (_tether != null && _tether.activeSelf != tetherShown) _tether.SetActive(tetherShown);
// Hide the OS cursor only while aiming AND focused; restore otherwise (focus loss / pre-spawn) so an
// unfocused editor or a windowed session is never stranded with an invisible pointer.
bool wantHidden = haveTarget && Application.isFocused;
@@ -131,6 +176,8 @@ namespace ProjectM.Client
if (_reticle != null) Object.Destroy(_reticle);
if (_reticleMat != null) Object.Destroy(_reticleMat);
if (_ringTex != null) Object.Destroy(_ringTex);
if (_tether != null) Object.Destroy(_tether);
if (_tetherMat != null) Object.Destroy(_tetherMat);
}
void BuildReticle()
@@ -154,6 +201,25 @@ namespace ProjectM.Client
_reticle.transform.rotation = Quaternion.Euler(90f, 0f, 0f); // lay flat on the ground
_reticle.transform.localScale = new Vector3(ReticleSize, ReticleSize, 1f);
_reticle.SetActive(false);
// Lock-on tether line (persistent; built once, GC-clean). Own material so the ring texture doesn't tint it.
var lineShader = Shader.Find("Sprites/Default");
if (lineShader == null) lineShader = Shader.Find("Universal Render Pipeline/Particles/Unlit");
_tetherMat = new Material(lineShader) { color = Color.white };
_tether = new GameObject("~AimTether");
_tetherLine = _tether.AddComponent<LineRenderer>();
_tetherLine.material = _tetherMat;
_tetherLine.positionCount = 2;
_tetherLine.useWorldSpace = true;
_tetherLine.numCapVertices = 2;
_tetherLine.alignment = LineAlignment.View;
_tetherLine.textureMode = LineTextureMode.Stretch;
_tetherLine.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
_tetherLine.receiveShadows = false;
_tetherLine.startColor = FeelConfig.LockOnLineColor;
_tetherLine.endColor = FeelConfig.LockOnLineColor;
_tetherLine.widthMultiplier = FeelConfig.LockOnLineWidth;
_tether.SetActive(false);
}
// ---- procedural ring texture (asset-free, like HudSystem's code-built uGUI) ----
@@ -0,0 +1,147 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Client-only AMBIENT audio + cycle-phase stingers. A managed presentation <see cref="SystemBase"/>
/// (<see cref="PresentationSystemGroup"/>, main thread, no Burst) that OBSERVES the replicated
/// <see cref="CycleState"/> and never touches the simulation. On start it plays a low, seamless-looping
/// procedural drone (asset-free, <c>AudioClip.Create</c> like <c>CombatFeedbackSystem.MakeClip</c>); each
/// time the cycle phase changes it plays a short procedural stinger and eases the drone's intensity by phase
/// (calmer at base, tenser during Defend / "wave incoming"). Lives only in the client world, so the server
/// never creates audio and nothing here affects determinism. Volumes are deliberately conservative + tunable.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class AmbientAudioSystem : SystemBase
{
AudioSource _ambient;
AudioClip _ambientClip;
AudioClip _stingExpedition;
AudioClip _stingDefend;
AudioClip _stingBuild;
GameObject _root;
byte _lastPhase;
bool _phaseInit;
const float AmbientBaseVolume = 0.10f; // low bed; Defend eases up to ~1.7x
protected override void OnCreate()
{
_ambientClip = MakeDrone();
_stingExpedition = MakeSting(520f, 880f, 0.45f, 0.30f); // airy rising "deploy"
_stingDefend = MakeSting(300f, 140f, 0.55f, 0.42f); // tense falling "wave incoming"
_stingBuild = MakeSting(440f, 660f, 0.40f, 0.26f); // soft confirm
}
protected override void OnStartRunning()
{
if (_root != null) return;
_root = new GameObject("~AmbientAudio");
_ambient = _root.AddComponent<AudioSource>();
_ambient.clip = _ambientClip;
_ambient.loop = true;
_ambient.playOnAwake = false;
_ambient.spatialBlend = 0f; // 2D bed
_ambient.volume = AmbientBaseVolume;
_ambient.Play();
}
protected override void OnDestroy()
{
if (_root != null) Object.Destroy(_root);
}
protected override void OnUpdate()
{
if (_ambient == null) return;
if (!SystemAPI.TryGetSingleton<CycleState>(out var cyc)) return;
byte phase = cyc.Phase;
if (!_phaseInit)
{
_lastPhase = phase; // adopt the current phase silently (no stinger on first observe)
_phaseInit = true;
}
else if (phase != _lastPhase)
{
PlaySting(phase);
_lastPhase = phase;
}
// Ease the drone intensity toward the phase target (tenser during Defend).
float target = phase == CyclePhase.Defend ? AmbientBaseVolume * 1.7f : AmbientBaseVolume;
_ambient.volume = Mathf.MoveTowards(_ambient.volume, target, SystemAPI.Time.DeltaTime * 0.25f);
}
void PlaySting(byte phase)
{
AudioClip clip = phase == CyclePhase.Defend ? _stingDefend
: phase == CyclePhase.Build ? _stingBuild
: _stingExpedition;
if (clip != null && _ambient != null)
_ambient.PlayOneShot(clip, 0.6f);
}
// ---- Procedural audio (asset-free; mirrors CombatFeedbackSystem.MakeClip) ----
// A low, seamless-looping pad: each partial completes an integer number of cycles over the buffer
// (freq snapped to k/duration) so the loop point has no click. A slow tremolo adds motion.
static AudioClip MakeDrone()
{
const int rate = 44100;
const float dur = 4f;
int len = (int)(dur * rate);
var clip = AudioClip.Create("ambient_drone", len, 1, rate, false);
var data = new float[len];
float f0 = Snap(55f, dur); // sub
float f1 = Snap(110f, dur); // root
float f2 = Snap(164.81f, dur); // fifth-ish
float f3 = Snap(220f, dur);
float trem = Snap(0.5f, dur); // slow amplitude wobble
for (int i = 0; i < len; i++)
{
float t = i / (float)rate;
float s = 0.50f * Mathf.Sin(2f * Mathf.PI * f0 * t)
+ 0.35f * Mathf.Sin(2f * Mathf.PI * f1 * t)
+ 0.18f * Mathf.Sin(2f * Mathf.PI * f2 * t)
+ 0.10f * Mathf.Sin(2f * Mathf.PI * f3 * t);
float amp = 0.75f + 0.25f * Mathf.Sin(2f * Mathf.PI * trem * t);
data[i] = s * amp * 0.5f; // peak ~0.57, no clipping
}
clip.SetData(data, 0);
return clip;
}
// freq snapped so freq*dur is an integer -> the waveform closes seamlessly at the loop point.
static float Snap(float freq, float dur)
{
float cycles = Mathf.Max(1f, Mathf.Round(freq * dur));
return cycles / dur;
}
// Short one-shot tone sweeping f0->f1 with an exponential decay envelope.
static AudioClip MakeSting(float f0, float f1, float dur, float vol)
{
const int rate = 44100;
int len = Mathf.Max(16, (int)(dur * rate));
var clip = AudioClip.Create("sting", len, 1, rate, false);
var data = new float[len];
float phase = 0f;
for (int i = 0; i < len; i++)
{
float t = i / (float)len;
float env = Mathf.Exp(-3.5f * t);
float freq = Mathf.Lerp(f0, f1, t);
phase += 2f * Mathf.PI * freq / rate;
data[i] = Mathf.Sin(phase) * env * vol;
}
clip.SetData(data, 0);
return clip;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a72ff134349646a408c917908bd6613c
@@ -35,7 +35,7 @@ namespace ProjectM.Client
[UpdateInGroup(typeof(PresentationSystemGroup))]
public partial class CombatFeedbackSystem : SystemBase
{
struct FxCache { public float Hp; public float3 Pos; public bool IsEnemy; }
struct FxCache { public float Hp; public float3 Pos; public bool IsEnemy; public uint Windup; }
readonly Dictionary<Entity, FxCache> _cache = new();
readonly HashSet<Entity> _seen = new();
@@ -55,6 +55,7 @@ namespace ProjectM.Client
AudioClip _hitClip;
AudioClip _deathClip;
AudioClip _fireClip;
AudioClip _telegraphClip;
Entity _localPlayer = Entity.Null;
uint _lastLocalFireTick;
@@ -70,6 +71,7 @@ namespace ProjectM.Client
_hitClip = MakeClip("husk_hit", 640f, 180f, 0.10f, 0.5f, noise: true);
_deathClip = MakeClip("husk_death", 320f, 50f, 0.34f, 0.55f, noise: false);
_fireClip = MakeClip("fire", 880f, 1500f, 0.07f, 0.30f, noise: false);
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
}
protected override void OnStartRunning()
@@ -101,6 +103,7 @@ namespace ProjectM.Client
// Make sure predicted/physics jobs writing these are done before this main-thread read.
EntityManager.CompleteDependencyBeforeRO<Health>();
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
// Resolve the local player (for hit colouring + fire feedback).
_localPlayer = Entity.Null;
@@ -121,28 +124,45 @@ namespace ProjectM.Client
float cur = health.ValueRO.Current;
float3 p = xf.ValueRO.Position;
bool isEnemy = SystemAPI.HasComponent<EnemyTag>(entity);
uint windup = isEnemy && SystemAPI.HasComponent<AttackWindup>(entity) ? SystemAPI.GetComponent<AttackWindup>(entity).WindUpUntilTick : 0u;
bool isLocalPlayer = entity == _localPlayer;
if (_cache.TryGetValue(entity, out var prev))
{
if (isEnemy && windup != 0 && prev.Windup == 0)
{
// Attack telegraph: the wind-up just began -> warn the player ~0.3s before the strike lands.
Burst(_hitFx, null, (Vector3)p + Vector3.up * 1.2f, 6);
PlayClip(_telegraphClip, (Vector3)p, 0.5f);
}
if (cur < prev.Hp - 0.001f)
{
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, 10);
PlayClip(_hitClip, (Vector3)p, 0.7f);
PrototypeCameraRig.AddShake(isLocalPlayer ? 0.32f : 0.10f);
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount);
PlayClip(_hitClip, (Vector3)p, FeelConfig.HitSfxVolume);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote);
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
}
// Respawn recovery: the LOCAL player's Health rising from <=0 back to positive. No healing
// mechanic exists, so a 0 -> positive edge is unambiguously a respawn (observer-only).
if (isLocalPlayer && FeelConfig.RespawnShimmerEnabled && cur > prev.Hp + 0.001f && prev.Hp <= 0f)
{
Burst(_muzzleFx, null, (Vector3)p + Vector3.up * 0.6f, FeelConfig.RespawnShimmerBurst);
PrototypeCameraRig.AddShake(FeelConfig.RespawnShimmerShake);
}
// Player death (players don't despawn — they respawn; Husk death is handled on prune).
if (!isEnemy && cur <= 0f && prev.Hp > 0f)
{
Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, 28);
Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, FeelConfig.DeathBurstCount);
PlayClip(_deathClip, (Vector3)p, 0.7f);
PrototypeCameraRig.AddShake(isLocalPlayer ? 0.5f : 0.25f);
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.PlayerDeathShake : FeelConfig.RemotePlayerDeathShake);
}
}
_cache[entity] = new FxCache { Hp = cur, Pos = p, IsEnemy = isEnemy };
_cache[entity] = new FxCache { Hp = cur, Pos = p, IsEnemy = isEnemy, Windup = windup };
}
// Prune despawned ghosts. A Husk that vanished was killed -> death VFX at its last position.
@@ -157,9 +177,10 @@ namespace ProjectM.Client
var c = _cache[_stale[i]];
if (c.IsEnemy)
{
Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.5f, 28);
PlayClip(_deathClip, (Vector3)c.Pos, 0.65f);
PrototypeCameraRig.AddShake(0.16f);
Burst(_deathFx, cfg != null ? cfg.EnemyDeath : null, (Vector3)c.Pos + Vector3.up * 0.5f, Mathf.Max(1, Mathf.RoundToInt(FeelConfig.DeathBurstCount * FeelConfig.KillBurstScale)));
PlayClip(_deathClip, (Vector3)c.Pos, FeelConfig.KillSfxVolume);
PrototypeCameraRig.AddShake(FeelConfig.KillShake);
PrototypeCameraRig.PunchFov(FeelConfig.KillFovKick, FeelConfig.HitStopDurationMs);
}
_cache.Remove(_stale[i]);
}
@@ -0,0 +1,113 @@
using UnityEngine;
namespace ProjectM.Client
{
/// <summary>
/// Live-tunable knobs for the client-only COMBAT-FEEL slice (Stage E). A static bridge — mirrors
/// <see cref="AimPresentation"/> — so values can be poked at runtime via MCP <c>execute_code</c>
/// (e.g. <c>ProjectM.Client.FeelConfig.HitShakeLocal = 0.4f;</c>) WITHOUT a recompile, for interactive
/// tuning. Read ONLY by client-presentation systems (<see cref="CombatFeedbackSystem"/>,
/// <see cref="AimReticleSystem"/>) and the <see cref="PrototypeCameraRig"/> MonoBehaviour — all non-Burst,
/// main-thread. NEVER read these from a <c>[BurstCompile]</c> system (managed-static + Color/enum-in-Burst
/// hazards); they are presentation-only and never touch the deterministic simulation.
/// <para>
/// Defaults match the values previously hardcoded in CombatFeedbackSystem so behaviour is byte-identical
/// until a knob is poked. <see cref="ResetDefaults"/> re-stamps every field on play-enter via
/// <c>[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]</c> because statics survive fast-enter-playmode
/// domain reloads — without it a poked value would leak across play-enters and flash stale feel (the exact
/// bug <see cref="AimPresentation"/>'s reset prevents).
/// </para>
/// </summary>
public static class FeelConfig
{
// ---- Feature 1: hit camera punch + (camera-only) hit-stop ----
/// <summary>Camera shake when the LOCAL player is hit (fed to PrototypeCameraRig.AddShake, clamp 0.8).</summary>
public static float HitShakeLocal;
/// <summary>Camera shake when a remote player / Husk is hit.</summary>
public static float HitShakeRemote;
/// <summary>Hit-spark particle burst count (procedural fallback path).</summary>
public static int HitBurstCount;
/// <summary>Hit SFX volume.</summary>
public static float HitSfxVolume;
/// <summary>Degrees of transient FOV "kick" on a LOCAL hit — the netcode-safe hit-stop (NEVER Time.timeScale). 0 = off.</summary>
public static float HitStopFovKick;
/// <summary>Milliseconds the FOV kick eases back to base.</summary>
public static float HitStopDurationMs;
// ---- Feature 1/2: death camera punch ----
/// <summary>Camera shake on LOCAL player death (loudest event by design).</summary>
public static float PlayerDeathShake;
/// <summary>Camera shake on a remote player's death.</summary>
public static float RemotePlayerDeathShake;
/// <summary>Base death-burst particle count (player death + Husk-death base).</summary>
public static int DeathBurstCount;
// ---- Feature 2: kill-shot fanfare (Husk death) ----
/// <summary>Camera shake on a Husk kill (nudged above a glancing hit, kept under PlayerDeathShake).</summary>
public static float KillShake;
/// <summary>Multiplier on DeathBurstCount for a Husk kill (result clamped by MaxActiveVfx).</summary>
public static float KillBurstScale;
/// <summary>Optional FOV kick on a kill (degrees). 0 = off.</summary>
public static float KillFovKick;
/// <summary>Husk-death SFX volume.</summary>
public static float KillSfxVolume;
// ---- Feature 3: respawn shimmer / fade-in (local player recovery) ----
/// <summary>Master gate for the local-player respawn shimmer.</summary>
public static bool RespawnShimmerEnabled;
/// <summary>Particle burst count for the recovery shimmer.</summary>
public static int RespawnShimmerBurst;
/// <summary>Light camera punch on recovery so respawn reads as "reinforcing".</summary>
public static float RespawnShimmerShake;
// ---- Feature 4: reticle lock-on tether (cosmetic aim HINT) ----
/// <summary>Master gate for the lock-on tether.</summary>
public static bool LockOnEnabled;
/// <summary>Show the tether only on the Gamepad scheme (mirrors the server's gamepad-only auto-target assist).</summary>
public static bool LockOnGamepadOnly;
/// <summary>Max world distance from the player to a tethered Husk.</summary>
public static float LockOnRange;
/// <summary>Forward half-arc (degrees) around PlayerFacing within which a Husk is eligible.</summary>
public static float LockOnArcDegrees;
/// <summary>Tether line tint (subtle highlight, not a laser).</summary>
public static Color LockOnLineColor;
/// <summary>Tether line width (world units).</summary>
public static float LockOnLineWidth;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
public static void ResetDefaults()
{
// Feature 1
HitShakeLocal = 0.32f;
HitShakeRemote = 0.10f;
HitBurstCount = 10;
HitSfxVolume = 0.70f;
HitStopFovKick = 1.5f;
HitStopDurationMs = 90f;
// Feature 1/2 death
PlayerDeathShake = 0.50f;
RemotePlayerDeathShake = 0.25f;
DeathBurstCount = 28;
// Feature 2 kill-shot
KillShake = 0.20f;
KillBurstScale = 1.5f;
KillFovKick = 1.0f;
KillSfxVolume = 0.75f;
// Feature 3 respawn
RespawnShimmerEnabled = true;
RespawnShimmerBurst = 24;
RespawnShimmerShake = 0.12f;
// Feature 4 tether
LockOnEnabled = true;
LockOnGamepadOnly = true;
LockOnRange = 9.0f;
LockOnArcDegrees = 60f;
LockOnLineColor = new Color(0.55f, 0.9f, 1f, 0.35f);
LockOnLineWidth = 0.05f;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be55ae1703fcd284bbc1acddb417133c
@@ -60,7 +60,7 @@ namespace ProjectM.Client
var endTick = new NetworkTick(cyc.PhaseEndTick);
string detail;
if (cyc.Phase == CyclePhase.Defend)
detail = _huskQuery.CalculateEntityCount() + " HUSKS";
detail = "WAVE " + cyc.WaveNumber + " - " + _huskQuery.CalculateEntityCount() + " HUSKS";
else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick))
detail = (endTick.TicksSince(nt.ServerTick) / 60) + "s";
else
@@ -33,6 +33,10 @@ namespace ProjectM.Client
/// <summary>Local player's planar facing (XZ), published each client tick for the aim look-ahead.</summary>
public static float2 TargetFacing;
/// <summary>Local player's planar MOVEMENT input (XZ), published each tick for the movement-based camera
/// look-ahead. Leading toward MOVEMENT (not aim/facing) keeps a stationary aim from "swimming" as the camera pans.</summary>
public static float2 TargetMoveDir;
/// <summary>Accumulated camera-shake amplitude (world units), decayed each LateUpdate. Driven by
/// AddShake from the client combat-feedback layer (presentation only, netcode-safe - never the sim).</summary>
static float s_shake;
@@ -40,6 +44,20 @@ namespace ProjectM.Client
/// <summary>Add a one-shot camera-shake impulse (clamped). Called by CombatFeedbackSystem on hits/deaths.</summary>
public static void AddShake(float amount) => s_shake = Mathf.Min(s_shake + amount, 0.8f);
/// <summary>Transient additive FOV "kick" (degrees) - the netcode-safe hit-stop. Decayed each LateUpdate.</summary>
static float s_fovKick;
static float s_fovLambda = 30f; // ease-back rate; PunchFov sets it from the requested duration
/// <summary>Add a one-shot FOV kick eased back over <paramref name="durationMs"/>. Presentation only
/// (NEVER Time.timeScale, which would desync the deterministic predicted sim).</summary>
public static void PunchFov(float degrees, float durationMs)
{
if (degrees <= 0f) return;
s_fovKick = Mathf.Max(s_fovKick, degrees);
float durSec = Mathf.Max(0.02f, durationMs * 0.001f);
s_fovLambda = 3f / durSec; // ~95% decayed after durSec (3 time constants)
}
[Header("Angle (degrees)")]
[Range(10f, 89f)] public float Pitch = 45f;
[Range(-180f, 180f)] public float Yaw = 0f;
@@ -60,7 +78,7 @@ namespace ProjectM.Client
[Tooltip("What to frame before a local player exists (edit mode / pre-spawn).")]
public Vector3 FallbackTarget = new Vector3(3f, 0f, 4f);
[Header("Aim look-ahead")]
[Header("Movement look-ahead")]
[Tooltip("Shift the framed point this many world units toward where the player is aiming (0 = off). " +
"Leads the camera toward the cursor / stick so aiming feels grounded. Smoothed by FollowSharpness.")]
[Range(0f, 8f)] public float AimLeadDistance = 2.5f;
@@ -74,18 +92,22 @@ namespace ProjectM.Client
if (_cam == null) _cam = GetComponent<Camera>();
_cam.orthographic = Orthographic;
_cam.fieldOfView = FieldOfView;
_cam.fieldOfView = FieldOfView + s_fovKick;
if (s_fovKick > 0.001f)
s_fovKick = Mathf.Lerp(s_fovKick, 0f, 1f - Mathf.Exp(-s_fovLambda * Time.deltaTime));
else
s_fovKick = 0f;
_cam.orthographicSize = OrthoSize;
Vector3 target = HasTarget ? (Vector3)TargetWorldPos : FallbackTarget;
target.y += TargetHeight;
// Aim look-ahead: lead the framed point toward the player's facing (cursor on KBM, stick on pad).
// Driven off the replicated PlayerFacing (not the live cursor projection) so there is no feedback
// loop with the reticle's camera-dependent ground projection. Smoothed by the FollowSharpness lerp.
if (AimLeadDistance > 0f && HasTarget && math.lengthsq(TargetFacing) > 1e-6f)
// Movement look-ahead: lead the framed point toward where the player is MOVING (not aiming).
// Leading toward AIM coupled the camera to the cursor: turning to face a near-cursor panned the cam,
// which re-projected the live mouse ray -> the aim swam (worst near the player). Smoothed by FollowSharpness.
if (AimLeadDistance > 0f && HasTarget && math.lengthsq(TargetMoveDir) > 1e-6f)
{
float2 f = math.normalize(TargetFacing);
float2 f = math.normalize(TargetMoveDir);
target += new Vector3(f.x, 0f, f.y) * AimLeadDistance;
}
@@ -115,11 +137,13 @@ namespace ProjectM.Client
protected override void OnUpdate()
{
bool found = false;
foreach (var (transform, facing) in SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>>()
foreach (var (transform, facing, input) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<PlayerFacing>, RefRO<PlayerInput>>()
.WithAll<GhostOwnerIsLocal, PlayerTag>())
{
PrototypeCameraRig.TargetWorldPos = transform.ValueRO.Position;
PrototypeCameraRig.TargetFacing = facing.ValueRO.Direction;
PrototypeCameraRig.TargetMoveDir = input.ValueRO.Move;
found = true;
break;
}
@@ -18,9 +18,9 @@ namespace ProjectM.Server
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct AbilityUpgradeSystem : ISystem
{
const uint UpgradeSourceId = 0x00A0E711u; // distinct sentinel so the upgrade modifier is found + grown
const float TierStep = 0.25f; // +25% damage per tier
const int CostAmount = 20; // Aether per tier
const uint UpgradeSourceId = Tuning.AbilityUpgradeSourceId; // distinct sentinel so the upgrade modifier is found + grown
const float TierStep = Tuning.AbilityUpgradeTierStep; // +25% damage per tier
const int CostAmount = Tuning.AbilityUpgradeCostAmount; // Aether per tier
[BurstCompile]
public void OnCreate(ref SystemState state)
@@ -59,14 +59,32 @@ namespace ProjectM.Server
float dt = SystemAPI.Time.DeltaTime;
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
uint now = serverTick.TickIndexForValidTick;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, stats, cooldown) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>>()
foreach (var (xform, stats, cooldown, knockback, windup) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>>()
.WithAll<EnemyTag>())
{
float3 pos = xform.ValueRO.Position;
// Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position.
var kb = knockback.ValueRO;
if (kb.UntilTick != 0)
{
var kbTick = new NetworkTick(kb.UntilTick);
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
{
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
kpos.y = pos.y;
xform.ValueRW.Position = kpos;
windup.ValueRW.WindUpUntilTick = 0; // a recoiling Husk does not wind up
continue; // recoiling: skip seek + strike this tick
}
knockback.ValueRW.UntilTick = 0; // window elapsed
}
// Nearest living player (planar XZ).
int best = -1;
float bestSq = float.MaxValue;
@@ -96,8 +114,35 @@ namespace ProjectM.Server
if (math.lengthsq(toTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(toTarget), math.up());
// Strike on contact once the cooldown has elapsed.
if (EnemyAIMath.InAttackRange(pos, targetPos, stats.ValueRO.AttackRange))
// Two-phase strike with a telegraph wind-up: commit a wind-up when first in-range + cooldown-ready,
// then strike when it elapses. WindUpUntilTick is a [GhostField] so the client can cue the ~0.3s
// tell; leaving range mid-windup cancels it. Tuning.AttackWindupTicks = 0/1 -> near-instant (legacy).
bool inRange = EnemyAIMath.InAttackRange(pos, targetPos, stats.ValueRO.AttackRange);
uint windRaw = windup.ValueRO.WindUpUntilTick;
if (windRaw != 0)
{
if (!inRange)
{
windup.ValueRW.WindUpUntilTick = 0; // target left range -> cancel the wind-up
}
else
{
var windTick = new NetworkTick(windRaw);
if (!(windTick.IsValid && windTick.IsNewerThan(serverTick)))
{
ecb.AppendToBuffer(playerEntities[best], new DamageEvent
{
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1, // environment / Husk, not a player
});
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks);
windup.ValueRW.WindUpUntilTick = 0;
}
}
}
else if (inRange)
{
uint nextRaw = cooldown.ValueRO.NextAttackTick;
bool ready = true;
@@ -107,16 +152,10 @@ namespace ProjectM.Server
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick))
ready = false;
}
if (ready)
{
ecb.AppendToBuffer(playerEntities[best], new DamageEvent
{
Amount = stats.ValueRO.AttackDamage,
SourceNetworkId = -1, // environment / Husk, not a player
});
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + cooldownTicks);
uint windupTicks = (uint)math.max(1, Tuning.AttackWindupTicks);
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + windupTicks);
}
}
}
@@ -40,6 +40,9 @@ namespace ProjectM.Server
/// <summary>Lookup used to read a target's owner so a projectile never hits its own caster.</summary>
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
/// <summary>RW lookup to stamp server-only knockback on a hit Husk (Husks bake KnockbackState; players/dummies don't).</summary>
ComponentLookup<KnockbackState> m_KnockbackLookup;
/// <summary>Extra forgiveness added to a target's hit radius to approximate the projectile's own size.</summary>
const float k_ProjectileRadius = 0.2f;
@@ -47,6 +50,7 @@ namespace ProjectM.Server
public void OnCreate(ref SystemState state)
{
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
state.RequireForUpdate<Projectile>();
@@ -56,6 +60,8 @@ namespace ProjectM.Server
public void OnUpdate(ref SystemState state)
{
m_GhostOwnerLookup.Update(ref state);
m_KnockbackLookup.Update(ref state);
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
float dt = SystemAPI.Time.DeltaTime;
var ecb = new EntityCommandBuffer(Allocator.Temp);
@@ -125,6 +131,16 @@ namespace ProjectM.Server
Amount = proj.ValueRO.Damage,
SourceNetworkId = projOwnerId,
});
var hitTarget = targetEntities[bestIdx];
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget))
{
m_KnockbackLookup[hitTarget] = new KnockbackState
{
Dir = proj.ValueRO.Direction,
Speed = Tuning.KnockbackSpeed,
UntilTick = TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)),
};
}
ecb.DestroyEntity(projectileEntity);
continue;
}
@@ -0,0 +1,56 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative expiry of TIMED <see cref="StatModifier"/>s. Each tick it walks every entity's
/// server-only <see cref="TimedModifier"/> buffer; for any row whose <see cref="TimedModifier.UntilTick"/> has
/// elapsed (wrap-safe <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/> compare, never raw uint&lt;) it removes
/// the matching StatModifier(s) by SourceId and the timed row. The shortened StatModifier [GhostField] buffer
/// auto-replicates (OwnerSendType.All), so StatRecomputeSystem reverts the effective stat on both worlds with no
/// change. Runs in the plain server <see cref="SimulationSystemGroup"/> (NOT the predicted loop) so it is applied
/// exactly once and never double-removed on rollback; a DynamicBuffer mutation is not a structural change, so it
/// is safe to mutate the iterated entity's own buffers in place.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct TimedModifierExpirySystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<TimedModifier>()));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
foreach (var (timed, mods) in
SystemAPI.Query<DynamicBuffer<TimedModifier>, DynamicBuffer<StatModifier>>())
{
for (int i = timed.Length - 1; i >= 0; i--)
{
uint until = timed[i].UntilTick;
if (until == 0)
continue; // inert (no expiry scheduled)
var untilTick = new NetworkTick(until);
if (untilTick.IsValid && untilTick.IsNewerThan(serverTick))
continue; // not yet due
TimedModifierUtil.RemoveBySourceId(mods, timed[i].SourceId);
timed.RemoveAtSwapBack(i);
}
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 94c6107954fa4d94f8ead51cfe4de3b7
@@ -28,7 +28,7 @@ namespace ProjectM.Server
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
public partial struct ResourceHarvestSystem : ISystem
{
const float k_ProjectileRadius = 0.2f;
const float k_ProjectileRadius = Tuning.HarvestProjectileRadius;
[BurstCompile]
public void OnCreate(ref SystemState state)
@@ -82,6 +82,10 @@ namespace ProjectM.Server
break;
}
// Surface the live wave number on the replicated CycleState for the HUD (single writer).
if (SystemAPI.TryGetSingleton<WaveState>(out var waveSync))
cycle.WaveNumber = waveSync.WaveNumber;
SystemAPI.SetComponent(cycleEntity, cycle);
SystemAPI.SetComponent(cycleEntity, runtime);
}
@@ -0,0 +1,19 @@
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation
{
/// <summary>
/// Replicated Husk attack-telegraph signal. While <see cref="WindUpUntilTick"/> is non-zero the Husk is
/// "winding up" to strike; EnemyAISystem sets it ~<see cref="Tuning.AttackWindupTicks"/> before the strike
/// lands, and the strike fires when the tick elapses. This is a [GhostField] (the only replicated Husk field
/// beyond the stock LocalTransform) so the CLIENT can play a ~0.3s pre-strike cue — the client has none of the
/// server-only timing inputs (EnemyStats / EnemyAttackCooldown), so the wind-up MUST be replicated. A uint tick
/// (not a [GhostEnabledBit]) so the cue can ramp/countdown and survive a missed snapshot (absolute, not an edge).
/// </summary>
public struct AttackWindup : IComponentData
{
/// <summary>Server tick the wind-up completes + the strike lands (0 = not winding up; scheduled via TickUtil.NonZero).</summary>
[GhostField] public uint WindUpUntilTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f2c9d899714758b4baefe6c1cbb3be0a
@@ -0,0 +1,26 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// SERVER-ONLY transient knockback on a Husk. While <see cref="UntilTick"/> has not elapsed, EnemyAISystem
/// moves the Husk along <see cref="Dir"/> at <see cref="Speed"/> (REPLACING its seek) and suppresses its strike.
/// Stamped by ProjectileDamageSystem on a hit (Dir = the projectile's heading). NOT a [GhostField] — the Husk's
/// displaced position already replicates via the stock LocalTransform default variant, so knockback adds NO
/// replicated surface (no ghost re-bake). EnemyAISystem must remain the SOLE writer of the Husk's Position, so
/// knockback is applied INSIDE it (never a competing system). Force/duration live in <see cref="Tuning"/>
/// (KnockbackSpeed = 0 disables knockback globally).
/// </summary>
public struct KnockbackState : IComponentData
{
/// <summary>Planar (XZ) knockback heading — the projectile's direction at impact.</summary>
public float2 Dir;
/// <summary>Knockback speed (world units/sec) applied for the window; 0 = not knocked.</summary>
public float Speed;
/// <summary>Server tick until which the knockback is active (0 = none; scheduled via TickUtil.NonZero).</summary>
public uint UntilTick;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9fa07e28f83ad6b43a8164b8c673a6b1
@@ -0,0 +1,35 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// SERVER-ONLY expiry tracker paired with a <see cref="StatModifier"/> by <see cref="SourceId"/>. It is NOT a
/// [GhostField] and lives in a SEPARATE buffer so the replicated <see cref="StatModifier"/> layout stays
/// byte-identical — adding ANY member (even non-ghost) to a [GhostField] buffer element regenerates its
/// serializer/stride/hash = an effective ghost re-bake. To grant a TIMED buff, append both a StatModifier and a
/// TimedModifier sharing one unique SourceId; <c>TimedModifierExpirySystem</c> removes the matching StatModifier
/// when <see cref="UntilTick"/> elapses, and that removal replicates for free via the StatModifier [GhostField]
/// buffer (OwnerSendType.All), so StatRecomputeSystem reverts the effective stat on both worlds with no change.
/// </summary>
public struct TimedModifier : IBufferElementData
{
/// <summary>Matches the <see cref="StatModifier.SourceId"/> this row governs.</summary>
public uint SourceId;
/// <summary>Server tick at which the paired StatModifier expires (0 = no expiry / inert; schedule via TickUtil.NonZero).</summary>
public uint UntilTick;
}
/// <summary>Pure helpers for removing modifiers by provenance (clear-by-type / timed expiry). Deterministic, no RNG/wall-clock.</summary>
public static class TimedModifierUtil
{
/// <summary>Remove every <see cref="StatModifier"/> row whose SourceId matches (RemoveAtSwapBack). Returns the count removed.</summary>
public static int RemoveBySourceId(DynamicBuffer<StatModifier> mods, uint sourceId)
{
int removed = 0;
for (int j = mods.Length - 1; j >= 0; j--)
if (mods[j].SourceId == sourceId) { mods.RemoveAtSwapBack(j); removed++; }
return removed;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 67465323b013e6a4cb59519111b1b9e5
@@ -0,0 +1,54 @@
namespace ProjectM.Simulation
{
/// <summary>
/// Central home for gameplay-balance constants that were previously buried as <c>private const</c>s
/// inside individual systems, so designers have one searchable place to tune them. Burst-safe (compile-time
/// <c>const</c>s only — they inline into the consuming systems with no runtime cost or managed reference).
/// <para>
/// Systems reference these via <c>Tuning.*</c> (wired in the 2026-06-04 polish pass, Stage C). When adding a
/// new tunable value, prefer adding it here over a local private const UNLESS it already has an obvious,
/// well-named public home (see the cross-references below) — duplicating a literal creates two sources of truth.
/// </para>
/// <para>
/// <b>Values that already live in a clear, public, semantically-named home (NOT duplicated here):</b>
/// <list type="bullet">
/// <item><see cref="CyclePhase.ExpeditionTicks"/> / <see cref="CyclePhase.BuildTicks"/> — cycle phase durations.</item>
/// <item><see cref="RegionMath.ExpeditionOffsetX"/> — base→expedition world-space offset.</item>
/// <item>Per-ability/character stats — authored in ScriptableObjects, baked to the AbilityDatabase blob (M3).</item>
/// </list>
/// </para>
/// </summary>
public static class Tuning
{
// ---- Ability damage upgrade (AbilityUpgradeSystem) ----
/// <summary>Distinct sentinel SourceId so the upgrade <c>StatModifier</c> is found + grown in place
/// (replace-by-SourceId keeps the bounded modifier buffer from growing a row per upgrade).</summary>
public const uint AbilityUpgradeSourceId = 0x00A0E711u;
/// <summary>Damage bonus added per upgrade tier (PercentAdd op): +25% per tier.</summary>
public const float AbilityUpgradeTierStep = 0.25f;
/// <summary>Aether cost charged to the shared ledger per upgrade tier.</summary>
public const int AbilityUpgradeCostAmount = 20;
// ---- Resource harvest (ResourceHarvestSystem) ----
/// <summary>Effective projectile radius used by the swept-segment node-hit test (added to the node's
/// <c>HitRadius</c>). Tunnel-safe because the segment is reconstructed from <c>Projectile.LastStep</c>.</summary>
public const float HarvestProjectileRadius = 0.2f;
// ---- Enemy knockback (ProjectileDamageSystem stamps on hit; EnemyAISystem applies + suppresses seek/strike) ----
/// <summary>Knockback speed (world units/sec) a Husk recoils at when shot; 0 disables knockback globally.</summary>
public const float KnockbackSpeed = 8f;
/// <summary>Server ticks the knockback lasts (~60 ticks/sec).</summary>
public const int KnockbackDurationTicks = 8;
// ---- Husk attack telegraph (EnemyAISystem 2-phase strike; client cue in CombatFeedbackSystem) ----
/// <summary>Wind-up ticks before a Husk strike lands (~0.3s @ 60 ticks/sec). 0/1 = near-instant (legacy behaviour).</summary>
public const int AttackWindupTicks = 18;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a78ec19017f42bd4a8a16bfa8a03886d
@@ -21,6 +21,9 @@ namespace ProjectM.Simulation
/// <summary>Server tick the current timed phase ends (Expedition/Build only; 0 in Defend).</summary>
[GhostField] public uint PhaseEndTick;
/// <summary>Live Husk wave number during Defend, synced from the server-only WaveState by CyclePhaseSystem so the replicated-state-only HUD can show it (holds the last wave number outside Defend; the HUD gates the display to the Defend phase).</summary>
[GhostField] public int WaveNumber;
}
/// <summary>Phase constants for <see cref="CycleState.Phase"/> (a byte, not an enum, for trivial Burst/serialization).</summary>