Entities/Portal.cs
namespace BlockParty;
public readonly record struct PortalPointerSettings(
float MinOffset,
float MaxOffset,
Color ColorA,
Color ColorB,
float MinOpacity,
float MaxOpacity,
float OpacityPower,
float PulseSpeed,
float MinOscillationDistance,
float MaxOscillationDistance,
float MinOscillationSpeed,
float MaxOscillationSpeed
);
/// <summary>
/// The Swapper character's portal: a persistent marker the player can swap positions with (see
/// <see cref="SwapPortalAbility"/>). It never expires and does not collide with anything. A projectile
/// hit can anchor it to a block at the impact offset; otherwise it remains stationary in world space.
///
/// Visually it's an outwardly-expanding, taller-than-wide rectangular portal: a small pool of ring
/// quads, staggered in phase, each growing from tiny to a player+-sized rectangle while fading, in
/// two alternating tones (a cyan and a slightly bluer cyan) so that at any moment two differently
/// coloured rects are expanding at once — the "portal breathing" look. It renders just above the
/// block/laser layer but below the player (see the child-order band below), so the player is always
/// drawn on top of their own portal.
///
/// Deterministic / replay-safe: the ring animation is driven purely by the fixed tick <c>dt</c> with
/// no RNG, and the entity only moves on an (input-driven, recorded) swap — so a replay reproduces it
/// exactly. It ticks inside the sim loop, so it also freezes during hit-stop along with everything else.
/// </summary>
public sealed class Portal : Entity2D
{
public GameStage Stage { get; set; }
// --- ring animation tuning ----------------------------------------------------------
// Number of expanding ring quads. Each gets its own child-order slot so they never share a Z
// (translucent sprites at equal Z would flicker); the band 5..8 sits above the block face layers
// (child 0-4) and the squid beam (child 6-8 is close, but the top ring at child 8+ clears it) yet
// stays below the player, whose sprite is a whole depth layer up (see Globals depth constants).
private const int NUM_RINGS = 4;
private const int FIRST_CHILD_ORDER = 5;
// One ring's grow-and-fade cycle (seconds). The rings are evenly staggered across this so the
// expansion reads as continuous.
private const float RING_PERIOD = 0.9f;
// The rectangular ring's size, tiny at birth to full (player+ sized, taller than it is wide) at
// the end of its expansion. The player art is 12x12 / collision 8x10, so this reads as clearly
// bigger than the character and portrait-shaped.
private static readonly Vector2 RING_MIN_SIZE = new( 2f, 3f );
private static readonly Vector2 RING_FULL_SIZE = new( 14f, 24f );
// The two alternating tones. Cyan matches the Swapper's body; the second is a touch bluer so the
// overlapping rects read as a two-colour portal rather than one flat glow.
private static readonly Color RING_COLOR_A = new( 72 / 255f, 205 / 255f, 218 / 255f );
private static readonly Color RING_COLOR_B = new( 60 / 255f, 150 / 255f, 210 / 255f );
private const float POINTER_HIDE_SPEED = 6f;
private const float POINTER_FULL_SIZE_SPEED = 220f;
private const float POINTER_MIN_LENGTH = 5f;
private const float POINTER_MAX_LENGTH = 8f;
private const int POINTER_CHILD_ORDER = FIRST_CHILD_ORDER + NUM_RINGS;
private SpriteRenderer[] _rings;
private SceneCustomObject _pointerSceneObject;
private Material _pointerMaterial;
private readonly List<Vertex> _pointerVertices = new( 3 );
private Vector2 _pointerVelocity;
private PortalPointerSettings _pointerSettings;
private bool _pointerEnabled;
private float _pointerPulsePhase;
private float _pointerOscillationPhase;
private float _phase; // 0..1 master phase, advanced by dt each tick
private int? _anchorBlockStageIndex;
private Vector2 _anchorOffset;
public void CreateVisuals()
{
Size = RING_FULL_SIZE; // logical footprint (unused for collision — purely nominal)
Depth = Globals.DEPTH_BLOCK; // base Z = block layer; the child orders lift the rings above it
_rings = new SpriteRenderer[NUM_RINGS];
for ( int i = 0; i < NUM_RINGS; i++ )
{
var sr = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", RING_MIN_SIZE, "idle", FIRST_CHILD_ORDER + i );
// Smooth translucency (same setup as GameStage.CreateOverlaySprite): don't discard low-alpha
// pixels and alpha-blend instead of dither, so the rings fade cleanly.
sr.Opaque = false;
sr.AlphaCutoff = 0f;
_rings[i] = sr;
}
CreatePointerSceneObject();
UpdateRings();
}
protected override void OnEnabled()
{
// Components are enabled before GameStage calls CreateVisuals; only recreate after that setup exists.
if ( _rings is not null ) CreatePointerSceneObject();
}
protected override void OnDisabled()
{
_pointerSceneObject?.Delete();
_pointerSceneObject = null;
}
private void CreatePointerSceneObject()
{
if ( _pointerSceneObject is not null ) return;
_pointerMaterial ??= Material.FromShader( "shaders/portal_pointer.shader" );
_pointerSceneObject = new SceneCustomObject( Scene.SceneWorld )
{
Transform = global::Transform.Zero,
RenderLayer = SceneRenderLayer.OverlayWithDepth,
RenderOverride = RenderPointer,
};
_pointerSceneObject.Flags.IsOpaque = false;
_pointerSceneObject.Flags.IsTranslucent = true;
_pointerSceneObject.Flags.CastShadows = false;
UpdatePointer();
}
public override void Tick( float dt )
{
UpdateAnchoredPosition();
_phase += dt / RING_PERIOD;
_phase -= MathF.Floor( _phase ); // wrap to [0,1) without drift
UpdateRings();
float magnitude = _pointerVelocity.Length;
float sizeFraction = PointerSizeFraction( magnitude );
_pointerPulsePhase = WrapPhase( _pointerPulsePhase + dt * Math.Max( 0f, _pointerSettings.PulseSpeed ) );
float oscillationSpeed = LerpOrdered(
_pointerSettings.MinOscillationSpeed,
_pointerSettings.MaxOscillationSpeed,
sizeFraction
);
_pointerOscillationPhase = WrapPhase( _pointerOscillationPhase + dt * Math.Max( 0f, oscillationSpeed ) );
UpdatePointer();
}
/// <summary>Re-snap to the anchor block's CURRENT position. Runs every Tick — but Portals tick
/// AFTER Players, so the swap must call this before reading <see cref="Entity2D.Pos"/> or it
/// teleports to where the block was LAST tick (up to a block-step behind the rendered portal,
/// biased into a moving block).</summary>
public void UpdateAnchoredPosition()
{
if ( _anchorBlockStageIndex is not int stageIndex ) return;
foreach ( Block block in Stage.GetBlocks() )
{
if ( block.StageIndex != stageIndex ) continue;
Pos = block.Pos + _anchorOffset;
return;
}
_anchorBlockStageIndex = null;
}
private void UpdateRings()
{
if ( _rings is null ) return;
for ( int i = 0; i < _rings.Length; i++ )
{
// Evenly stagger each ring across the cycle so the expansion is continuous.
float t = _phase + (float)i / NUM_RINGS;
t -= MathF.Floor( t );
// Grow fast then settle toward full size.
float w = Utils.Map( t, 0f, 1f, RING_MIN_SIZE.x, RING_FULL_SIZE.x, true, EasingType.SineEaseOut );
float h = Utils.Map( t, 0f, 1f, RING_MIN_SIZE.y, RING_FULL_SIZE.y, true, EasingType.SineEaseOut );
// Fade in quickly, hold, then fade out over the back half — so a ring is visible while it's
// actually expanding and never pops in/out at full/zero size.
float alpha = MathF.Min( MathF.Min( t / 0.12f, (1f - t) / 0.5f ), 1f );
if ( alpha < 0f ) alpha = 0f;
var sr = _rings[i];
sr.Size = new Vector2( w, h );
Color baseColor = (i % 2 == 0) ? RING_COLOR_A : RING_COLOR_B;
sr.Color = baseColor.WithAlpha( alpha );
}
}
/// <summary>Show the velocity the player will receive on the next swap.</summary>
public void SetStoredVelocityPointer( Vector2 velocity, bool enabled, PortalPointerSettings settings )
{
_pointerVelocity = velocity;
_pointerEnabled = enabled;
_pointerSettings = settings;
UpdatePointer();
}
private void UpdatePointer()
{
_pointerVertices.Clear();
if ( _pointerSceneObject is null ) return;
float magnitude = _pointerVelocity.Length;
if ( !_pointerEnabled || magnitude <= POINTER_HIDE_SPEED ) return;
Vector2 direction = _pointerVelocity / magnitude;
Vector2 perpendicular = new( -direction.y, direction.x );
float sizeFraction = PointerSizeFraction( magnitude );
float minOffset = Math.Max( 0f, _pointerSettings.MinOffset );
float maxOffset = Math.Max( minOffset, _pointerSettings.MaxOffset );
float baseOffset = minOffset + (maxOffset - minOffset) * sizeFraction;
float oscillationDistance = LerpOrdered(
_pointerSettings.MinOscillationDistance,
_pointerSettings.MaxOscillationDistance,
sizeFraction
);
float oscillation = MathF.Sin( _pointerOscillationPhase * MathF.PI * 2f ) * Math.Max( 0f, oscillationDistance );
float length = POINTER_MIN_LENGTH + (POINTER_MAX_LENGTH - POINTER_MIN_LENGTH) * sizeFraction;
float halfWidth = 1.8f + 0.8f * sizeFraction;
Vector2 center = Pos + direction * (baseOffset + oscillation);
Vector2 tip = center + direction * length * 0.55f;
Vector2 rearCenter = center - direction * length * 0.45f;
Vector2 rearA = rearCenter + perpendicular * halfWidth;
Vector2 rearB = rearCenter - perpendicular * halfWidth;
float z = Globals.DepthToZ( Globals.DEPTH_BLOCK ) + POINTER_CHILD_ORDER * SpriteLayer.LAYER_Z_STEP;
float pulse = 0.5f - 0.5f * MathF.Cos( _pointerPulsePhase * MathF.PI * 2f );
float minOpacity = Math.Clamp( _pointerSettings.MinOpacity, 0f, 1f );
float maxOpacity = Math.Clamp( _pointerSettings.MaxOpacity, minOpacity, 1f );
float opacityFraction = MathF.Pow( sizeFraction, Math.Max( 0.01f, _pointerSettings.OpacityPower ) );
float opacity = minOpacity + (maxOpacity - minOpacity) * opacityFraction;
Color32 color = Color.Lerp( _pointerSettings.ColorA, _pointerSettings.ColorB, pulse ).WithAlpha( opacity );
_pointerVertices.Add( new Vertex( new Vector3( rearA.x, rearA.y, z ), color ) );
_pointerVertices.Add( new Vertex( new Vector3( rearB.x, rearB.y, z ), color ) );
_pointerVertices.Add( new Vertex( new Vector3( tip.x, tip.y, z ), color ) );
// Vertices are already world-space; this transform supplies the translucent object's sort position.
_pointerSceneObject.Transform = new Transform( new Vector3( center.x, center.y, z ) );
}
private static float PointerSizeFraction( float magnitude )
{
float fraction = Math.Clamp(
(magnitude - POINTER_HIDE_SPEED) / (POINTER_FULL_SIZE_SPEED - POINTER_HIDE_SPEED),
0f,
1f
);
return MathF.Sin( fraction * MathF.PI * 0.5f );
}
private static float LerpOrdered( float min, float max, float fraction )
{
min = Math.Max( 0f, min );
max = Math.Max( min, max );
return min + (max - min) * fraction;
}
private static float WrapPhase( float phase ) => phase - MathF.Floor( phase );
private void RenderPointer( SceneObject sceneObject )
{
if ( _pointerVertices.Count == 0 || _pointerMaterial is null ) return;
Graphics.Draw( _pointerVertices, _pointerVertices.Count, _pointerMaterial, primitiveType: Graphics.PrimitiveType.Triangles );
}
/// <summary>Teleport the portal in world space (used by the swap), detaching it from any block.</summary>
public void MoveTo( Vector2 pos )
{
_anchorBlockStageIndex = null;
Pos = pos;
}
/// <summary>Anchor the portal to a block while preserving the exact world-space impact position.</summary>
public void MoveToBlock( Vector2 pos, Block block )
{
_anchorBlockStageIndex = block.StageIndex;
_anchorOffset = pos - block.Pos;
Pos = pos;
}
/// <summary>Place the portal just outside a contacted solid face, using its visual footprint.</summary>
public void MoveToSurface( Vector2 impact, Direction surfaceFace, float padding )
{
_anchorBlockStageIndex = null;
Vector2 normal = Globals.GetVectorForDirection( surfaceFace );
float halfExtent = normal.x != 0f ? Width * 0.5f : Height * 0.5f;
Pos = impact + normal * (halfExtent + Math.Max( 0f, padding ));
}
}