Vault Re-Alignment
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &-592490988042947487
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 11
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
|
||||
version: 10
|
||||
--- !u!21 &2100000
|
||||
Material:
|
||||
serializedVersion: 8
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: M_Enemy_Charger_Animated
|
||||
m_Shader: {fileID: -6465566751694194690, guid: 32ea7846f763cb340950fafed798ecf6, type: 3}
|
||||
m_Parent: {fileID: 0}
|
||||
m_ModifiedSerializedProperties: 0
|
||||
m_ValidKeywords:
|
||||
- _USE_VERTEX_COLOR
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 2
|
||||
m_EnableInstancingVariants: 0
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap: {}
|
||||
disabledShaderPasses:
|
||||
- MOTIONVECTORS
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
m_TexEnvs:
|
||||
- _BaseColorMap:
|
||||
m_Texture: {fileID: 2800000, guid: db556d465e9f4ab4caffaf339cf5b656, type: 3}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MaskMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _NormalMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- unity_Lightmaps:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- unity_LightmapsInd:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- unity_ShadowMasks:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_Ints: []
|
||||
m_Floats:
|
||||
- _DeformedMeshIndex: 0
|
||||
- _Metallic: 0
|
||||
- _QueueControl: 0
|
||||
- _QueueOffset: 0
|
||||
- _Smoothness: 0
|
||||
- _USE_VERTEX_COLOR: 1
|
||||
m_Colors:
|
||||
- _BaseColor: {r: 1, g: 0.42, b: 0.36, a: 1}
|
||||
- _DeformationParamsForMotionVectors: {r: 0, g: 0, b: 0, a: 0}
|
||||
m_BuildTextureStacks: []
|
||||
m_AllowLocking: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d043f274e34de8a43b8c0201f2edaafd
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,164 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &3885353946372160549
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 3572766465862231365}
|
||||
- component: {fileID: 3909651526955663392}
|
||||
- component: {fileID: 3320445911748035220}
|
||||
- component: {fileID: 9053853372340598254}
|
||||
- component: {fileID: 6834786618115927220}
|
||||
- component: {fileID: 2544095781123180609}
|
||||
- component: {fileID: 5988865165041574521}
|
||||
m_Layer: 0
|
||||
m_Name: EnemyCharger
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &3572766465862231365
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!33 &3909651526955663392
|
||||
MeshFilter:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Mesh: {fileID: 10207, guid: 0000000000000000e000000000000000, type: 0}
|
||||
--- !u!23 &3320445911748035220
|
||||
MeshRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Enabled: 1
|
||||
m_CastShadows: 1
|
||||
m_ReceiveShadows: 1
|
||||
m_DynamicOccludee: 1
|
||||
m_StaticShadowCaster: 0
|
||||
m_MotionVectors: 1
|
||||
m_LightProbeUsage: 1
|
||||
m_ReflectionProbeUsage: 1
|
||||
m_RayTracingMode: 2
|
||||
m_RayTraceProcedural: 0
|
||||
m_RayTracingAccelStructBuildFlagsOverride: 0
|
||||
m_RayTracingAccelStructBuildFlags: 1
|
||||
m_SmallMeshCulling: 1
|
||||
m_ForceMeshLod: -1
|
||||
m_MeshLodSelectionBias: 0
|
||||
m_RenderingLayerMask: 1
|
||||
m_RendererPriority: 0
|
||||
m_Materials:
|
||||
- {fileID: 2100000, guid: fc4f991205d0aa347b65d89045f80c70, type: 2}
|
||||
m_StaticBatchInfo:
|
||||
firstSubMesh: 0
|
||||
subMeshCount: 0
|
||||
m_StaticBatchRoot: {fileID: 0}
|
||||
m_ProbeAnchor: {fileID: 0}
|
||||
m_LightProbeVolumeOverride: {fileID: 0}
|
||||
m_ScaleInLightmap: 1
|
||||
m_ReceiveGI: 1
|
||||
m_PreserveUVs: 1
|
||||
m_IgnoreNormalsForChartDetection: 0
|
||||
m_ImportantGI: 0
|
||||
m_StitchLightmapSeams: 1
|
||||
m_SelectedEditorRenderState: 3
|
||||
m_MinimumChartSize: 4
|
||||
m_AutoUVMaxDistance: 0.5
|
||||
m_AutoUVMaxAngle: 89
|
||||
m_LightmapParameters: {fileID: 0}
|
||||
m_GlobalIlluminationMeshLod: 0
|
||||
m_SortingLayerID: 0
|
||||
m_SortingLayer: 0
|
||||
m_SortingOrder: 0
|
||||
m_MaskInteraction: 0
|
||||
m_AdditionalVertexStreams: {fileID: 0}
|
||||
--- !u!114 &9053853372340598254
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: c16549610bfe4458aa9389201d072bb6, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.Entities.Hybrid::Unity.Entities.Hybrid.Baking.LinkedEntityGroupAuthoring
|
||||
--- !u!114 &6834786618115927220
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 7c79d771cedb4794bf100ce60df5f764, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.NetCode.Authoring.Hybrid::Unity.NetCode.GhostAuthoringComponent
|
||||
HasOwner: 0
|
||||
SupportAutoCommandTarget: 1
|
||||
TrackInterpolationDelay: 0
|
||||
GhostGroup: 0
|
||||
UsePreSerialization: 0
|
||||
UseSingleBaseline: 0
|
||||
RollbackPredictedSpawnedGhostState: 0
|
||||
RollbackPredictionOnStructuralChanges: 1
|
||||
DefaultGhostMode: 0
|
||||
SupportedGhostModes: 3
|
||||
OptimizationMode: 0
|
||||
Importance: 1
|
||||
MaxSendRate: 0
|
||||
prefabId:
|
||||
--- !u!114 &2544095781123180609
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 6b014797e9092694b9568c5b66d34a55, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.EnemyAuthoring
|
||||
MaxHealth: 45
|
||||
HitRadius: 0.8
|
||||
MoveSpeed: 2.6
|
||||
AttackRange: 1.7
|
||||
AttackDamage: 14
|
||||
AttackCooldownTicks: 48
|
||||
--- !u!114 &5988865165041574521
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3885353946372160549}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 9565191e0ea7fc94db934ae91a43a4cf, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ChargerAuthoring
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6da6b938e0fd50f48a885f6f50ac9d61
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0d42b4a8ef76489458ee1ecf51b4dbca
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,27 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Entities;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-1 — marks a Husk prefab as a CHARGER variant. Compose this WITH <see cref="EnemyAuthoring"/> on the
|
||||
/// prefab root (both bakers share the primary entity): EnemyAuthoring bakes the common Husk components and
|
||||
/// Charger-tuned stats; this bakes the server-only <see cref="LungeState"/> (zeroed = not lunging).
|
||||
/// Component-PRESENCE is the discriminator <c>EnemyAISystem</c> branches on — no enum/brain byte (the Burst
|
||||
/// cross-assembly-enum hazard) — routing the Charger to the commit→lunge→whiff-stagger pass while the Grunt
|
||||
/// pass excludes it via <c>.WithNone<LungeState>()</c>. NOT a <c>[GhostField]</c>: the lunged position
|
||||
/// replicates via stock LocalTransform like every Husk.
|
||||
/// </summary>
|
||||
public class ChargerAuthoring : MonoBehaviour
|
||||
{
|
||||
private class ChargerBaker : Baker<ChargerAuthoring>
|
||||
{
|
||||
public override void Bake(ChargerAuthoring authoring)
|
||||
{
|
||||
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
|
||||
AddComponent<LungeState>(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9565191e0ea7fc94db934ae91a43a4cf
|
||||
@@ -84,6 +84,9 @@ namespace ProjectM.Authoring
|
||||
AddComponent(entity, new HitRadius { Value = authoring.HitRadius });
|
||||
AddComponent<AbilityCooldown>(entity);
|
||||
AddBuffer<DamageEvent>(entity);
|
||||
// MC-1 dash: predicted dash window (derived from PlayerInput.Dash) + cooldown gate, baked idle/ready.
|
||||
AddComponent<DashState>(entity);
|
||||
AddComponent(entity, new DashCooldown { NextTick = 0 });
|
||||
|
||||
// Death gate (enableable, derived from Health by PlayerDeathStateSystem) baked DISABLED = alive;
|
||||
// plus the server-only respawn timer.
|
||||
|
||||
@@ -62,6 +62,20 @@ namespace ProjectM.Client
|
||||
if (GUILayout.Button("Go Expedition")) DebugCommandSendSystem.Teleport(RegionId.Expedition);
|
||||
GUILayout.EndHorizontal();
|
||||
|
||||
GUILayout.Space(6);
|
||||
GUILayout.Label("- Telemetry (MC-0) -");
|
||||
if (DevTelemetryReadout.HasData)
|
||||
{
|
||||
var t = DevTelemetryReadout.Latest;
|
||||
GUILayout.Label($"tick {t.LastSampleTick} husks {t.LiveEnemyCount}");
|
||||
GUILayout.Label($"dash neg {t.DashIFrameNegatedHits} / wasted {t.DashesWasted}");
|
||||
GUILayout.Label($"whiff open {t.ChargerWhiffWindowsOpened} / punish {t.ChargerWhiffPunishesLanded}");
|
||||
}
|
||||
else
|
||||
{
|
||||
GUILayout.Label("(waiting for server telemetry...)");
|
||||
}
|
||||
|
||||
GUILayout.EndArea();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#if UNITY_EDITOR
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectM.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-0 — EDITOR-ONLY client receiver for the periodic <see cref="ProjectM.Simulation.DebugTelemetryReport"/>.
|
||||
/// Drains the snapshot into <see cref="DevTelemetryReadout"/> (a plain static) so the IMGUI <c>DebugOverlay</c>
|
||||
/// reads NO ECS state directly (the job-safety rule for presentation). Plain client
|
||||
/// <see cref="SimulationSystemGroup"/>; non-Burst (touches a managed static).
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct DevTelemetryReceiveSystem : ISystem
|
||||
{
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<ProjectM.Simulation.DebugTelemetryReport, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
}
|
||||
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (report, reqEntity) in
|
||||
SystemAPI.Query<RefRO<ProjectM.Simulation.DebugTelemetryReport>>()
|
||||
.WithAll<ReceiveRpcCommandRequest>().WithEntityAccess())
|
||||
{
|
||||
var r = report.ValueRO;
|
||||
DevTelemetryReadout.Latest = new DevTelemetryReadout.Snapshot
|
||||
{
|
||||
DashIFrameNegatedHits = r.DashIFrameNegatedHits,
|
||||
DashesWasted = r.DashesWasted,
|
||||
ChargerWhiffWindowsOpened = r.ChargerWhiffWindowsOpened,
|
||||
ChargerWhiffPunishesLanded = r.ChargerWhiffPunishesLanded,
|
||||
LiveEnemyCount = r.LiveEnemyCount,
|
||||
LastSampleTick = r.LastSampleTick,
|
||||
};
|
||||
DevTelemetryReadout.HasData = true;
|
||||
ecb.DestroyEntity(reqEntity);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MC-0 — static bridge from the ECS telemetry receiver to the IMGUI <c>DebugOverlay</c> (so the overlay reads
|
||||
/// a plain struct, never ECS state). Reset on play-enter so a fast-enter-playmode reload can't show stale data.
|
||||
/// </summary>
|
||||
public static class DevTelemetryReadout
|
||||
{
|
||||
public struct Snapshot
|
||||
{
|
||||
public uint DashIFrameNegatedHits;
|
||||
public uint DashesWasted;
|
||||
public uint ChargerWhiffWindowsOpened;
|
||||
public uint ChargerWhiffPunishesLanded;
|
||||
public uint LiveEnemyCount;
|
||||
public uint LastSampleTick;
|
||||
}
|
||||
|
||||
public static Snapshot Latest;
|
||||
public static bool HasData;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
static void Reset()
|
||||
{
|
||||
Latest = default;
|
||||
HasData = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 685c34765eac3434aadf08c13fce1aa5
|
||||
@@ -77,6 +77,7 @@ namespace ProjectM.Client
|
||||
var gamepad = UnityEngine.InputSystem.Gamepad.current;
|
||||
var mouse = UnityEngine.InputSystem.Mouse.current;
|
||||
var keyboard = UnityEngine.InputSystem.Keyboard.current;
|
||||
bool dashPressed = ((keyboard != null && keyboard.leftShiftKey.wasPressedThisFrame) || (gamepad != null && gamepad.buttonEast.wasPressedThisFrame)) && !BuildPaletteState.Active;
|
||||
|
||||
float2 rightStick = float2.zero;
|
||||
bool gamepadActive = false;
|
||||
@@ -102,7 +103,7 @@ namespace ProjectM.Client
|
||||
kbmActive =
|
||||
keyboard.wKey.isPressed || keyboard.aKey.isPressed || keyboard.sKey.isPressed || keyboard.dKey.isPressed ||
|
||||
keyboard.upArrowKey.isPressed || keyboard.downArrowKey.isPressed ||
|
||||
keyboard.leftArrowKey.isPressed || keyboard.rightArrowKey.isPressed || keyboard.spaceKey.isPressed;
|
||||
keyboard.leftArrowKey.isPressed || keyboard.rightArrowKey.isPressed || keyboard.spaceKey.isPressed || keyboard.leftShiftKey.isPressed;
|
||||
}
|
||||
|
||||
if (gamepadActive && kbmActive)
|
||||
@@ -160,6 +161,9 @@ namespace ProjectM.Client
|
||||
input.ValueRW.Fire = default;
|
||||
if (firePressed)
|
||||
input.ValueRW.Fire.Set();
|
||||
input.ValueRW.Dash = default;
|
||||
if (dashPressed)
|
||||
input.ValueRW.Dash.Set();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,14 +52,18 @@ namespace ProjectM.Client
|
||||
ParticleSystem _hitFx;
|
||||
ParticleSystem _deathFx;
|
||||
ParticleSystem _muzzleFx;
|
||||
ParticleSystem _dashFx;
|
||||
AudioClip _hitClip;
|
||||
AudioClip _deathClip;
|
||||
AudioClip _fireClip;
|
||||
AudioClip _telegraphClip;
|
||||
AudioClip _dashClip;
|
||||
|
||||
Entity _localPlayer = Entity.Null;
|
||||
uint _lastLocalFireTick;
|
||||
bool _fireTickInit;
|
||||
uint _lastLocalDashTick;
|
||||
bool _dashTickInit;
|
||||
|
||||
const int NumberPoolSize = 32;
|
||||
const int MaxActiveVfx = 40; // bound one-shot VFX GameObject churn under sustained combat
|
||||
@@ -72,6 +76,7 @@ namespace ProjectM.Client
|
||||
_deathClip = MakeClip("husk_death", 320f, 50f, 0.34f, 0.55f, noise: false);
|
||||
_fireClip = MakeClip("fire", 880f, 1500f, 0.07f, 0.30f, noise: false);
|
||||
_telegraphClip = MakeClip("telegraph", 680f, 1020f, 0.12f, 0.35f, noise: false);
|
||||
_dashClip = MakeClip("dash", 950f, 240f, 0.12f, 0.50f, noise: false);
|
||||
}
|
||||
|
||||
protected override void OnStartRunning()
|
||||
@@ -83,6 +88,7 @@ namespace ProjectM.Client
|
||||
_hitFx = MakeBurst("HitSparks", mat, new Color(3f, 2.2f, 0.6f), 0.13f, 7f, 0.32f, 256);
|
||||
_deathFx = MakeBurst("DeathBurst", mat, new Color(3.2f, 0.7f, 0.25f), 0.22f, 9f, 0.55f, 512);
|
||||
_muzzleFx = MakeBurst("Muzzle", mat, new Color(0.6f, 2.4f, 3.2f), 0.12f, 5f, 0.20f, 128);
|
||||
_dashFx = MakeBurst("DashWhoosh", mat, new Color(0.7f, 2.6f, 3.0f), 0.16f, 4f, 0.30f, 256);
|
||||
|
||||
for (int i = 0; i < NumberPoolSize; i++)
|
||||
_numbers.Add(CreateNumber());
|
||||
@@ -104,6 +110,8 @@ namespace ProjectM.Client
|
||||
EntityManager.CompleteDependencyBeforeRO<Health>();
|
||||
EntityManager.CompleteDependencyBeforeRO<LocalTransform>();
|
||||
EntityManager.CompleteDependencyBeforeRO<AttackWindup>();
|
||||
EntityManager.CompleteDependencyBeforeRO<DashState>();
|
||||
EntityManager.CompleteDependencyBeforeRO<DashCooldown>();
|
||||
|
||||
// Resolve the local player (for hit colouring + fire feedback).
|
||||
_localPlayer = Entity.Null;
|
||||
@@ -115,6 +123,17 @@ namespace ProjectM.Client
|
||||
localPos = xf.ValueRO.Position;
|
||||
}
|
||||
|
||||
// Client-derived dash window of the LOCAL player (DashSystem runs in the client prediction loop
|
||||
// too): drives the i-frame shimmer + the hit-feedback suppression below. Observe-only.
|
||||
bool localIFrameActive = false;
|
||||
if (_localPlayer != Entity.Null && EntityManager.HasComponent<DashState>(_localPlayer)
|
||||
&& SystemAPI.TryGetSingleton<NetworkTime>(out var dashNetTime) && dashNetTime.ServerTick.IsValid)
|
||||
{
|
||||
var localDash = EntityManager.GetComponentData<DashState>(_localPlayer);
|
||||
localIFrameActive = localDash.IFrameUntilTick != 0u
|
||||
&& new NetworkTick(localDash.IFrameUntilTick).IsNewerThan(dashNetTime.ServerTick);
|
||||
}
|
||||
|
||||
// Edge-detect Health on every damageable ghost (players + Husks).
|
||||
_seen.Clear();
|
||||
foreach (var (health, xf, entity) in
|
||||
@@ -136,7 +155,9 @@ namespace ProjectM.Client
|
||||
PlayClip(_telegraphClip, (Vector3)p, 0.5f);
|
||||
}
|
||||
|
||||
if (cur < prev.Hp - 0.001f)
|
||||
// Local hit feedback is SUPPRESSED while the local i-frame window is active: the server
|
||||
// negates the hit; any transient Health dip is reconciliation flicker, not a real hit.
|
||||
if (cur < prev.Hp - 0.001f && !(isLocalPlayer && localIFrameActive && FeelConfig.DashHitSuppress))
|
||||
{
|
||||
SpawnNumber(prev.Hp - cur, (Vector3)p, isLocalPlayer, cam);
|
||||
Burst(_hitFx, cfg != null ? cfg.Hit : null, (Vector3)p + Vector3.up * 0.8f, FeelConfig.HitBurstCount);
|
||||
@@ -201,6 +222,26 @@ namespace ProjectM.Client
|
||||
_fireTickInit = true;
|
||||
}
|
||||
|
||||
// Local-player dash feedback (MC-1): DashCooldown.NextTick advances exactly once per dash
|
||||
// (replicated [GhostField], predicted both sides; raw uint edge like the muzzle flash — cosmetic
|
||||
// only). Whoosh + afterimage burst + camera punch on start, shimmer trail while i-frames last.
|
||||
if (_localPlayer != Entity.Null && EntityManager.HasComponent<DashCooldown>(_localPlayer))
|
||||
{
|
||||
uint nextDash = EntityManager.GetComponentData<DashCooldown>(_localPlayer).NextTick;
|
||||
if (_dashTickInit && nextDash != 0 && nextDash != _lastLocalDashTick)
|
||||
{
|
||||
EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.6f, FeelConfig.DashBurstCount);
|
||||
PlayClip(_dashClip, (Vector3)localPos, FeelConfig.DashSfxVolume);
|
||||
PrototypeCameraRig.AddShake(FeelConfig.DashShake);
|
||||
PrototypeCameraRig.PunchFov(FeelConfig.DashFovKick, FeelConfig.HitStopDurationMs);
|
||||
}
|
||||
_lastLocalDashTick = nextDash;
|
||||
_dashTickInit = true;
|
||||
|
||||
if (localIFrameActive) // i-frame shimmer trail while the local window is active
|
||||
EmitAt(_dashFx, (Vector3)localPos + Vector3.up * 0.7f, FeelConfig.DashShimmerPerFrame);
|
||||
}
|
||||
|
||||
UpdateProjectileTrails(cfg);
|
||||
PruneVfx();
|
||||
AnimateNumbers(dt, cam);
|
||||
|
||||
@@ -74,6 +74,21 @@ namespace ProjectM.Client
|
||||
/// <summary>Tether line width (world units).</summary>
|
||||
public static float LockOnLineWidth;
|
||||
|
||||
// ---- Feature 5 (MC-1): dash juice ----
|
||||
/// <summary>Camera shake on the local player's dash start.</summary>
|
||||
public static float DashShake;
|
||||
/// <summary>Transient FOV punch (degrees) on dash start — the "lurch" read (camera punch, never Time.timeScale).</summary>
|
||||
public static float DashFovKick;
|
||||
/// <summary>Afterimage/whoosh particle burst count at dash start.</summary>
|
||||
public static int DashBurstCount;
|
||||
/// <summary>Dash whoosh SFX volume.</summary>
|
||||
public static float DashSfxVolume;
|
||||
/// <summary>Particles emitted per frame while the local i-frame window is active (the shimmer trail).</summary>
|
||||
public static int DashShimmerPerFrame;
|
||||
/// <summary>Suppress local hit-feedback during the local i-frame window (masks the prediction-reconciliation
|
||||
/// Health flicker on a clean dodge — the documented acceptable-not-a-bug interaction).</summary>
|
||||
public static bool DashHitSuppress;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
public static void ResetDefaults()
|
||||
{
|
||||
@@ -108,6 +123,14 @@ namespace ProjectM.Client
|
||||
LockOnArcDegrees = 60f;
|
||||
LockOnLineColor = new Color(0.55f, 0.9f, 1f, 0.35f);
|
||||
LockOnLineWidth = 0.05f;
|
||||
|
||||
// Feature 5 dash (MC-1)
|
||||
DashShake = 0.18f;
|
||||
DashFovKick = 1.2f;
|
||||
DashBurstCount = 14;
|
||||
DashSfxVolume = 0.55f;
|
||||
DashShimmerPerFrame = 2;
|
||||
DashHitSuppress = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ namespace ProjectM.EditorTools
|
||||
const string SyntyWerewolf = "Assets/Synty/PolygonWerewolf/Prefabs/Characters/SM_Chr_Werewolf_01.prefab";
|
||||
const string SyntyKaiju = "Assets/Synty/PolygonKaiju/Prefabs/Characters/SM_Chr_Kaiju_01.prefab";
|
||||
|
||||
// MC-1 Charger (SciFi-City Muscle — verified Generic rig, distinct charging silhouette; see Synty_Asset_Inventory).
|
||||
const string ChargerAtlas = "Assets/Synty/PolygonSciFiCity/Textures/Alts/PolygonScifi_01_A.png";
|
||||
const string MatCharger = "Assets/_Project/Materials/M_Enemy_Charger_Animated.mat";
|
||||
const string SyntyMuscle = "Assets/Synty/PolygonSciFiCity/Prefabs/Characters/SM_Chr_Muscle_Male_01.prefab";
|
||||
|
||||
struct Variant
|
||||
{
|
||||
public string Name, Template, Synty, Output, Material;
|
||||
@@ -49,8 +54,12 @@ namespace ProjectM.EditorTools
|
||||
new Variant { Name = "Grunt (Werewolf)", Template = "Assets/_Project/Prefabs/Enemy.prefab", Synty = SyntyWerewolf, Output = "Assets/_Project/Prefabs/EnemyWerewolf.prefab", Material = MatWerewolf, RootY = -1.25f, Scale = 0f },
|
||||
new Variant { Name = "Swarmer (Werewolf Undead)", Template = "Assets/_Project/Prefabs/EnemySwarmer.prefab", Synty = SyntyWerewolf, Output = "Assets/_Project/Prefabs/EnemyWerewolfUndead.prefab", Material = MatWerewolfUndead, RootY = -1.67f, Scale = 0f },
|
||||
new Variant { Name = "Brute (Kaiju)", Template = "Assets/_Project/Prefabs/EnemyBrute.prefab", Synty = SyntyKaiju, Output = "Assets/_Project/Prefabs/EnemyKaiju.prefab", Material = MatKaiju, RootY = -0.52f, Scale = 0f },
|
||||
ChargerVariant(),
|
||||
};
|
||||
|
||||
/// <summary>MC-1 Charger: SciFi-City Muscle silhouette via the standard DR-023 pipeline (the inventory's prescribed next-faction path). Template scale 1.0 -> RootY -1.0 (humanoid -1/scale rule); fine-tune feet in Play.</summary>
|
||||
static Variant ChargerVariant() => new Variant { Name = "Charger (SciFi Muscle)", Template = "Assets/_Project/Prefabs/EnemyCharger.prefab", Synty = SyntyMuscle, Output = "Assets/_Project/Prefabs/EnemyChargerMuscle.prefab", Material = MatCharger, RootY = -1.00f, Scale = 0f };
|
||||
|
||||
[MenuItem("ProjectM/Animation/Enemy Rigs - Build All")]
|
||||
public static void BuildAll()
|
||||
{
|
||||
@@ -60,6 +69,18 @@ namespace ProjectM.EditorTools
|
||||
Debug.Log("[EnemyRigTools] Build All complete. Re-point WaveDirector.EnemyPrefabs[] at the new prefabs and re-bake the gameplay subscene.");
|
||||
}
|
||||
|
||||
/// <summary>MC-1: build ONLY the Charger material + prefab (leaves the committed Werewolf/Kaiju outputs untouched).</summary>
|
||||
[MenuItem("ProjectM/Animation/Enemy Rigs - Build Charger (MC-1)")]
|
||||
public static void BuildCharger()
|
||||
{
|
||||
MakeMat(MatCharger, ChargerAtlas, new Color(1f, 0.42f, 0.36f, 1f)); // red-shifted SciFi atlas = danger read
|
||||
AssetDatabase.SaveAssets();
|
||||
BuildOne(ChargerVariant());
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
Debug.Log("[EnemyRigTools] Charger built -> EnemyChargerMuscle.prefab; add it to WaveDirector.EnemyPrefabs[] in the gameplay subscene.");
|
||||
}
|
||||
|
||||
// ---- 1. Materials ------------------------------------------------------------------------------------
|
||||
|
||||
[MenuItem("ProjectM/Animation/Enemy Rigs - 1 Build Materials")]
|
||||
|
||||
@@ -96,6 +96,7 @@ namespace ProjectM.Server
|
||||
{
|
||||
Amount = turret.ValueRO.Damage,
|
||||
SourceNetworkId = -1,
|
||||
SourceTick = TickUtil.NonZero(now),
|
||||
});
|
||||
uint cd = (uint)math.max(1, turret.ValueRO.CooldownTicks);
|
||||
ps.ValueRW.NextTick = TickUtil.NonZero(now + cd);
|
||||
|
||||
@@ -71,7 +71,7 @@ namespace ProjectM.Server
|
||||
foreach (var (xform, stats, cooldown, knockback, windup) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>>()
|
||||
.WithAll<EnemyTag>())
|
||||
.WithAll<EnemyTag>().WithNone<LungeState>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
|
||||
@@ -145,6 +145,7 @@ namespace ProjectM.Server
|
||||
{
|
||||
Amount = stats.ValueRO.AttackDamage,
|
||||
SourceNetworkId = -1, // environment / Husk, not a player
|
||||
SourceTick = TickUtil.NonZero(now),
|
||||
});
|
||||
uint cooldownTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
|
||||
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cooldownTicks);
|
||||
@@ -170,6 +171,138 @@ namespace ProjectM.Server
|
||||
}
|
||||
}
|
||||
|
||||
// --- Charger pass: a Husk variant baked with LungeState commits to a punishable fixed-direction lunge.
|
||||
// Component-presence is the discriminator; the Grunt pass above excludes these via .WithNone<LungeState>().
|
||||
const float ChargerLungeSpeed = 16f; // units/s while lunging
|
||||
const uint ChargerLungeDurationTicks = 18; // ~0.30 s of committed travel
|
||||
const uint ChargerWindupTicks = 30; // ~0.50 s readable telegraph (>= interp delay + reaction)
|
||||
const uint ChargerWhiffStaggerTicks = 36; // ~0.60 s punish window on a whiff
|
||||
uint chargerWhiffsThisTick = 0;
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, lunge) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>>()
|
||||
.WithAll<EnemyTag>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
|
||||
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
|
||||
var kb = knockback.ValueRO;
|
||||
if (kb.UntilTick != 0)
|
||||
{
|
||||
var kbTick = new NetworkTick(kb.UntilTick);
|
||||
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
|
||||
{
|
||||
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
|
||||
kpos.y = pos.y;
|
||||
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = kpos;
|
||||
windup.ValueRW.WindUpUntilTick = 0;
|
||||
lunge.ValueRW.UntilTick = 0;
|
||||
continue;
|
||||
}
|
||||
knockback.ValueRW.UntilTick = 0;
|
||||
}
|
||||
|
||||
// Nearest living player (reuse the snapshot taken above).
|
||||
int cbest = -1; float cbestSq = float.MaxValue;
|
||||
for (int i = 0; i < playerPositions.Length; i++)
|
||||
{
|
||||
float2 dd = playerPositions[i].xz - pos.xz;
|
||||
float sq = math.lengthsq(dd);
|
||||
if (sq < cbestSq) { cbestSq = sq; cbest = i; }
|
||||
}
|
||||
float3 cTargetPos = playerPositions[cbest];
|
||||
|
||||
// 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff.
|
||||
var lg = lunge.ValueRO;
|
||||
if (lg.UntilTick != 0)
|
||||
{
|
||||
var lgTick = new NetworkTick(lg.UntilTick);
|
||||
if (lgTick.IsValid && lgTick.IsNewerThan(serverTick))
|
||||
{
|
||||
float3 intended = pos + new float3(lg.Dir.x, 0f, lg.Dir.y) * (lg.Speed * dt);
|
||||
intended.y = pos.y;
|
||||
float3 moved = sweep ? SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended;
|
||||
xform.ValueRW.Position = moved;
|
||||
if (math.lengthsq(lg.Dir) > 1e-6f)
|
||||
xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lg.Dir.x, 0f, lg.Dir.y), math.up());
|
||||
|
||||
if (EnemyAIMath.InAttackRange(moved, cTargetPos, stats.ValueRO.AttackRange))
|
||||
{
|
||||
ecb.AppendToBuffer(playerEntities[cbest], new DamageEvent
|
||||
{
|
||||
Amount = stats.ValueRO.AttackDamage,
|
||||
SourceNetworkId = -1,
|
||||
SourceTick = TickUtil.NonZero(now),
|
||||
});
|
||||
uint cdTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
|
||||
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cdTicks);
|
||||
lunge.ValueRW.UntilTick = 0; // landed -> end the lunge
|
||||
}
|
||||
else
|
||||
{
|
||||
float intendedDist = math.distance(pos.xz, intended.xz);
|
||||
float actualDist = math.distance(pos.xz, moved.xz);
|
||||
if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f)
|
||||
{
|
||||
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks);
|
||||
lunge.ValueRW.UntilTick = 0; // wall-stop whiff -> stagger (the punish window)
|
||||
chargerWhiffsThisTick++;
|
||||
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
|
||||
}
|
||||
}
|
||||
continue; // committed this tick
|
||||
}
|
||||
|
||||
// Timer elapsed without landing -> overshoot whiff -> stagger, then seek this tick.
|
||||
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks);
|
||||
lunge.ValueRW.UntilTick = 0;
|
||||
chargerWhiffsThisTick++;
|
||||
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
|
||||
}
|
||||
|
||||
// 3. Seek + face (shared shape with the Grunt path).
|
||||
float cStop = stats.ValueRO.AttackRange * 0.9f;
|
||||
float3 cvel = EnemyAIMath.SeekVelocity(pos, cTargetPos, stats.ValueRO.MoveSpeed, cStop);
|
||||
float3 cNewPos = pos + cvel * dt; cNewPos.y = pos.y;
|
||||
if (sweep) cNewPos = SweptMove(in physics, pos, cNewPos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = cNewPos;
|
||||
float3 cToTarget = cTargetPos - pos; cToTarget.y = 0f;
|
||||
if (math.lengthsq(cToTarget) > 1e-6f)
|
||||
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(cToTarget), math.up());
|
||||
|
||||
// 4. Commit: a wind-up elapses -> LOCK the lunge direction + fire. NO cancel-on-leave-range — the
|
||||
// whole point is the commit lands even if the player dodged out of range (the punishable tell).
|
||||
uint cWindRaw = windup.ValueRO.WindUpUntilTick;
|
||||
if (cWindRaw != 0)
|
||||
{
|
||||
var cWindTick = new NetworkTick(cWindRaw);
|
||||
if (!(cWindTick.IsValid && cWindTick.IsNewerThan(serverTick)))
|
||||
{
|
||||
float3 toT = cTargetPos - pos; toT.y = 0f;
|
||||
float2 ldir = math.lengthsq(toT) > 1e-6f ? math.normalize(toT.xz) : new float2(0f, 1f);
|
||||
lunge.ValueRW.Dir = ldir;
|
||||
lunge.ValueRW.Speed = ChargerLungeSpeed;
|
||||
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + ChargerLungeDurationTicks);
|
||||
windup.ValueRW.WindUpUntilTick = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bool cInRange = EnemyAIMath.InAttackRange(pos, cTargetPos, stats.ValueRO.AttackRange);
|
||||
if (cInRange)
|
||||
{
|
||||
bool cReady = cooldown.ValueRO.NextAttackTick == 0
|
||||
|| !new NetworkTick(cooldown.ValueRO.NextAttackTick).IsNewerThan(serverTick);
|
||||
if (cReady)
|
||||
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + ChargerWindupTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton<DevTelemetry>())
|
||||
SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick;
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
|
||||
ecb.Dispose();
|
||||
|
||||
@@ -25,6 +25,12 @@ namespace ProjectM.Server
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(ProjectileDamageSystem))]
|
||||
// Pin the drain AFTER DashSystem: a same-tick player-sourced projectile (ProjectileDamageSystem stamps
|
||||
// SourceTick = now and this system drains the SAME tick) must see a dash window STARTED this tick —
|
||||
// without the edge the negation at src == StartTick is an unconstrained sorter tiebreak. The Dash chain
|
||||
// (StatRecompute→PlayerControl→Dash) and the projectile chain (PlayerAim→AbilityFire→ProjectileMove→
|
||||
// ProjectileDamage→here) are otherwise disjoint, so this edge cannot form a cycle (Play-validated).
|
||||
[UpdateAfter(typeof(DashSystem))]
|
||||
[BurstCompile]
|
||||
public partial struct HealthApplyDamageSystem : ISystem
|
||||
{
|
||||
@@ -33,6 +39,8 @@ namespace ProjectM.Server
|
||||
{
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var netTime);
|
||||
uint negatedThisTick = 0;
|
||||
uint punishesThisTick = 0;
|
||||
|
||||
foreach (var (health, dmg, entity) in
|
||||
SystemAPI.Query<RefRW<Health>, DynamicBuffer<DamageEvent>>()
|
||||
@@ -63,10 +71,55 @@ namespace ProjectM.Server
|
||||
}
|
||||
}
|
||||
|
||||
bool hasDash = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<DashState>(entity);
|
||||
DashState ds = hasDash ? SystemAPI.GetComponent<DashState>(entity) : default;
|
||||
|
||||
bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<LungeState>(entity);
|
||||
uint negatedForThisEntity = 0u;
|
||||
float total = 0f;
|
||||
for (int i = 0; i < dmg.Length; i++)
|
||||
{
|
||||
uint src = dmg[i].SourceTick;
|
||||
if (hasDash && src != 0u && ds.IFrameUntilTick != 0u)
|
||||
{
|
||||
var srcTick = new NetworkTick(src);
|
||||
var startTick = new NetworkTick(ds.StartTick);
|
||||
var untilTick = new NetworkTick(ds.IFrameUntilTick);
|
||||
// Dash i-frames cover the HALF-OPEN window [StartTick, IFrameUntilTick): negate iff src >= start AND src < until.
|
||||
bool atOrAfterStart = srcTick.IsValid && startTick.IsValid && !startTick.IsNewerThan(srcTick);
|
||||
bool beforeUntil = untilTick.IsValid && untilTick.IsNewerThan(srcTick);
|
||||
if (atOrAfterStart && beforeUntil)
|
||||
{
|
||||
negatedThisTick++;
|
||||
negatedForThisEntity++;
|
||||
continue; // dash i-frame negates this hit (per-element, not a whole-buffer clear)
|
||||
}
|
||||
}
|
||||
total += dmg[i].Amount;
|
||||
|
||||
// MC-1 punish scoring: a player-sourced hit (SourceNetworkId >= 0) landing inside a Charger's
|
||||
// whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1.
|
||||
if (isCharger && dmg[i].SourceNetworkId >= 0)
|
||||
{
|
||||
var lunge = SystemAPI.GetComponent<LungeState>(entity);
|
||||
if (lunge.StaggerUntilTick != 0u)
|
||||
{
|
||||
var stag = new NetworkTick(lunge.StaggerUntilTick);
|
||||
if (stag.IsValid && stag.IsNewerThan(netTime.ServerTick))
|
||||
{
|
||||
punishesThisTick++;
|
||||
lunge.StaggerUntilTick = 0u;
|
||||
SystemAPI.SetComponent(entity, lunge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dmg.Clear();
|
||||
if (negatedForThisEntity != 0u)
|
||||
{
|
||||
ds.NegatedCount += negatedForThisEntity; // server-side spam signal; DashSystem reads it at window-close
|
||||
SystemAPI.SetComponent(entity, ds);
|
||||
}
|
||||
|
||||
float newHp = health.ValueRO.Current - total;
|
||||
|
||||
@@ -83,6 +136,12 @@ namespace ProjectM.Server
|
||||
if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity)))
|
||||
ecb.DestroyEntity(entity);
|
||||
}
|
||||
if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton<DevTelemetry>())
|
||||
{
|
||||
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
|
||||
telem.ValueRW.DashIFrameNegatedHits += negatedThisTick;
|
||||
telem.ValueRW.ChargerWhiffPunishesLanded += punishesThisTick;
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
|
||||
@@ -130,6 +130,7 @@ namespace ProjectM.Server
|
||||
{
|
||||
Amount = proj.ValueRO.Damage,
|
||||
SourceNetworkId = projOwnerId,
|
||||
SourceTick = haveTick ? TickUtil.NonZero(nt.ServerTick.TickIndexForValidTick) : 0u,
|
||||
});
|
||||
var hitTarget = targetEntities[bestIdx];
|
||||
if (haveTick && Tuning.KnockbackSpeed > 0f && m_KnockbackLookup.HasComponent(hitTarget))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#if UNITY_EDITOR
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-0 — EDITOR-ONLY server telemetry sampler/sender. Ensures the <see cref="DevTelemetry"/> singleton,
|
||||
/// samples live-enemy-count + the server tick each tick, and every <see cref="ReportPeriodTicks"/> ships a
|
||||
/// <see cref="DebugTelemetryReport"/> snapshot to every connection (so the dev overlay shows live fun-gate
|
||||
/// counters over a real connection too). Combat systems increment the real counters at the stamp sites (MC-1+).
|
||||
/// Plain server <see cref="SimulationSystemGroup"/> (NOT the predicted loop); non-Burst (managed-simple,
|
||||
/// editor-only). Stripped from builds; the wire TYPE <see cref="DebugTelemetryReport"/> is unconditional.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct DevTelemetrySystem : ISystem
|
||||
{
|
||||
const uint ReportPeriodTicks = 15;
|
||||
EntityQuery m_Husks;
|
||||
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||
if (state.GetEntityQuery(ComponentType.ReadWrite<DevTelemetry>()).IsEmpty)
|
||||
state.EntityManager.CreateEntity(typeof(DevTelemetry));
|
||||
}
|
||||
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
|
||||
telem.ValueRW.LiveEnemyCount = (uint)m_Husks.CalculateEntityCount();
|
||||
telem.ValueRW.LastSampleTick = now;
|
||||
|
||||
if (now == 0 || (now % ReportPeriodTicks) != 0)
|
||||
return;
|
||||
|
||||
var t = telem.ValueRO;
|
||||
var report = new DebugTelemetryReport
|
||||
{
|
||||
DashIFrameNegatedHits = t.DashIFrameNegatedHits,
|
||||
DashesWasted = t.DashesWasted,
|
||||
ChargerWhiffWindowsOpened = t.ChargerWhiffWindowsOpened,
|
||||
ChargerWhiffPunishesLanded = t.ChargerWhiffPunishesLanded,
|
||||
LiveEnemyCount = t.LiveEnemyCount,
|
||||
LastSampleTick = t.LastSampleTick,
|
||||
};
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (netId, connEnt) in SystemAPI.Query<RefRO<NetworkId>>().WithEntityAccess())
|
||||
{
|
||||
var req = ecb.CreateEntity();
|
||||
ecb.AddComponent(req, report);
|
||||
ecb.AddComponent(req, new SendRpcCommandRequest { TargetConnection = connEnt });
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ef1e1f5e7e01b77489dcb181652176a0
|
||||
@@ -16,5 +16,11 @@ namespace ProjectM.Simulation
|
||||
|
||||
/// <summary>NetworkId of the firing player that caused this hit (attribution / self-hit filtering upstream).</summary>
|
||||
public int SourceNetworkId;
|
||||
|
||||
/// <summary>Raw ServerTick at which this hit logically LANDS (the appending tick), stamped via
|
||||
/// <c>TickUtil.NonZero</c> at every append site (0 = unstamped). The dash i-frame negation compares it
|
||||
/// against the dashing player's <c>DashState</c> window, so a strike appended a tick before it is
|
||||
/// drained is judged against the tick it was AUTHORED, not the tick it was applied.</summary>
|
||||
public uint SourceTick;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-1 — server-only Charger lunge state (a KnockbackState SHAPE-twin). Component PRESENCE is the Charger
|
||||
/// discriminator (no enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted):
|
||||
/// a Husk variant baked with LungeState is driven by the Charger branch, every other Husk by the Grunt branch
|
||||
/// (which excludes these via <c>.WithNone<LungeState>()</c>). On a wind-up commit the Charger LOCKS
|
||||
/// <see cref="Dir"/> toward the target and travels at <see cref="Speed"/> until <see cref="UntilTick"/> — dealing
|
||||
/// contact damage if it connects, or staggering into a punish window if it whiffs (wall-stop or overshoot).
|
||||
/// NOT a <c>[GhostField]</c> (the lunged position replicates via the stock LocalTransform variant, like
|
||||
/// KnockbackState). All ticks via <c>TickUtil.NonZero</c>; compared with <see cref="Unity.NetCode.NetworkTick"/> only.
|
||||
/// </summary>
|
||||
public struct LungeState : IComponentData
|
||||
{
|
||||
/// <summary>Fixed planar lunge heading, locked at commit (world XZ -> float2 x,y).</summary>
|
||||
public float2 Dir;
|
||||
|
||||
/// <summary>Lunge speed (world units/s); only meaningful while <see cref="UntilTick"/> is active.</summary>
|
||||
public float Speed;
|
||||
|
||||
/// <summary>Raw tick the lunge ends (NonZero). <c>0</c> = not lunging. Active while .IsNewerThan(serverTick).</summary>
|
||||
public uint UntilTick;
|
||||
|
||||
/// <summary>Raw tick the whiff-stagger punish window ends (NonZero; set at BOTH whiff sites). 0 = not
|
||||
/// staggered — or already punished: HealthApplyDamageSystem zeroes it when the first player-sourced hit
|
||||
/// lands so a window counts ONCE in DevTelemetry.ChargerWhiffPunishesLanded. The attack lockout itself
|
||||
/// rides EnemyAttackCooldown.NextAttackTick; this field only scores the punish.</summary>
|
||||
public uint StaggerUntilTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cc65446b98bef1040bc5b9beaac094ba
|
||||
@@ -0,0 +1,49 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-0 — server-only dev-telemetry accumulator (a singleton). Counters are incremented at the
|
||||
/// combat stamp sites (wired in MC-1+) so the fun-gate is MEASURED, not argued. NOT a
|
||||
/// <c>[GhostField]</c> (no ghost-hash change); shipped to dev clients via a periodic
|
||||
/// <see cref="DebugTelemetryReport"/> RPC. The component type is unconditional (stable across
|
||||
/// release/dev peers); only the dev send/sample/receive SYSTEMS are <c>#if UNITY_EDITOR</c>.
|
||||
/// </summary>
|
||||
public struct DevTelemetry : IComponentData
|
||||
{
|
||||
/// <summary>Hits a dash i-frame window negated (incremented in HealthApplyDamageSystem, MC-1).</summary>
|
||||
public uint DashIFrameNegatedHits;
|
||||
|
||||
/// <summary>Dashes whose i-frame window negated nothing (spam signal, MC-1).</summary>
|
||||
public uint DashesWasted;
|
||||
|
||||
/// <summary>Charger lunges that whiffed and opened a punish window (EnemyAISystem, MC-1).</summary>
|
||||
public uint ChargerWhiffWindowsOpened;
|
||||
|
||||
/// <summary>Of those, the ones the player actually punished (MC-1).</summary>
|
||||
public uint ChargerWhiffPunishesLanded;
|
||||
|
||||
/// <summary>Living Husks, sampled each report — proof-of-life (changes during play, no MC-1 dep).</summary>
|
||||
public uint LiveEnemyCount;
|
||||
|
||||
/// <summary>Server tick at the last sample — proof-of-life that the pipe is live.</summary>
|
||||
public uint LastSampleTick;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MC-0 — server → dev-client telemetry snapshot (sent periodically by the editor-only sampler).
|
||||
/// <b>Unconditional wire type</b> (like <see cref="DebugCommandRequest"/>) so the reflection-built
|
||||
/// RpcCollection hash matches across release/dev peers; only the send/receive SYSTEMS are
|
||||
/// <c>#if UNITY_EDITOR</c>. The dev overlay reads the latest snapshot to show live fun-gate counters.
|
||||
/// </summary>
|
||||
public struct DebugTelemetryReport : IRpcCommand
|
||||
{
|
||||
public uint DashIFrameNegatedHits;
|
||||
public uint DashesWasted;
|
||||
public uint ChargerWhiffWindowsOpened;
|
||||
public uint ChargerWhiffPunishesLanded;
|
||||
public uint LiveEnemyCount;
|
||||
public uint LastSampleTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6c5dfccd35c016940914a8357204f4e8
|
||||
@@ -0,0 +1,19 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-1 — predicted per-player dash cooldown gate (an <c>AbilityCooldown</c> twin). <c>[GhostField]</c> so the
|
||||
/// owning client does not mispredict the cooldown across rollback / reconnect: re-predicted ticks see the same
|
||||
/// authoritative gate the server applied and converge without a double-dash. <c>0</c> = ready; set to
|
||||
/// <c>serverTick + dashCooldownTicks</c> via <c>TickUtil.NonZero</c> on dash-start; compare by wrapping into a
|
||||
/// <see cref="NetworkTick"/> and using <see cref="NetworkTick.IsNewerThan"/> (raw uint subtraction is unsafe
|
||||
/// across tick wraparound). Baked <c>{NextTick = 0}</c>.
|
||||
/// </summary>
|
||||
public struct DashCooldown : IComponentData
|
||||
{
|
||||
/// <summary>Raw tick of the earliest tick the player may dash again. <c>0</c> = ready.</summary>
|
||||
[GhostField] public uint NextTick;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f6f32a690284a3a47bf02829323ed8f5
|
||||
@@ -0,0 +1,33 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-1 — predicted, NON-replicated dash window on the owner-predicted player. A SHAPE-clone of
|
||||
/// <c>KnockbackState</c>, but UNLIKE KnockbackState it lives on a PREDICTED player and is re-simulated from
|
||||
/// the replicated <see cref="PlayerInput.Dash"/> InputEvent every predicted tick — so it is authoritative on
|
||||
/// the server at the tick <c>HealthApplyDamageSystem</c> drains damage, even for a melee strike appended a
|
||||
/// tick earlier in the plain group. All ticks routed through <c>TickUtil.NonZero</c>; compared via
|
||||
/// <see cref="Unity.NetCode.NetworkTick"/> only (never raw uint). NOT a <c>[GhostField]</c> (no player-ghost
|
||||
/// re-bake). Baked all-zero (idle).
|
||||
/// </summary>
|
||||
public struct DashState : IComponentData
|
||||
{
|
||||
/// <summary>Planar XZ dash heading, captured at dash-start.</summary>
|
||||
public float2 Dir;
|
||||
|
||||
/// <summary>Raw ServerTick at dash-start (NonZero-coerced). Lower (inclusive) bound of the i-frame window.</summary>
|
||||
public uint StartTick;
|
||||
|
||||
/// <summary>StartTick + i-frame window (NonZero). I-frames cover the HALF-OPEN range [StartTick, IFrameUntilTick).</summary>
|
||||
public uint IFrameUntilTick;
|
||||
|
||||
/// <summary>IFrameUntilTick + recovery tail (NonZero). Movement-lock tail (no i-frames) so a panic-dash is punishable.</summary>
|
||||
public uint RecoverUntilTick;
|
||||
|
||||
/// <summary>Hits negated by THIS dash's i-frame window. SERVER-written (HealthApplyDamageSystem);
|
||||
/// 0 at window-close = a wasted dash (DevTelemetry.DashesWasted spam signal). The client copy stays 0.</summary>
|
||||
public uint NegatedCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28b7317dff9952841a0fb4b66df54f90
|
||||
@@ -0,0 +1,113 @@
|
||||
using Unity.Burst;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-1 — the predicted dodge dash. On a fresh <see cref="PlayerInput.Dash"/> press (cooldown ready and not
|
||||
/// already mid-dash) it captures the dash heading from <see cref="PlayerFacing"/> and opens a HALF-OPEN i-frame
|
||||
/// window [StartTick, IFrameUntilTick) plus a recovery tail. While the i-frame window is active it OVERRIDES
|
||||
/// <see cref="CharacterControl.MoveVelocity"/> with the dash velocity and raises
|
||||
/// <see cref="CharacterComponent.GroundedMovementSharpness"/> to ~200 so the move reads as a BLINK (the CC
|
||||
/// processor lerps RelativeVelocity toward MoveVelocity at that sharpness — no CharacterProcessor edit needed).
|
||||
/// During the recovery tail movement is locked to zero (no i-frames) so a panic-dash is punishable.
|
||||
/// <see cref="ProjectM.Server"/>'s HealthApplyDamageSystem reads the window to negate hits authored inside it.
|
||||
/// <para>
|
||||
/// Runs in <see cref="PredictedSimulationSystemGroup"/> AFTER <see cref="PlayerControlSystem"/> (it overrides
|
||||
/// the input-derived MoveVelocity that system wrote this tick) and is gated
|
||||
/// <c>.WithAll<Simulate>().WithDisabled<Dead>()</c>. The START is an idempotent pure function of
|
||||
/// replicated input + tick (no IsFirstTimeFullyPredictingTick guard); the OVERRIDE re-applies on EVERY predicted
|
||||
/// pass so rollback re-simulation converges. All ticks routed through <c>TickUtil.NonZero</c>; compared via
|
||||
/// <see cref="NetworkTick"/> only. DashSystem owns GroundedMovementSharpness on the player (base = the CC default
|
||||
/// 15); PlayerDeathStateSystem restores it + clears the window on death.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(PlayerControlSystem))]
|
||||
[BurstCompile]
|
||||
public partial struct DashSystem : ISystem
|
||||
{
|
||||
// Baked-first feel knobs (MC-1; promote to a live TuningConfig later). Sim runs at 60 ticks/sec.
|
||||
const float DashDistance = 4.0f; // world units covered during the i-frame window
|
||||
const uint IFrameWindowTicks = 12; // ~0.20 s of i-frames
|
||||
const uint RecoverTailTicks = 9; // ~0.15 s movement-locked tail (punishes spam)
|
||||
const uint DashCooldownTicks = 45; // ~0.75 s
|
||||
const float DashSharpness = 200f; // GroundedMovementSharpness during the dash -> blink
|
||||
const float DefaultSharpness = 15f; // CharacterComponent.GetDefault() base
|
||||
const float SimTickRate = 60f;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
if (!SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) || !netTime.ServerTick.IsValid)
|
||||
return;
|
||||
var serverTick = netTime.ServerTick;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
float dashSpeed = DashDistance / (IFrameWindowTicks / SimTickRate);
|
||||
|
||||
foreach (var (ds, cd, control, character, input, facing) in
|
||||
SystemAPI.Query<RefRW<DashState>, RefRW<DashCooldown>, RefRW<CharacterControl>,
|
||||
RefRW<CharacterComponent>, RefRO<PlayerInput>, RefRO<PlayerFacing>>()
|
||||
.WithAll<Simulate>().WithDisabled<Dead>())
|
||||
{
|
||||
// --- START (idempotent: fresh press + cooldown ready + not already mid-dash) ---
|
||||
bool ready = cd.ValueRO.NextTick == 0u
|
||||
|| !new NetworkTick(cd.ValueRO.NextTick).IsNewerThan(serverTick);
|
||||
bool inWindow = ds.ValueRO.RecoverUntilTick != 0u
|
||||
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
|
||||
if (input.ValueRO.Dash.IsSet && ready && !inWindow)
|
||||
{
|
||||
float2 dir = facing.ValueRO.Direction;
|
||||
if (math.lengthsq(dir) < 1e-6f) dir = new float2(0f, 1f);
|
||||
dir = math.normalize(dir);
|
||||
ds.ValueRW.Dir = dir;
|
||||
ds.ValueRW.StartTick = TickUtil.NonZero(now);
|
||||
ds.ValueRW.IFrameUntilTick = TickUtil.NonZero(now + IFrameWindowTicks);
|
||||
ds.ValueRW.RecoverUntilTick = TickUtil.NonZero(now + IFrameWindowTicks + RecoverTailTicks);
|
||||
cd.ValueRW.NextTick = TickUtil.NonZero(now + DashCooldownTicks);
|
||||
}
|
||||
|
||||
// --- OVERRIDE (runs every predicted pass so rollback re-simulation re-applies it) ---
|
||||
// The lower bound matters: DashState is non-replicated, so prediction rollback does NOT restore
|
||||
// it — a re-simulated PRE-dash tick (serverTick < StartTick) still sees the post-press window and,
|
||||
// gated on the upper bound alone, would stomp dash velocity onto ticks that never had it
|
||||
// (dash-start overshoot under real latency). Membership = the half-open [StartTick, …) test.
|
||||
bool inDashWindow = ds.ValueRO.StartTick != 0u
|
||||
&& !new NetworkTick(ds.ValueRO.StartTick).IsNewerThan(serverTick);
|
||||
bool iFrameActive = inDashWindow && ds.ValueRO.IFrameUntilTick != 0u
|
||||
&& new NetworkTick(ds.ValueRO.IFrameUntilTick).IsNewerThan(serverTick);
|
||||
bool recoverActive = inDashWindow && ds.ValueRO.RecoverUntilTick != 0u
|
||||
&& new NetworkTick(ds.ValueRO.RecoverUntilTick).IsNewerThan(serverTick);
|
||||
if (iFrameActive)
|
||||
{
|
||||
float2 d = ds.ValueRO.Dir;
|
||||
control.ValueRW.MoveVelocity = new float3(d.x, 0f, d.y) * dashSpeed;
|
||||
character.ValueRW.GroundedMovementSharpness = DashSharpness;
|
||||
}
|
||||
else if (recoverActive)
|
||||
{
|
||||
control.ValueRW.MoveVelocity = float3.zero; // movement locked during the punishable tail
|
||||
character.ValueRW.GroundedMovementSharpness = DefaultSharpness;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (character.ValueRO.GroundedMovementSharpness != DefaultSharpness)
|
||||
character.ValueRW.GroundedMovementSharpness = DefaultSharpness; // restore after the dash
|
||||
|
||||
// Window-close edge: score a wasted dash (negated nothing) ONCE, then clear the window.
|
||||
// SERVER-only — the DevTelemetry singleton exists only in the (editor) server world; the
|
||||
// client keeps its copy un-zeroed so rollback re-simulation of the tail stays intact. All
|
||||
// in-window strikes drain >= 9 ticks before this edge, so clearing can't eat a negation.
|
||||
if (ds.ValueRO.RecoverUntilTick != 0u && SystemAPI.TryGetSingletonRW<DevTelemetry>(out var telem))
|
||||
{
|
||||
if (ds.ValueRO.NegatedCount == 0u)
|
||||
telem.ValueRW.DashesWasted++;
|
||||
ds.ValueRW = default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 58536af899e5e9442b81c594c17bc034
|
||||
@@ -27,15 +27,28 @@ namespace ProjectM.Simulation
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
foreach (var (health, control, deadEnabled) in
|
||||
foreach (var (health, control, deadEnabled, entity) in
|
||||
SystemAPI.Query<RefRO<Health>, RefRW<CharacterControl>, EnabledRefRW<Dead>>()
|
||||
.WithAll<PlayerTag, Simulate>()
|
||||
.WithPresent<Dead>())
|
||||
.WithPresent<Dead>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
bool isDead = health.ValueRO.Current <= 0f;
|
||||
deadEnabled.ValueRW = isDead;
|
||||
if (isDead)
|
||||
{
|
||||
control.ValueRW.MoveVelocity = float3.zero;
|
||||
// MC-1: clear any in-flight dash window + restore base sharpness so a death mid-dash leaves
|
||||
// no stale i-frames / stuck-fast on respawn (DashSystem skips dead players via .WithDisabled<Dead>()).
|
||||
if (SystemAPI.HasComponent<DashState>(entity))
|
||||
SystemAPI.SetComponent(entity, default(DashState));
|
||||
if (SystemAPI.HasComponent<CharacterComponent>(entity))
|
||||
{
|
||||
var cc = SystemAPI.GetComponent<CharacterComponent>(entity);
|
||||
cc.GroundedMovementSharpness = 15f;
|
||||
SystemAPI.SetComponent(entity, cc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ namespace ProjectM.Simulation
|
||||
|
||||
/// <summary>Primary ability fire. InputEvent survives the frame→tick→rollback boundary so a press fires exactly once.</summary>
|
||||
[GhostField] public InputEvent Fire;
|
||||
/// <summary>Dodge dash. InputEvent twin of <see cref="Fire"/>: survives the frame-tick-rollback boundary
|
||||
/// so one press dashes exactly once; read by the predicted DashSystem (MC-1).</summary>
|
||||
[GhostField] public InputEvent Dash;
|
||||
|
||||
/// <summary>Active input scheme this tick (<see cref="InputSchemeId"/>: 0 = mouse/keyboard, 1 = gamepad).
|
||||
/// The server reads it so the auto-target assist applies only to gamepad shots; precise mouse aim is left
|
||||
@@ -32,7 +35,7 @@ namespace ProjectM.Simulation
|
||||
var s = new FixedString512Bytes();
|
||||
s.Append(Move.x); s.Append(','); s.Append(Move.y); s.Append(';');
|
||||
s.Append(Aim.x); s.Append(','); s.Append(Aim.y); s.Append(';');
|
||||
s.Append(Fire.Count); s.Append(';'); s.Append(Scheme);
|
||||
s.Append(Fire.Count); s.Append(';'); s.Append(Scheme); s.Append(';'); s.Append(Dash.Count);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2537,6 +2537,7 @@ MonoBehaviour:
|
||||
- {fileID: 3885353946372160549, guid: a6c2004a3cc32cc44b1bb7a795f86519, type: 3}
|
||||
- {fileID: 3885353946372160549, guid: f77a36036567c814496e6c59c42b2082, type: 3}
|
||||
- {fileID: 3885353946372160549, guid: 31d233e9e507acf45a411f8ab0997bed, type: 3}
|
||||
- {fileID: 3885353946372160549, guid: 0d42b4a8ef76489458ee1ecf51b4dbca, type: 3}
|
||||
RingRadius: 16
|
||||
RingSlots: 10
|
||||
BaseCount: 4
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the MC-1 Charger branch in EnemyAISystem. A Husk variant baked with
|
||||
/// LungeState commits to a fixed-direction lunge on wind-up elapse (UNLIKE the Grunt, it does NOT cancel when
|
||||
/// the target leaves range — the commit is the punishable tell), deals contact damage if it connects, and
|
||||
/// staggers (extends EnemyAttackCooldown + clears the lunge + opens a telemetry whiff window) if it overshoots
|
||||
/// or wall-stops. Knockback cancels an in-flight lunge so EnemyAISystem stays the SOLE Position writer.
|
||||
/// </summary>
|
||||
public class ChargerTests
|
||||
{
|
||||
static void SetServerTick(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, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||
}
|
||||
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<EnemyAISystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, serverTick);
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakePlayer(EntityManager em, float3 pos)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
em.AddBuffer<DamageEvent>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeCharger(EntityManager em, float3 pos)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, LocalTransform.FromPosition(pos));
|
||||
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 12f, AttackCooldownTicks = 36 });
|
||||
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
|
||||
em.AddComponentData(e, new KnockbackState());
|
||||
em.AddComponentData(e, new AttackWindup());
|
||||
em.AddComponentData(e, new LungeState());
|
||||
em.AddComponent<EnemyTag>(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Commit_Fires_Even_When_Target_Left_Range()
|
||||
{
|
||||
var (world, group) = MakeWorld("ChargerCommit", 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakePlayer(em, new float3(10, 1, 0)); // far out of AttackRange (1.6)
|
||||
var charger = MakeCharger(em, new float3(0, 1, 0));
|
||||
em.SetComponentData(charger, new AttackWindup { WindUpUntilTick = 200 }); // elapses this tick
|
||||
|
||||
group.Update(); // tick 200
|
||||
|
||||
var lunge = em.GetComponentData<LungeState>(charger);
|
||||
Assert.AreNotEqual(0u, lunge.UntilTick, "Charger commits the lunge even with the target out of range (no cancel-on-leave-range).");
|
||||
Assert.Greater(lunge.Dir.x, 0.5f, "Lunge direction is locked toward the target at commit (+X).");
|
||||
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(charger).WindUpUntilTick, "The wind-up clears on commit.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Overshoot_Whiff_Staggers_And_Opens_A_Punish_Window()
|
||||
{
|
||||
var (world, group) = MakeWorld("ChargerWhiff", 206);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakePlayer(em, new float3(-10, 1, 0)); // player is behind; the lunge goes +X, never connects
|
||||
var charger = MakeCharger(em, new float3(0, 1, 0));
|
||||
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 205 }); // expiring lunge
|
||||
em.CreateEntity(typeof(DevTelemetry)); // so the whiff telemetry increment is observable
|
||||
|
||||
group.Update(); // tick 206 > 205 -> lunge timer elapsed without landing -> overshoot whiff
|
||||
|
||||
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick, "A whiffed lunge is cleared.");
|
||||
Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData<EnemyAttackCooldown>(charger).NextAttackTick,
|
||||
"An overshoot whiff extends the attack cooldown by the stagger window (the punish window).");
|
||||
using var tq = em.CreateEntityQuery(typeof(DevTelemetry));
|
||||
Assert.AreEqual(1u, tq.GetSingleton<DevTelemetry>().ChargerWhiffWindowsOpened, "A whiff opens one telemetry punish window.");
|
||||
Assert.AreEqual(TickUtil.NonZero(206 + 36), em.GetComponentData<LungeState>(charger).StaggerUntilTick,
|
||||
"The whiff stamps the scoreable StaggerUntilTick window (ChargerWhiffPunishesLanded source).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Knockback_Cancels_An_InFlight_Lunge()
|
||||
{
|
||||
var (world, group) = MakeWorld("ChargerKnockback", 305);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakePlayer(em, new float3(10, 1, 0));
|
||||
var charger = MakeCharger(em, new float3(0, 1, 0));
|
||||
em.SetComponentData(charger, new LungeState { Dir = new float2(1, 0), Speed = 16f, UntilTick = 320 }); // mid-lunge +X
|
||||
em.SetComponentData(charger, new KnockbackState { Dir = new float2(-1, 0), Speed = 10f, UntilTick = 315 }); // recoil -X
|
||||
|
||||
group.Update(); // tick 305: knockback (until 315) wins
|
||||
|
||||
Assert.AreEqual(0u, em.GetComponentData<LungeState>(charger).UntilTick,
|
||||
"Knockback cancels the in-flight lunge (no two-writer contention on Position).");
|
||||
Assert.Less(em.GetComponentData<LocalTransform>(charger).Position.x, 0f,
|
||||
"The recoiling Charger moved along its knockback direction (-X), not its lunge direction.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c31affe7e592820448b105987c883868
|
||||
@@ -0,0 +1,171 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the MC-1 dash i-frame negation in HealthApplyDamageSystem.
|
||||
/// The negation compares each DamageEvent.SourceTick against the dashing player's DashState window
|
||||
/// [StartTick, IFrameUntilTick) using NetworkTick arithmetic (wrap-safe), PER-ELEMENT (unlike the
|
||||
/// whole-buffer RespawnInvuln / GodMode clears). The drain tick (current ServerTick) is seeded valid
|
||||
/// but does NOT affect the result — only SourceTick-vs-window does — which is exactly why a melee strike
|
||||
/// appended a tick earlier in the plain group is still judged against the tick it was AUTHORED.
|
||||
/// The actual N->N+1 cross-group timing is a Play-validation item (MC-1 review agenda #1), not
|
||||
/// EditMode-reproducible (plain worlds register systems unsorted, one group, one tick).
|
||||
/// </summary>
|
||||
public class DashIFrameNegationTests
|
||||
{
|
||||
static void SetServerTick(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, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||
}
|
||||
|
||||
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint drainTick)
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<HealthApplyDamageSystem>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, drainTick);
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static Entity MakeDasher(EntityManager em, float hp, uint startTick, uint iFrameUntilTick)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DashState));
|
||||
em.SetComponentData(e, new Health { Current = hp, Max = hp });
|
||||
em.SetComponentData(e, new DashState
|
||||
{
|
||||
Dir = new float2(0, 1),
|
||||
StartTick = startTick,
|
||||
IFrameUntilTick = iFrameUntilTick,
|
||||
RecoverUntilTick = iFrameUntilTick, // irrelevant to the negation
|
||||
});
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Window_Is_Half_Open_StartInclusive_UntilExclusive()
|
||||
{
|
||||
// S=100, W=12 -> window [100, 112). drainTick 113 (arbitrary valid; result is tick-of-current independent).
|
||||
const uint S = 100, W = 12;
|
||||
var (world, group) = MakeWorld("DashHalfOpen", 113);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, 100f, S, S + W);
|
||||
var dmg = em.GetBuffer<DamageEvent>(e);
|
||||
// Distinct amounts so the exact negated set is provable from the surviving Health.
|
||||
dmg.Add(new DamageEvent { Amount = 1f, SourceNetworkId = -1, SourceTick = S - 1 }); // before -> applies
|
||||
dmg.Add(new DamageEvent { Amount = 2f, SourceNetworkId = -1, SourceTick = S }); // start -> negated (inclusive)
|
||||
dmg.Add(new DamageEvent { Amount = 4f, SourceNetworkId = -1, SourceTick = S + 1 }); // inside -> negated
|
||||
dmg.Add(new DamageEvent { Amount = 8f, SourceNetworkId = -1, SourceTick = S + W - 1 }); // last in -> negated
|
||||
dmg.Add(new DamageEvent { Amount = 16f, SourceNetworkId = -1, SourceTick = S + W }); // until -> applies (exclusive)
|
||||
dmg.Add(new DamageEvent { Amount = 32f, SourceNetworkId = -1, SourceTick = S + W + 1 }); // after -> applies
|
||||
|
||||
group.Update();
|
||||
|
||||
// Applied 1 + 16 + 32 = 49; negated 2 + 4 + 8 = 14. Health 100 - 49 = 51.
|
||||
Assert.AreEqual(51f, em.GetComponentData<Health>(e).Current, 1e-4f,
|
||||
"Half-open [S, S+W): S, S+1, S+W-1 negated; S-1, S+W, S+W+1 applied.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Negation_Is_Wraparound_Safe_NetworkTick_Not_Raw_Uint()
|
||||
{
|
||||
// Window straddles the uint wrap: [S, 3) with S = uint.MaxValue-2. Avoids SourceTick 0 (the sentinel).
|
||||
const uint S = uint.MaxValue - 2; // 4294967293
|
||||
const uint until = 3u; // (S + 6) wrapped past 0
|
||||
var (world, group) = MakeWorld("DashWrap", 3);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, 1000f, S, until);
|
||||
var dmg = em.GetBuffer<DamageEvent>(e);
|
||||
dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = -1, SourceTick = S + 1 }); // 4294967294, inside -> negated
|
||||
dmg.Add(new DamageEvent { Amount = 20f, SourceNetworkId = -1, SourceTick = 2u }); // wrapped inside -> negated
|
||||
dmg.Add(new DamageEvent { Amount = 40f, SourceNetworkId = -1, SourceTick = 5u }); // after -> applies
|
||||
dmg.Add(new DamageEvent { Amount = 80f, SourceNetworkId = -1, SourceTick = S - 1 }); // 4294967292, before -> applies
|
||||
|
||||
group.Update();
|
||||
|
||||
// NetworkTick (wrap-correct) applies {5, S-1} = 120 -> 880. A raw-uint compare would
|
||||
// mis-bucket {S+1, 2} and apply all four (-> 850), which this exact value rejects.
|
||||
Assert.AreEqual(880f, em.GetComponentData<Health>(e).Current, 1e-4f,
|
||||
"Wraparound window negates {S+1, 2} and applies {5, S-1} via NetworkTick, not raw uint.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Unstamped_SourceTick_Zero_Is_Never_Negated_FailSafe()
|
||||
{
|
||||
var (world, group) = MakeWorld("DashZeroSentinel", 113);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, 100f, 100, 112); // active window
|
||||
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 25f, SourceNetworkId = -1, SourceTick = 0u });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(75f, em.GetComponentData<Health>(e).Current, 1e-4f,
|
||||
"An unstamped (SourceTick==0) hit is never i-framed (fail-safe: damage applies).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Negation_Is_Per_Element_Not_Whole_Buffer()
|
||||
{
|
||||
var (world, group) = MakeWorld("DashPerElement", 113);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, 100f, 100, 112);
|
||||
var dmg = em.GetBuffer<DamageEvent>(e);
|
||||
dmg.Add(new DamageEvent { Amount = 5f, SourceNetworkId = -1, SourceTick = 105 }); // in window -> negated
|
||||
dmg.Add(new DamageEvent { Amount = 30f, SourceNetworkId = -1, SourceTick = 200 }); // out window -> applies
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(70f, em.GetComponentData<Health>(e).Current, 1e-4f,
|
||||
"Per-element: only the in-window event is negated; the out-of-window event still applies " +
|
||||
"(unlike the whole-buffer RespawnInvuln/GodMode clears).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RespawnInvuln_Still_Clears_Whole_Buffer_Before_The_Dash_Loop()
|
||||
{
|
||||
// drainTick 100; RespawnInvuln.UntilTick 200 (active). DashState window does NOT cover the hits,
|
||||
// so if the per-element dash loop ran they would apply — but RespawnInvuln clears the whole buffer first.
|
||||
var (world, group) = MakeWorld("DashRespawnInvuln", 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DashState), typeof(RespawnInvuln));
|
||||
em.SetComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||
em.SetComponentData(e, new DashState { StartTick = 500, IFrameUntilTick = 512, RecoverUntilTick = 512 });
|
||||
em.SetComponentData(e, new RespawnInvuln { UntilTick = 200 });
|
||||
var dmg = em.GetBuffer<DamageEvent>(e);
|
||||
dmg.Add(new DamageEvent { Amount = 40f, SourceNetworkId = -1, SourceTick = 100 }); // outside dash window
|
||||
dmg.Add(new DamageEvent { Amount = 30f, SourceNetworkId = -1, SourceTick = 600 }); // outside dash window
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(100f, em.GetComponentData<Health>(e).Current, 1e-4f,
|
||||
"RespawnInvuln clears the WHOLE buffer (and continues) before the per-element dash loop runs.");
|
||||
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(e).Length, "The buffer is drained under RespawnInvuln.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 638643ac1f79886438103e43ece254cf
|
||||
@@ -0,0 +1,235 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plain-Entities EditMode tests for the MC-1 predicted DashSystem (velocity/sharpness OVERRIDE + start/cooldown)
|
||||
/// and the PlayerDeathStateSystem dash cleanup. The systems carry [UpdateInGroup(PredictedSimulationSystemGroup)]
|
||||
/// but world-system filtering is ignored when added to a group manually, so they run in this netcode-free world.
|
||||
/// The override/cleanup logic is fully headless; the input-driven START path uses PlayerInput.Dash.IsSet (the
|
||||
/// real apply-under-prediction is a Play-validation item).
|
||||
/// </summary>
|
||||
public class DashSystemTests
|
||||
{
|
||||
static void SetServerTick(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, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||
}
|
||||
|
||||
static (World world, SimulationSystemGroup group) MakeWorld<T>(string name, uint serverTick) where T : unmanaged, ISystem
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, serverTick);
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
// Dash speed derived from the baked knobs: 4.0 units / (12 ticks / 60) = 20 units/s.
|
||||
const float ExpectedDashSpeed = 20f;
|
||||
|
||||
static Entity MakeDasher(EntityManager em, float2 facing)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, new DashState());
|
||||
em.AddComponentData(e, new DashCooldown { NextTick = 0 });
|
||||
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
|
||||
em.AddComponentData(e, CharacterComponent.GetDefault()); // GroundedMovementSharpness = 15
|
||||
em.AddComponentData(e, new PlayerInput());
|
||||
em.AddComponentData(e, new PlayerFacing { Direction = facing });
|
||||
em.AddComponent<Simulate>(e); // enabled by default
|
||||
em.AddComponent<Dead>(e);
|
||||
em.SetComponentEnabled<Dead>(e, false); // alive
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IFrame_Window_Overrides_Velocity_To_Dash_Speed_And_Sharpness_To_Blink()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashOverride", 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
||||
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); // as if PlayerControlSystem wrote input velocity
|
||||
|
||||
group.Update(); // tick 100: i-frame window [100,112) active
|
||||
|
||||
var ctrl = em.GetComponentData<CharacterControl>(e).MoveVelocity;
|
||||
Assert.AreEqual(0f, ctrl.x, 1e-3f, "Dash heading (0,1) overrides X to 0.");
|
||||
Assert.AreEqual(0f, ctrl.y, 1e-3f, "Planar dash keeps Y at 0.");
|
||||
Assert.AreEqual(ExpectedDashSpeed, ctrl.z, 1e-3f, "i-frame window overrides velocity to Dir*dashSpeed (20).");
|
||||
Assert.AreEqual(200f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"i-frame window raises GroundedMovementSharpness to ~200 (the blink).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Recovery_Tail_Locks_Movement_And_Restores_Sharpness()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashRecover", 105);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
|
||||
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.SetComponentData(e, cc);
|
||||
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) });
|
||||
|
||||
group.Update(); // tick 105: iFrame ended (100<=105), recover active (109>105)
|
||||
|
||||
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(e).MoveVelocity), 1e-3f,
|
||||
"Recovery tail locks movement to zero (the punishable window).");
|
||||
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"Recovery tail restores sharpness to the default (crisp stop).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void After_Window_Restores_Sharpness_And_Leaves_Input_Velocity_Untouched()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashRestore", 200);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
|
||||
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.SetComponentData(e, cc);
|
||||
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(7, 0, 0) }); // input velocity, must survive
|
||||
|
||||
group.Update(); // tick 200: window fully elapsed
|
||||
|
||||
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"Sharpness restored to default after the dash window elapses.");
|
||||
Assert.AreEqual(7f, em.GetComponentData<CharacterControl>(e).MoveVelocity.x, 1e-3f,
|
||||
"Outside the window DashSystem does NOT touch MoveVelocity (PlayerControlSystem's input stands).");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dash_Starts_On_Press_When_Ready_And_Sets_Window_And_Cooldown()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashStart", 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
||||
Assert.IsTrue(em.GetComponentData<PlayerInput>(e).Dash.IsSet,
|
||||
"Precondition: a Set() dash reads IsSet=true (guards the InputEvent assumption).");
|
||||
|
||||
group.Update(); // tick 100
|
||||
|
||||
var ds = em.GetComponentData<DashState>(e);
|
||||
Assert.AreEqual(100u, ds.StartTick, "StartTick = now.");
|
||||
Assert.AreEqual(112u, ds.IFrameUntilTick, "IFrameUntilTick = now + 12.");
|
||||
Assert.AreEqual(121u, ds.RecoverUntilTick, "RecoverUntilTick = now + 12 + 9.");
|
||||
Assert.AreEqual(145u, em.GetComponentData<DashCooldown>(e).NextTick, "Cooldown = now + 45.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dash_Start_Is_Idempotent_At_The_Same_Tick()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashIdem", 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
||||
|
||||
group.Update(); // starts the dash at tick 100
|
||||
var first = em.GetComponentData<DashState>(e);
|
||||
group.Update(); // same ServerTick, Dash still set -> must NOT re-start (mid-window)
|
||||
var second = em.GetComponentData<DashState>(e);
|
||||
|
||||
Assert.AreEqual(first.StartTick, second.StartTick, "Re-running the start tick must not re-trigger the dash.");
|
||||
Assert.AreEqual(first.IFrameUntilTick, second.IFrameUntilTick, "The window is set exactly once.");
|
||||
Assert.AreEqual(first.RecoverUntilTick, second.RecoverUntilTick, "The window is set exactly once.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Dash_Is_Gated_By_Cooldown_Until_It_Expires()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("DashCooldown", 130);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
// As if dashed at 100: window elapsed by 130, cooldown until 145.
|
||||
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
||||
em.SetComponentData(e, new DashCooldown { NextTick = 145 });
|
||||
|
||||
var pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
||||
group.Update(); // tick 130 < cooldown 145 -> NO new dash
|
||||
Assert.AreEqual(100u, em.GetComponentData<DashState>(e).StartTick, "On cooldown: a press does not start a new dash.");
|
||||
|
||||
SetServerTick(world, 150); // past the cooldown
|
||||
pi = new PlayerInput(); pi.Dash.Set(); em.SetComponentData(e, pi);
|
||||
group.Update();
|
||||
Assert.AreEqual(150u, em.GetComponentData<DashState>(e).StartTick, "Past cooldown: a press starts a new dash.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Death_Mid_Dash_Clears_Window_And_Restores_Sharpness()
|
||||
{
|
||||
var (world, group) = MakeWorld<PlayerDeathStateSystem>("DashDeath", 100);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponent<PlayerTag>(e);
|
||||
em.AddComponentData(e, new Health { Current = 0f, Max = 100f }); // dead
|
||||
em.AddComponentData(e, new CharacterControl { MoveVelocity = new float3(3, 0, 0) });
|
||||
em.AddComponentData(e, new DashState { Dir = new float2(1, 0), StartTick = 90, IFrameUntilTick = 110, RecoverUntilTick = 119 }); // in-flight
|
||||
var cc = CharacterComponent.GetDefault(); cc.GroundedMovementSharpness = 200f; em.AddComponentData(e, cc);
|
||||
em.AddComponent<Simulate>(e);
|
||||
em.AddComponent<Dead>(e);
|
||||
em.SetComponentEnabled<Dead>(e, false);
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.IsTrue(em.IsComponentEnabled<Dead>(e), "Health<=0 derives Dead enabled.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<DashState>(e).IFrameUntilTick, "Death clears the dash window (no stale i-frames on respawn).");
|
||||
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f, "Death restores base sharpness.");
|
||||
Assert.AreEqual(0f, math.length(em.GetComponentData<CharacterControl>(e).MoveVelocity), 1e-3f, "Death zeroes movement.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Rollback_ReSimulated_PreDash_Tick_Gets_No_Override()
|
||||
{
|
||||
// DashState is NON-replicated, so prediction rollback does NOT restore it: a re-simulated tick
|
||||
// BEFORE StartTick still sees the post-press window. The override must include the StartTick lower
|
||||
// bound or it stomps dash velocity onto pre-dash ticks (dash-start overshoot under real latency).
|
||||
var (world, group) = MakeWorld<DashSystem>("DashRollback", 95); // serverTick 95 < StartTick 100
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDasher(em, new float2(0, 1));
|
||||
em.SetComponentData(e, new DashState { Dir = new float2(0, 1), StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
||||
em.SetComponentData(e, new CharacterControl { MoveVelocity = new float3(5, 0, 0) }); // input velocity of the pre-dash tick
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(5f, em.GetComponentData<CharacterControl>(e).MoveVelocity.x, 1e-3f,
|
||||
"A re-simulated PRE-dash tick keeps PlayerControl's input velocity (no dash override, no recovery lock).");
|
||||
Assert.AreEqual(15f, em.GetComponentData<CharacterComponent>(e).GroundedMovementSharpness, 1e-3f,
|
||||
"A re-simulated PRE-dash tick keeps base sharpness.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c276f751d9353641a1d66d42d7e68ee
|
||||
@@ -10,6 +10,7 @@
|
||||
"Unity.Collections",
|
||||
"Unity.Mathematics",
|
||||
"Unity.Physics",
|
||||
"Unity.CharacterController",
|
||||
"Unity.NetCode",
|
||||
"UnityEngine.TestRunner",
|
||||
"UnityEditor.TestRunner"
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectM.Server;
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Core;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// EditMode coverage for the MC-0/MC-1 DevTelemetry counter WIRING (the fun-gate is measured, not argued):
|
||||
/// DashIFrameNegatedHits + DashState.NegatedCount (HealthApplyDamageSystem), DashesWasted (DashSystem
|
||||
/// window-close edge, server-gated on the DevTelemetry singleton), and ChargerWhiffPunishesLanded
|
||||
/// (player-sourced hit inside a Charger's StaggerUntilTick window, scored ONCE per window).
|
||||
/// </summary>
|
||||
public class TelemetryCountersTests
|
||||
{
|
||||
static void SetServerTick(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, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||
}
|
||||
|
||||
static (World world, SimulationSystemGroup group) MakeWorld<T>(string name, uint serverTick, bool withTelemetry)
|
||||
where T : unmanaged, ISystem
|
||||
{
|
||||
var world = new World(name);
|
||||
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||
group.AddSystemToUpdateList(world.GetOrCreateSystem<T>());
|
||||
group.SortSystems();
|
||||
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||
SetServerTick(world, serverTick);
|
||||
if (withTelemetry)
|
||||
world.EntityManager.CreateEntity(typeof(DevTelemetry));
|
||||
return (world, group);
|
||||
}
|
||||
|
||||
static DevTelemetry Telemetry(EntityManager em)
|
||||
{
|
||||
using var q = em.CreateEntityQuery(typeof(DevTelemetry));
|
||||
return q.GetSingleton<DevTelemetry>();
|
||||
}
|
||||
|
||||
static Entity MakeDashingPlayer(EntityManager em, DashState ds)
|
||||
{
|
||||
var e = em.CreateEntity();
|
||||
em.AddComponentData(e, ds);
|
||||
em.AddComponentData(e, new DashCooldown { NextTick = 0 });
|
||||
em.AddComponentData(e, new CharacterControl { MoveVelocity = float3.zero });
|
||||
em.AddComponentData(e, CharacterComponent.GetDefault());
|
||||
em.AddComponentData(e, new PlayerInput());
|
||||
em.AddComponentData(e, new PlayerFacing { Direction = new float2(0, 1) });
|
||||
em.AddComponent<Simulate>(e);
|
||||
em.AddComponent<Dead>(e);
|
||||
em.SetComponentEnabled<Dead>(e, false);
|
||||
return e;
|
||||
}
|
||||
|
||||
static Entity MakeStaggeredCharger(EntityManager em, uint staggerUntil)
|
||||
{
|
||||
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(LungeState));
|
||||
em.SetComponentData(e, new Health { Current = 200f, Max = 200f });
|
||||
em.SetComponentData(e, new LungeState { StaggerUntilTick = staggerUntil });
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Negation_Increments_DevTelemetry_And_DashState_NegatedCount()
|
||||
{
|
||||
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemNegate", 113, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = em.CreateEntity(typeof(Health), typeof(DamageEvent), typeof(DashState));
|
||||
em.SetComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||
em.SetComponentData(e, new DashState { StartTick = 100, IFrameUntilTick = 112, RecoverUntilTick = 121 });
|
||||
var dmg = em.GetBuffer<DamageEvent>(e);
|
||||
dmg.Add(new DamageEvent { Amount = 5f, SourceNetworkId = -1, SourceTick = 101 }); // in-window -> negated
|
||||
dmg.Add(new DamageEvent { Amount = 6f, SourceNetworkId = -1, SourceTick = 111 }); // in-window -> negated
|
||||
dmg.Add(new DamageEvent { Amount = 7f, SourceNetworkId = -1, SourceTick = 113 }); // outside -> applies
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(2u, Telemetry(em).DashIFrameNegatedHits, "Both in-window hits counted.");
|
||||
Assert.AreEqual(2u, em.GetComponentData<DashState>(e).NegatedCount,
|
||||
"NegatedCount written back onto DashState (the wasted-dash signal source).");
|
||||
Assert.AreEqual(93f, em.GetComponentData<Health>(e).Current, 1e-4f, "Only the outside hit applied.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Wasted_Dash_Counts_Once_On_Window_Close_With_Zero_Negations()
|
||||
{
|
||||
// Window fully elapsed at tick 120 (recover ended at 109) and nothing was negated.
|
||||
var (world, group) = MakeWorld<DashSystem>("TelemWasted", 120, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDashingPlayer(em, new DashState
|
||||
{ Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109, NegatedCount = 0 });
|
||||
|
||||
group.Update();
|
||||
Assert.AreEqual(1u, Telemetry(em).DashesWasted, "A dash whose window negated nothing scores wasted.");
|
||||
Assert.AreEqual(0u, em.GetComponentData<DashState>(e).RecoverUntilTick, "Close edge clears the window (one-shot).");
|
||||
|
||||
group.Update();
|
||||
Assert.AreEqual(1u, Telemetry(em).DashesWasted, "The close edge fires exactly once.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Effective_Dash_Is_Not_Wasted()
|
||||
{
|
||||
var (world, group) = MakeWorld<DashSystem>("TelemEffective", 120, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
MakeDashingPlayer(em, new DashState
|
||||
{ Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109, NegatedCount = 2 });
|
||||
|
||||
group.Update();
|
||||
Assert.AreEqual(0u, Telemetry(em).DashesWasted, "A dash that negated hits is not wasted.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Without_Telemetry_Singleton_The_Close_Edge_Leaves_DashState_Intact()
|
||||
{
|
||||
// Client-world behavior: no DevTelemetry singleton -> no zeroing, so rollback re-simulation of the
|
||||
// tail window stays correct on the client (the server is the only world that clears).
|
||||
var (world, group) = MakeWorld<DashSystem>("TelemClientNoZero", 120, withTelemetry: false);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeDashingPlayer(em, new DashState
|
||||
{ Dir = new float2(0, 1), StartTick = 80, IFrameUntilTick = 100, RecoverUntilTick = 109 });
|
||||
|
||||
group.Update();
|
||||
Assert.AreEqual(109u, em.GetComponentData<DashState>(e).RecoverUntilTick,
|
||||
"No telemetry singleton (client world): the window is never zeroed by the close edge.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Player_Hit_On_Staggered_Charger_Scores_Punish_Once()
|
||||
{
|
||||
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunish", 120, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeStaggeredCharger(em, staggerUntil: 150); // stagger window active at 120
|
||||
var dmg = em.GetBuffer<DamageEvent>(e);
|
||||
dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 119 }); // player hit
|
||||
dmg.Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 119 }); // same drain, same window
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(1u, Telemetry(em).ChargerWhiffPunishesLanded,
|
||||
"A stagger window counts at most ONE punish (ratio to windows-opened stays <= 1).");
|
||||
Assert.AreEqual(0u, em.GetComponentData<LungeState>(e).StaggerUntilTick,
|
||||
"Scoring zeroes StaggerUntilTick (the one-shot).");
|
||||
Assert.AreEqual(180f, em.GetComponentData<Health>(e).Current, 1e-4f, "Both hits still apply damage.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NonPlayer_Hit_On_Staggered_Charger_Does_Not_Score()
|
||||
{
|
||||
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunishTurret", 120, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeStaggeredCharger(em, staggerUntil: 150);
|
||||
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 10f, SourceNetworkId = -1, SourceTick = 119 });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0u, Telemetry(em).ChargerWhiffPunishesLanded,
|
||||
"Environment/turret damage (SourceNetworkId=-1) never scores a punish.");
|
||||
Assert.AreEqual(150u, em.GetComponentData<LungeState>(e).StaggerUntilTick,
|
||||
"The window stays scoreable for a real player hit.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Expired_Stagger_Window_Does_Not_Score()
|
||||
{
|
||||
var (world, group) = MakeWorld<HealthApplyDamageSystem>("TelemPunishLate", 200, withTelemetry: true);
|
||||
using (world)
|
||||
{
|
||||
var em = world.EntityManager;
|
||||
var e = MakeStaggeredCharger(em, staggerUntil: 150); // already over at 200
|
||||
em.GetBuffer<DamageEvent>(e).Add(new DamageEvent { Amount = 10f, SourceNetworkId = 1, SourceTick = 199 });
|
||||
|
||||
group.Update();
|
||||
|
||||
Assert.AreEqual(0u, Telemetry(em).ChargerWhiffPunishesLanded,
|
||||
"A hit after the stagger window elapses is not a punish.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e5cfa8e6208aa14ea84b6dc8510bb40
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 48947564183e33a42a7da983e236d8ef
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 63b0477da41e2bb4dab1165e6ef6fd4a
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0b6fb78380a9249488262ddd760135d2
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user