Compare commits
3 Commits
9d6977cd64
...
5569fcd552
| Author | SHA1 | Date | |
|---|---|---|---|
| 5569fcd552 | |||
| 040463ad07 | |||
| 2aebc37115 |
@@ -10,8 +10,8 @@ namespace ProjectM.Authoring
|
||||
/// <c>AbilityFireSystem</c> — only the Vortex FLAG is authored here (so <c>AbilityFireSystem</c> can pick
|
||||
/// ZoneEffect vs DecoyTag by prefab membership, and the pull behaviour is baked-in). The prefab's ghost setup
|
||||
/// (GhostAuthoringComponent: interpolated, ownerless) is inherited by DUPLICATING an existing ownerless
|
||||
/// interpolated ghost, so it replicates to all clients via the stock LocalTransform variant (no hand-written
|
||||
/// <c>[GhostField]</c>). <c>GetEntity(Dynamic)</c> gives a runtime-mutable LocalTransform for the spawn override.
|
||||
/// interpolated ghost, so position replicates via the stock LocalTransform variant. 07-21 G6: ZoneEffect's
|
||||
/// CasterNetworkId / Radius / NextTick are hand-written <c>[GhostField]</c>s (the client fill telegraph). <c>GetEntity(Dynamic)</c> gives a runtime-mutable LocalTransform for the spawn override.
|
||||
/// </summary>
|
||||
public class ZoneAuthoring : MonoBehaviour
|
||||
{
|
||||
|
||||
@@ -81,6 +81,7 @@ namespace ProjectM.Authoring
|
||||
// MC-4 melee combo: predicted, owner-replicated combo anchor (Step/SwingStartTick/LockUntilTick), baked idle/zero.
|
||||
AddComponent<MeleeCombo>(entity);
|
||||
AddComponent<MeleeCleavePending>(entity); // 07-20 G2.1: server-only scheduled cleave (baked zeroed, not replicated)
|
||||
AddComponent<ConeContactPending>(entity); // 07-21 G6: server-only scheduled Cone-socket slam (baked zeroed, not replicated)
|
||||
|
||||
// Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive;
|
||||
// plus the server-only respawn timer.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>07-21 G4 (review wf_98bf1268) — editor-only saturation-stress toggle, flipped by the DebugOverlay
|
||||
/// row. While ON, CombatFeedbackSystem synthesizes the ally attack package at 3 orbiting fake-caster positions
|
||||
/// (the 4-player worst case without 4 connections). Lives in the Debug family, NOT FeelConfig —
|
||||
/// FeelProfileService.SaveCurrent snapshots every FeelConfig field and a captured profile would re-arm stress
|
||||
/// mode on Apply (review finding). Resets on play-enter per the static-presentation-bridge ★ rule (statics
|
||||
/// survive fast-enter-playmode reloads; a leaked-on flag would spew phantom ally FX with no overlay in
|
||||
/// Game.unity to clear it).</summary>
|
||||
public static class CombatStressDebug
|
||||
{
|
||||
/// <summary>While true, CombatFeedbackSystem emits the fake-caster ally-FX package every ~0.5 s.</summary>
|
||||
public static bool StressAllyFx;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void ResetOnEnterPlayMode() => StressAllyFx = false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e0854c467017c474b8eac1fe74950370
|
||||
@@ -40,6 +40,11 @@ namespace ProjectM.Client
|
||||
if (GUILayout.Button("Stop Waves")) DebugCommandSendSystem.StopWaves();
|
||||
if (GUILayout.Button("Clear Enemies")) DebugCommandSendSystem.ClearEnemies();
|
||||
|
||||
GUILayout.Space(6);
|
||||
GUILayout.Label("- Saturation (G4) -");
|
||||
if (GUILayout.Button("Spawn 12 Drowners")) DebugCommandSendSystem.SpawnEnemy(0, 12); // gym roster kind 0; the standing worst-case load
|
||||
CombatStressDebug.StressAllyFx = GUILayout.Toggle(CombatStressDebug.StressAllyFx, "Stress ally FX (3 fake casters)");
|
||||
|
||||
GUILayout.Space(6);
|
||||
GUILayout.Label("- Resources -");
|
||||
_grantAmount = IntField("Amount", _grantAmount);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// 07-21 UI rework (operator: "I want to see the cds of socketed abilities + dash") — the bottom-center
|
||||
/// ABILITY BAR: one slot per socket (keys 1-4; socket 0 doubles as RMB) + the dash (SHIFT). Each slot shows
|
||||
/// the Spark's initials + name (from the AbilityDatabase blob), a bottom-anchored dark overlay that DRAINS
|
||||
/// as the cooldown recovers, a seconds countdown, and a brief ready-flash on the ready edge. Replaces the
|
||||
/// old single-socket-0 charge strip in HudSystem's vitals block.
|
||||
/// B5 sibling pattern: OWN UIDocument (~HUDAbilityBar, MenuUi.LoadPanelSettings(), sortingOrder 49 — under
|
||||
/// HudSystem's 50), observe-only <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>, tree
|
||||
/// built once rootVisualElement != null, root pickingMode = Ignore. Cooldown math = the HudSystem idiom:
|
||||
/// remaining = NextFire.TicksSince(nt.ServerTick) vs EffectiveSocketStats.CooldownTicks (the OWNER's own
|
||||
/// cooldowns ride the PREDICTED tick — this is local-player state, unlike the zone telegraph's interpolated
|
||||
/// read). Dash = DashCooldown.NextTick vs TuningConfig.DashCooldownTicks with the Defaults() fallback
|
||||
/// (review wf_98bf1268: the dev TuningConfig singleton is editor-only — release must match Defaults()).
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class AbilityBarSystem : SystemBase
|
||||
{
|
||||
const int SlotCount = SocketId.Count + 1; // 4 sockets + dash
|
||||
const int DashSlot = SocketId.Count;
|
||||
const float k_ReadyFlashSeconds = 0.28f;
|
||||
|
||||
GameObject _hudGo;
|
||||
UIDocument _doc;
|
||||
bool _built;
|
||||
|
||||
readonly VisualElement[] _slotBox = new VisualElement[SlotCount];
|
||||
readonly VisualElement[] _cdOverlay = new VisualElement[SlotCount];
|
||||
readonly Label[] _glyph = new Label[SlotCount];
|
||||
readonly Label[] _countdown = new Label[SlotCount];
|
||||
readonly Label[] _name = new Label[SlotCount];
|
||||
readonly byte[] _shownSpark = new byte[SocketId.Count]; // rebuild labels only when the loadout changes
|
||||
readonly int[] _prevRemaining = new int[SlotCount];
|
||||
readonly float[] _flashUntil = new float[SlotCount];
|
||||
|
||||
static readonly Color EmptyCol = new(1f, 1f, 1f, 0.22f);
|
||||
static readonly Color ReadyGlyphCol = new(0.92f, 0.96f, 1f, 1f);
|
||||
static readonly Color CoolingGlyphCol = new(0.92f, 0.96f, 1f, 0.35f);
|
||||
static readonly Color OverlayCol = new(0f, 0f, 0f, 0.72f);
|
||||
static readonly Color SlotBg = new(0.05f, 0.09f, 0.12f, 0.92f);
|
||||
static readonly Color FlashBorder = new(0.55f, 1f, 0.95f, 1f);
|
||||
static readonly Color IdleBorder = new(1f, 1f, 1f, 0.10f);
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
if (_hudGo != null) return;
|
||||
MenuUi.EnsureEventSystem();
|
||||
_hudGo = new GameObject("~HUDAbilityBar");
|
||||
Object.DontDestroyOnLoad(_hudGo);
|
||||
_doc = _hudGo.AddComponent<UIDocument>();
|
||||
_doc.panelSettings = MenuUi.LoadPanelSettings();
|
||||
_doc.sortingOrder = 49;
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_hudGo != null) Object.Destroy(_hudGo);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (_doc == null || _doc.rootVisualElement == null) return;
|
||||
if (!_built) { BuildTree(_doc.rootVisualElement); _built = true; }
|
||||
|
||||
EntityManager.CompleteDependencyBeforeRO<SocketCooldown>();
|
||||
EntityManager.CompleteDependencyBeforeRO<EffectiveSocketStats>();
|
||||
EntityManager.CompleteDependencyBeforeRO<AbilitySocket>();
|
||||
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
|
||||
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var nt) || !nt.ServerTick.IsValid) return;
|
||||
var now = nt.ServerTick;
|
||||
var tcfg = SystemAPI.TryGetSingleton<TuningConfig>(out var tcv) ? tcv : TuningConfig.Defaults();
|
||||
|
||||
bool haveDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var db) && db.Value.IsCreated;
|
||||
|
||||
foreach (var (cd, dashCd, entity) in
|
||||
SystemAPI.Query<RefRO<SocketCooldown>, RefRO<DashCooldown>>()
|
||||
.WithAll<GhostOwnerIsLocal, PlayerTag>().WithEntityAccess())
|
||||
{
|
||||
if (!EntityManager.HasBuffer<AbilitySocket>(entity) || !EntityManager.HasBuffer<EffectiveSocketStats>(entity))
|
||||
continue;
|
||||
var sockets = EntityManager.GetBuffer<AbilitySocket>(entity, true);
|
||||
var effs = EntityManager.GetBuffer<EffectiveSocketStats>(entity, true);
|
||||
|
||||
int n = math.min(SocketId.Count, math.min(sockets.Length, effs.Length));
|
||||
for (int i = 0; i < SocketId.Count; i++)
|
||||
{
|
||||
byte spark = i < n ? sockets[i].SparkId : (byte)0;
|
||||
if (spark != _shownSpark[i])
|
||||
{
|
||||
_shownSpark[i] = spark;
|
||||
RefreshSlotIdentity(i, spark, haveDb, db);
|
||||
}
|
||||
if (spark == 0 || i >= n) { UpdateSlotCooldown(i, 0, 1); continue; }
|
||||
int total = math.max(1, effs[i].CooldownTicks);
|
||||
UpdateSlotCooldown(i, RemainingTicks(cd.ValueRO.Get(i), now), total);
|
||||
}
|
||||
|
||||
int dashTotal = math.max(1, (int)tcfg.DashCooldownTicks);
|
||||
UpdateSlotCooldown(DashSlot, RemainingTicks(dashCd.ValueRO.NextTick, now), dashTotal);
|
||||
break; // one local player
|
||||
}
|
||||
}
|
||||
|
||||
static int RemainingTicks(uint nextFireRaw, NetworkTick now)
|
||||
{
|
||||
if (nextFireRaw == 0u) return 0;
|
||||
var nextTick = new NetworkTick(nextFireRaw);
|
||||
if (!nextTick.IsValid || !nextTick.IsNewerThan(now)) return 0;
|
||||
return math.max(0, nextTick.TicksSince(now));
|
||||
}
|
||||
|
||||
void UpdateSlotCooldown(int slot, int remaining, int total)
|
||||
{
|
||||
float frac = math.saturate(remaining / (float)total);
|
||||
_cdOverlay[slot].style.height = Length.Percent(frac * 100f);
|
||||
_countdown[slot].text = remaining > 0
|
||||
? (remaining < 597 ? (remaining / 60f).ToString("0.0") : Mathf.CeilToInt(remaining / 60f).ToString())
|
||||
: "";
|
||||
bool empty = slot < SocketId.Count && _shownSpark[slot] == 0;
|
||||
_glyph[slot].style.color = empty ? EmptyCol : (remaining > 0 ? CoolingGlyphCol : ReadyGlyphCol);
|
||||
|
||||
if (_prevRemaining[slot] > 0 && remaining == 0 && !empty)
|
||||
_flashUntil[slot] = UnityEngine.Time.time + k_ReadyFlashSeconds;
|
||||
_prevRemaining[slot] = remaining;
|
||||
|
||||
bool flashing = UnityEngine.Time.time < _flashUntil[slot];
|
||||
MenuUi.Border(_slotBox[slot], flashing ? FlashBorder : IdleBorder, flashing ? 2 : 1);
|
||||
}
|
||||
|
||||
void RefreshSlotIdentity(int i, byte spark, bool haveDb, AbilityDatabase db)
|
||||
{
|
||||
if (spark == 0)
|
||||
{
|
||||
_glyph[i].text = "—";
|
||||
_name[i].text = "empty";
|
||||
_glyph[i].style.color = EmptyCol;
|
||||
return;
|
||||
}
|
||||
string full = "Spark " + spark;
|
||||
byte arch = 255;
|
||||
if (haveDb && db.Value.Value.TryGetAbility(spark, out var def))
|
||||
{
|
||||
full = def.Name.ToString();
|
||||
arch = def.Archetype;
|
||||
}
|
||||
_name[i].text = full;
|
||||
_glyph[i].text = Initials(full);
|
||||
_slotBox[i].style.unityBackgroundImageTintColor = ArchTint(arch);
|
||||
_slotBox[i].style.backgroundColor = SlotBg;
|
||||
}
|
||||
|
||||
static string Initials(string name)
|
||||
{
|
||||
var s = "";
|
||||
for (int i = 0; i < name.Length && s.Length < 2; i++)
|
||||
if (char.IsUpper(name[i])) s += name[i];
|
||||
if (s.Length == 0 && name.Length > 0) s = char.ToUpperInvariant(name[0]).ToString();
|
||||
return s;
|
||||
}
|
||||
|
||||
static Color ArchTint(byte archetype)
|
||||
{
|
||||
switch (archetype)
|
||||
{
|
||||
case (byte)AbilityArchetype.Aoe: return new Color(0.35f, 0.75f, 0.70f, 0.95f); // zones = teal
|
||||
case (byte)AbilityArchetype.Movement: return new Color(0.40f, 0.60f, 0.95f, 0.95f); // blink = cool blue
|
||||
case (byte)AbilityArchetype.Cone: return new Color(0.95f, 0.70f, 0.30f, 0.95f); // slam = lamp-amber
|
||||
case (byte)AbilityArchetype.Hitscan:
|
||||
case (byte)AbilityArchetype.Projectile: return new Color(0.85f, 0.80f, 0.60f, 0.95f); // skillshots = warm white
|
||||
default: return new Color(0.6f, 0.6f, 0.6f, 0.9f);
|
||||
}
|
||||
}
|
||||
|
||||
void BuildTree(VisualElement root)
|
||||
{
|
||||
root.style.position = Position.Absolute;
|
||||
root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0;
|
||||
root.pickingMode = PickingMode.Ignore;
|
||||
|
||||
var bar = HudUi.Group(Align.Center);
|
||||
bar.style.position = Position.Absolute;
|
||||
bar.style.bottom = 84; // clear of the build-palette row (24) + discovery chip (28)
|
||||
bar.style.left = 0; bar.style.right = 0;
|
||||
bar.style.flexDirection = FlexDirection.Row;
|
||||
bar.style.justifyContent = Justify.Center;
|
||||
root.Add(bar);
|
||||
|
||||
string[] keys = { "1·RMB", "2", "3", "4", "SHIFT" };
|
||||
for (int i = 0; i < SlotCount; i++)
|
||||
{
|
||||
var col = HudUi.Group(Align.Center);
|
||||
col.style.marginLeft = 5; col.style.marginRight = 5;
|
||||
|
||||
var box = HudUi.Panel(SlotBg);
|
||||
box.style.width = 52; box.style.height = 52;
|
||||
box.style.justifyContent = Justify.Center;
|
||||
box.style.alignItems = Align.Center;
|
||||
MenuUi.Border(box, IdleBorder, 1);
|
||||
_slotBox[i] = box;
|
||||
|
||||
var glyph = HudUi.Display(i == DashSlot ? "DA" : "—", 18, ReadyGlyphCol, TextAnchor.MiddleCenter);
|
||||
_glyph[i] = glyph;
|
||||
box.Add(glyph);
|
||||
|
||||
var overlay = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
overlay.style.position = Position.Absolute;
|
||||
overlay.style.left = 0; overlay.style.right = 0; overlay.style.bottom = 0;
|
||||
overlay.style.height = Length.Percent(0);
|
||||
overlay.style.backgroundColor = OverlayCol;
|
||||
box.Add(overlay);
|
||||
_cdOverlay[i] = overlay;
|
||||
|
||||
var count = HudUi.Display("", 14, new Color(1f, 1f, 1f, 0.95f), TextAnchor.MiddleCenter);
|
||||
count.style.position = Position.Absolute;
|
||||
count.style.left = 0; count.style.right = 0; count.style.top = 0; count.style.bottom = 0;
|
||||
box.Add(count);
|
||||
_countdown[i] = count;
|
||||
|
||||
col.Add(box);
|
||||
col.Add(HudUi.Text(keys[i], 10, MenuUi.Accent, TextAnchor.MiddleCenter));
|
||||
var name = HudUi.Text(i == DashSlot ? "Dash" : "", 9, MenuUi.SubCol, TextAnchor.MiddleCenter);
|
||||
_name[i] = name;
|
||||
col.Add(name);
|
||||
bar.Add(col);
|
||||
}
|
||||
|
||||
if (_glyph[DashSlot] != null)
|
||||
{
|
||||
_slotBox[DashSlot].style.unityBackgroundImageTintColor = new Color(0.45f, 0.85f, 1f, 0.95f);
|
||||
_slotBox[DashSlot].style.backgroundColor = SlotBg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d61b6256713fa5949b17cd75b7b66357
|
||||
@@ -67,6 +67,14 @@ namespace ProjectM.Client
|
||||
Mesh _smearMesh; MeshRenderer _smearMr; Material _smearMat; // 07-20 G2.3: blade-smear ribbon (leading-edge band at blade height)
|
||||
uint _pendingConnectTick; // 07-20 G2.1 (review C14): the local swing's CONTACT tick; connect cues fire THEN (0 = none)
|
||||
int _pendingConnectStep;
|
||||
uint _pendingConeConnectTick; // 07-21 G6 (C14 idiom): the cone socket's CONTACT tick, latched at the fire edge (0 = none)
|
||||
float _pendingConeRange; // cone reach/half latched at the fire edge (folded socket stats)
|
||||
float _pendingConeHalf;
|
||||
float _allyFxScale = 1f; // 07-21 G4: saturation-derived ally-FX degradation (1 = full loudness)
|
||||
#if UNITY_EDITOR
|
||||
float _nextStressTime; // 07-21 G4: fake-caster cadence while CombatStressDebug.StressAllyFx is on
|
||||
int _stressBeat;
|
||||
#endif
|
||||
double _lastHoldTime; // C4: last hit-stop hold time (throttle so a horde wipe doesn't stutter)
|
||||
|
||||
// Remote teammates' melee cleave arcs (deferred-items pass, co-op): one pooled slash renderer per remote
|
||||
@@ -106,10 +114,13 @@ namespace ProjectM.Client
|
||||
const int NumberPoolSize = 32;
|
||||
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
|
||||
|
||||
EntityQuery _remotePlayersQuery; // 07-21 G4: ally census (PlayerTag + disabled GhostOwnerIsLocal)
|
||||
struct TimedVfx { public GameObject Go; public double Kill; }
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
// 07-21 G4 (review wf_9757d214): remote-player census — solo (0 remotes) forces _allyFxScale = 1.
|
||||
_remotePlayersQuery = SystemAPI.QueryBuilder().WithAll<PlayerTag>().WithDisabled<GhostOwnerIsLocal>().Build();
|
||||
_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);
|
||||
@@ -232,7 +243,7 @@ namespace ProjectM.Client
|
||||
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
|
||||
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);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.HitShakeLocal : FeelConfig.HitShakeRemote * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation
|
||||
if (isLocalPlayer) PrototypeCameraRig.PunchFov(FeelConfig.HitStopFovKick, FeelConfig.HitStopDurationMs);
|
||||
if (isLocalPlayer && (prev.Hp - cur) >= 20f) TryHold(); // C4: crunch on a heavy incoming hit (e.g. a boss slam)
|
||||
|
||||
@@ -282,7 +293,15 @@ namespace ProjectM.Client
|
||||
{
|
||||
Burst(_deathFx, PlayerDeathPrefab(cfg), (Vector3)p + Vector3.up * 0.5f, FeelConfig.DeathBurstCount);
|
||||
PlayClip(_deathClip, (Vector3)p, 0.7f);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.PlayerDeathShake : FeelConfig.RemotePlayerDeathShake);
|
||||
PrototypeCameraRig.AddShake(isLocalPlayer ? FeelConfig.PlayerDeathShake : FeelConfig.RemotePlayerDeathShake * _allyFxScale); // 07-21 G4: ally-side shake degrades under saturation
|
||||
if (isLocalPlayer)
|
||||
{
|
||||
// Post-impl review wf_9757d214: the server dropped any scheduled damage on death
|
||||
// (PlayerDeathStateSystem zeroes both pendings) — drop the latched connect CUES too,
|
||||
// or the corpse plays a full connect package for a hit that never landed.
|
||||
_pendingConnectTick = 0u;
|
||||
_pendingConeConnectTick = 0u;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (isEnemy && _scanPrimed && cur >= health.ValueRO.Max - 0.001f)
|
||||
@@ -334,6 +353,34 @@ namespace ProjectM.Client
|
||||
}
|
||||
}
|
||||
|
||||
// 07-21 G4 (SoD's co-op saturation failure is our ceiling): ally-attributed FX degrade as the live
|
||||
// enemy count rises — remote arcs dim, remote shakes shrink. LOCAL-player FX are untouched and enemy
|
||||
// telegraphs NEVER degrade (EnemyDangerTelegraphSystem is structurally separate; guidelines G4).
|
||||
int liveEnemies = 0;
|
||||
foreach (var kv in _cache) if (kv.Value.IsEnemy && kv.Value.Hp > 0f) liveEnemies++; // LIVING only (review wf_9757d214: Dying corpses linger ~1s in the cache)
|
||||
_allyFxScale = _remotePlayersQuery.CalculateEntityCount() == 0
|
||||
? 1f // solo: no ally FX exist to budget — never degrade the local player's own feedback (review wf_9757d214)
|
||||
: SaturationMath.AllyScale(liveEnemies,
|
||||
FeelConfig.AllyFxDegradeStart, FeelConfig.AllyFxDegradeFull, FeelConfig.AllyFxFloor);
|
||||
#if UNITY_EDITOR
|
||||
// 07-21 G4: fake-caster saturation stress (DebugOverlay toggle) — synthesizes the ally attack package
|
||||
// at 3 orbiting positions so the 4-caster worst case is testable without 4 connections. Editor-only;
|
||||
// the flag resets on play-enter (CombatStressDebug, the static-presentation-bridge rule).
|
||||
if (CombatStressDebug.StressAllyFx && _localPlayer != Entity.Null && UnityEngine.Time.time >= _nextStressTime)
|
||||
{
|
||||
_nextStressTime = UnityEngine.Time.time + 0.5f;
|
||||
_stressBeat++;
|
||||
for (int fake = 0; fake < 3; fake++)
|
||||
{
|
||||
float ang = _stressBeat * 0.7f + fake * 2.094f;
|
||||
Vector3 fpos = (Vector3)localPos + new Vector3(Mathf.Cos(ang), 0f, Mathf.Sin(ang)) * 3.5f;
|
||||
EmitTinted(_swingFx, fpos + Vector3.up * 0.9f,
|
||||
(int)Mathf.Ceil(8f * _allyFxScale), FeelConfig.RemoteSlashColor * _allyFxScale);
|
||||
PlayClip(_swingClip, fpos, 0.3f * _allyFxScale);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// LANTERN 4-socket fire feedback: edge-detect each socket's SocketCooldown (raw uint edge, cosmetic
|
||||
// only like dash/melee). A non-Cone Spark -> muzzle flash + zap; a Cone Spark -> the aimed slash-arc
|
||||
// cue (server-only cleave has no projectile). Replaces the single-AbilityCooldown muzzle + cone blocks.
|
||||
@@ -379,28 +426,39 @@ namespace ProjectM.Client
|
||||
var es = effs[sk];
|
||||
float coneRange = Mathf.Max(0.1f, es.Range);
|
||||
float coneHalf = Mathf.Clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f);
|
||||
bool coneConnected = false; Vector3 coneHit = (Vector3)localPos; float cnd = float.MaxValue;
|
||||
float coneCos = Mathf.Cos(coneHalf);
|
||||
foreach (var kv in _cache)
|
||||
{
|
||||
if (!kv.Value.IsEnemy) continue;
|
||||
if (MeleeConeMath.InCone(localPos, sfdir, coneRange, coneCos, kv.Value.Pos))
|
||||
{
|
||||
float cd2 = math.distancesq(localPos, kv.Value.Pos);
|
||||
if (cd2 < cnd) { cnd = cd2; coneHit = (Vector3)kv.Value.Pos; coneConnected = true; }
|
||||
}
|
||||
}
|
||||
TriggerSlash((Vector3)localPos, sfdir, coneRange, coneHalf, 1, 1, coneConnected);
|
||||
// 07-21 G6 (review wf_98bf1268): the slam's damage now lands at fire+ConeContactTicks — the arc
|
||||
// reveal completes AT that contact (C13) and the connect package is LATCHED to it via the C14
|
||||
// idiom (knob 0 = legacy immediate). Defaults() fallback matches the release server's timing.
|
||||
var coneTcfg = SystemAPI.TryGetSingleton<TuningConfig>(out var coneTcv) ? coneTcv : TuningConfig.Defaults();
|
||||
uint coneContactTicks = (uint)math.max(0f, coneTcfg.ConeContactTicks);
|
||||
float coneLife = Mathf.Max(0.34f, (coneContactTicks / 60f) / 0.6f);
|
||||
TriggerSlash((Vector3)localPos, sfdir, coneRange, coneHalf, 1, 1, false, coneLife);
|
||||
PlayClip(_swingClip, (Vector3)localPos, 0.5f);
|
||||
PrototypeCameraRig.AddShake(0.06f);
|
||||
if (coneConnected)
|
||||
_pendingConeRange = coneRange;
|
||||
_pendingConeHalf = coneHalf;
|
||||
if (coneContactTicks == 0u)
|
||||
{
|
||||
Burst(_hitFx, cfg != null ? cfg.Hit : null, coneHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
|
||||
PlayClip(_meleeConnectClip, coneHit, FeelConfig.MeleeConnectVolume);
|
||||
PrototypeCameraRig.PunchFov(FeelConfig.MeleeConnectFovKick, FeelConfig.HitStopDurationMs);
|
||||
_pendingConeConnectTick = 0u;
|
||||
EvaluateConeConnect(localPos); // knob 0 = legacy same-tick connect
|
||||
}
|
||||
else
|
||||
{
|
||||
_pendingConeConnectTick = TickUtil.NonZero(
|
||||
TickWindowMath.FireStartRaw(nf, es.CooldownTicks) + coneContactTicks);
|
||||
}
|
||||
}
|
||||
_socketFireInit = true;
|
||||
|
||||
// 07-21 G6: fire the deferred cone CONNECT when the slam lands (contact tick reached; wrap-safe;
|
||||
// the C14 idiom — latched once at the fire edge, never reconstructed per-frame).
|
||||
if (_pendingConeConnectTick != 0u
|
||||
&& SystemAPI.TryGetSingleton<NetworkTime>(out var coneNt) && coneNt.ServerTick.IsValid
|
||||
&& !new NetworkTick(_pendingConeConnectTick).IsNewerThan(coneNt.ServerTick))
|
||||
{
|
||||
EvaluateConeConnect(localPos);
|
||||
_pendingConeConnectTick = 0u;
|
||||
}
|
||||
}
|
||||
|
||||
// Local-player dash feedback (MC-1): DashCooldown.NextTick advances exactly once per dash
|
||||
@@ -444,7 +502,8 @@ namespace ProjectM.Client
|
||||
EmitAt(_swingFx, (Vector3)localPos + Vector3.up * 0.9f + face * 0.8f, 6 + (step - 1) * 5);
|
||||
PlayClip(_swingClip, (Vector3)localPos, 0.45f);
|
||||
PrototypeCameraRig.AddShake(0.04f * step);
|
||||
int comboLen = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3) : 3;
|
||||
var tcfg = SystemAPI.TryGetSingleton<TuningConfig>(out var tcv2) ? tcv2 : TuningConfig.Defaults(); // review wf_98bf1268: release fallback = Defaults(), matching the server sim
|
||||
int comboLen = (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3);
|
||||
bool finisher = step >= comboLen;
|
||||
float slashRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.2f;
|
||||
float slashHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
||||
@@ -480,7 +539,8 @@ namespace ProjectM.Client
|
||||
&& SystemAPI.TryGetSingleton<NetworkTime>(out var meleeNt) && meleeNt.ServerTick.IsValid
|
||||
&& !new NetworkTick(_pendingConnectTick).IsNewerThan(meleeNt.ServerTick))
|
||||
{
|
||||
int cLen = SystemAPI.TryGetSingleton<TuningConfig>(out var ct2) ? (int)math.clamp((int)ct2.MeleeComboLength, 1, 3) : 3;
|
||||
var ct2 = SystemAPI.TryGetSingleton<TuningConfig>(out var ctv2) ? ctv2 : TuningConfig.Defaults(); // review wf_98bf1268: release fallback
|
||||
int cLen = (int)math.clamp((int)ct2.MeleeComboLength, 1, 3);
|
||||
EvaluateMeleeConnect(localPos, _pendingConnectStep, cLen);
|
||||
_pendingConnectTick = 0u;
|
||||
}
|
||||
@@ -888,12 +948,11 @@ namespace ProjectM.Client
|
||||
var cfg = VFXConfig.Instance;
|
||||
bool finisher = step >= comboLen;
|
||||
float range = 2.2f, half = 0.9f, finRange = 1.25f;
|
||||
if (SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg))
|
||||
{
|
||||
if (tcfg.MeleeRange > 0f) range = tcfg.MeleeRange;
|
||||
if (tcfg.MeleeConeHalfAngleRad > 0f) half = tcfg.MeleeConeHalfAngleRad;
|
||||
if (tcfg.MeleeFinisherRangeMult > 0f) finRange = tcfg.MeleeFinisherRangeMult;
|
||||
}
|
||||
// Review wf_98bf1268: Defaults() fallback — release clients must match the release server's timing.
|
||||
var tcfg = SystemAPI.TryGetSingleton<TuningConfig>(out var mcv) ? mcv : TuningConfig.Defaults();
|
||||
if (tcfg.MeleeRange > 0f) range = tcfg.MeleeRange;
|
||||
if (tcfg.MeleeConeHalfAngleRad > 0f) half = tcfg.MeleeConeHalfAngleRad;
|
||||
if (tcfg.MeleeFinisherRangeMult > 0f) finRange = tcfg.MeleeFinisherRangeMult;
|
||||
if (EntityManager.HasBuffer<StatModifier>(_localPlayer))
|
||||
range = math.max(0f, StatMath.Apply(range, StatTarget.MeleeRange,
|
||||
EntityManager.GetBuffer<StatModifier>(_localPlayer, true)));
|
||||
@@ -903,17 +962,7 @@ namespace ProjectM.Client
|
||||
fdir = FacingMath.ResolveAim(
|
||||
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
|
||||
EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction);
|
||||
bool connected = false; Vector3 nearestHit = (Vector3)localPos; float ndist = float.MaxValue;
|
||||
float cosHalf = Mathf.Cos(half);
|
||||
foreach (var kv in _cache)
|
||||
{
|
||||
if (!kv.Value.IsEnemy) continue;
|
||||
if (MeleeConeMath.InCone(localPos, fdir, range, cosHalf, kv.Value.Pos))
|
||||
{
|
||||
float d2 = math.distancesq(localPos, kv.Value.Pos);
|
||||
if (d2 < ndist) { ndist = d2; nearestHit = (Vector3)kv.Value.Pos; connected = true; }
|
||||
}
|
||||
}
|
||||
bool connected = NearestEnemyInCone(localPos, fdir, range, Mathf.Cos(half), out var nearestHit); // review wf_98bf1268: shared scan
|
||||
if (connected)
|
||||
{
|
||||
Burst(_hitFx, cfg != null ? cfg.Hit : null, nearestHit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
|
||||
@@ -930,6 +979,42 @@ namespace ProjectM.Client
|
||||
}
|
||||
}
|
||||
|
||||
// 07-21 G6 (review wf_98bf1268): ONE nearest-living-enemy-in-cone scan over the FX cache — shared by the
|
||||
// socket-fire cue, the melee connect package and the deferred cone connect (three copies would drift).
|
||||
bool NearestEnemyInCone(float3 pos, float2 dir, float range, float cosHalf, out Vector3 hit)
|
||||
{
|
||||
hit = (Vector3)pos; float best = float.MaxValue; bool found = false;
|
||||
foreach (var kv in _cache)
|
||||
{
|
||||
if (!kv.Value.IsEnemy) continue;
|
||||
if (!MeleeConeMath.InCone(pos, dir, range, cosHalf, kv.Value.Pos)) continue;
|
||||
float d2 = math.distancesq(pos, kv.Value.Pos);
|
||||
if (d2 < best) { best = d2; hit = (Vector3)kv.Value.Pos; found = true; }
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// 07-21 G6: the cone socket's CONNECT package at the CONTACT tick — the EvaluateMeleeConnect mirror for
|
||||
// the SpecialSlam (aim recomputed LIVE at contact; range/half latched at the fire edge from the socket's
|
||||
// folded stats). Fired by the deferred check next to the socket-fire branch (C14 idiom).
|
||||
void EvaluateConeConnect(float3 localPos)
|
||||
{
|
||||
if (_localPlayer == Entity.Null || !EntityManager.Exists(_localPlayer)) return;
|
||||
var cfg = VFXConfig.Instance;
|
||||
float2 fdir = new float2(0f, 1f);
|
||||
if (EntityManager.HasComponent<PlayerFacing>(_localPlayer) && EntityManager.HasComponent<PlayerInput>(_localPlayer))
|
||||
fdir = FacingMath.ResolveAim(
|
||||
EntityManager.GetComponentData<PlayerInput>(_localPlayer).Aim,
|
||||
EntityManager.GetComponentData<PlayerFacing>(_localPlayer).Direction);
|
||||
if (!NearestEnemyInCone(localPos, fdir, _pendingConeRange, Mathf.Cos(_pendingConeHalf), out var hit)) return;
|
||||
Burst(_hitFx, cfg != null ? cfg.Hit : null, hit + Vector3.up * 0.7f, FeelConfig.HitBurstCount);
|
||||
PlayClip(_meleeConnectClip, hit, 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 (_slashActive) _slashTint *= 1.4f; // the bite brighten, AT the slam's landing
|
||||
}
|
||||
|
||||
|
||||
void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int step, int comboLen, bool connected, float lifeOverride = 0f)
|
||||
{
|
||||
@@ -997,7 +1082,10 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
|
||||
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;
|
||||
// Review wf_98bf1268: Defaults() fallback — release clients must match the release server's timing
|
||||
// (the dev TuningConfig singleton is editor-only; default(TuningConfig) reads knob 0 = legacy cues).
|
||||
var tcfg = SystemAPI.TryGetSingleton<TuningConfig>(out var rtcv) ? rtcv : TuningConfig.Defaults();
|
||||
int comboLen = (int)math.clamp((int)tcfg.MeleeComboLength, 1, 3);
|
||||
float baseRange = tcfg.MeleeRange > 0f ? tcfg.MeleeRange : 2.6f;
|
||||
float baseHalf = tcfg.MeleeConeHalfAngleRad > 0f ? tcfg.MeleeConeHalfAngleRad : 0.9f;
|
||||
float finisherMult = tcfg.MeleeFinisherRangeMult > 0f ? tcfg.MeleeFinisherRangeMult : 1.25f; // 07-20 G2.2: REACH-only finisher mult
|
||||
@@ -1023,7 +1111,7 @@ void TriggerSlash(Vector3 pos, float2 facing, float range, float halfAngle, int
|
||||
rs.Range = finisher ? rRange * finisherMult : rRange;
|
||||
rs.Half = baseHalf;
|
||||
rs.SweepSign = (step % 2 == 0) ? -1 : 1;
|
||||
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f);
|
||||
rs.Tint = FeelConfig.RemoteSlashColor * (finisher ? 1.5f : 1f) * _allyFxScale; // 07-21 G4: ally FX dim under saturation (brightness only — Life stays contact-honest, C13)
|
||||
rs.Life = Mathf.Max(finisher ? 0.50f : 0.34f,
|
||||
(MeleeTiming.ContactTicks((byte)step, remoteContactKnob) / 60f) / 0.6f); // 07-20: reveal ends AT contact (knob-aware, review C13)
|
||||
rs.Age = 0f;
|
||||
|
||||
@@ -204,5 +204,32 @@ namespace ProjectM.Client
|
||||
m.RecalculateBounds();
|
||||
return m;
|
||||
}
|
||||
|
||||
// Unit-radius thin RING (annulus) on the XZ plane — the always-on rim of a zone telegraph (rim = the TRUE
|
||||
// damage radius, guidelines G2) while a separate fill disc grows inside it. Scale x/z to the radius; inner
|
||||
// edge fixed at innerFrac of the outer. No vertex colours (white) so an MPB _Color fully tints + fades it.
|
||||
public static Mesh BuildRing(int segments, float innerFrac = 0.92f)
|
||||
{
|
||||
if (segments < 6) segments = 6;
|
||||
innerFrac = Mathf.Clamp(innerFrac, 0.05f, 0.98f);
|
||||
var m = new Mesh { name = "Ring" };
|
||||
var v = new Vector3[segments * 2];
|
||||
var tris = new int[segments * 6];
|
||||
for (int i = 0; i < segments; i++)
|
||||
{
|
||||
float a = i / (float)segments * Mathf.PI * 2f;
|
||||
var dir = new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a));
|
||||
v[i * 2] = dir * innerFrac;
|
||||
v[i * 2 + 1] = dir;
|
||||
int n = (i + 1) % segments;
|
||||
int b = i * 6;
|
||||
tris[b] = i * 2; tris[b + 1] = n * 2 + 1; tris[b + 2] = i * 2 + 1;
|
||||
tris[b + 3] = i * 2; tris[b + 4] = n * 2; tris[b + 5] = n * 2 + 1;
|
||||
}
|
||||
m.vertices = v;
|
||||
m.triangles = tris;
|
||||
m.RecalculateBounds();
|
||||
return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,14 @@ namespace ProjectM.Client
|
||||
/// <summary>Marker glyph font size (px).</summary>
|
||||
public static float EnemyMarkerSize;
|
||||
|
||||
// ---- 07-21 G4: co-op saturation budget (guidelines G4 — SoD's failure is our ceiling) ----
|
||||
/// <summary>Live enemy count at which ALLY-attributed FX (remote arcs, remote shakes) begin to degrade. Local-player FX and enemy telegraphs NEVER degrade.</summary>
|
||||
public static int AllyFxDegradeStart;
|
||||
/// <summary>Live enemy count at which ally FX sit at AllyFxFloor.</summary>
|
||||
public static int AllyFxDegradeFull;
|
||||
/// <summary>Ally-FX brightness/shake floor under full saturation (0..1).</summary>
|
||||
public static float AllyFxFloor;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
public static void ResetDefaults()
|
||||
{
|
||||
@@ -328,6 +336,10 @@ namespace ProjectM.Client
|
||||
EnemyMarkerMinAlpha = 0.28f;
|
||||
EnemyMarkerColor = new Color(1f, 0.86f, 0.3f, 1f); // warm amber, reads over the cool world
|
||||
EnemyMarkerSize = 24f;
|
||||
// 07-21 G4 co-op saturation budget (review wf_98bf1268)
|
||||
AllyFxDegradeStart = 8;
|
||||
AllyFxDegradeFull = 16;
|
||||
AllyFxFloor = 0.35f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ namespace ProjectM.Client
|
||||
bool _themed; // HudTheme + PanelBox present (drives sprite-tint vs flat-colour retint)
|
||||
|
||||
// vitals
|
||||
VisualElement _healthFill, _cooldownFill, _shieldRow, _cdRow;
|
||||
VisualElement _healthFill, _shieldRow;
|
||||
Label _healthText;
|
||||
|
||||
// threat
|
||||
@@ -327,7 +327,7 @@ namespace ProjectM.Client
|
||||
|
||||
// ---- Per-player vitals ----
|
||||
bool found = false;
|
||||
float hp = 0f, maxHp = 1f, cdFrac = 1f;
|
||||
float hp = 0f, maxHp = 1f;
|
||||
bool dead = false, shielded = false;
|
||||
|
||||
foreach (var (health, effChar, cd, invuln, entity) in
|
||||
@@ -340,19 +340,7 @@ namespace ProjectM.Client
|
||||
maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max;
|
||||
dead = SystemAPI.IsComponentEnabled<Dead>(entity);
|
||||
|
||||
// Cooldown bar = socket 0 (the primary Spark) of the 4-socket kit (the legacy single
|
||||
// AbilityCooldown died — LANTERN purge). EffectiveSocketStats row 0 supplies the duration.
|
||||
uint nextFire = cd.ValueRO.Get(0);
|
||||
int cdTicks = 0;
|
||||
if (SystemAPI.HasBuffer<EffectiveSocketStats>(entity))
|
||||
{
|
||||
var effSockets = SystemAPI.GetBuffer<EffectiveSocketStats>(entity);
|
||||
if (effSockets.Length > 0) cdTicks = effSockets[0].CooldownTicks;
|
||||
}
|
||||
var nextTick = new NetworkTick(nextFire);
|
||||
cdFrac = (haveTick && nextFire != 0 && cdTicks > 0 && nextTick.IsValid && nextTick.IsNewerThan(nt.ServerTick))
|
||||
? Mathf.Clamp01(1f - nextTick.TicksSince(nt.ServerTick) / (float)cdTicks)
|
||||
: 1f;
|
||||
// (07-21 UI rework: socket/dash cooldown readouts moved to AbilityBarSystem.)
|
||||
|
||||
uint invulnUntil = invuln.ValueRO.UntilTick;
|
||||
var invulnTick = new NetworkTick(invulnUntil);
|
||||
@@ -381,9 +369,6 @@ namespace ProjectM.Client
|
||||
_healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp);
|
||||
_shieldRow.style.display = shielded ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
|
||||
HudUi.SetFill(_cooldownFill, cdFrac);
|
||||
// A READY weapon (full bar) recedes; a CHARGING one is bright — so the inverted-vs-health polarity reads.
|
||||
if (_cdRow != null) _cdRow.style.opacity = cdFrac >= 1f ? 0.4f : 1f;
|
||||
if (dead)
|
||||
{
|
||||
// Client-local countdown: latch the death edge; the baked (non-replicated) DelayTicks is the
|
||||
@@ -649,19 +634,8 @@ namespace ProjectM.Client
|
||||
_shieldRow.style.display = DisplayStyle.None;
|
||||
panel.Add(_shieldRow);
|
||||
|
||||
// cooldown row: weapon icon + thin bar
|
||||
_cdRow = new VisualElement();
|
||||
_cdRow.style.flexDirection = FlexDirection.Row;
|
||||
_cdRow.style.alignItems = Align.Center;
|
||||
_cdRow.style.marginBottom = 6;
|
||||
_cdRow.pickingMode = PickingMode.Ignore;
|
||||
var cdIcon = HudUi.Icon(theme != null ? theme.CooldownIcon : null, 22, AetherCyan);
|
||||
cdIcon.style.marginRight = 8;
|
||||
_cdRow.Add(cdIcon);
|
||||
var cdBar = HudUi.Bar(420, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill);
|
||||
_cdRow.Add(cdBar);
|
||||
panel.Add(_cdRow);
|
||||
|
||||
// 07-21 UI rework: the single socket-0 charge strip is gone — AbilityBarSystem (bottom-center)
|
||||
// now shows ALL socket cooldowns + dash.
|
||||
// health row: health icon + big bar with numeric overlay
|
||||
var hpRow = new VisualElement();
|
||||
hpRow.style.flexDirection = FlexDirection.Row;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>07-21 G4 (review wf_98bf1268) — pure co-op saturation-budget math (no ECS; unit-tested like
|
||||
/// <c>HudVisualMath</c>, the Client-side presentation-math precedent). Maps a live on-screen enemy count to
|
||||
/// the ALLY-FX degradation scale: 1 at <= start, linear down to <paramref name="floor"/> at >= full.
|
||||
/// Enemy telegraphs never ride this (guidelines G4: they never degrade); local-player FX never ride this.
|
||||
/// Note: <c>EnemyMarkerSystem</c>'s pip fade equals <c>AllyScale(count, start, 2*start, floor)</c> — a later
|
||||
/// pass can retrofit it onto this helper.</summary>
|
||||
public static class SaturationMath
|
||||
{
|
||||
/// <summary>1 at enemies <= start; linear to <paramref name="floor"/> at enemies >= full; clamped.
|
||||
/// Degenerate inputs (full <= start) snap straight to floor once past start.</summary>
|
||||
public static float AllyScale(int enemies, int start, int full, float floor)
|
||||
{
|
||||
floor = math.clamp(floor, 0f, 1f);
|
||||
if (enemies <= start) return 1f;
|
||||
if (full <= start) return floor;
|
||||
float t = math.saturate((enemies - start) / (float)(full - start));
|
||||
return math.lerp(1f, floor, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93f34c59962a12243aec9840a608e2da
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Collections.Generic;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
using UnityEngine;
|
||||
using static ProjectM.Client.FeedbackFx;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// 07-21 G6 (review wf_98bf1268) — client-only ZONE-socket fill telegraph (Vortex / LightZone), the
|
||||
/// WildStar-grade honesty decal for player zones: a thin RIM always drawn at the replicated
|
||||
/// <see cref="ZoneEffect.Radius"/> (rim = the TRUE folded damage radius, guidelines G2) plus an inner FILL
|
||||
/// disc whose arrival at the rim IS the damage moment — fill derives from the replicated absolute
|
||||
/// <see cref="ZoneEffect.NextTick"/> over <see cref="ZoneEffect.PulsePeriodTicks"/>; each server re-stamp
|
||||
/// naturally resets it (the persistent-zone fill encoding, guidelines G6/R6). Observe-only
|
||||
/// <see cref="SystemBase"/> in <see cref="PresentationSystemGroup"/>, templated on
|
||||
/// <see cref="GeyserTelegraphSystem"/> (shared <see cref="FeedbackFx.BuildDisc"/>/<see cref="FeedbackFx.BuildRing"/>
|
||||
/// unit meshes + MPB alpha + pooled GOs + per-frame silent prune; the value-latch + was-counting-down
|
||||
/// arm-guard pulse contract, review H1 — never edge-detect the re-stamp).
|
||||
/// <para>
|
||||
/// DELIBERATE divergence from the Geyser precedent: ticks are evaluated against
|
||||
/// <c>NetworkTime.InterpolationTick</c>, NOT the predicted ServerTick — a geyser threatens the PREDICTED
|
||||
/// local player, but a zone's observables (enemy HP drops, vortex pull) live on the INTERPOLATED timeline
|
||||
/// the zone ghost itself renders on; the predicted tick would complete the fill ~RTT early and pin it at
|
||||
/// full (invisible on loopback, 20-40% of the bar wrong at internet RTTs — review finding, confirmed).
|
||||
/// Ownership tint per guidelines G3: local caster = warm (LightZone) / teal (Vortex); ally = dimmer
|
||||
/// cool-blue of the same shapes; never red. Enemy telegraphs live elsewhere and NEVER degrade (G4).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(PresentationSystemGroup))]
|
||||
public partial class ZoneTelegraphSystem : SystemBase
|
||||
{
|
||||
static readonly Color LocalLightColor = new Color(2.2f, 1.6f, 0.7f); // warm lamp-amber — light is territory
|
||||
static readonly Color LocalVortexColor = new Color(0.5f, 1.9f, 1.8f); // teal swirl
|
||||
static readonly Color AllyColor = new Color(0.35f, 0.55f, 1.1f); // dim cool-blue (same shapes, G3)
|
||||
static readonly int ColorId = Shader.PropertyToID("_Color");
|
||||
const float k_PulseFlashSeconds = 0.14f;
|
||||
|
||||
Transform _fxRoot;
|
||||
Material _mat;
|
||||
Mesh _discMesh;
|
||||
Mesh _ringMesh;
|
||||
MaterialPropertyBlock _mpb;
|
||||
|
||||
// Per-zone pooled pair: [0] = fill disc, [1] = rim ring (children of one root GO).
|
||||
readonly Dictionary<Entity, GameObject> _zones = new();
|
||||
readonly Dictionary<Entity, uint> _armed = new(); // NextTick seen counting down (arm-guard)
|
||||
readonly Dictionary<Entity, uint> _lastFired = new(); // NextTick we last pulsed for (value latch)
|
||||
readonly Dictionary<Entity, float> _flashUntil = new();
|
||||
readonly HashSet<Entity> _seen = new();
|
||||
readonly List<Entity> _stale = new();
|
||||
|
||||
protected override void OnCreate()
|
||||
{
|
||||
_mpb = new MaterialPropertyBlock();
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
{
|
||||
if (_fxRoot != null) return;
|
||||
_fxRoot = new GameObject("~ZoneTelegraphFX").transform;
|
||||
_mat = MakeParticleMaterial("ZoneTelegraph");
|
||||
_discMesh = BuildDisc(40);
|
||||
_ringMesh = BuildRing(48);
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_fxRoot != null) Object.Destroy(_fxRoot.gameObject);
|
||||
if (_mat != null) Object.Destroy(_mat);
|
||||
if (_discMesh != null) Object.Destroy(_discMesh);
|
||||
if (_ringMesh != null) Object.Destroy(_ringMesh);
|
||||
}
|
||||
|
||||
protected override void OnUpdate()
|
||||
{
|
||||
if (_fxRoot == null || _mat == null) return;
|
||||
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var nt)) return;
|
||||
// The interpolated timeline — see the class doc for why NOT the predicted ServerTick.
|
||||
var tick = nt.InterpolationTick.IsValid ? nt.InterpolationTick : nt.ServerTick;
|
||||
if (!tick.IsValid) return;
|
||||
int localNetId = SystemAPI.TryGetSingleton<NetworkId>(out var nid) ? nid.Value : -1;
|
||||
|
||||
EntityManager.CompleteDependencyBeforeRO<ZoneEffect>();
|
||||
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
||||
|
||||
_seen.Clear();
|
||||
foreach (var (zone, xf, e) in
|
||||
SystemAPI.Query<RefRO<ZoneEffect>, RefRO<LocalTransform>>().WithEntityAccess())
|
||||
{
|
||||
var ze = zone.ValueRO;
|
||||
_seen.Add(e);
|
||||
if (ze.Radius <= 0.01f) continue; // pre-first-snapshot (baked zero) — nothing honest to draw yet
|
||||
|
||||
float3 pos = xf.ValueRO.Position;
|
||||
bool mine = ze.CasterNetworkId == localNetId;
|
||||
bool vortex = (ze.Flags & ZoneEffectFlag.Vortex) != 0;
|
||||
Color baseCol = mine ? (vortex ? LocalVortexColor : LocalLightColor) : AllyColor;
|
||||
|
||||
// Fill from the replicated ABSOLUTE next-pulse tick (0 = unscheduled -> rim only).
|
||||
float fill = 0f;
|
||||
uint next = ze.NextTick;
|
||||
if (next != 0u && new NetworkTick(next).IsValid)
|
||||
{
|
||||
int lead = new NetworkTick(next).TicksSince(tick); // >0 counting down, <=0 arrived/past
|
||||
if (lead > 0)
|
||||
{
|
||||
_armed[e] = next; // arm this pulse while it counts down
|
||||
// Operator report 07-21 ("casts seem to auto-recast"): a fill RE-GROWING every pulse reads
|
||||
// as a fresh cast. ARM phase (before the first pulse) grows 0->1 (fill reaches the rim =
|
||||
// first damage); the PERSISTENT phase DRAINS 1->0 toward each pulse (a metronome, not a
|
||||
// cast) - and the direction split is the G6/R6 one-shot-vs-persistent encoding, done right.
|
||||
float frac = math.saturate(lead / (float)ZoneEffect.PulsePeriodTicks);
|
||||
fill = _lastFired.ContainsKey(e) ? frac : 1f - frac;
|
||||
}
|
||||
else
|
||||
{
|
||||
fill = 1f;
|
||||
bool armedForThis = _armed.TryGetValue(e, out var av) && av == next;
|
||||
bool alreadyFired = _lastFired.TryGetValue(e, out var lf) && lf == next;
|
||||
if (armedForThis && !alreadyFired)
|
||||
{
|
||||
_flashUntil[e] = UnityEngine.Time.time + k_PulseFlashSeconds; // quiet tier: a rim flash, no burst (G4)
|
||||
_lastFired[e] = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!_zones.TryGetValue(e, out var go) || go == null)
|
||||
{
|
||||
go = new GameObject("ZoneTelegraph");
|
||||
go.transform.SetParent(_fxRoot, false);
|
||||
MakeChild(go.transform, "Fill", _discMesh);
|
||||
MakeChild(go.transform, "Rim", _ringMesh);
|
||||
_zones[e] = go;
|
||||
}
|
||||
if (!go.activeSelf) go.SetActive(true);
|
||||
go.transform.position = new Vector3(pos.x, 0.05f, pos.z);
|
||||
|
||||
bool flashing = _flashUntil.TryGetValue(e, out var fu) && UnityEngine.Time.time < fu;
|
||||
var fillTr = go.transform.GetChild(0);
|
||||
var rimTr = go.transform.GetChild(1);
|
||||
float fillRadius = ze.Radius * fill;
|
||||
fillTr.localScale = new Vector3(fillRadius, 1f, fillRadius);
|
||||
rimTr.localScale = new Vector3(ze.Radius, 1f, ze.Radius);
|
||||
|
||||
float fillAlpha = (mine ? 0.16f : 0.10f) * (0.35f + 0.65f * fill);
|
||||
float rimAlpha = (mine ? 0.55f : 0.35f) + (flashing ? 0.4f : 0f);
|
||||
SetTint(fillTr, baseCol, fillAlpha);
|
||||
SetTint(rimTr, baseCol, rimAlpha);
|
||||
}
|
||||
|
||||
// Prune despawned zones (expiry / teardown / relevancy drop) — destroy, drop tracking, emit nothing.
|
||||
if (_zones.Count > 0)
|
||||
{
|
||||
_stale.Clear();
|
||||
foreach (var kv in _zones) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||
for (int i = 0; i < _stale.Count; i++)
|
||||
{
|
||||
if (_zones[_stale[i]] != null) Object.Destroy(_zones[_stale[i]]);
|
||||
_zones.Remove(_stale[i]);
|
||||
}
|
||||
}
|
||||
PruneMap(_armed);
|
||||
PruneMap(_lastFired);
|
||||
PruneFloatMap(_flashUntil);
|
||||
}
|
||||
|
||||
void MakeChild(Transform parent, string name, Mesh mesh)
|
||||
{
|
||||
var child = new GameObject(name);
|
||||
child.transform.SetParent(parent, false);
|
||||
child.AddComponent<MeshFilter>().sharedMesh = mesh;
|
||||
var mr = child.AddComponent<MeshRenderer>();
|
||||
mr.sharedMaterial = _mat;
|
||||
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
|
||||
mr.receiveShadows = false;
|
||||
mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
|
||||
}
|
||||
|
||||
void SetTint(Transform tr, Color c, float alpha)
|
||||
{
|
||||
_mpb.SetColor(ColorId, new Color(c.r, c.g, c.b, alpha));
|
||||
tr.GetComponent<MeshRenderer>().SetPropertyBlock(_mpb);
|
||||
}
|
||||
|
||||
void PruneMap(Dictionary<Entity, uint> dict)
|
||||
{
|
||||
if (dict.Count == 0) return;
|
||||
_stale.Clear();
|
||||
foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||
for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]);
|
||||
}
|
||||
|
||||
void PruneFloatMap(Dictionary<Entity, float> dict)
|
||||
{
|
||||
if (dict.Count == 0) return;
|
||||
_stale.Clear();
|
||||
foreach (var kv in dict) if (!_seen.Contains(kv.Key)) _stale.Add(kv.Key);
|
||||
for (int i = 0; i < _stale.Count; i++) dict.Remove(_stale[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7546a7bb0f6fd634287c3d5b98faa28c
|
||||
@@ -93,8 +93,14 @@ namespace ProjectM.EditorTools
|
||||
}
|
||||
else sb.AppendLine(" Reach: weapon mesh not found (run Attach Melee Weapon first)");
|
||||
|
||||
sb.AppendLine(" NOTE: SpecialSlam (cone socket) still damages AT FIRE, ~0.35s before its visual contact — the");
|
||||
sb.AppendLine(" same dishonesty melee just fixed; it rides the SOCKET pipeline (guidelines G6 follow-up).");
|
||||
// 07-21 G6 (review wf_98bf1268): the cone socket (SpecialSlam) lands at ConeContactTicks via
|
||||
// ConeContactPending (0 = legacy at-fire). Visual contact estimate ~0.35s (21t) into the slam anim.
|
||||
uint coneContact = (uint)Mathf.Max(0f, t.ConeContactTicks);
|
||||
sb.AppendLine(coneContact == 0
|
||||
? " Cone (SpecialSlam): knob 32 = 0 -> LEGACY at-fire damage (~0.35s before the visual contact)"
|
||||
: $" Cone (SpecialSlam): contact {coneContact}t ({coneContact / 60f:F2}s after fire; visual estimate ~21t)");
|
||||
if (coneContact > PlayerAimSystem.CastFacingTicks)
|
||||
sb.AppendLine($" !! cone contact {coneContact}t > CastFacingTicks {PlayerAimSystem.CastFacingTicks} (a resting gamepad stick resolves a MOVE-facing slam)");
|
||||
Debug.Log(sb.ToString());
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ namespace ProjectM.Server
|
||||
sockets.Add(new AbilitySocket { SparkId = f3 });
|
||||
if (SystemAPI.HasComponent<SocketCooldown>(player))
|
||||
SystemAPI.SetComponent(player, default(SocketCooldown)); // 0 = ready: the swapped kit fires now
|
||||
if (SystemAPI.HasComponent<ConeContactPending>(player))
|
||||
SystemAPI.SetComponent(player, default(ConeContactPending)); // 07-21 G6: socket identity changed — drop any armed slam (parity with the dev SetClass op)
|
||||
if (haveDb && SystemAPI.HasComponent<Health>(player) && SystemAPI.HasComponent<CharacterStatsRef>(player))
|
||||
{
|
||||
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(player).Id;
|
||||
|
||||
@@ -26,7 +26,6 @@ namespace ProjectM.Server
|
||||
[UpdateBefore(typeof(EnemyAISystem))]
|
||||
public partial struct ZonePulseSystem : ISystem
|
||||
{
|
||||
const uint k_PulsePeriodTicks = 30u; // damage cadence (~0.5s at 60 ticks/sec)
|
||||
const float k_VortexPullSpeed = 4f; // gentle inward drift for a Vortex (world units/sec)
|
||||
const float k_VortexDeadzoneSq = 0.25f; // don't re-aim (or NaN) an enemy already at the zone centre
|
||||
|
||||
@@ -49,7 +48,7 @@ namespace ProjectM.Server
|
||||
if (!serverTick.IsValid) return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
uint stamp = TickUtil.NonZero(now);
|
||||
uint reschedule = TickUtil.NonZero(now + k_PulsePeriodTicks);
|
||||
uint reschedule = TickUtil.NonZero(now + ZoneEffect.PulsePeriodTicks);
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
m_BossLookup.Update(ref state);
|
||||
|
||||
|
||||
@@ -173,6 +173,8 @@ namespace ProjectM.Server
|
||||
swSockets.Add(new AbilitySocket { SparkId = sf3 });
|
||||
if (SystemAPI.HasComponent<SocketCooldown>(sender))
|
||||
SystemAPI.SetComponent(sender, default(SocketCooldown));
|
||||
if (SystemAPI.HasComponent<ConeContactPending>(sender))
|
||||
SystemAPI.SetComponent(sender, default(ConeContactPending)); // 07-21 G6: socket identity changed — drop any armed slam
|
||||
if (SystemAPI.HasComponent<Health>(sender) && SystemAPI.HasComponent<CharacterStatsRef>(sender)
|
||||
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb2))
|
||||
{
|
||||
|
||||
@@ -51,6 +51,8 @@ namespace ProjectM.Simulation
|
||||
ComponentLookup<ZoneEffect> m_ZoneLookup;
|
||||
ComponentLookup<DecoyTag> m_DecoyLookup;
|
||||
ComponentLookup<LocalTransform> m_LtLookup;
|
||||
// 07-21 G6: server-only scheduled Cone cleave (the MeleeCleavePending idiom on the socket kit).
|
||||
ComponentLookup<ConeContactPending> m_ConePendingLookup;
|
||||
|
||||
/// <summary>~9 degree gap between adjacent Split-Shot projectiles (tunable).</summary>
|
||||
const float k_ForkSpreadRad = 0.157f;
|
||||
@@ -71,6 +73,7 @@ namespace ProjectM.Simulation
|
||||
m_ZoneLookup = state.GetComponentLookup<ZoneEffect>(isReadOnly: true);
|
||||
m_DecoyLookup = state.GetComponentLookup<DecoyTag>(isReadOnly: true);
|
||||
m_LtLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
|
||||
m_ConePendingLookup = state.GetComponentLookup<ConeContactPending>(isReadOnly: false);
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -89,6 +92,9 @@ namespace ProjectM.Simulation
|
||||
ref var adb = ref abilityDb.Value.Value;
|
||||
|
||||
bool isServer = state.WorldUnmanaged.IsServer();
|
||||
// 07-21 G6: cone contact knob (0 = legacy immediate). Defaults() fallback matches release servers.
|
||||
var tcfg = SystemAPI.TryGetSingleton<TuningConfig>(out var tcv) ? tcv : TuningConfig.Defaults();
|
||||
uint coneContact = (uint)math.max(0f, tcfg.ConeContactTicks);
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
m_BossLookup.Update(ref state);
|
||||
m_BoonEffectsLookup.Update(ref state);
|
||||
@@ -98,6 +104,7 @@ namespace ProjectM.Simulation
|
||||
m_ZoneLookup.Update(ref state);
|
||||
m_DecoyLookup.Update(ref state);
|
||||
m_LtLookup.Update(ref state);
|
||||
m_ConePendingLookup.Update(ref state);
|
||||
|
||||
// Server-only LIVING-enemy target set (auto-target assist + Cone cleave), collected once.
|
||||
var candidatePositions = new NativeList<float3>(Allocator.Temp);
|
||||
@@ -131,6 +138,28 @@ namespace ProjectM.Simulation
|
||||
BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default;
|
||||
bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0;
|
||||
|
||||
// 07-21 G6 (review wf_98bf1268): fire a DUE scheduled cone BEFORE the cast loop (the
|
||||
// MeleeCleavePending idiom — wrap-safe elapsed compare, tick-batch-proof, consumed by zeroing).
|
||||
// Server-only state; the client copy stays zero (contact cues are presentation-side). The armed
|
||||
// socket is RE-VALIDATED (SetClass can swap the loadout mid-flight) — consume-drop on mismatch.
|
||||
bool hasConePending = m_ConePendingLookup.HasComponent(entity);
|
||||
if (isServer && hasConePending)
|
||||
{
|
||||
var pend = m_ConePendingLookup[entity];
|
||||
if (pend.ResolveTick != 0u && !new NetworkTick(pend.ResolveTick).IsNewerThan(serverTick))
|
||||
{
|
||||
if (pend.Socket < sockets.Length && pend.Socket < effSockets.Length
|
||||
&& adb.TryGetAbility(sockets[pend.Socket].SparkId, out var pendDef)
|
||||
&& pendDef.Archetype == (byte)AbilityArchetype.Cone)
|
||||
{
|
||||
float2 pFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
|
||||
FireCone(xform.ValueRO.Position, pFace, effSockets[pend.Socket], owner.ValueRO.NetworkId,
|
||||
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
|
||||
}
|
||||
m_ConePendingLookup[entity] = default; // consume (drop on mismatch)
|
||||
}
|
||||
}
|
||||
|
||||
// Replicated command buffer (windup resolve + per-socket fire count for the SpawnId + scheme for aim assist).
|
||||
var inputBuffer = SystemAPI.GetBuffer<InputBufferData<PlayerInput>>(entity);
|
||||
|
||||
@@ -156,7 +185,17 @@ namespace ProjectM.Simulation
|
||||
resolveTick = new NetworkTick(riNow - (uint)adef.WindupTicks);
|
||||
}
|
||||
if (!inputBuffer.GetDataAtTick(resolveTick, out var applied)) continue; // history gap -> no-fire
|
||||
if (!applied.InternalInput.GetSocket(sk).IsSet) continue; // socket not fired at the resolve tick
|
||||
// 07-21 AUTO-RECAST FIX (live repro: one press → a recast at EVERY cooldown reopen, forever):
|
||||
// the netcode copy layer ACCUMULATES InputEvent counts on the wire — a raw buffer entry's
|
||||
// IsSet means "ever pressed", not "pressed THIS tick" (only the decoded COMPONENT is
|
||||
// delta-corrected). A press AT resolveTick = a count STEP vs the previous tick's command
|
||||
// (Netcode's own decode semantics). Missing previous command → no-fire (dropping a
|
||||
// buffer-edge windup press beats an infinite recast loop).
|
||||
var prevTick = resolveTick;
|
||||
prevTick.Decrement();
|
||||
if (!inputBuffer.GetDataAtTick(prevTick, out var prevCmd)) continue;
|
||||
if (applied.InternalInput.GetSocket(sk).Count == prevCmd.InternalInput.GetSocket(sk).Count)
|
||||
continue; // no NEW press at the resolve tick
|
||||
|
||||
// Per-socket cooldown gate (0 = ready).
|
||||
uint nextFireRaw = cd.Get(sk);
|
||||
@@ -166,28 +205,38 @@ namespace ProjectM.Simulation
|
||||
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick)) continue;
|
||||
}
|
||||
|
||||
// CONE: no projectile ghost. Predict the cooldown on both worlds; apply server-only cleave.
|
||||
// CONE (SpecialSlam): no projectile ghost. Predict the cooldown on both worlds; server-only cleave.
|
||||
// 07-21 G6 (review wf_98bf1268): with the contact knob armed, damage lands at the slam's visual
|
||||
// contact via ConeContactPending (schedule-and-consume); knob 0 / missing slot = legacy at-fire.
|
||||
if (archetype == (byte)AbilityArchetype.Cone)
|
||||
{
|
||||
if (isServer)
|
||||
{
|
||||
float2 cFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15): cursor wins; facing fallback = resting gamepad stick
|
||||
float cRange = math.max(0.1f, es.Range);
|
||||
float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f));
|
||||
uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
|
||||
for (int ci = 0; ci < coneTargets.Length; ci++)
|
||||
if (coneContact > 0u && hasConePending)
|
||||
{
|
||||
if (!MeleeConeMath.InCone(xform.ValueRO.Position, cFace, cRange, cCosHalf, coneTargetPos[ci]))
|
||||
continue;
|
||||
ecb.AppendToBuffer(coneTargets[ci], new DamageEvent
|
||||
// EARLY-FLUSH a still-armed pending (re-validated) so no knob combination can lose a slam.
|
||||
var armed = m_ConePendingLookup[entity];
|
||||
if (armed.ResolveTick != 0u
|
||||
&& armed.Socket < sockets.Length && armed.Socket < effSockets.Length
|
||||
&& adb.TryGetAbility(sockets[armed.Socket].SparkId, out var flushDef)
|
||||
&& flushDef.Archetype == (byte)AbilityArchetype.Cone)
|
||||
{
|
||||
Amount = es.Damage,
|
||||
SourceNetworkId = owner.ValueRO.NetworkId,
|
||||
SourceTick = cStamp,
|
||||
});
|
||||
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, coneTargets[ci],
|
||||
xform.ValueRO.Position, coneTargetPos[ci], cFace, Tuning.KnockbackSpeed,
|
||||
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull);
|
||||
float2 fFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
|
||||
FireCone(xform.ValueRO.Position, fFace, effSockets[armed.Socket], owner.ValueRO.NetworkId,
|
||||
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
|
||||
}
|
||||
m_ConePendingLookup[entity] = new ConeContactPending
|
||||
{
|
||||
ResolveTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + coneContact),
|
||||
Socket = (byte)sk,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Legacy immediate (knob 0, or a plain test world without the baked pending slot).
|
||||
float2 cFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15): cursor wins; facing fallback = resting gamepad stick
|
||||
FireCone(xform.ValueRO.Position, cFace, es, owner.ValueRO.NetworkId,
|
||||
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
|
||||
}
|
||||
}
|
||||
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
|
||||
@@ -336,5 +385,31 @@ namespace ProjectM.Simulation
|
||||
coneTargets.Dispose();
|
||||
coneTargetPos.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Resolve one Cone-socket cleave from LIVE state — shared by the legacy immediate path, the
|
||||
/// due-fire and the early-flush (review wf_98bf1268: ONE resolve path, no drift). Server-only callers.</summary>
|
||||
static void FireCone(float3 casterPos, float2 face, in EffectiveSocketStats es, int ownerNetId,
|
||||
NetworkTick serverTick, bool pull, in NativeList<Entity> coneTargets,
|
||||
in NativeList<float3> coneTargetPos, ref EntityCommandBuffer ecb,
|
||||
ref ComponentLookup<KnockbackState> knockbackLookup, in ComponentLookup<BossState> bossLookup)
|
||||
{
|
||||
float cRange = math.max(0.1f, es.Range);
|
||||
float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f));
|
||||
uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
|
||||
for (int ci = 0; ci < coneTargets.Length; ci++)
|
||||
{
|
||||
if (!MeleeConeMath.InCone(casterPos, face, cRange, cCosHalf, coneTargetPos[ci]))
|
||||
continue;
|
||||
ecb.AppendToBuffer(coneTargets[ci], new DamageEvent
|
||||
{
|
||||
Amount = es.Damage,
|
||||
SourceNetworkId = ownerNetId,
|
||||
SourceTick = cStamp,
|
||||
});
|
||||
KnockbackUtil.Stamp(ref knockbackLookup, bossLookup, coneTargets[ci],
|
||||
casterPos, coneTargetPos[ci], face, Tuning.KnockbackSpeed,
|
||||
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>07-21 G6 (socket honesty, design review wf_98bf1268): SERVER-ONLY scheduled Cone-socket cleave —
|
||||
/// the <see cref="MeleeCleavePending"/> schedule-and-consume idiom applied to the SpecialSlam (Cone archetype)
|
||||
/// socket, so its damage lands at the animation's visual contact instead of the fire tick. Stamped by
|
||||
/// AbilityFireSystem when a Cone socket fires (ResolveTick = fire + TuningConfig.ConeContactTicks; knob 0 =
|
||||
/// legacy immediate, no stamp), fired on a wrap-safe elapsed compare from LIVE state (cast-turn steers the
|
||||
/// cone until contact; stats re-folded at resolve), CONSUMED by zeroing. A still-armed pending is FLUSHED
|
||||
/// (fired early) before a recast overwrites it. Cleared on death (PlayerDeathStateSystem — an armed pending
|
||||
/// must never fire from the respawn position) and by the dev SetClass swap (socket identity changes).
|
||||
/// At resolve the socket is re-validated (bounds + still a Cone Spark) — consume-drop on mismatch.
|
||||
/// Not replicated; client contact cues are presentation-side (CombatFeedbackSystem). Baked zeroed.</summary>
|
||||
public struct ConeContactPending : IComponentData
|
||||
{
|
||||
/// <summary>NonZero tick the armed cone's slam lands; 0 = nothing pending.</summary>
|
||||
public uint ResolveTick;
|
||||
/// <summary>Socket index (0..SocketId.Count-1) of the ARMED cast — stats/aim re-read live at resolve.</summary>
|
||||
public byte Socket;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e1f7f8852ab4e548babedafccbc6084
|
||||
@@ -1,32 +1,42 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY periodic-AoE state on a player-cast zone ghost (Vortex / LightZone) — the LANTERN Aoe/zone
|
||||
/// archetype. NOT a <c>[GhostField]</c>: the zone is a server-spawned INTERPOLATED ownerless ghost whose
|
||||
/// position replicates via the stock LocalTransform variant; only its damage schedule lives here, server-side.
|
||||
/// <c>ZonePulseSystem</c> borrows the <c>GeyserEruptSystem</c> skeleton (the INVERTED invalid-tick guard so a
|
||||
/// born-0 tick never storm-fires + the <c>= now + period</c> reschedule) but is ENEMY-ONLY (no friendly fire),
|
||||
/// stamps <see cref="DamageEvent.SourceNetworkId"/> = <see cref="CasterNetworkId"/> (kills credit the caster,
|
||||
/// feeding KillRewardSystem — NOT the -1 environment convention), and has NO region gate (a no-world gym).
|
||||
/// DestroyEntity when <see cref="ExpireTick"/> elapses.
|
||||
/// Periodic-AoE state on a player-cast zone ghost (Vortex / LightZone) — the LANTERN Aoe/zone archetype.
|
||||
/// The zone is a server-spawned INTERPOLATED ownerless ghost; position replicates via the stock LocalTransform
|
||||
/// variant. 07-21 G6 (design review wf_98bf1268): THREE fields are now <c>[GhostField]</c> so the client can
|
||||
/// draw an HONEST fill telegraph (ZoneTelegraphSystem): <see cref="CasterNetworkId"/> (G3 ownership tint),
|
||||
/// <see cref="Radius"/> (rim = the TRUE folded damage radius) and <see cref="NextTick"/> (fill arrival = the
|
||||
/// damage moment — server mutations propagate to all clients on an ownerless ghost). Damage schedule/amount
|
||||
/// stay server-only. <c>ZonePulseSystem</c> borrows the <c>GeyserEruptSystem</c> skeleton (the INVERTED
|
||||
/// invalid-tick guard so a born-0 tick never storm-fires + the <c>= now + period</c> reschedule) but is
|
||||
/// ENEMY-ONLY (no friendly fire), stamps <see cref="DamageEvent.SourceNetworkId"/> = <see cref="CasterNetworkId"/>
|
||||
/// (kills credit the caster, feeding KillRewardSystem — NOT the -1 environment convention), and has NO region
|
||||
/// gate (a no-world gym). DestroyEntity when <see cref="ExpireTick"/> elapses.
|
||||
/// </summary>
|
||||
public struct ZoneEffect : IComponentData
|
||||
{
|
||||
/// <summary>NetworkId of the casting player (damage/kill attribution).</summary>
|
||||
public int CasterNetworkId;
|
||||
/// <summary>Pulse cadence in ticks — single source for ZonePulseSystem (damage) and ZoneTelegraphSystem
|
||||
/// (the client fill window). Hoisted 07-21 G6 (was ZonePulseSystem.k_PulsePeriodTicks).</summary>
|
||||
public const uint PulsePeriodTicks = 30;
|
||||
|
||||
/// <summary>Planar (XZ) damage radius, world units.</summary>
|
||||
public float Radius;
|
||||
/// <summary>NetworkId of the casting player (damage/kill attribution + the client's mine-vs-ally tint).</summary>
|
||||
[GhostField] public int CasterNetworkId;
|
||||
|
||||
/// <summary>Damage dealt to each living enemy in radius per pulse.</summary>
|
||||
/// <summary>Planar (XZ) damage radius, world units. Replicated so the drawn rim is the TRUE folded radius.</summary>
|
||||
[GhostField(Quantization = 100)] public float Radius;
|
||||
|
||||
/// <summary>Raw next-pulse tick (NonZero). <c>0</c> = unscheduled → lazy-stamped born-correct (GeyserErupt H2
|
||||
/// rule, never storm-fires). Replicated for the client fill: ride the ABSOLUTE tick + a value-latch + a
|
||||
/// was-counting-down arm-guard (the Geyser ★ rule — never edge-detect the re-stamp).</summary>
|
||||
[GhostField] public uint NextTick;
|
||||
|
||||
/// <summary>Damage dealt to each living enemy in radius per pulse. Server-only.</summary>
|
||||
public float DamagePerPulse;
|
||||
|
||||
/// <summary>Raw next-pulse tick (NonZero). <c>0</c> = unscheduled → lazy-stamped born-correct (GeyserErupt H2 rule, never storm-fires).</summary>
|
||||
public uint NextTick;
|
||||
|
||||
/// <summary>Raw tick the zone despawns (NonZero). Active while .IsNewerThan(serverTick).</summary>
|
||||
/// <summary>Raw tick the zone despawns (NonZero). Active while .IsNewerThan(serverTick). Server-only.</summary>
|
||||
public uint ExpireTick;
|
||||
|
||||
/// <summary>See <see cref="ZoneEffectFlag"/>. bit0 = Vortex (also pull enemies toward the zone center each pulse).</summary>
|
||||
|
||||
@@ -35,12 +35,22 @@ namespace ProjectM.Simulation
|
||||
public static bool FireActive(uint nextFireRaw, int cooldownTicks, NetworkTick serverTick, uint animTicks)
|
||||
{
|
||||
if (nextFireRaw == 0u || cooldownTicks <= 0 || !serverTick.IsValid) return false;
|
||||
uint startRaw = TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks);
|
||||
uint startRaw = FireStartRaw(nextFireRaw, cooldownTicks);
|
||||
var start = new NetworkTick(startRaw);
|
||||
var end = new NetworkTick(TickUtil.NonZero(startRaw + animTicks));
|
||||
return start.IsValid && end.IsValid && !start.IsNewerThan(serverTick) && end.IsNewerThan(serverTick);
|
||||
}
|
||||
|
||||
/// <summary>Reconstructed fire-window START (raw NonZero tick) from the replicated per-socket cooldown
|
||||
/// stamp minus the locally-derived CooldownTicks; 0 = no window (unstamped / degenerate inputs). The ONE
|
||||
/// home of this reconstruction (07-21 G6 review wf_98bf1268: the cone connect-cue latches
|
||||
/// contact = FireStartRaw + ConeContactTicks at the fire edge — never re-derive it inline).</summary>
|
||||
public static uint FireStartRaw(uint nextFireRaw, int cooldownTicks)
|
||||
{
|
||||
if (nextFireRaw == 0u || cooldownTicks <= 0) return 0u;
|
||||
return TickUtil.NonZero(nextFireRaw - (uint)cooldownTicks);
|
||||
}
|
||||
|
||||
/// <summary>LANTERN 4-socket fire/cone resolution (any-socket model): firing if ANY socketed,
|
||||
/// NON-Movement Spark's per-socket window is mid-fire; cone if any such active socket holds a
|
||||
/// Cone-archetype Spark. Movement sockets are skipped OUTRIGHT (blink = dodge, not cast). Without a
|
||||
|
||||
@@ -53,6 +53,10 @@ namespace ProjectM.Simulation
|
||||
public float MeleeBufferTicks;
|
||||
/// <summary>Base contact delay (ticks from swing start to the blade landing; per-step via MeleeTiming). 0 = IMMEDIATE (legacy same-tick cleave).</summary>
|
||||
public float MeleeContactTicks;
|
||||
/// <summary>07-21 G6 (socket honesty): ticks from the Cone-socket fire tick to its damage landing (the
|
||||
/// SpecialSlam visual contact). 0 = IMMEDIATE (legacy at-fire cleave). Keep < CastFacingTicks (26) or a
|
||||
/// resting gamepad stick resolves a movement-facing cone; default 21 sits 1 tick under WarriorCone cooldown 22.</summary>
|
||||
public float ConeContactTicks;
|
||||
|
||||
// EB-1 fortress aggro: a <1 multiplier on a Husk's SQUARED distance to a structure (so structures are
|
||||
// preferred targets); a closer player 'in the way' still wins. Read server-side by EnemyAISystem.
|
||||
@@ -96,6 +100,7 @@ namespace ProjectM.Simulation
|
||||
MeleeFinisherRangeMult = 1.25f, // 07-20 G2.2: finisher REACH (damage/recover/knockback keep MeleeFinisherMult)
|
||||
MeleeBufferTicks = 10f, // 07-21 HEAVY LOCK: wider buffer fits the slower cadence (0 = off)
|
||||
MeleeContactTicks = 20f, // 07-21 HEAVY LOCK: blade lands 20/12/25t per step -- clip speeds retuned to MATCH (wire tool 1.0/0.85/0.88; audit must show zero drift). Step-3 (25t) sits 1 tick under CastFacingTicks 26 -- do NOT raise without raising the cast window.
|
||||
ConeContactTicks = 21f, // 07-21 G6: SpecialSlam contact ~0.35s after fire; keep < CastFacingTicks 26
|
||||
StructureAggroWeight = 0.7f, // EB-1: <1 prefers structures (fortress aggro); live-tunable
|
||||
StaggerKnockbackSpeed = 7f, // B2 poise: kb.Speed >= this interrupts windups/lunges; below = nudge only
|
||||
SeparationMaxSpeed = 3f, // B1: max separation push (units/s) so soft-collision can't fling
|
||||
@@ -130,6 +135,7 @@ namespace ProjectM.Simulation
|
||||
case TuningKnob.MeleeFinisherRangeMult:
|
||||
case TuningKnob.MeleeBufferTicks: // 07-20: 0 = buffer OFF sentinel (must survive ClampKnob — review C10)
|
||||
case TuningKnob.MeleeContactTicks: // 07-20: 0 = IMMEDIATE sentinel (legacy same-tick cleave)
|
||||
case TuningKnob.ConeContactTicks: // 07-21 G6: 0 = IMMEDIATE sentinel (legacy at-fire cone)
|
||||
return math.max(0f, value);
|
||||
// tick knobs: >= 1 (a 0 tick count is degenerate; a 0 i-frame window divides-by-zero in DashSystem).
|
||||
// FinalSiegeMultiplier also lands here on purpose — a final siege should never be < 1x a normal one.
|
||||
@@ -172,6 +178,7 @@ namespace ProjectM.Simulation
|
||||
case TuningKnob.MeleeBufferTicks: c.MeleeBufferTicks = value; break;
|
||||
case TuningKnob.MeleeFinisherRangeMult: c.MeleeFinisherRangeMult = value; break;
|
||||
case TuningKnob.MeleeContactTicks: c.MeleeContactTicks = value; break;
|
||||
case TuningKnob.ConeContactTicks: c.ConeContactTicks = value; break;
|
||||
|
||||
// unknown index -> no-op (matches the no-default switch convention in DebugCommandReceiveSystem)
|
||||
}
|
||||
@@ -210,6 +217,7 @@ namespace ProjectM.Simulation
|
||||
case TuningKnob.MeleeBufferTicks: return c.MeleeBufferTicks;
|
||||
case TuningKnob.MeleeFinisherRangeMult: return c.MeleeFinisherRangeMult;
|
||||
case TuningKnob.MeleeContactTicks: return c.MeleeContactTicks;
|
||||
case TuningKnob.ConeContactTicks: return c.ConeContactTicks;
|
||||
|
||||
default: return 0f;
|
||||
}
|
||||
@@ -246,6 +254,7 @@ namespace ProjectM.Simulation
|
||||
MeleeBufferTicks = c.MeleeBufferTicks,
|
||||
MeleeFinisherRangeMult = c.MeleeFinisherRangeMult,
|
||||
MeleeContactTicks = c.MeleeContactTicks,
|
||||
ConeContactTicks = c.ConeContactTicks,
|
||||
|
||||
};
|
||||
|
||||
@@ -280,6 +289,7 @@ namespace ProjectM.Simulation
|
||||
MeleeBufferTicks = r.MeleeBufferTicks,
|
||||
MeleeFinisherRangeMult = r.MeleeFinisherRangeMult,
|
||||
MeleeContactTicks = r.MeleeContactTicks,
|
||||
ConeContactTicks = r.ConeContactTicks,
|
||||
|
||||
};
|
||||
}
|
||||
@@ -318,9 +328,10 @@ namespace ProjectM.Simulation
|
||||
public const byte MeleeBufferTicks = 29; // 07-20 G7 (0 = off sentinel)
|
||||
public const byte MeleeFinisherRangeMult = 30; // 07-20 G2.2
|
||||
public const byte MeleeContactTicks = 31; // 07-20 G2.1 (0 = immediate sentinel)
|
||||
public const byte ConeContactTicks = 32; // 07-21 G6 (0 = immediate sentinel)
|
||||
|
||||
/// <summary>Knob count (overlay iteration bound).</summary>
|
||||
public const byte Count = 32;
|
||||
public const byte Count = 33;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -359,6 +370,7 @@ namespace ProjectM.Simulation
|
||||
public float MeleeBufferTicks; // 07-20 melee feel forks (dev-protocol bump: rebuild both peers)
|
||||
public float MeleeFinisherRangeMult;
|
||||
public float MeleeContactTicks;
|
||||
public float ConeContactTicks; // 07-21 G6 socket honesty (dev-protocol bump: rebuild both peers)
|
||||
}
|
||||
|
||||
// NOTE: appending fields = a DEV-PROTOCOL BUMP (RpcCollection hash) — rebuild both peers together.
|
||||
|
||||
@@ -45,6 +45,14 @@ namespace ProjectM.Simulation
|
||||
// MC-4: clear any in-flight combo so a death mid-combo leaves no stale lock/step on respawn.
|
||||
if (SystemAPI.HasComponent<MeleeCombo>(entity))
|
||||
SystemAPI.SetComponent(entity, default(MeleeCombo));
|
||||
// 07-21 G6 (review wf_98bf1268): zero any ARMED scheduled cleave — a pending surviving death
|
||||
// fires from the RESPAWN position once Dead re-disables (the elapsed compare passes instantly).
|
||||
// Fixes the shipped melee variant + the new cone in one stroke. Idempotent default-writes;
|
||||
// client copies are always zero, so the both-world predicted write is rollback-safe.
|
||||
if (SystemAPI.HasComponent<MeleeCleavePending>(entity))
|
||||
SystemAPI.SetComponent(entity, default(MeleeCleavePending));
|
||||
if (SystemAPI.HasComponent<ConeContactPending>(entity))
|
||||
SystemAPI.SetComponent(entity, default(ConeContactPending));
|
||||
if (SystemAPI.HasComponent<CharacterComponent>(entity))
|
||||
{
|
||||
var cc = SystemAPI.GetComponent<CharacterComponent>(entity);
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Collections;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// 07-21 G6 (review wf_98bf1268) — the Cone-socket (SpecialSlam) damage-at-contact schedule: EditMode tests
|
||||
/// for <see cref="ConeContactPending"/> through the REAL <see cref="AbilityFireSystem"/> (a GameServer-flagged
|
||||
/// world so IsServer gates damage on, NetworkTime flagged IsFirstTimeFullyPredictingTick so the system runs,
|
||||
/// an AbilityDatabase blob with a Cone Spark, and InputBufferData<PlayerInput> command pushes — the
|
||||
/// review-specified fixture; MeleeComboTests' lighter harness cannot exercise the socket loop). Effective
|
||||
/// socket stats are hand-filled (no StatRecomputeSystem — removes same-tick ordering flake). Every press is
|
||||
/// followed by a release command so a held InputEvent can never re-fire across the cooldown edge (the
|
||||
/// MeleeComboTests C6 lesson). Pins: schedule + exactly-once fire + consume · knob 0 = legacy immediate ·
|
||||
/// early-flush on recast-before-contact (no slam is ever lost) · death clears the armed pending (never a
|
||||
/// respawn-position slam) · socket-swap consume-drop (SetClass mid-flight).
|
||||
/// </summary>
|
||||
public class AbilityFireSystemConeTests
|
||||
{
|
||||
const byte ConeSpark = 4; // AbilityId.WarriorCone
|
||||
const uint Contact = 21; // pinned knob 32 value for these tests (defaults may drift for feel)
|
||||
|
||||
static BlobAssetReference<AbilityDatabaseBlob> BuildConeDb(int cooldownTicks)
|
||||
{
|
||||
using var b = new BlobBuilder(Allocator.Temp);
|
||||
ref var root = ref b.ConstructRoot<AbilityDatabaseBlob>();
|
||||
var a = b.Allocate(ref root.Abilities, 1);
|
||||
a[0] = new AbilityDefBlob
|
||||
{
|
||||
Id = ConeSpark, Archetype = (byte)AbilityArchetype.Cone, Damage = 25f, Range = 3f,
|
||||
AutoTargetConeRadians = 0.9f, CooldownTicks = cooldownTicks, Name = "TestCone"
|
||||
};
|
||||
var c = b.Allocate(ref root.Characters, 1);
|
||||
c[0] = new CharacterStatsBlob { Id = 0, MoveSpeed = 6f, TurnRateRadiansPerSec = 12.5f, MaxHealth = 100f, Name = "T" };
|
||||
return b.CreateBlobAssetReference<AbilityDatabaseBlob>(Allocator.Persistent);
|
||||
}
|
||||
|
||||
// NetworkTime.Flags is internal (review fixture note): outside the real prediction groups the
|
||||
// IsFirstTimeFullyPredictingTick gate can only be satisfied via reflection — test-only, editor-only.
|
||||
static readonly System.Reflection.FieldInfo s_FlagsField = typeof(NetworkTime).GetField(
|
||||
"Flags", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
static NetworkTime MakePredictingTime(uint tick)
|
||||
{
|
||||
object boxed = new NetworkTime { ServerTick = new NetworkTick(tick) };
|
||||
s_FlagsField.SetValue(boxed, NetworkTimeFlags.IsInPredictionLoop | NetworkTimeFlags.IsFirstTimeFullyPredictingTick);
|
||||
return (NetworkTime)boxed;
|
||||
}
|
||||
|
||||
|
||||
static void SetTick(World world, uint tick)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
using var q = em.CreateEntityQuery(typeof(NetworkTime));
|
||||
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
|
||||
em.SetComponentData(e, MakePredictingTime(tick));
|
||||
}
|
||||
|
||||
static (World world, SimulationSystemGroup group, Entity player, Entity enemy, BlobAssetReference<AbilityDatabaseBlob> blob)
|
||||
MakeWorld(uint tick, float coneContactKnob, int cooldownTicks = 22, bool withDeathSystem = false, bool withPendingSlot = true)
|
||||
{
|
||||
var world = new World("ConeTest", WorldFlags.Game | WorldFlags.GameServer);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<AbilityFireSystem>());
|
||||
if (withDeathSystem)
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<PlayerDeathStateSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetTick(world, tick);
|
||||
|
||||
var em = world.EntityManager;
|
||||
var blob = BuildConeDb(cooldownTicks);
|
||||
var dbEntity = em.CreateEntity(typeof(AbilityDatabase));
|
||||
em.SetComponentData(dbEntity, new AbilityDatabase { Value = blob });
|
||||
em.AddBuffer<AbilityPrefabElement>(dbEntity); // required by the fire path; the Cone branch spawns nothing
|
||||
|
||||
var tuning = TuningConfig.Defaults();
|
||||
tuning.ConeContactTicks = coneContactKnob;
|
||||
em.SetComponentData(em.CreateEntity(typeof(TuningConfig)), tuning);
|
||||
|
||||
var player = em.CreateEntity();
|
||||
em.AddComponentData(player, new PlayerInput());
|
||||
em.AddComponentData(player, new PlayerFacing { Direction = new float2(0f, 1f) });
|
||||
em.AddComponentData(player, LocalTransform.FromPosition(float3.zero));
|
||||
em.AddComponentData(player, new GhostOwner { NetworkId = 7 });
|
||||
em.AddComponent<Simulate>(player);
|
||||
em.AddComponent<Dead>(player);
|
||||
em.SetComponentEnabled<Dead>(player, false);
|
||||
if (withPendingSlot) em.AddComponent<ConeContactPending>(player); // review wf_9757d214: the no-slot fallback is a pinned contract
|
||||
var socks = em.AddBuffer<AbilitySocket>(player);
|
||||
socks.Add(new AbilitySocket { SparkId = ConeSpark });
|
||||
em.AddComponentData(player, default(SocketCooldown));
|
||||
var effs = em.AddBuffer<EffectiveSocketStats>(player);
|
||||
effs.Add(new EffectiveSocketStats { Damage = 25f, Range = 3f, AutoTargetConeRadians = 0.9f, CooldownTicks = cooldownTicks });
|
||||
em.AddBuffer<InputBufferData<PlayerInput>>(player);
|
||||
if (withDeathSystem)
|
||||
{
|
||||
em.AddComponent<PlayerTag>(player);
|
||||
em.AddComponentData(player, new Health { Current = 100f, Max = 100f });
|
||||
em.AddComponentData(player, new CharacterControl());
|
||||
}
|
||||
|
||||
var enemy = em.CreateEntity();
|
||||
em.AddComponent<EnemyTag>(enemy);
|
||||
em.AddComponentData(enemy, new Health { Current = 200f, Max = 200f });
|
||||
em.AddComponentData(enemy, LocalTransform.FromPosition(new float3(0f, 0f, 2f)));
|
||||
em.AddBuffer<DamageEvent>(enemy);
|
||||
em.AddComponentData(enemy, new KnockbackState());
|
||||
return (world, group, player, enemy, blob);
|
||||
}
|
||||
|
||||
/// <summary>Push a socket-0 press at <paramref name="tick"/> the way the WIRE actually carries it (07-21
|
||||
/// auto-recast repro): InputEvent counts ACCUMULATE monotonically across commands — the per-frame gather
|
||||
/// reset never reaches the buffer — so a press is a count STEP at one tick and the stepped count PERSISTS
|
||||
/// in every later command. Pushes baseline(tick-1) + press(tick, +1) + held(tick+1, same stepped count).</summary>
|
||||
static void PressSocket0(EntityManager em, Entity player, uint tick)
|
||||
{
|
||||
var buf = em.GetBuffer<InputBufferData<PlayerInput>>(player);
|
||||
uint baseCount = 0;
|
||||
if (buf.Length > 0) baseCount = buf[buf.Length - 1].InternalInput.Socket0.Count;
|
||||
var idle = new PlayerInput();
|
||||
idle.Socket0.Count = baseCount;
|
||||
var pressed = new PlayerInput();
|
||||
pressed.Socket0.Count = baseCount + 1;
|
||||
buf.Add(new InputBufferData<PlayerInput> { Tick = new NetworkTick(tick - 1), InternalInput = idle });
|
||||
buf.Add(new InputBufferData<PlayerInput> { Tick = new NetworkTick(tick), InternalInput = pressed });
|
||||
buf.Add(new InputBufferData<PlayerInput> { Tick = new NetworkTick(tick + 1), InternalInput = pressed });
|
||||
}
|
||||
|
||||
static void Step(World world, SimulationSystemGroup group, uint tick)
|
||||
{
|
||||
SetTick(world, tick);
|
||||
group.Update();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cone_Resolves_At_The_Contact_Tick_Exactly_Once()
|
||||
{
|
||||
var (world, group, player, enemy, blob) = MakeWorld(100, Contact);
|
||||
try
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
PressSocket0(em, player, 100);
|
||||
Step(world, group, 100);
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "no damage at the fire tick (scheduled)");
|
||||
var pend = em.GetComponentData<ConeContactPending>(player);
|
||||
Assert.AreEqual(TickUtil.NonZero(100u + Contact), pend.ResolveTick, "pending armed at fire+contact");
|
||||
Assert.AreEqual(0, pend.Socket, "armed socket index");
|
||||
|
||||
Step(world, group, 120);
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "still counting down 1 tick before contact");
|
||||
|
||||
Step(world, group, 121);
|
||||
var events = em.GetBuffer<DamageEvent>(enemy);
|
||||
Assert.AreEqual(1, events.Length, "exactly one cleave at the contact tick");
|
||||
Assert.AreEqual(25f, events[0].Amount, 1e-3f, "live folded socket damage");
|
||||
Assert.AreEqual(7, events[0].SourceNetworkId, "credited to the caster");
|
||||
Assert.AreEqual(0u, em.GetComponentData<ConeContactPending>(player).ResolveTick, "consumed by zeroing");
|
||||
|
||||
Step(world, group, 121); // same-tick re-run (batching shape): consumed pending must not refire
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "never a second fire");
|
||||
}
|
||||
finally { world.Dispose(); blob.Dispose(); }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cone_Knob_Zero_Is_Legacy_Immediate()
|
||||
{
|
||||
var (world, group, player, enemy, blob) = MakeWorld(100, 0f);
|
||||
try
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
PressSocket0(em, player, 100);
|
||||
Step(world, group, 100);
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "knob 0 = the legacy at-fire cleave");
|
||||
Assert.AreEqual(0u, em.GetComponentData<ConeContactPending>(player).ResolveTick, "nothing scheduled");
|
||||
}
|
||||
finally { world.Dispose(); blob.Dispose(); }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cone_Recast_Early_Flushes_The_Armed_Pending()
|
||||
{
|
||||
// Cooldown 10 < contact 21: the recast lands BEFORE the first slam's contact -> the armed pending is
|
||||
// FLUSHED (fired early) then re-armed. No knob combination may lose a slam (the melee C0/C12 contract).
|
||||
var (world, group, player, enemy, blob) = MakeWorld(100, Contact, cooldownTicks: 10);
|
||||
try
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
PressSocket0(em, player, 100);
|
||||
Step(world, group, 100);
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length);
|
||||
|
||||
PressSocket0(em, player, 110); // cooldown re-opened at 110; pending (121) still counting down
|
||||
Step(world, group, 110);
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "recast FLUSHED the armed slam early");
|
||||
Assert.AreEqual(TickUtil.NonZero(110u + Contact), em.GetComponentData<ConeContactPending>(player).ResolveTick,
|
||||
"re-armed for the new cast");
|
||||
|
||||
Step(world, group, 131);
|
||||
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(enemy).Length, "second slam lands at ITS contact; none lost");
|
||||
}
|
||||
finally { world.Dispose(); blob.Dispose(); }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Death_Clears_The_Armed_Pending_No_Respawn_Slam()
|
||||
{
|
||||
var (world, group, player, enemy, blob) = MakeWorld(100, Contact, withDeathSystem: true);
|
||||
try
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
PressSocket0(em, player, 100);
|
||||
Step(world, group, 100);
|
||||
Assert.AreNotEqual(0u, em.GetComponentData<ConeContactPending>(player).ResolveTick, "armed");
|
||||
|
||||
em.SetComponentData(player, new Health { Current = 0f, Max = 100f }); // die 5 ticks before contact
|
||||
Step(world, group, 105);
|
||||
Assert.AreEqual(0u, em.GetComponentData<ConeContactPending>(player).ResolveTick,
|
||||
"death zeroes the pending (review wf_98bf1268: a pending surviving death slams from the respawn ring)");
|
||||
|
||||
em.SetComponentData(player, new Health { Current = 100f, Max = 100f }); // revive well past contact
|
||||
Step(world, group, 140);
|
||||
Step(world, group, 141);
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "no ghost slam after respawn");
|
||||
}
|
||||
finally { world.Dispose(); blob.Dispose(); }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cast_Never_Refires_When_The_Cooldown_Reopens()
|
||||
{
|
||||
// THE 07-21 operator-reported auto-recast (live repro: one press → a recast at EVERY reopen): wire
|
||||
// counts persist in every later command, and the old raw-IsSet gate read "ever pressed". Knob 0
|
||||
// isolates the cast gate (no pending schedule in the way).
|
||||
var (world, group, player, enemy, blob) = MakeWorld(100, 0f);
|
||||
try
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
PressSocket0(em, player, 100);
|
||||
Step(world, group, 100);
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "the press casts once");
|
||||
Step(world, group, 122); // cooldown (22t) reopened; the held stepped-count command still answers GetDataAtTick
|
||||
Step(world, group, 123);
|
||||
Step(world, group, 200);
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "a reopened cooldown must NEVER re-fire a stale press");
|
||||
}
|
||||
finally { world.Dispose(); blob.Dispose(); }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cone_Missing_Pending_Slot_Falls_Back_To_Immediate()
|
||||
{
|
||||
// knob > 0 but NO baked pending slot (plain/legacy worlds): the hasPending fallback fires at-cast.
|
||||
var (world, group, player, enemy, blob) = MakeWorld(100, Contact, withPendingSlot: false);
|
||||
try
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
PressSocket0(em, player, 100);
|
||||
Step(world, group, 100);
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(enemy).Length, "no slot = legacy immediate, never a silent no-damage");
|
||||
}
|
||||
finally { world.Dispose(); blob.Dispose(); }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cone_Resolves_With_The_Aim_At_Contact_Not_Cast()
|
||||
{
|
||||
// The pending stores {tick, socket} ONLY — the cast-turn steers the cone until contact (live-state resolve).
|
||||
var (world, group, player, enemy, blob) = MakeWorld(100, Contact);
|
||||
try
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var side = em.CreateEntity();
|
||||
em.AddComponent<EnemyTag>(side);
|
||||
em.AddComponentData(side, new Health { Current = 200f, Max = 200f });
|
||||
em.AddComponentData(side, Unity.Transforms.LocalTransform.FromPosition(new float3(2f, 0f, 0f)));
|
||||
em.AddBuffer<DamageEvent>(side);
|
||||
em.AddComponentData(side, new KnockbackState());
|
||||
|
||||
PressSocket0(em, player, 100);
|
||||
Step(world, group, 100); // armed facing +z (enemy 'enemy' at (0,0,2) is in the cast-time cone)
|
||||
em.SetComponentData(player, new PlayerFacing { Direction = new float2(1f, 0f) }); // turn to +x before contact
|
||||
Step(world, group, 121);
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length, "the cast-tick direction must NOT be latched");
|
||||
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(side).Length, "damage follows the aim AT the contact tick");
|
||||
}
|
||||
finally { world.Dispose(); blob.Dispose(); }
|
||||
}
|
||||
|
||||
|
||||
[Test]
|
||||
public void Socket_Swap_Mid_Flight_Consume_Drops()
|
||||
{
|
||||
var (world, group, player, enemy, blob) = MakeWorld(100, Contact);
|
||||
try
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
PressSocket0(em, player, 100);
|
||||
Step(world, group, 100);
|
||||
Assert.AreNotEqual(0u, em.GetComponentData<ConeContactPending>(player).ResolveTick, "armed");
|
||||
|
||||
var socks = em.GetBuffer<AbilitySocket>(player);
|
||||
socks[0] = new AbilitySocket { SparkId = 99 }; // SetClass-style swap: no longer a known Cone Spark
|
||||
|
||||
Step(world, group, 121);
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(enemy).Length,
|
||||
"mismatched socket at resolve = consume-drop, never a slam with foreign stats");
|
||||
Assert.AreEqual(0u, em.GetComponentData<ConeContactPending>(player).ResolveTick, "consumed");
|
||||
}
|
||||
finally { world.Dispose(); blob.Dispose(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c98d85ca9d797144bab5a889bfb85fd5
|
||||
@@ -0,0 +1,45 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Client;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>07-21 G4 — pins the ally-FX saturation ramp (SaturationMath.AllyScale): full loudness at or
|
||||
/// under the start count, linear to the floor at the full count, clamped floor, degenerate-range snap.
|
||||
/// Enemy telegraphs never ride this scale (structural, no knob) — that is pinned by ARCHITECTURE, not here.</summary>
|
||||
public class SaturationMathTests
|
||||
{
|
||||
[Test]
|
||||
public void At_Or_Below_Start_Is_Full()
|
||||
{
|
||||
Assert.AreEqual(1f, SaturationMath.AllyScale(0, 8, 16, 0.35f), 1e-4f);
|
||||
Assert.AreEqual(1f, SaturationMath.AllyScale(8, 8, 16, 0.35f), 1e-4f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Midpoint_Is_Half_Lerped()
|
||||
{
|
||||
Assert.AreEqual(0.675f, SaturationMath.AllyScale(12, 8, 16, 0.35f), 1e-4f); // lerp(1, .35, .5)
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void At_Or_Past_Full_Sits_At_Floor()
|
||||
{
|
||||
Assert.AreEqual(0.35f, SaturationMath.AllyScale(16, 8, 16, 0.35f), 1e-4f);
|
||||
Assert.AreEqual(0.35f, SaturationMath.AllyScale(40, 8, 16, 0.35f), 1e-4f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Degenerate_Range_Snaps_To_Floor_Past_Start()
|
||||
{
|
||||
Assert.AreEqual(1f, SaturationMath.AllyScale(8, 8, 8, 0.5f), 1e-4f);
|
||||
Assert.AreEqual(0.5f, SaturationMath.AllyScale(9, 8, 8, 0.5f), 1e-4f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Floor_Is_Clamped_01()
|
||||
{
|
||||
Assert.AreEqual(1f, SaturationMath.AllyScale(100, 8, 16, 1.7f), 1e-4f);
|
||||
Assert.AreEqual(0f, SaturationMath.AllyScale(100, 8, 16, -2f), 1e-4f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 876bdcb19d45464418a4a634ebc134c8
|
||||
@@ -152,5 +152,27 @@ namespace ProjectM.Tests
|
||||
Assert.IsTrue(firing);
|
||||
Assert.IsTrue(cone);
|
||||
}
|
||||
|
||||
// ---- 07-21 G6 (review wf_98bf1268): FireStartRaw — the ONE home of the window-start reconstruction ----
|
||||
|
||||
[Test]
|
||||
public void FireStartRaw_Reconstructs_The_Fire_Tick()
|
||||
{
|
||||
Assert.AreEqual(978u, TickWindowMath.FireStartRaw(1000u, 22));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FireStartRaw_Degenerate_Inputs_Return_Zero()
|
||||
{
|
||||
Assert.AreEqual(0u, TickWindowMath.FireStartRaw(0u, 22), "unstamped cooldown row");
|
||||
Assert.AreEqual(0u, TickWindowMath.FireStartRaw(1000u, 0), "no cooldown length");
|
||||
Assert.AreEqual(0u, TickWindowMath.FireStartRaw(1000u, -5), "negative cooldown length");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FireStartRaw_Wrap_To_Zero_Coerces_Through_NonZero()
|
||||
{
|
||||
Assert.AreEqual(TickUtil.NonZero(0u), TickWindowMath.FireStartRaw(10u, 10), "the 0-sentinel is never returned for a REAL window start");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user