Rendering/SpriteLayer.cs
using Sandbox.Rendering;
namespace BlockParty;
/// <summary>
/// Helper for building the layered 2D sprites each entity is composed of. Wraps the
/// engine's <see cref="SpriteRenderer"/> with the conventions this game needs:
/// point filtering (crisp pixels), 1 logical pixel = 1 world unit, and a small local-Z
/// offset so layers stack in a defined order within an entity.
/// </summary>
public static class SpriteLayer
{
/// <summary>Local-Z spacing between an entity's stacked layers (avoids z-fighting).</summary>
public const float LAYER_Z_STEP = 0.1f;
/// <summary>The game-wide sprite opacity default — the single switch for the parked
/// opaque-by-default policy (see the comment in <see cref="Add"/>). Everything that restores a
/// sprite to "normal" opacity (e.g. Block.SetLayersTranslucent) must use this rather than a
/// literal true, so no sprite can join the engine's opaque batch while the default is off.</summary>
public const bool OPAQUE_DEFAULT = false;
/// <summary>Returns a sprite center whose edges land on the logical pixel grid.</summary>
public static Vector2 PixelAlignedCenter( Vector2 center, Vector2 size )
{
float left = MathF.Round( center.x - size.x * 0.5f );
float bottom = MathF.Round( center.y - size.y * 0.5f );
return new Vector2( left + size.x * 0.5f, bottom + size.y * 0.5f );
}
/// <summary>Turns a sprite in the view plane so its art-up points along <paramref name="up"/> (and its
/// Size.x axis along <paramref name="up"/> rotated 90° clockwise). Use this instead of
/// BillboardMode.None: the engine gives every translucent non-billboard sprite its own scene
/// object, sorted by bounds distance outside the Z-sorted batch (drawn behind it). With the camera
/// looking straight down -Z, a rolled billboard is the same flat quad. Positive billboard roll
/// turns clockwise on screen.</summary>
public static Rotation FlatRotation( Vector2 up ) => Rotation.FromRoll( MathF.Atan2( up.x, up.y ).RadianToDegree() );
/// <summary>
/// Add a sprite layer as a child of <paramref name="parent"/>.
/// </summary>
/// <param name="parent">Owning entity GameObject.</param>
/// <param name="spritePath">Path to the baked .sprite resource (e.g. "sprites/player.sprite").</param>
/// <param name="size">Quad size in logical pixels (= source art pixel dimensions).</param>
/// <param name="startingAnim">Animation to start playing.</param>
/// <param name="childOrder">Stacking order within the entity (higher = nearer camera).</param>
public static SpriteRenderer Add( GameObject parent, string spritePath, Vector2 size, string startingAnim = "idle", int childOrder = 0 )
{
var go = new GameObject();
go.Name = $"layer:{startingAnim}";
go.SetParent( parent );
// 0.1 spacing (not 0.01) so layers don't z-fight: ortho depth precision over the
// camera's near/far range can't reliably separate 0.01-spaced layers.
go.LocalPosition = new Vector3( 0, 0, childOrder * LAYER_Z_STEP );
var sr = go.Components.Create<SpriteRenderer>();
sr.Sprite = ResourceLibrary.Get<Sprite>( spritePath );
sr.Size = size;
sr.TextureFilter = FilterMode.Point;
sr.Color = Color.White;
sr.PlaybackSpeed = 1f;
// Sort by depth (Z) so layering is driven by our DepthToZ, NOT scene/creation order.
// Without this, draw order follows object creation; after stage churn (play→die→menu→
// play) the order changes and e.g. the background rect ends up drawn on top of everything.
sr.IsSorted = true;
// TRANSLUCENT by default — for now. Opaque-by-default is where this wants to end up (nearly
// all our sprites are hard-edged pixel art, and depth-writing sprites are ordered by the
// depth buffer instead of the sort). Older engines drew the opaque and transparent sprite
// batches in bounds-dependent order with no pass flags, so whichever drew second stomped the
// other (the e4dd570 edge-blocker regression); the opaque batch's membership stays frozen at
// the known-good set: player, walls/bands/teeth, glass borders/teeth, obstacle fills. Engine
// 98907a9109 now puts the batches in the opaque/translucent passes, so flipping
// OPAQUE_DEFAULT is unblocked but needs a playtest pass (an opaque sprite at alpha < 1 blends
// only with what the opaque pass already drew). Translucent sites already declare themselves
// explicitly (sr.Opaque = false + sr.AlphaCutoff = 0).
sr.Opaque = OPAQUE_DEFAULT;
if ( sr.Sprite is not null && !string.IsNullOrEmpty( startingAnim ) )
sr.PlayAnimation( startingAnim );
return sr;
}
}