using Unity.Burst; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; namespace ProjectM.Simulation { /// /// LANTERN Blink — the Movement-archetype Spark: a second, socketed dash. A behaviour-clone of /// (velocity blink, NOT a LocalTransform teleport — the CC processor lerps /// RelativeVelocity toward MoveVelocity at a high GroundedMovementSharpness so it reads as a blink; a raw /// transform write would tunnel Environment colliders and reset Scale). Triggered by the ability socket /// whose Spark has : AbilityFireSystem skips movement sockets /// (falls through), and BlinkSystem is the SOLE writer of that socket's row /// (so the HUD's per-socket bar stays uniform). Reads the socket loadout / cooldown by entity via lookups /// (keeps the query under the 7-type cap). /// /// Runs in AFTER (overrides /// the input-derived MoveVelocity) and AFTER — dash wins precedence: while a dash /// window is active this tick, blink neither starts nor overrides. START is an idempotent pure function of /// the replicated socket input + tick (no IsFirstTimeFullyPredictingTick guard); the OVERRIDE re-applies on /// EVERY predicted pass, lower-bounded on the half-open window, so rollback re-simulation converges. All /// ticks routed through TickUtil.NonZero; compared via only. /// /// [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [UpdateAfter(typeof(PlayerControlSystem))] [UpdateAfter(typeof(DashSystem))] [BurstCompile] public partial struct BlinkSystem : ISystem { const float DefaultSharpness = CharacterComponent.DefaultGroundedSharpness; const float SimTickRate = 60f; // Blink feel (consts for v1; promote to TuningConfig with the rest of the gym knobs later). const float k_BlinkDistance = 6.5f; const uint k_BlinkWindowTicks = 5; const uint k_BlinkCooldownTicks = 150; const float k_BlinkSharpness = 200f; BufferLookup m_SocketLookup; ComponentLookup m_SocketCdLookup; [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); m_SocketLookup = state.GetBufferLookup(isReadOnly: true); m_SocketCdLookup = state.GetComponentLookup(isReadOnly: false); } [BurstCompile] public void OnUpdate(ref SystemState state) { if (!SystemAPI.TryGetSingleton(out var netTime) || !netTime.ServerTick.IsValid) return; var serverTick = netTime.ServerTick; uint now = serverTick.TickIndexForValidTick; var abilityDb = SystemAPI.GetSingleton(); ref var adb = ref abilityDb.Value.Value; m_SocketLookup.Update(ref state); m_SocketCdLookup.Update(ref state); float blinkSpeed = k_BlinkDistance / (k_BlinkWindowTicks / SimTickRate); // window>=1 -> no div-by-0 // 07-15 underwater feel: the restore target honors the MoveSharpness dev-override (0 = authored const). var t = SystemAPI.TryGetSingleton(out var tcfg) ? tcfg : TuningConfig.Defaults(); float baseSharpness = t.MoveSharpness > 0f ? t.MoveSharpness : DefaultSharpness; foreach (var (blink, control, character, input, facing, dash, entity) in SystemAPI.Query, RefRW, RefRW, RefRO, RefRO, RefRO>() .WithAll().WithDisabled().WithEntityAccess()) { if (!m_SocketLookup.HasBuffer(entity) || !m_SocketCdLookup.HasComponent(entity)) continue; // Which socket holds the Movement (Blink) Spark? var sockets = m_SocketLookup[entity]; int blinkSk = -1; int n = math.min(SocketId.Count, sockets.Length); for (int sk = 0; sk < n; sk++) { byte sid = sockets[sk].SparkId; if (sid == 0) continue; if (adb.TryGetAbility(sid, out var d) && d.Archetype == (byte)AbilityArchetype.Movement) { blinkSk = sk; break; } } // Dash wins: while a dash window is active this tick, blink neither starts nor overrides. bool dashActive = dash.ValueRO.StartTick != 0u && !new NetworkTick(dash.ValueRO.StartTick).IsNewerThan(serverTick) && dash.ValueRO.RecoverUntilTick != 0u && new NetworkTick(dash.ValueRO.RecoverUntilTick).IsNewerThan(serverTick); var cd = m_SocketCdLookup[entity]; // START (idempotent): blink socket fired, cooldown ready, not mid-blink, no dash active. if (blinkSk >= 0 && !dashActive && input.ValueRO.GetSocket(blinkSk).IsSet) { uint nextRaw = cd.Get(blinkSk); bool ready = nextRaw == 0u || !new NetworkTick(nextRaw).IsNewerThan(serverTick); bool inWindow = blink.ValueRO.UntilTick != 0u && new NetworkTick(blink.ValueRO.UntilTick).IsNewerThan(serverTick); if (ready && !inWindow) { // 07-15 fork: Move → cursor Aim → last facing (stationary blink keeps going toward the cursor). float2 mv = input.ValueRO.Move; float2 dir = math.lengthsq(mv) > 1e-4f ? mv : math.lengthsq(input.ValueRO.Aim) > 1e-6f ? input.ValueRO.Aim : facing.ValueRO.Direction; if (math.lengthsq(dir) < 1e-6f) dir = new float2(0f, 1f); dir = math.normalize(dir); blink.ValueRW.Dir = dir; blink.ValueRW.StartTick = TickUtil.NonZero(now); blink.ValueRW.UntilTick = TickUtil.NonZero(now + k_BlinkWindowTicks); cd.Set(blinkSk, TickUtil.NonZero(now + k_BlinkCooldownTicks)); m_SocketCdLookup[entity] = cd; } } // OVERRIDE (every predicted pass; lower-bounded on the half-open window). Dash wins. bool inBlink = !dashActive && blink.ValueRO.StartTick != 0u && !new NetworkTick(blink.ValueRO.StartTick).IsNewerThan(serverTick) && blink.ValueRO.UntilTick != 0u && new NetworkTick(blink.ValueRO.UntilTick).IsNewerThan(serverTick); if (inBlink) { float2 d = blink.ValueRO.Dir; control.ValueRW.MoveVelocity = new float3(d.x, 0f, d.y) * blinkSpeed; character.ValueRW.GroundedMovementSharpness = k_BlinkSharpness; } else if (!dashActive && character.ValueRO.GroundedMovementSharpness == k_BlinkSharpness) { // restore only what WE raised (dash owns its own restore); never stomp an active dash. character.ValueRW.GroundedMovementSharpness = baseSharpness; } } } } }