Entities/Abilities/WrapArenaEdgesAbility.cs
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
namespace BlockParty;
/// <summary>
/// Screen-wrap ability: once the player has passed far enough through a (non-spiked) arena wall,
/// they re-emerge from the OPPOSITE wall, still slightly poking out so the motion reads as one
/// continuous slide across the seam. Velocity is preserved; only the position teleports. Emerging
/// THROUGH a spiked destination wall (or obstacle exit face) is lethal — but the wrap still happens
/// first and the kill lands next tick, so the player visibly dies ON the spikes they emerged into
/// rather than vanishing mid-wrap (see <see cref="ScheduleSpikeDeath"/>). A crossing whose emergence
/// spot is itself non-playable — wall-flush obstacles, the arena seam, or both — resolves atomically
/// across the whole composite in one tick (see <see cref="ResolveCrossing"/>).
///
/// To keep it from trivialising death, wrapping is NOT instantly reversible: after wrapping, the
/// wall you just emerged from is briefly CLOSED, so you can't immediately wrap straight back the
/// way you came. Cross-axis wraps are unaffected (you can wrap up and right together — just not up
/// then straight back down). A closed wall behaves like a SOLID wall, not a lethal one: crossing
/// its trigger line while it's closed pins you there (outward velocity cancelled) until it re-opens,
/// so gravity dragging you back after a weak jump through the ceiling is harmless. Only a block
/// genuinely squeezing you against the closed wall — the edge-camping case the cooldown exists
/// for — crushes you.
///
/// This only does anything for a character whose <see cref="CharacterAbilities.CollidesWithArenaWalls"/>
/// is false (otherwise the walls stop the player before they can poke through). BLOCK collisions are
/// never affected (the player still collides with, is crushed by, and presses block sides normally).
/// Runs in <see cref="PostTick"/>, after the tick's bounds handling, so a spiked wall (which kills on
/// contact) is resolved before we ever get here.
///
/// Deterministic / replay-safe: reads only the player's position + size and the stage's wall-face
/// spike state (the shared <c>GameStage.WallDeadlyAt</c> "deadly now" predicate — see <see cref="WallDeadlyAt"/>),
/// and its cooldown state advances on the fixed tick <c>dt</c> — no RNG, raw input or wall time.
/// </summary>
public sealed class WrapArenaEdgesAbility : PlayerAbility
{
// How far past a wall (as a fraction of the body's extent on that axis) the player must travel
// before wrapping (OUT), and how far they still poke out of the opposite wall when they re-emerge
// (IN). IN < OUT so they reappear a little more in-bounds and are moving inward, clear of the
// trigger — and the OUT-IN gap is the distance gravity must drag an apex-wrapped player before
// they re-touch the closed wall's trigger line, so keep it generous.
private const float OUT_FRACTION = 0.4f;
private const float IN_FRACTION = 0.1f;
// How long (seconds) after a wrap the wall you emerged from stays closed to wrapping. Short — just
// long enough that you can't oscillate through the same axis to dodge a block camping the edge.
private const float REVERSE_WRAP_COOLDOWN = 0.35f;
// How far (px) the player's centre must get PAST an obstacle's rect before its re-wrap cooldown is
// re-armed (cleared). The cooldown exists only to stop a player lingering at the emergence edge from
// oscillating straight back through; once they've clearly left the obstacle a fresh approach — e.g.
// falling back onto the same tower after a lap of the arena — must always wrap, regardless of how
// much of the 0.35s remains. Must exceed the emergence poke (IN_FRACTION of the body) so it doesn't
// clear the instant the player emerges just outside the far face.
private const float OBSTACLE_REARM_MARGIN = 12f;
// Minimum gap between wrap sound effects. A corner exit wraps on both axes in the same step; this
// stops the warp sfx doubling up (and rate-limits rapid successive wraps).
private const float WRAP_SFX_MIN_INTERVAL = 0.2f;
private const int WRAP_HITSTOP_FRAMES = 5;
private const float WRAP_CRUSH_BLOCK_SHAKE = 4f;
// Portal flash at each end of a wrap: a player-sized (the art is 10x12) dark-purple square that
// very rapidly shrinks away, marking where the player vanished and where they re-emerged, plus a
// smaller body-purple highlight on top that shrinks away even faster — the two-tone iris-close
// look the sticky block's goo fx gets from layering its two colours.
private const float PORTAL_LIFETIME = 0.16f;
private const int PORTAL_SIZE = 12;
private const float PORTAL_HIGHLIGHT_LIFETIME = 0.12f;
private const int PORTAL_HIGHLIGHT_SIZE = 8;
// Padding between a clamped portal flash and the arena's inner wall faces. A wrap point sits
// buried in a wall — or fully out of bounds (the bottom wall is flush with the window edge) —
// so the flash is pulled entirely into the play area to stay visible.
private const float PORTAL_CLAMP_PADDING = 1f;
// How far PAST the entry face (on the side the player came from) to sample when deciding whether a
// vanish flash borders playable space (see CameFromPlayable). Anchored to the face — not the player,
// who may overshoot the trigger far out of bounds on a fast fall — so just a small step to clear the
// face and read the region the player travelled through (open space vs a wall-flush obstacle).
private const float PORTAL_PROBE_PAD = 2f;
// Remaining cooldown per EXIT wall (the wall you'd leave through). Set on the OPPOSITE wall when a
// wrap happens (the side you emerge on), so a shove back out that side can't wrap — it kills.
private float _cdLeft, _cdRight, _cdUp, _cdDown;
// Remaining time before the wrap sfx may play again (see WRAP_SFX_MIN_INTERVAL).
private float _sfxCooldown;
// Per-interior-obstacle re-wrap cooldown: after wrapping across an obstacle we briefly stop
// re-wrapping it, so a stationary or reversing player doesn't oscillate through it every frame.
private readonly Dictionary<Obstacle, float> _obstacleCd = new();
// The face the player entered each currently-overlapping obstacle through — locked on the first
// overlapping frame (from the movement raycast) so the wrap axis stays fixed even as gravity turns
// the velocity while inside. Cleared when the player leaves / after wrapping.
private readonly Dictionary<Obstacle, Direction> _entryNormal = new();
// The player's position at the end of last tick, so we can raycast the movement segment
// [_prevPos -> current] to find which obstacle face the path actually crossed.
private Vector2 _prevPos;
private bool _hasPrev;
// The spike direction of a deferred emerged-into-spikes death (Direction.None = none pending).
// Set by ScheduleSpikeDeath when a wrap emerges through a live spiked face; fires next PostTick.
private Direction _pendingSpikeKill = Direction.None;
public override void PostTick( Player player, float dt )
{
// Last tick's wrap emerged through live spikes: the player spent one visible tick impaled on
// them (so the death reads as "the spikes got me" at the spot they appeared, not a mystery
// kill mid-wrap) — now it lands. If input already stepped them into ClampToBounds' kill
// during that tick, we never get here (abilities don't tick once dead) — same death, same wall.
if ( _pendingSpikeKill != Direction.None )
{
player.KillBySpikes( _pendingSpikeKill );
_pendingSpikeKill = Direction.None;
return;
}
_cdLeft = Math.Max( 0f, _cdLeft - dt );
_cdRight = Math.Max( 0f, _cdRight - dt );
_cdUp = Math.Max( 0f, _cdUp - dt );
_cdDown = Math.Max( 0f, _cdDown - dt );
_sfxCooldown = Math.Max( 0f, _sfxCooldown - dt );
// The wrapper bypasses normal obstacle collision resolution, so detect spike contact from the
// swept body BEFORE any arena wrap teleports the player. Waiting for the obstacle wrap threshold
// lets thin obstacles be crossed in one step without ever testing their entry spikes.
if ( TryKillOnObstacleSpikeContact( player ) )
return;
float hw = player.Width / 2f;
float hh = player.Height / 2f;
const float leftFace = Arena.WALL_SIZE;
const float rightFace = Arena.WIDTH - Arena.WALL_SIZE;
const float bottomFace = Arena.WALL_SIZE;
const float topFace = Arena.HEIGHT - Arena.WALL_SIZE;
float leftTrig = leftFace + hw - OUT_FRACTION * player.Width;
float rightTrig = rightFace - hw + OUT_FRACTION * player.Width;
float bottomTrig = bottomFace + hh - OUT_FRACTION * player.Height;
float topTrig = topFace - hh + OUT_FRACTION * player.Height;
// --- horizontal ---
// Each branch resolves the WHOLE crossing atomically (see ResolveCrossing): emerging THROUGH
// a spiked destination wall is lethal (position-aware, deferred so the player dies visibly ON
// the spikes), a block at the destination crushes, and a destination inside a wall-flush
// obstacle chains straight across it in the same step.
if ( player.X <= leftTrig && !WallDeadlyAt( player, Direction.Left ) )
{
// Exiting LEFT. If we just emerged here the wall is still closed — pin, don't wrap.
if ( _cdLeft > 0f ) { if ( PinAtClosedWall( player, horizontal: true, leftTrig ) ) return; }
else if ( ResolveCrossing( player, Direction.Left, player.Pos, leftFace, new List<Obstacle>() ) == Crossing.Ended ) return;
}
else if ( player.X >= rightTrig && !WallDeadlyAt( player, Direction.Right ) )
{
if ( _cdRight > 0f ) { if ( PinAtClosedWall( player, horizontal: true, rightTrig ) ) return; }
else if ( ResolveCrossing( player, Direction.Right, player.Pos, rightFace, new List<Obstacle>() ) == Crossing.Ended ) return;
}
// --- vertical --- (player.Pos is re-read after any horizontal resolution, so a corner exit
// wraps on both axes in the same frame).
if ( player.Y <= bottomTrig && !WallDeadlyAt( player, Direction.Down ) )
{
if ( _cdDown > 0f ) { if ( PinAtClosedWall( player, horizontal: false, bottomTrig ) ) return; }
else if ( ResolveCrossing( player, Direction.Down, player.Pos, bottomFace, new List<Obstacle>() ) == Crossing.Ended ) return;
}
else if ( player.Y >= topTrig && !WallDeadlyAt( player, Direction.Up ) )
{
if ( _cdUp > 0f ) { if ( PinAtClosedWall( player, horizontal: false, topTrig ) ) return; }
else if ( ResolveCrossing( player, Direction.Up, player.Pos, topFace, new List<Obstacle>() ) == Crossing.Ended ) return;
}
// Interior obstacles wrap the same way: the phasing player sinks into a non-playable rect and
// re-emerges from its OPPOSITE face, skipping the interior. (No-op unless the level has any.)
TryWrapObstacles( player, dt );
// Remember where we ended up (post-wrap) so next tick can raycast our movement segment to find
// which obstacle FACE we entered through (see ComputeEntryFace).
_prevPos = player.Pos;
_hasPrev = true;
}
/// <summary>Wrap the phasing player straight across any interior obstacle they're inside: they enter
/// one FACE and pop out the OPPOSITE one along that same cardinal axis, keeping the perpendicular
/// coordinate and their velocity. The entry face is found by raycasting the player's movement segment
/// against the box (so a diagonal fall that clips the left face wraps left→right, not down), locked on
/// the first overlapping frame. Triggers once the player has sunk OUT_FRACTION past the entry face and
/// re-emerges IN_FRACTION inside the far face — mirroring the arena-edge wrap. If that spot lies
/// inside another obstacle (flush rects forming a composite shape), the wrap CHAINS across it along
/// the same axis — and across the arena seam itself when the composite runs out of bounds (see
/// <see cref="ResolveCrossing"/>) — until it emerges into open space. Phasing into a spiked face kills; wrapping into a
/// spot occupied by a block crushes (nowhere to emerge). Merely GRAZING a neighbour obstacle at the
/// destination is harmless — the phase char passes through obstacles, so only a solid block crushes.
/// A per-obstacle cooldown prevents oscillation, and is re-armed once the player fully leaves.</summary>
private void TryWrapObstacles( Player player, float dt )
{
var obstacles = player.Stage.GetObstacles();
if ( obstacles.Count == 0 ) return;
// Cooldown bookkeeping runs as a PRE-PASS over every obstacle, because the wrap loop below
// returns as soon as any wrap fires — later-listed obstacles must still get their decrement
// and re-arm that tick.
foreach ( var ob in obstacles )
{
// Advance this obstacle's re-wrap cooldown (kept in a dict so Obstacle stays a pure solid).
if ( !_obstacleCd.TryGetValue( ob, out var cd ) )
continue;
_obstacleCd[ob] = Math.Max( 0f, cd - dt );
// Re-arm the re-wrap cooldown once the player has fully cleared the obstacle (centre
// beyond its rect by OBSTACLE_REARM_MARGIN). Without this, the fixed 0.35s cooldown —
// which is comparable to the time to fall a lap of the arena — can still be running when
// the player legitimately falls back onto the same tower, suppressing the wrap so they
// phase straight through it (and skip the entry flash) every other loop. A player merely
// hovering at the emergence edge stays within the margin, so oscillation is still cooled.
if ( OutsideByMargin( ob.GetRect(), player.Pos, OBSTACLE_REARM_MARGIN ) )
_obstacleCd.Remove( ob );
}
// At most ONE wrap (chain) fires per tick: a wrap teleports the player, and after any teleport
// the [_prevPos -> pos] segment is no longer physical movement — letting later-listed obstacles
// sweep it would wrap obstacles the player never actually crossed (e.g. one sitting in the
// emergence sliver that the chain's Touches test skipped), making the outcome depend on the
// authored obstacle order. In the vanishingly rare tick where the player genuinely crosses two
// unconnected obstacles, the second is a silent phase-through — cosmetic only (spikes are
// handled by the body-contact sweep before wraps, blocks by normal collision).
foreach ( var ob in obstacles )
{
// GLASS is solid to the wrap character like to everyone else (Player.ObstacleSolidToUs):
// it collides with the pane and never wraps across it.
if ( ob.IsGlass ) continue;
RectF r = ob.GetRect();
// Gate on the CENTRE being inside the obstacle (not just a box-edge graze): only then has the
// player committed to passing through, and only then does the movement raycast reliably cross
// the entry edge. Locking the entry face on a mere box-touch (centre still outside) would fall
// back to the velocity axis and, for a diagonal fall into a side, wrap on the wrong axis.
bool inside = player.X > r.Left && player.X < r.Right && player.Y > r.Bottom && player.Y < r.Top;
if ( !inside )
{
// A THIN obstacle can be crossed without the centre ever being observed inside past the
// sink threshold — either skipped entirely in one fast step, or stepped over the sliver of
// a trigger band a sub-body-thickness rect leaves (see PastThreshold's clamp). If this
// tick's movement segment exited through the face OPPOSITE the entry face, the crossing
// really happened: fire the same wrap (fx, spike/block checks, cooldowns) now, instead of
// silently phasing through. Checked BEFORE the entry face is forgotten below.
bool crossed = CompletedCrossing( ob, r, player, out Direction tunnelEntry );
_entryNormal.Remove( ob ); // left the obstacle — forget the entry face
if ( crossed && !(_obstacleCd.TryGetValue( ob, out var cd ) && cd > 0f) )
{
WrapAcross( player, ob, r, tunnelEntry );
return; // teleported (or killed, or closed-wall abort) — stop here
}
continue;
}
// Lock the entry face on the frame the centre crosses in (raycast the movement path) —
// BEFORE the cooldown gate, because a cooling player can still phase in (e.g. reverse
// gravity dragging them straight back into the band they just emerged from) and only this
// frame's movement segment crosses the entry edge. Locking after the cooldown expires
// loses the face to ComputeEntryFace's velocity fallback, which can pick the wrong axis
// and resolve a crossing along a fully non-playable row = bogus "nowhere to emerge" crush.
if ( !_entryNormal.TryGetValue( ob, out var entry ) )
{
entry = ComputeEntryFace( r, player );
_entryNormal[ob] = entry;
}
if ( _obstacleCd.TryGetValue( ob, out var cooldown ) && cooldown > 0f )
continue; // still cooling — don't re-wrap this one
if ( !PastThreshold( player, r, entry ) )
continue;
// Keep the locked entry face on a closed-wall abort: the player is still inside the
// obstacle, and the retry (once the wall re-opens) must cross the same face.
if ( WrapAcross( player, ob, r, entry ) != Crossing.Aborted )
_entryNormal.Remove( ob );
return; // teleported (or killed, or aborted at a closed wall) — stop here
}
}
/// <summary>How a committed crossing ended (see <see cref="ResolveCrossing"/>).</summary>
private enum Crossing
{
/// <summary>The wrap completed: the player teleported to a playable emergence point.</summary>
Emerged,
/// <summary>The crossing ended in a kill (or a deferred spike death was scheduled) — stop all
/// wrap processing for the tick.</summary>
Ended,
/// <summary>A still-closed (cooling) wall blocks the crossing: nothing happened. The player
/// keeps phasing until the arena pass pins them at the closed wall's trigger line.</summary>
Aborted,
}
/// <summary>The committed wrap across <paramref name="ob"/> entered through <paramref name="entry"/>:
/// the entry-face spike fallback, then the full atomic resolution — chaining across flush
/// neighbours AND the arena seam, exit-face spike / blocked-destination kills, teleport + fx +
/// cooldowns — via <see cref="ResolveCrossing"/>. Shared by the sunk-past-threshold path and the
/// completed-crossing (thin obstacle) path. Always ends the caller's obstacle processing for the
/// tick: every outcome kills, teleports, or (closed-wall abort) leaves the player phasing in place.</summary>
private Crossing WrapAcross( Player player, Obstacle ob, RectF r, Direction entry )
{
// Fallback for unusual starts or spikes becoming deadly while the wrapper is already inside;
// normal movement contact is caught immediately by TryKillOnObstacleSpikeContact above.
// Position-aware: a split side kills only on the segment actually being crossed.
Vector2 entryContact = EntryContactPos( player, r, entry, player.Pos );
if ( player.Stage.ObstacleFaceDeadlyAt( ob, entry, entryContact ) )
{
player.X = entryContact.x;
player.Y = entryContact.y;
player.KillBySpikes( entry );
return Crossing.Ended;
}
return ResolveCrossing( player, Globals.GetOppositeDirection( entry ), ExitPos( player, r, entry ),
EntryFaceCoord( r, entry ), new List<Obstacle> { ob } );
}
/// <summary>Resolve a committed crossing to its final emergence point in ONE tick. Starting from
/// <paramref name="dest"/> — the spot just past the first crossed region, or the player's own
/// position for an arena-edge crossing — keep advancing along the travel axis while the spot is
/// still non-playable: a spot inside a non-glass obstacle exits across it (flush rects forming a
/// composite shape are one contiguous region), and a spot carried past the arena's far side
/// mirrors to the opposite wall's emergence point and continues from there. The whole composite —
/// obstacles, the arena seam, or both interleaved — is crossed with ONE teleport, fx pair, sfx
/// and hitstop. Resolving atomically matters because the travel direction is only known reliably
/// HERE: after a teleport the movement-segment raycast is invalid (<see cref="MAX_STEP"/>) and
/// the velocity fallback guesses wrong near a jump apex or with input held, which used to strand
/// the player wrapping invisibly around closed rings of wall-flush obstacles (wrapper-start's
/// border bands) when the crossing was handed from tick to tick instead.
///
/// Kill semantics match the old per-step wraps: phasing OUT through a live spiked wall, or
/// emerging THROUGH a spiked wall/obstacle face, wraps to that face and defers the kill one tick
/// so the player dies visibly on the spikes; a block at the destination crushes (nowhere to
/// emerge); a destination inside GLASS (solid to the wrap character) crushes. Mirroring the same
/// axis twice — or re-entering an already-crossed obstacle — means the composite spans the whole
/// axis and playable space simply doesn't exist along it: crush. A mirror through a wall that is
/// still CLOSED (reverse-wrap cooldown) aborts the whole crossing untouched instead: the closed
/// wall stays solid, and the arena pass pins the player at its trigger line once they physically
/// reach it. Deterministic: pure geometry over fixed-tick state.</summary>
private Crossing ResolveCrossing( Player player, Direction travel, Vector2 dest, float vanishFace, List<Obstacle> traversed )
{
Direction entry = Globals.GetOppositeDirection( travel );
bool horizontal = travel == Direction.Left || travel == Direction.Right;
var obstacles = player.Stage.GetObstacles();
Obstacle exitOb = traversed.Count > 0 ? traversed[^1] : null;
bool mirrored = false, justMirrored = false;
for ( bool progressed = true; progressed; )
{
progressed = false;
// Inside a non-glass obstacle → exit across it, same axis. (GLASS never joins a chain:
// it's solid to the wrap character, so a pane at the destination crushes, below.)
foreach ( var next in obstacles )
{
if ( next.IsGlass || !next.GetRect().Touches( dest ) )
continue;
if ( traversed.Contains( next ) )
{
// Re-entered a region already crossed (only possible after a mirror): the
// composite spans the axis's whole extent — nowhere to emerge.
player.KillByCrush( horizontal );
return Crossing.Ended;
}
// The FIRST obstacle phased into right after a mirror is entered through a real,
// possibly-spiked face bordering the wall gap (unlike mid-chain flush seams, which
// carry no faces): a live one kills, exactly like the entry-face check on a fresh
// obstacle wrap did when the seam hand-off ran a tick later.
if ( justMirrored )
{
Vector2 entryContact = EntryContactPos( player, next.GetRect(), entry, dest );
if ( player.Stage.ObstacleFaceDeadlyAt( next, entry, entryContact ) )
{
WrapWithFx( player, entryContact.x, entryContact.y, travel, vanishFace );
ScheduleSpikeDeath( player, entry );
return Crossing.Ended;
}
}
dest = ExitPos( player, next.GetRect(), entry );
traversed.Add( next );
exitOb = next;
justMirrored = false;
progressed = true;
break;
}
if ( progressed )
continue;
if ( !PastWallTrigger( player, dest, travel ) )
break; // in playable space — done
// Carried past the arena's far side. Leaving the LAST obstacle straight into the wall
// region still crosses its exit face: a live one kills before the seam carries the
// crossing on (the pre-mirror twin of the post-chain check below) — regardless of wall
// cooldowns, which the obstacle wrap never consulted for this.
if ( exitOb is not null && player.Stage.ObstacleFaceDeadlyAt( exitOb, travel, dest ) )
{
WrapWithFx( player, dest.x, dest.y, travel, vanishFace );
ScheduleSpikeDeath( player, travel );
return Crossing.Ended;
}
// → the arena edge-wrap, inlined into the chain.
if ( mirrored )
{
player.KillByCrush( horizontal ); // a full lap: no playable space on this axis at all
return Crossing.Ended;
}
if ( WallCooldown( travel ) > 0f )
{
if ( Player.DeathTraceEnabled )
Player.TraceDeathGlobal( $"wrap crossing {travel} ABORTED: exit wall closed (cd={WallCooldown( travel ):0.00}s)" );
return Crossing.Aborted; // the exit wall is still closed — no crossing happens
}
// Phasing OUT through a live spiked wall is lethal: wrap to the wall, die visibly on it.
Vector2 exitContact = WallContactPos( player, dest, travel );
if ( player.Stage.WallDeadlyAt( travel, exitContact ) )
{
WrapWithFx( player, exitContact.x, exitContact.y, travel, vanishFace );
ScheduleSpikeDeath( player, travel );
return Crossing.Ended;
}
dest = MirrorAcrossArena( player, dest, travel );
mirrored = true;
justMirrored = true;
exitOb = null;
CloseWall( entry ); // the wall we just emerged from closes to an immediate reverse wrap
// Emerging THROUGH a spiked destination wall is lethal too, deferred the same way.
if ( player.Stage.WallDeadlyAt( entry, dest ) )
{
WrapWithFx( player, dest.x, dest.y, travel, vanishFace );
ScheduleSpikeDeath( player, entry );
return Crossing.Ended;
}
progressed = true;
}
// Emerging THROUGH a spiked exit face (of the LAST obstacle in the chain) — same deferral,
// so the player dies visibly on the far face's spikes.
if ( exitOb is not null && player.Stage.ObstacleFaceDeadlyAt( exitOb, travel, dest ) )
{
WrapWithFx( player, dest.x, dest.y, travel, vanishFace );
ScheduleSpikeDeath( player, travel );
return Crossing.Ended;
}
// Nowhere to emerge — a BLOCK occupies the far side → crushed (wrapped first, so the death
// reads at the blocked spot)...
if ( TryGetBlockingBlock( player, dest, out Block blockingBlock ) )
{
WrapWithFx( player, dest.x, dest.y, travel, vanishFace );
KillAfterWrapIntoBlock( player, blockingBlock, travel, exitOb is null ? "arena" : "obstacle" );
return Crossing.Ended;
}
// ...or a GLASS pane does — the only obstacle that can still contain the spot (everything
// else was chained across). Solid to the wrap character, so it crushes too.
foreach ( var glass in obstacles )
{
if ( glass.IsGlass && glass.GetRect().Touches( dest ) )
{
player.KillByCrush( horizontal );
return Crossing.Ended;
}
}
WrapWithFx( player, dest.x, dest.y, travel, vanishFace );
// Cool down EVERY obstacle crossed, so a reversing player can't immediately wrap back in
// through the far end of the composite.
foreach ( var t in traversed )
_obstacleCd[t] = REVERSE_WRAP_COOLDOWN;
return Crossing.Emerged;
}
/// <summary>Has this spot carried past the arena wall in the travel direction — the same trigger
/// line the arena edge-wrap fires on for the physically-moving player?</summary>
private static bool PastWallTrigger( Player p, Vector2 dest, Direction travel ) => travel switch
{
Direction.Left => dest.x <= Arena.WALL_SIZE + p.Width / 2f - OUT_FRACTION * p.Width,
Direction.Right => dest.x >= Arena.WIDTH - Arena.WALL_SIZE - p.Width / 2f + OUT_FRACTION * p.Width,
Direction.Down => dest.y <= Arena.WALL_SIZE + p.Height / 2f - OUT_FRACTION * p.Height,
Direction.Up => dest.y >= Arena.HEIGHT - Arena.WALL_SIZE - p.Height / 2f + OUT_FRACTION * p.Height,
_ => false,
};
/// <summary>The arena edge-wrap's emergence point for a crossing travelling <paramref name="travel"/>:
/// poking IN_FRACTION out of the OPPOSITE wall, keeping the perpendicular coordinate.</summary>
private static Vector2 MirrorAcrossArena( Player p, Vector2 dest, Direction travel ) => travel switch
{
Direction.Left => new Vector2( Arena.WIDTH - Arena.WALL_SIZE - p.Width / 2f + IN_FRACTION * p.Width, dest.y ),
Direction.Right => new Vector2( Arena.WALL_SIZE + p.Width / 2f - IN_FRACTION * p.Width, dest.y ),
Direction.Down => new Vector2( dest.x, Arena.HEIGHT - Arena.WALL_SIZE - p.Height / 2f + IN_FRACTION * p.Height ),
Direction.Up => new Vector2( dest.x, Arena.WALL_SIZE + p.Height / 2f - IN_FRACTION * p.Height ),
_ => dest,
};
/// <summary>The player's centre at body contact with the arena wall being exited through — where
/// a spiked-wall death on the way out of the arena is anchored, so the kill lands visibly.</summary>
private static Vector2 WallContactPos( Player p, Vector2 dest, Direction travel ) => travel switch
{
Direction.Left => new Vector2( Arena.WALL_SIZE + p.Width / 2f, dest.y ),
Direction.Right => new Vector2( Arena.WIDTH - Arena.WALL_SIZE - p.Width / 2f, dest.y ),
Direction.Down => new Vector2( dest.x, Arena.WALL_SIZE + p.Height / 2f ),
Direction.Up => new Vector2( dest.x, Arena.HEIGHT - Arena.WALL_SIZE - p.Height / 2f ),
_ => dest,
};
/// <summary>Remaining reverse-wrap cooldown on the wall exited through <paramref name="wall"/>.</summary>
private float WallCooldown( Direction wall ) => wall switch
{
Direction.Left => _cdLeft,
Direction.Right => _cdRight,
Direction.Down => _cdDown,
Direction.Up => _cdUp,
_ => 0f,
};
/// <summary>Close <paramref name="wall"/> to wrapping for <see cref="REVERSE_WRAP_COOLDOWN"/> —
/// called on the wall a crossing just emerged from, so it can't immediately reverse.</summary>
private void CloseWall( Direction wall )
{
switch ( wall )
{
case Direction.Left: _cdLeft = REVERSE_WRAP_COOLDOWN; break;
case Direction.Right: _cdRight = REVERSE_WRAP_COOLDOWN; break;
case Direction.Down: _cdDown = REVERSE_WRAP_COOLDOWN; break;
case Direction.Up: _cdUp = REVERSE_WRAP_COOLDOWN; break;
}
}
/// <summary>Did this tick's movement segment complete a crossing of <paramref name="r"/> — exit
/// through the face OPPOSITE the one it entered by? Catches the two ways a thin obstacle escapes
/// the sunk-past-threshold wrap: a fast step that jumps the whole rect between ticks (the centre is
/// never observed inside), and an inside player stepping from short-of-threshold to fully out the
/// far side in one tick. The entry face is the locked one when the player WAS inside last tick,
/// otherwise it's raycast from the segment (nearest crossed edge, exactly like ComputeEntryFace but
/// with no velocity fallback — no real crossing, no wrap). A perpendicular exit (corner clip) is
/// not a crossing: the wrap is strictly straight-across.</summary>
private bool CompletedCrossing( Obstacle ob, RectF r, Player player, out Direction entry )
{
entry = Direction.None;
if ( !_hasPrev )
return false;
Vector2 movement = player.Pos - _prevPos;
if ( movement.Length <= 0f || movement.Length >= MAX_STEP )
return false; // no movement, or an earlier teleport made this an invalid sweep
var seg = new Line( _prevPos, player.Pos );
if ( !_entryNormal.TryGetValue( ob, out entry ) )
{
float bestDistSqr = float.MaxValue;
TryEdge( seg, new Line( r.BottomLeft, r.TopLeft ), Direction.Left, ref entry, ref bestDistSqr );
TryEdge( seg, new Line( r.BottomRight, r.TopRight ), Direction.Right, ref entry, ref bestDistSqr );
TryEdge( seg, new Line( r.BottomLeft, r.BottomRight ), Direction.Down, ref entry, ref bestDistSqr );
TryEdge( seg, new Line( r.TopLeft, r.TopRight ), Direction.Up, ref entry, ref bestDistSqr );
if ( entry == Direction.None )
return false;
}
Line exitEdge = entry switch
{
Direction.Left => new Line( r.BottomRight, r.TopRight ),
Direction.Right => new Line( r.BottomLeft, r.TopLeft ),
Direction.Down => new Line( r.TopLeft, r.TopRight ),
Direction.Up => new Line( r.BottomLeft, r.BottomRight ),
_ => default,
};
return entry != Direction.None && seg.Intersects( exitEdge, out _ );
}
/// <summary>A wrap just emerged through live spikes: don't kill mid-wrap (a death at the vanish
/// point reads as random), let the player appear impaled on the destination face for one tick,
/// then kill them there (top of next <see cref="PostTick"/>). The wrap-axis velocity is zeroed so
/// they hang ON the spikes for that visible tick instead of sliding off them. Deterministic: pure
/// fixed-step state, and the kill fires regardless of what input does in between (at worst input
/// steps them into ClampToBounds' own spike kill on the same wall a half-tick earlier).</summary>
private void ScheduleSpikeDeath( Player player, Direction spikes, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
{
if ( Player.DeathTraceEnabled )
Player.TraceDeathGlobal( $"wrap scheduled spike death from={spikes} at {caller}:{callerLine} pos=({player.X:0.00},{player.Y:0.00})" );
if ( spikes == Direction.Left || spikes == Direction.Right )
player.VelX = 0f;
else
player.VelY = 0f;
_pendingSpikeKill = spikes;
}
// Guard: a movement segment longer than any real one-tick step means a teleport happened this tick
// (an arena edge-wrap ran earlier in PostTick), so the raycast would span the arena — use velocity.
private const float MAX_STEP = 40f;
/// <summary>Kill on the first frame the wrapper's BODY reaches a live obstacle spike face. The
/// movement ray uses each obstacle expanded by the player's half-size, so its intersection is the
/// player's centre at physical body contact. This remains reliable when a fast step crosses an
/// obstacle completely (the centre is never observed inside), and anchoring the death at the hit
/// point keeps blood on the face instead of inside the obstacle. Flush shared seams are harmless:
/// they have no <see cref="WallFace"/>, so <c>ObstacleFaceDeadlyAt</c> returns false.</summary>
private bool TryKillOnObstacleSpikeContact( Player player )
{
if ( !_hasPrev ) return false;
Vector2 movement = player.Pos - _prevPos;
if ( movement.Length <= 0f || movement.Length >= MAX_STEP )
return false; // no movement, or an earlier teleport made this an invalid sweep
var sweep = new Line( _prevPos, player.Pos );
float hw = player.Width / 2f;
float hh = player.Height / 2f;
float bestDistSqr = float.MaxValue;
Direction bestNormal = Direction.None;
Vector2 bestHit = Vector2.Zero;
foreach ( var ob in player.Stage.GetObstacles() )
{
// GLASS is physically solid to the wrap character, so its spiked faces kill through the
// normal unpenetrate path exactly like they do every other character — no phase sweep.
if ( ob.IsGlass ) continue;
RectF r = ob.GetRect();
var expanded = new RectF( r.Left - hw, r.Bottom - hh, r.Right + hw, r.Top + hh );
// Already overlapping this obstacle's expanded body-contact region: this isn't a fresh
// entry. The threshold-path fallback below handles spikes that become deadly while inside.
if ( _prevPos.x > expanded.Left && _prevPos.x < expanded.Right &&
_prevPos.y > expanded.Bottom && _prevPos.y < expanded.Top )
continue;
// Test lethality PER edge rather than choosing a geometric edge first. At an exact corner
// two edges are equally near; an unspiked edge must not hide the equally-contacted spiked one.
TryDeadlySweepEdge( player, ob, sweep, new Line( expanded.BottomLeft, expanded.TopLeft ), Direction.Left,
ref bestNormal, ref bestHit, ref bestDistSqr );
TryDeadlySweepEdge( player, ob, sweep, new Line( expanded.BottomRight, expanded.TopRight ), Direction.Right,
ref bestNormal, ref bestHit, ref bestDistSqr );
TryDeadlySweepEdge( player, ob, sweep, new Line( expanded.BottomLeft, expanded.BottomRight ), Direction.Down,
ref bestNormal, ref bestHit, ref bestDistSqr );
TryDeadlySweepEdge( player, ob, sweep, new Line( expanded.TopLeft, expanded.TopRight ), Direction.Up,
ref bestNormal, ref bestHit, ref bestDistSqr );
}
if ( bestNormal == Direction.None ) return false;
player.X = bestHit.x;
player.Y = bestHit.y;
player.KillBySpikes( bestNormal );
return true;
}
private static void TryDeadlySweepEdge( Player player, Obstacle obstacle, Line sweep, Line edge,
Direction normal, ref Direction bestNormal, ref Vector2 bestHit, ref float bestDistSqr )
{
if ( !sweep.Intersects( edge, out Vector2 hit ) ) return;
float distSqr = (hit - sweep.A).LengthSquared;
if ( distSqr >= bestDistSqr || !player.Stage.ObstacleFaceDeadlyAt( obstacle, normal, hit ) ) return;
bestDistSqr = distSqr;
bestNormal = normal;
bestHit = hit;
}
/// <summary>Which face of <paramref name="r"/> the player crossed to get inside: the nearest edge the
/// movement segment [_prevPos -> now] intersects. Falls back to the dominant-velocity axis (entry face
/// opposite the motion) when there's no usable previous position.</summary>
private Direction ComputeEntryFace( RectF r, Player player )
{
if ( _hasPrev && (player.Pos - _prevPos).Length < MAX_STEP )
{
var seg = new Line( _prevPos, player.Pos );
Direction best = Direction.None;
float bestDistSqr = float.MaxValue;
TryEdge( seg, new Line( r.BottomLeft, r.TopLeft ), Direction.Left, ref best, ref bestDistSqr );
TryEdge( seg, new Line( r.BottomRight, r.TopRight ), Direction.Right, ref best, ref bestDistSqr );
TryEdge( seg, new Line( r.BottomLeft, r.BottomRight ), Direction.Down, ref best, ref bestDistSqr );
TryEdge( seg, new Line( r.TopLeft, r.TopRight ), Direction.Up, ref best, ref bestDistSqr );
if ( best != Direction.None )
return best;
}
// Fallback: entered the face opposite the dominant motion (moving right -> entered the left face).
if ( Math.Abs( player.VelX ) >= Math.Abs( player.VelY ) )
return player.VelX >= 0f ? Direction.Left : Direction.Right;
return player.VelY >= 0f ? Direction.Down : Direction.Up;
}
private void TryEdge( Line seg, Line edge, Direction normal, ref Direction best, ref float bestDistSqr )
{
if ( seg.Intersects( edge, out Vector2 hit ) )
{
float d = (hit - seg.A).LengthSquared;
if ( d < bestDistSqr ) { bestDistSqr = d; best = normal; }
}
}
/// <summary>Has the player sunk OUT_FRACTION of their body past the entry face into the obstacle?
/// On an obstacle THINNER than that sink distance the commit point is clamped to the rect's
/// midpoint — otherwise the trigger band (thickness minus sink) can be a fraction of a pixel wide
/// and discrete ticks step straight over it (a 5px wall vs the 12px-tall body leaves 0.2px).
/// Crossings that skip even the clamped band are caught by <see cref="CompletedCrossing"/>.</summary>
private static bool PastThreshold( Player p, RectF r, Direction entry )
{
float sinkX = Math.Min( OUT_FRACTION * p.Width, (r.Right - r.Left) / 2f );
float sinkY = Math.Min( OUT_FRACTION * p.Height, (r.Top - r.Bottom) / 2f );
return entry switch
{
Direction.Left => p.X >= r.Left + sinkX, // entered left, heading right
Direction.Right => p.X <= r.Right - sinkX,
Direction.Down => p.Y >= r.Bottom + sinkY, // entered bottom, heading up
Direction.Up => p.Y <= r.Top - sinkY,
_ => false,
};
}
/// <summary>Emergence position: straight across on the entry axis, keeping the perpendicular
/// coordinate, re-emerging IN_FRACTION inside the far face.</summary>
private static Vector2 ExitPos( Player p, RectF r, Direction entry )
{
float hw = p.Width / 2f, hh = p.Height / 2f;
return entry switch
{
Direction.Left => new Vector2( r.Right + hw - IN_FRACTION * p.Width, p.Y ), // exit right
Direction.Right => new Vector2( r.Left - hw + IN_FRACTION * p.Width, p.Y ), // exit left
Direction.Down => new Vector2( p.X, r.Top + hh - IN_FRACTION * p.Height ), // exit up
Direction.Up => new Vector2( p.X, r.Bottom - hh + IN_FRACTION * p.Height ), // exit down
_ => p.Pos,
};
}
/// <summary>The player's centre at first body contact with an obstacle entry face, preserving the
/// current perpendicular coordinate for position-aware split-face spike lookup.</summary>
private static Vector2 EntryContactPos( Player p, RectF r, Direction entry, Vector2 pos )
{
float hw = p.Width / 2f, hh = p.Height / 2f;
return entry switch
{
Direction.Left => new Vector2( r.Left - hw, pos.y ),
Direction.Right => new Vector2( r.Right + hw, pos.y ),
Direction.Down => new Vector2( pos.x, r.Bottom - hh ),
Direction.Up => new Vector2( pos.x, r.Top + hh ),
_ => pos,
};
}
/// <summary>Is this arena wall deadly to cross RIGHT NOW at the player's position? Delegates to the
/// shared <c>GameStage.WallDeadlyAt</c> "deadly now" predicate (live spikes, not mid-transition),
/// exactly matching the kill check in <c>Player.ClampToBounds</c> and the obstacle gate
/// (<c>ObstacleFaceDeadlyAt</c>). The wrap gate MUST use this same predicate: if it refused a
/// switching face (deadly-OR-switching) while the kill and clamp both stood down, a wrap character
/// crossing during the transition would be neither wrapped, clamped, nor killed, and would sail
/// clean out of the arena (the wrap_repro level reproduces exactly that).</summary>
private static bool WallDeadlyAt( Player player, Direction side )
=> player.Stage.WallDeadlyAt( side, player.Pos );
/// <summary>The player crossed a wall's trigger line while that wall is still closed (reverse-wrap
/// cooldown). The closed wall behaves like a SOLID wall rather than a lethal one: hold the player
/// at the trigger coordinate <paramref name="pin"/> and cancel whatever main-channel velocity was
/// carrying them out (external channels — ExtraVel/wind/magnet — can't be touched from here, but
/// the pin re-asserts every tick, so they can push against the wall without escaping). Harmless on
/// its own: gravity dragging an apex-wrapped player back just parks them here until the wall
/// re-opens. But if a block overlaps the pinned spot the player is genuinely being squeezed
/// against the closed wall — the edge-camping case the cooldown exists for — and is crushed.
/// (Intersects is open-interval, so merely resting flush against a block never triggers this.)
/// Returns true if the player was killed.</summary>
private static bool PinAtClosedWall( Player player, bool horizontal, float pin )
{
if ( Player.DeathTraceEnabled )
Player.TraceDeathGlobal( $"wrap pinned at closed wall {(horizontal ? "x" : "y")}={pin:0.00} pos=({player.X:0.00},{player.Y:0.00}) vel=({player.VelX:0.00},{player.VelY:0.00})" );
if ( horizontal )
{
if ( (pin > player.X && player.VelX < 0f) || (pin < player.X && player.VelX > 0f) )
player.VelX = 0f;
player.X = pin;
}
else
{
if ( (pin > player.Y && player.VelY < 0f) || (pin < player.Y && player.VelY > 0f) )
player.VelY = 0f;
player.Y = pin;
}
RectF r = player.GetRect();
foreach ( var b in player.Stage.GetBlocks() )
{
if ( r.Intersects( b.GetRect() ) )
{
player.KillByCrush( horizontal );
return true;
}
}
return false;
}
// A BLOCK occupying the emergence spot means there's nowhere to go → crush. Only the player-sized
// rect at the emergence CENTRE is tested — merely GRAZING a neighbour obstacle there is harmless
// (the phase char passes through obstacles; ResolveCrossing chains across any that actually
// contain the spot).
private static bool TryGetBlockingBlock( Player player, Vector2 dest, out Block blockingBlock )
{
RectF destinationRect = player.GetRect( dest.x, dest.y );
foreach ( var block in player.Stage.GetBlocks() )
{
if ( !destinationRect.Intersects( block.GetRect() ) ) continue;
blockingBlock = block;
return true;
}
blockingBlock = null;
return false;
}
private static void KillAfterWrapIntoBlock( Player player, Block block, Direction wrapDirection, string source )
{
Vector2 shake = Globals.GetVectorForDirection( wrapDirection ) * WRAP_CRUSH_BLOCK_SHAKE;
//Log.Info( $"[Wrap] {source} crush: player={player.Pos}, block={block.Pos}, direction={wrapDirection}, shake={shake}" );
block.AddShake( shake );
player.KillByCrush( wrapDirection == Direction.Left || wrapDirection == Direction.Right );
}
/// <summary>Perform a wrap teleport to (x,y) with its presentation: a portal flash where the player
/// vanished and another where they re-emerged (see <see cref="SpawnPortalFx"/>), plus the warp sfx.
/// <paramref name="travelDir"/> is the direction the player is heading through the wrap (the exit
/// normal); <paramref name="entryFace"/> is the coordinate (along the travel axis) of the face the
/// player crossed to enter the region (an arena wall face, or an obstacle face). A corner exit calls
/// this once per axis: the intermediate flashes land on the same spot and simply overlap.
///
/// Each end's flash is spawned ONLY if it borders playable space. A crossing can start or finish
/// buried in a non-playable region — e.g. one resolved from a player pinned at a closed wall
/// inside a wall-flush obstacle, or a wrap onto a spiked face landing against a wall — and an end
/// the player could never have seen must not flash a portal (with both ends suppressed, the sfx
/// is skipped too). Deterministic: pure geometry, no RNG/time.</summary>
private void WrapWithFx( Player player, float x, float y, Direction travelDir, float entryFace, [CallerMemberName] string caller = "", [CallerLineNumber] int callerLine = 0 )
{
var from = player.Pos;
var dest = new Vector2( x, y );
if ( Player.DeathTraceEnabled )
Player.TraceDeathGlobal( $"wrap teleport ({from.x:0.00},{from.y:0.00}) -> ({x:0.00},{y:0.00}) travel={travelDir} at {caller}:{callerLine}" );
// The vanish flash marks where the player CROSSED INTO the region, not where they ended up:
// on a fast fall the body sinks OUT_FRACTION (or more, on overshoot) past the entry face before
// the wrap fires, so anchor the flash to the entry face on the travel axis (keeping the
// perpendicular coordinate) — i.e. the point they entered through, e.g. the top of the obstacle.
Vector2 vanishAt = (travelDir == Direction.Left || travelDir == Direction.Right)
? new Vector2( entryFace, from.y )
: new Vector2( from.x, entryFace );
// Vanish flash shows only if the player arrived from playable space (sampled just past the entry
// face, on the side they came from). Emergence flash shows only if the destination sits in
// playable space. Either end buried in a wall/obstacle or out of bounds is an internal seam.
bool showVanish = CameFromPlayable( player, from, travelDir, entryFace );
bool showEmerge = IsPlayable( player, dest );
player.InheritPlatformVelocityForWrap();
if ( showVanish ) SpawnPortalFx( player, vanishAt );
player.TeleportTo( x, y );
if ( showEmerge ) SpawnPortalFx( player, dest ); // player.Pos == dest now
if ( showVanish || showEmerge ) PlayWrapSfx( player );
player.Stage.RequestHitStop( WRAP_HITSTOP_FRAMES );
}
/// <summary>Is <paramref name="p"/> inside the playable area — within the inner arena walls and not
/// buried in an interior obstacle? Used to decide whether a wrap end is a real, player-visible edge
/// (as opposed to an internal seam of a contiguous non-playable region). Open intervals so a point
/// resting exactly on a wall face or obstacle edge counts as just-in-bounds / just-outside a rect.</summary>
private static bool IsPlayable( Player player, Vector2 p )
{
if ( p.x <= Arena.WALL_SIZE || p.x >= Arena.WIDTH - Arena.WALL_SIZE ||
p.y <= Arena.WALL_SIZE || p.y >= Arena.HEIGHT - Arena.WALL_SIZE )
return false;
foreach ( var ob in player.Stage.GetObstacles() )
{
RectF r = ob.GetRect();
if ( p.x > r.Left && p.x < r.Right && p.y > r.Bottom && p.y < r.Top )
return false;
}
return true;
}
/// <summary>Did the player enter this wrap from playable space? Samples a point just PAST the entry
/// face on the side the player came from (opposite the travel direction), anchored to the FACE — not
/// the player's position, which on a fast fall can overshoot the trigger far out of bounds and give
/// a false "buried" reading. If that region is playable the vanish is a real edge (e.g. falling onto
/// the top of an obstacle from open space); if it's another obstacle or out of bounds it's an
/// internal seam (e.g. the arena edge-wrap that follows a wrap through a wall-flush obstacle). The
/// perpendicular coordinate is taken from the vanish position so we sample directly behind where the
/// player crossed.</summary>
private static bool CameFromPlayable( Player player, Vector2 vanishPos, Direction travelDir, float entryFace )
{
Vector2 back = -Globals.GetVectorForDirection( travelDir ); // unit toward the came-from side
Vector2 probe = (travelDir == Direction.Left || travelDir == Direction.Right)
? new Vector2( entryFace + back.x * PORTAL_PROBE_PAD, vanishPos.y )
: new Vector2( vanishPos.x, entryFace + back.y * PORTAL_PROBE_PAD );
return IsPlayable( player, probe );
}
/// <summary>The coordinate (along the crossed axis) of the obstacle face the player entered through.</summary>
private static float EntryFaceCoord( RectF r, Direction entry ) => entry switch
{
Direction.Left => r.Left,
Direction.Right => r.Right,
Direction.Down => r.Bottom,
Direction.Up => r.Top,
_ => 0f,
};
/// <summary>Is <paramref name="p"/> outside <paramref name="r"/> by more than <paramref name="margin"/>
/// on at least one axis — i.e. the player's centre has cleared the obstacle by a comfortable gap?</summary>
private static bool OutsideByMargin( RectF r, Vector2 p, float margin )
=> p.x < r.Left - margin || p.x > r.Right + margin ||
p.y < r.Bottom - margin || p.y > r.Top + margin;
/// <summary>One portal flash: a player-sized dark-purple square with a smaller, shorter-lived
/// body-purple highlight on top (its own depth layer, so the pair never z-fights), both rapidly
/// shrinking in place. The wrap point itself can be buried in a wall or out of bounds, so the
/// flash is clamped fully inside the arena (plus <see cref="PORTAL_CLAMP_PADDING"/>) to stay
/// visible — e.g. the bottom wall is flush with the window edge, hiding anything past it.</summary>
private static void SpawnPortalFx( Player player, Vector2 pos )
{
const float inset = PORTAL_SIZE / 2f + PORTAL_CLAMP_PADDING;
pos.x = Math.Clamp( pos.x, Arena.WALL_SIZE + inset, Arena.WIDTH - Arena.WALL_SIZE - inset );
pos.y = Math.Clamp( pos.y, Arena.WALL_SIZE + inset, Arena.HEIGHT - Arena.WALL_SIZE - inset );
player.Stage.AddPortalParticle( pos, PORTAL_LIFETIME, PORTAL_SIZE, ParticleKind.WrapPortal0, Globals.DEPTH_PORTAL );
player.Stage.AddPortalParticle( pos, PORTAL_HIGHLIGHT_LIFETIME, PORTAL_HIGHLIGHT_SIZE, ParticleKind.WrapPortal1, Globals.DEPTH_PORTAL_HIGHLIGHT );
}
// A low, warpy "phase" cue on wrap (the Smile block's turn-invisible sound, pitched down),
// rate-limited so a corner (two-axis) wrap doesn't double it up.
private void PlayWrapSfx( Player player )
{
if ( _sfxCooldown > 0f ) return;
_sfxCooldown = WRAP_SFX_MIN_INTERVAL;
Audio.PlaySfx( SfxType.TurnInvisible, 0.8f, 0.6f );
// Behind the same rate limit as the cue, so a corner (two-axis) wrap thumps once, not twice.
if ( player.DrivesHaptics )
Haptics.Pulse( 0.38f, 0.09f, 0f, Haptics.TONE_HEAVY );
}
}