From a7fdd6f71d858d1bf1d32ff90176f9a986ecbc05 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 18 Jun 2026 00:30:20 -0700 Subject: [PATCH] Slice 2 (WIP): class carrier (GoInGameRequest.ClassId) + Warrior cone archetype The per-player class travels on GoInGameRequest.ClassId (client reads a ClassSelection singleton); GoInGameServerSystem seeds the class at spawn via ClassTraits (AbilityRef + permanent trait StatModifiers on a reserved ClassSourceId; CharacterStatsRef stays Default so the DRG-asymmetry deltas ride the replicated OwnerSendType.All buffer). AbilityFireSystem gains the aim-directed Cone archetype: cooldown predicted both worlds, server-only cone damage to living enemies (same-tick, SourceTick-stamped, like the melee cleave). 345/345. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Client/Connection/GoInGameClientSystem.cs | 3 +- .../Server/Connection/GoInGameServerSystem.cs | 10 +++- .../Simulation/Combat/AbilityFireSystem.cs | 43 +++++++++++++-- .../Simulation/Combat/ClassSelection.cs | 16 ++++++ .../Simulation/Combat/ClassSelection.cs.meta | 2 + .../Scripts/Simulation/Combat/ClassTraits.cs | 52 +++++++++++++++++++ .../Simulation/Combat/ClassTraits.cs.meta | 2 + .../Simulation/Connection/GoInGameRequest.cs | 2 +- Assets/_Project/Scripts/Simulation/Tuning.cs | 6 ++- 9 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ClassSelection.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ClassSelection.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs.meta diff --git a/Assets/_Project/Scripts/Client/Connection/GoInGameClientSystem.cs b/Assets/_Project/Scripts/Client/Connection/GoInGameClientSystem.cs index ee81d7fef..4e7345cf2 100644 --- a/Assets/_Project/Scripts/Client/Connection/GoInGameClientSystem.cs +++ b/Assets/_Project/Scripts/Client/Connection/GoInGameClientSystem.cs @@ -36,8 +36,9 @@ namespace ProjectM.Client { ecb.AddComponent(connection); + byte classId = SystemAPI.HasSingleton() ? SystemAPI.GetSingleton().ClassId : (byte)0; var request = ecb.CreateEntity(); - ecb.AddComponent(request); + ecb.AddComponent(request, new GoInGameRequest { ClassId = classId }); // Slice 2: carry the chosen class ecb.AddComponent(request, new SendRpcCommandRequest { TargetConnection = connection }); } diff --git a/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs b/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs index a5aacee2d..fd6d554eb 100644 --- a/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs +++ b/Assets/_Project/Scripts/Server/Connection/GoInGameServerSystem.cs @@ -42,8 +42,8 @@ namespace ProjectM.Server center = BaseGridMath.PlotCenter(baseAnchor); var ecb = new EntityCommandBuffer(Allocator.Temp); - foreach (var (receive, requestEntity) in - SystemAPI.Query>().WithAll().WithEntityAccess()) + foreach (var (receive, goReq, requestEntity) in + SystemAPI.Query, RefRO>().WithEntityAccess()) { var connection = receive.ValueRO.SourceConnection; ecb.AddComponent(connection); @@ -55,6 +55,12 @@ namespace ProjectM.Server ecb.SetComponent(player, new GhostOwner { NetworkId = networkId.Value }); // Tag the player into the base region (M6 region/relevancy split). ecb.AddComponent(player, new RegionTag { Region = RegionId.Base }); + // Slice 2: seed the chosen class on the just-instantiated player. AbilityRef selects the Fire slot + // (Warrior = cone / Ranger = projectile); the DRG-asymmetry traits ride permanent StatModifiers + // (CharacterStatsRef stays Default -> deltas replicate via the OwnerSendType.All buffer). 0 -> Warrior. + byte classId = ClassTraits.Normalize(goReq.ValueRO.ClassId); + ecb.SetComponent(player, new AbilityRef { Id = ClassTraits.AbilityFor(classId) }); + ClassTraits.AppendSeeds(classId, player, ecb); // Auto-despawn the player when its owning connection is removed. ecb.AppendToBuffer(connection, new LinkedEntityGroup { Value = player }); diff --git a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs index 4b1545a76..295748712 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/AbilityFireSystem.cs @@ -64,14 +64,18 @@ namespace ProjectM.Simulation bool isServer = state.WorldUnmanaged.IsServer(); - // Server-only auto-target candidate set: training-dummy world XZ positions, collected once. + // Server-only target set (LIVING enemies/dummies), collected once: positions feed the gamepad + // auto-target assist, and entities+positions feed the Warrior CONE archetype's server-only cleave. var candidatePositions = new NativeList(Allocator.Temp); + var coneTargets = new NativeList(Allocator.Temp); + var coneTargetPos = new NativeList(Allocator.Temp); if (isServer) { - foreach (var dummyTransform in - SystemAPI.Query>().WithAny()) + foreach (var (tx, th, te) in + SystemAPI.Query, RefRO>().WithAny().WithEntityAccess()) { - candidatePositions.Add(dummyTransform.ValueRO.Position); + candidatePositions.Add(tx.ValueRO.Position); + if (th.ValueRO.Current > 0f) { coneTargets.Add(te); coneTargetPos.Add(tx.ValueRO.Position); } } } var candidates = candidatePositions.AsArray(); @@ -105,6 +109,35 @@ namespace ProjectM.Simulation // abilities are Projectile (0); hitscan/cone/aoe archetypes plug in at this point in MC-6. ref var adb = ref abilityDb.Value.Value; byte archetype = adb.TryGetAbility(abilityRef.ValueRO.Id, out var adef) ? adef.Archetype : (byte)AbilityArchetype.Projectile; + + // Slice 2: the Warrior's aim-directed CONE secondary (no projectile ghost). Predict the cooldown on + // both worlds; apply server-only cone damage to living enemies (mirrors the MeleeComboSystem cleave, + // same-tick: this runs before HealthApplyDamageSystem in the predicted group). SourceTick-stamped. + if (archetype == (byte)AbilityArchetype.Cone) + { + if (isServer) + { + float2 cFace = facing.ValueRO.Direction; + cFace = math.lengthsq(cFace) < 1e-6f ? new float2(0f, 1f) : math.normalize(cFace); + float cRange = math.max(0.1f, eff.ValueRO.Range); + float cCosHalf = math.cos(math.clamp(eff.ValueRO.AutoTargetConeRadians, 0.01f, 3.14159f)); + uint cStamp = TickUtil.NonZero(serverTick.TickIndexForValidTick); + for (int ci = 0; ci < coneTargets.Length; ci++) + { + if (!MeleeConeMath.InCone(xform.ValueRO.Position, cFace, cRange, cCosHalf, coneTargetPos[ci])) + continue; + ecb.AppendToBuffer(coneTargets[ci], new DamageEvent + { + Amount = eff.ValueRO.Damage, + SourceNetworkId = owner.ValueRO.NetworkId, + SourceTick = cStamp, + }); + } + } + uint coneCd = (uint)math.max(1, eff.ValueRO.CooldownTicks); + cd.ValueRW.NextFireTick = TickUtil.NonZero(serverTick.TickIndexForValidTick + coneCd); + continue; + } if (archetype != (byte)AbilityArchetype.Projectile) continue; @@ -179,6 +212,8 @@ namespace ProjectM.Simulation ecb.Playback(state.EntityManager); ecb.Dispose(); candidatePositions.Dispose(); + coneTargets.Dispose(); + coneTargetPos.Dispose(); } } } diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassSelection.cs b/Assets/_Project/Scripts/Simulation/Combat/ClassSelection.cs new file mode 100644 index 000000000..0ba544e84 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ClassSelection.cs @@ -0,0 +1,16 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Slice 2: the local player's chosen class (a byte), staged in the CLIENT world as a + /// singleton by the menu / WorldLauncher before going in-game. picks it up + /// (GoInGameClientSystem) so the server seeds the right class at spawn. NOT replicated — it is client-local + /// intent; the class travels to the server via the RPC, then back to all clients via the seeded StatModifiers + + /// the replicated AbilityRef. 0 = unset (the server defaults to Warrior). + /// + public struct ClassSelection : IComponentData + { + public byte ClassId; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassSelection.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ClassSelection.cs.meta new file mode 100644 index 000000000..e128b979f --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ClassSelection.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 22b13a41197f0fc42b3cce8c99bf02ba \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs b/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs new file mode 100644 index 000000000..d805968d0 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs @@ -0,0 +1,52 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Slice 2 — pure mapping from a chosen class (a byte) to its spawn-time setup: the + /// Fire-slot ability id + the permanent trait s (tagged with the reserved + /// , NEVER stripped). Applied by GoInGameServerSystem on the just-spawned + /// player and unit-tested. Burst-safe (byte-only, no managed types). + /// + /// Trait deltas seed onto the character (no per-class blob row needed — the + /// deltas ride the replicated StatModifier buffer, OwnerSendType.All, so the owning client folds the correct + /// EffectiveCharacterStats). The DRG-asymmetry (operator-locked): Warrior = melee bruiser (tankier, + /// slower, harder + longer-reach melee via MeleeDamage/MeleeRange); Ranger = ranged anchor (faster, + /// squishier, longer projectile range + a wider auto-target assist — the co-op synergy hook the Warrior's + /// knockback feeds). The Warrior's Fire = the aim-directed cone (); the + /// Ranger's Fire = the default projectile (). + /// + /// + public static class ClassTraits + { + public const byte WarriorClass = (byte)CharacterId.Warrior; + public const byte RangerClass = (byte)CharacterId.Ranger; + + /// Normalize a wire ClassId to a known class (0 / unknown -> Warrior). + public static byte Normalize(byte classId) => classId == RangerClass ? RangerClass : WarriorClass; + + /// The Fire-slot ability id for a class (Warrior = cone, Ranger = the default projectile). + public static byte AbilityFor(byte classId) + => classId == RangerClass ? (byte)AbilityId.Primary : (byte)AbilityId.WarriorCone; + + /// Append a class's permanent trait modifiers onto a player's StatModifier buffer (via ECB at spawn). + public static void AppendSeeds(byte classId, Entity player, EntityCommandBuffer ecb) + { + uint src = Tuning.ClassSourceId; + if (classId == RangerClass) + { + ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = 0.15f, SourceId = src }); + ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.PercentMult, Value = -0.15f, SourceId = src + 1u }); + ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.Range, Op = (byte)ModOp.PercentAdd, Value = 0.30f, SourceId = src + 2u }); + ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.AutoTargetRange, Op = (byte)ModOp.Flat, Value = 3f, SourceId = src + 3u }); + } + else + { + ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f, SourceId = src }); + ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = -0.15f, SourceId = src + 1u }); + ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MeleeDamage, Op = (byte)ModOp.Flat, Value = 6f, SourceId = src + 2u }); + ecb.AppendToBuffer(player, new StatModifier { Target = (byte)StatTarget.MeleeRange, Op = (byte)ModOp.Flat, Value = 0.8f, SourceId = src + 3u }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs.meta new file mode 100644 index 000000000..e4edf082b --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ClassTraits.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5f2897774c07bf14a87b2338114910ae \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Connection/GoInGameRequest.cs b/Assets/_Project/Scripts/Simulation/Connection/GoInGameRequest.cs index 9d215b707..16bd627de 100644 --- a/Assets/_Project/Scripts/Simulation/Connection/GoInGameRequest.cs +++ b/Assets/_Project/Scripts/Simulation/Connection/GoInGameRequest.cs @@ -7,5 +7,5 @@ namespace ProjectM.Simulation /// NetworkStreamInGame to the connection (enabling snapshot/command flow) and spawns the /// client's player ghost. Lives in Simulation so both worlds see the type for RPC source-gen. /// - public struct GoInGameRequest : IRpcCommand { } + public struct GoInGameRequest : IRpcCommand { public byte ClassId; } // Slice 2: per-player class (CharacterId byte; 0 -> server defaults to Warrior) } diff --git a/Assets/_Project/Scripts/Simulation/Tuning.cs b/Assets/_Project/Scripts/Simulation/Tuning.cs index 0a118b5f2..0b2fae13b 100644 --- a/Assets/_Project/Scripts/Simulation/Tuning.cs +++ b/Assets/_Project/Scripts/Simulation/Tuning.cs @@ -76,9 +76,13 @@ namespace ProjectM.Simulation // inline mods share that one id and are stripped target-agnostically via // TimedModifierUtil.RemoveBySourceId on unequip/swap. Full StatModifier SourceId map (keep DISJOINT): // 0u = pickups + debug-injection; 0x00A0E711 = ability-damage upgrade; 0x00DEB061 = debug stat command; - // 0x00E91000.. = equipment (4 slots). + // 0x00E91000.. = equipment (4 slots); 0x00C1A550.. = class traits (Slice 2, permanent). /// Base for per-slot equipment SourceIds; slot i tags its mods with EquipSourceIdBase + i. public const uint EquipSourceIdBase = 0x00E91000u; + + /// Slice 2: base SourceId for a class's permanent trait StatModifiers (Warrior/Ranger seeds). + /// DISJOINT from equipment/upgrade/debug ranges; class traits are NEVER stripped (permanent per session). + public const uint ClassSourceId = 0x00C1A550u; } }