From d9d67c4e787a4c85f7ce4c5a7b3da3fb7ee5b04b Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 18 Jun 2026 00:23:56 -0700 Subject: [PATCH] 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) --- .../Scripts/Simulation/Combat/StatIds.cs | 5 +++++ .../Simulation/Player/CharacterStatsRef.cs | 7 ++++--- .../Simulation/Player/MeleeComboSystem.cs | 16 ++++++++++++---- .../2026-06-17_Design_Redirect_Coop_Roguelite.md | 14 ++++++++++++-- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Assets/_Project/Scripts/Simulation/Combat/StatIds.cs b/Assets/_Project/Scripts/Simulation/Combat/StatIds.cs index b7377478e..9c798cbf6 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/StatIds.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/StatIds.cs @@ -7,6 +7,7 @@ namespace ProjectM.Simulation Primary = 1, FastLight = 2, SlowHeavy = 3, + WarriorCone = 4, // Slice 2: the Warrior's aim-directed short-range cone secondary (Fire slot) } /// Stable key for an authored character-stats definition in the AbilityDatabase blob. @@ -14,6 +15,8 @@ namespace ProjectM.Simulation { None = 0, 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) } /// @@ -31,6 +34,8 @@ namespace ProjectM.Simulation MoveSpeed = 6, TurnRate = 7, 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) } /// How a combines into the effective stat. diff --git a/Assets/_Project/Scripts/Simulation/Player/CharacterStatsRef.cs b/Assets/_Project/Scripts/Simulation/Player/CharacterStatsRef.cs index 40d6be2bd..2a1c86e4d 100644 --- a/Assets/_Project/Scripts/Simulation/Player/CharacterStatsRef.cs +++ b/Assets/_Project/Scripts/Simulation/Player/CharacterStatsRef.cs @@ -1,15 +1,16 @@ using Unity.Entities; +using Unity.NetCode; namespace ProjectM.Simulation { /// /// 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 - /// worlds); promote to a GhostField if runtime character changes are ever needed. Id stores a + /// blob, replacing M2's inlined PlayerMoveStats values. NOW a [GhostField] (Slice 2 classes) so the + /// server-written per-player class id replicates -> the owning client folds correct stats. Id stores a /// . /// public struct CharacterStatsRef : IComponentData { - public byte Id; + [GhostField] public byte Id; } } diff --git a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs index db76214af..d74e59c2f 100644 --- a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs @@ -51,6 +51,7 @@ namespace ProjectM.Simulation ComponentLookup m_KnockbackLookup; ComponentLookup m_RegionLookup; BufferLookup m_InvLookup; + BufferLookup m_StatModLookup; [BurstCompile] public void OnCreate(ref SystemState state) @@ -58,6 +59,7 @@ namespace ProjectM.Simulation m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false); m_RegionLookup = state.GetComponentLookup(isReadOnly: true); m_InvLookup = state.GetBufferLookup(isReadOnly: false); + m_StatModLookup = state.GetBufferLookup(isReadOnly: true); state.RequireForUpdate(); } @@ -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 // when at least one swing actually started — no per-tick enemy gather on idle/client ticks). var cleaves = isServer ? new NativeList(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, RefRO, RefRO, RefRO, RefRO, RefRO>() - .WithAll().WithDisabled()) + .WithAll().WithDisabled().WithEntityAccess()) { // A dash window (i-frame OR recovery) active = dash owns movement + blocks a swing start (dash-cancel). bool dashActive = ds.ValueRO.StartTick != 0u @@ -138,14 +141,19 @@ namespace ProjectM.Simulation if (swingStarted && isServer) { 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; face = math.lengthsq(face) < 1e-6f ? new float2(0f, 1f) : math.normalize(face); cleaves.Add(new PendingCleave { From = xform.ValueRO.Position, Face = face, - Damage = isFin ? baseDamage * finisherMult : baseDamage, - Range = isFin ? baseRange * finisherMult : baseRange, + Damage = isFin ? pDamage * finisherMult : pDamage, + Range = isFin ? pRange * finisherMult : pRange, KnockSpeed = isFin ? knockSpeed * finisherMult : knockSpeed, OwnerId = owner.ValueRO.NetworkId, Stamp = stamp, diff --git a/Docs/Vault/07_Sessions/2026/2026-06-17_Design_Redirect_Coop_Roguelite.md b/Docs/Vault/07_Sessions/2026/2026-06-17_Design_Redirect_Coop_Roguelite.md index 87469c570..1c9662867 100644 --- a/Docs/Vault/07_Sessions/2026/2026-06-17_Design_Redirect_Coop_Roguelite.md +++ b/Docs/Vault/07_Sessions/2026/2026-06-17_Design_Redirect_Coop_Roguelite.md @@ -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. -## 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.