Slice 2 (WIP): class data layer + melee-augment routing

Foundation for Two Classes (DR-037). New ids (CharacterId.Warrior/Ranger,
AbilityId.WarriorCone, StatTarget.MeleeDamage/MeleeRange); CharacterStatsRef.Id ->
[GhostField] so the owning client folds the right class stats; MeleeComboSystem
folds per-player MeleeDamage/MeleeRange off the replicated StatModifier buffer
(HasBuffer-guarded -> identity without class seeds, so behavior-preserving).
345/345 EditMode. Slice 2 design review + locked forks logged in the session note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 00:23:56 -07:00
parent f98125f0b2
commit d9d67c4e78
4 changed files with 33 additions and 9 deletions
@@ -7,6 +7,7 @@ namespace ProjectM.Simulation
Primary = 1, Primary = 1,
FastLight = 2, FastLight = 2,
SlowHeavy = 3, SlowHeavy = 3,
WarriorCone = 4, // Slice 2: the Warrior's aim-directed short-range cone secondary (Fire slot)
} }
/// <summary>Stable key for an authored character-stats definition in the AbilityDatabase blob.</summary> /// <summary>Stable key for an authored character-stats definition in the AbilityDatabase blob.</summary>
@@ -14,6 +15,8 @@ namespace ProjectM.Simulation
{ {
None = 0, None = 0,
Default = 1, Default = 1,
Warrior = 2, // Slice 2: melee-anchor bruiser (tankier, slower, longer/harder melee)
Ranger = 3, // Slice 2: ranged-anchor (squishier, faster, longer projectile range; weaker melee)
} }
/// <summary> /// <summary>
@@ -31,6 +34,8 @@ namespace ProjectM.Simulation
MoveSpeed = 6, MoveSpeed = 6,
TurnRate = 7, TurnRate = 7,
MaxHealth = 8, MaxHealth = 8,
MeleeDamage = 9, // Slice 2: melee combo + Warrior cone damage axis (folded onto TuningConfig.MeleeDamage)
MeleeRange = 10, // Slice 2: melee reach axis (Warrior longer, Ranger shorter)
} }
/// <summary>How a <see cref="StatModifier"/> combines into the effective stat.</summary> /// <summary>How a <see cref="StatModifier"/> combines into the effective stat.</summary>
@@ -1,15 +1,16 @@
using Unity.Entities; using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Simulation namespace ProjectM.Simulation
{ {
/// <summary> /// <summary>
/// Which authored character-stats definition this entity uses - a light key into the CharacterStats /// Which authored character-stats definition this entity uses - a light key into the CharacterStats
/// blob, replacing M2's inlined PlayerMoveStats values. Not replicated (baked identically on both /// blob, replacing M2's inlined PlayerMoveStats values. NOW a [GhostField] (Slice 2 classes) so the
/// worlds); promote to a GhostField if runtime character changes are ever needed. <c>Id</c> stores a /// server-written per-player class id replicates -> the owning client folds correct stats. <c>Id</c> stores a
/// <see cref="CharacterId"/>. /// <see cref="CharacterId"/>.
/// </summary> /// </summary>
public struct CharacterStatsRef : IComponentData public struct CharacterStatsRef : IComponentData
{ {
public byte Id; [GhostField] public byte Id;
} }
} }
@@ -51,6 +51,7 @@ namespace ProjectM.Simulation
ComponentLookup<KnockbackState> m_KnockbackLookup; ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<RegionTag> m_RegionLookup; ComponentLookup<RegionTag> m_RegionLookup;
BufferLookup<InventorySlot> m_InvLookup; BufferLookup<InventorySlot> m_InvLookup;
BufferLookup<StatModifier> m_StatModLookup;
[BurstCompile] [BurstCompile]
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
@@ -58,6 +59,7 @@ namespace ProjectM.Simulation
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false); m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true); m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false); m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
state.RequireForUpdate<NetworkTime>(); state.RequireForUpdate<NetworkTime>();
} }
@@ -87,11 +89,12 @@ namespace ProjectM.Simulation
// Server-only queue of cleaves to resolve after the player loop (so enemies are gathered ONCE, and only // Server-only queue of cleaves to resolve after the player loop (so enemies are gathered ONCE, and only
// when at least one swing actually started — no per-tick enemy gather on idle/client ticks). // when at least one swing actually started — no per-tick enemy gather on idle/client ticks).
var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default; var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default;
m_StatModLookup.Update(ref state); // Slice 2: per-player melee stat fold (read inside the player loop)
foreach (var (mc, control, input, facing, xform, owner, ds) in foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>, SystemAPI.Query<RefRW<MeleeCombo>, RefRW<CharacterControl>, RefRO<PlayerInput>,
RefRO<PlayerFacing>, RefRO<LocalTransform>, RefRO<GhostOwner>, RefRO<DashState>>() RefRO<PlayerFacing>, RefRO<LocalTransform>, RefRO<GhostOwner>, RefRO<DashState>>()
.WithAll<Simulate>().WithDisabled<Dead>()) .WithAll<Simulate>().WithDisabled<Dead>().WithEntityAccess())
{ {
// A dash window (i-frame OR recovery) active = dash owns movement + blocks a swing start (dash-cancel). // A dash window (i-frame OR recovery) active = dash owns movement + blocks a swing start (dash-cancel).
bool dashActive = ds.ValueRO.StartTick != 0u bool dashActive = ds.ValueRO.StartTick != 0u
@@ -138,14 +141,19 @@ namespace ProjectM.Simulation
if (swingStarted && isServer) if (swingStarted && isServer)
{ {
bool isFin = swingStep >= comboLen; bool isFin = swingStep >= comboLen;
// Slice 2: fold the player's class/run StatModifiers onto the live-tunable melee base so the
// PRIMARY verb scales with class identity (Warrior +MeleeDamage/+reach) + run augments.
bool hasMods = m_StatModLookup.HasBuffer(entity);
float pDamage = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, m_StatModLookup[entity]) : baseDamage);
float pRange = math.max(0f, hasMods ? StatMath.Apply(baseRange, StatTarget.MeleeRange, m_StatModLookup[entity]) : baseRange);
float2 face = facing.ValueRO.Direction; float2 face = facing.ValueRO.Direction;
face = math.lengthsq(face) < 1e-6f ? new float2(0f, 1f) : math.normalize(face); face = math.lengthsq(face) < 1e-6f ? new float2(0f, 1f) : math.normalize(face);
cleaves.Add(new PendingCleave cleaves.Add(new PendingCleave
{ {
From = xform.ValueRO.Position, From = xform.ValueRO.Position,
Face = face, Face = face,
Damage = isFin ? baseDamage * finisherMult : baseDamage, Damage = isFin ? pDamage * finisherMult : pDamage,
Range = isFin ? baseRange * finisherMult : baseRange, Range = isFin ? pRange * finisherMult : pRange,
KnockSpeed = isFin ? knockSpeed * finisherMult : knockSpeed, KnockSpeed = isFin ? knockSpeed * finisherMult : knockSpeed,
OwnerId = owner.ValueRO.NetworkId, OwnerId = owner.ValueRO.NetworkId,
Stamp = stamp, Stamp = stamp,
@@ -33,6 +33,16 @@ Evaluate the operator's playtest [[Scratch Notes 6152026]] (2026-06-15, playing
Built all four do-now wins ([[DR-038_Slice1_Combat_Readability_HUD_Declutter]]) via a grounding+design workflow (4 read-only agents → exact edit sites + the two resolved design points) then serial Unity-MCP edits across 12 files + a test file. **Validation:** clean compile (0 err/warn incl. the Burst-affecting `EnemyAISystem` query + the `[GhostEnabledBit]` source-gen); **345/345 EditMode** (342 baseline + 3 new `IsLunging` derive tests); **Play-validated** — the ghost-hash change did NOT break the handshake (server+client `conns=1`), `EnemyTelegraph` baked on all 4 enemy prefabs, `IsLunging` baked-DISABLED on the Charger + replicated to the client, zero runtime errors. Resolved design points: `EnemyTelegraph` = a **baked client-safe** component (TuningConfig is server-only) so the client sizes the danger ramp; `IsLunging` = a single-bit `[GhostEnabledBit]` **derived once/tick** from `LungeState.UntilTick` (the Dead idiom), direction derived from the replicated rotation. **Open:** the operator visual fun-gate (bar look, cone-persists-through-lunge, toggle feel) + the commit. Built all four do-now wins ([[DR-038_Slice1_Combat_Readability_HUD_Declutter]]) via a grounding+design workflow (4 read-only agents → exact edit sites + the two resolved design points) then serial Unity-MCP edits across 12 files + a test file. **Validation:** clean compile (0 err/warn incl. the Burst-affecting `EnemyAISystem` query + the `[GhostEnabledBit]` source-gen); **345/345 EditMode** (342 baseline + 3 new `IsLunging` derive tests); **Play-validated** — the ghost-hash change did NOT break the handshake (server+client `conns=1`), `EnemyTelegraph` baked on all 4 enemy prefabs, `IsLunging` baked-DISABLED on the Charger + replicated to the client, zero runtime errors. Resolved design points: `EnemyTelegraph` = a **baked client-safe** component (TuningConfig is server-only) so the client sizes the danger ramp; `IsLunging` = a single-bit `[GhostEnabledBit]` **derived once/tick** from `LungeState.UntilTick` (the Dead idiom), direction derived from the replicated rotation. **Open:** the operator visual fun-gate (bar look, cone-persists-through-lunge, toggle feel) + the commit.
## Next ## Slice 2 — Two Classes (Warrior/Ranger): design review done + forks locked + Phase 1 built (IN PROGRESS)
Operator **visual fun-gate** on Slice 1 (eyes-on: health bars, Charger lunge cone, telegraph ramp, build-mode toggle), then **commit**. Then open **Slice 2 — Two Classes (Warrior/Ranger)** with its adversarial netcode/determinism design review FIRST ([[validate-netcode-design-before-coding]]) before any `create_script`. Ran the adversarial pre-code review (1 ground + 3 lenses: netcode/determinism · class-feel · reuse/scope — all **GO-WITH-CHANGES**). It caught two real issues the strawman got wrong: the class carrier (`ConnectionConfig` is per-world, cleared pre-spawn → **use `GoInGameRequest` + `byte ClassId`**, written in `GoInGameServerSystem`) and a client-prediction gap (**promote `CharacterStatsRef.Id` to `[GhostField]`** so the owning client folds the right MaxHealth/MoveSpeed). The feel lens flagged the strawman as a **palette swap** → fixed via asymmetric melee + a distinct aim-directed cone + a co-op synergy seed.
**Operator forks LOCKED (all recommended):** asymmetric melee (DRG model — Warrior longer-reach/harder-hitting/slower + tankier; Ranger shorter/weaker melee/faster + longer range); **aim-directed** Warrior cone (short range, wide arc, short cooldown); **menu picker** (per-player class on the spawn RPC); **two seeds + co-op synergy** (Ranger gets +AutoTargetRange so the Warrior's knockback feeds it).
**Locked design (refined at code time):** melee asymmetry folds new `StatTarget.MeleeDamage`/`MeleeRange` directly in `MeleeComboSystem` off the player's replicated mods (server-side, deterministic; live-tunable base preserved) — NOT through `EffectiveAbilityStats` (avoids conflating with the Fire ability's Damage); the Warrior cone = a separate server-only `ConeFireDamageSystem` signalled by a `PendingConeFire` component (mirrors Projectile→ProjectileDamageSystem); class carried via a client-world `ClassSelection` singleton read by the Bursted `GoInGameClientSystem`; class write = `AbilityRef.Id` + `CharacterStatsRef.Id` + 2 seed `StatModifier`s (reserved `Tuning.ClassSourceId`, never stripped) in `GoInGameServerSystem`.
**Phase 1 BUILT + green (this session):** `StatIds` (CharacterId.Warrior=2/Ranger=3, AbilityId.WarriorCone=4, StatTarget.MeleeDamage=9/MeleeRange=10); `CharacterStatsRef.Id``[GhostField]` (the one re-bake); `MeleeComboSystem` folds the per-player melee mods (defensive `HasBuffer` guard → identity without seeds, so behavior-preserving). Compiles clean; **345/345 EditMode**. UNCOMMITTED (partial slice).
## Next (Slice 2 continuation)
Build the rest of Slice 2: (1) `Tuning.ClassSourceId`; (2) the Warrior cone — `PendingConeFire` + `AbilityFireSystem` Cone-archetype branch (aim-directed, cooldown both worlds, signal server-only) + `ConeFireDamageSystem`; (3) the class carrier — `GoInGameRequest.ClassId` + `ClassSelection` singleton + `GoInGameClientSystem` send + `GoInGameServerSystem` class writes; (4) AbilityDatabase authoring rows (Warrior/Ranger `CharacterStatsBlob`; WarriorCone `AbilityDefBlob`, archetype Cone, wide angle in `AutoTargetConeRadians`); (5) the menu picker UI (`MainMenuController``WorldLauncher``ClassSelection`); (6) a client cone VFX + the slash-arc reading the folded melee range. Then re-bake + Play-validate (server==client, the class asymmetry, the cone, clean handshake) + class-fold/cone EditMode tests, then commit Slice 2 as one unit (DR-039). Slice 1's operator visual fun-gate also still open.