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 );
	}
}