Spiludvikling etiketine sahip kayıtlar gösteriliyor. Tüm kayıtları göster
Spiludvikling etiketine sahip kayıtlar gösteriliyor. Tüm kayıtları göster

27 Eylül 2026 Pazar

2D-kollisionsdetektion i MonoGame: Fra grundlæggende rektangler til pixel-perfekte systemer med nultildeling

2D-kollisionsdetektion i MonoGame: Fra grundlæggende rektangler til pixel-perfekte systemer med nultildeling

Når du bygger 2D-spil i visuelle motorer som Unity eller Godot, føles kollisionsdetektion ofte som en række af inspektørafkrydsningsfelter: du smækker på en BoxCollider2D eller CircleCollider2D, vedhæfter en Rigidbody og håber, at det interne fysiktrin ikke hakker på mobile enheder.

I MonoGame og C# har du dog total kontrol. Der er ingen skjult fysik overhead, ingen uønsket rotationsinerti og ingen mystiske skraldesamlinger (GC) spidser, der stjæler dit rammebudget.

På Arar Games, da vi byggede Blocked: Pixel Panzer og Paint Trek, var vores arkadespilsløjfer nødvendige for at behandle hundredvis af højhastighedsfjendtlige kugler, eksploderende murstensgitre, roterende tanktårne, jagerfly-flybys og partikelskjolde ved 60 til 120 FPS på tværs af både Windows-pc'er og Android-enheder. En fysikmotor til generelle formål var udelukket – vi havde brug for en specialbygget, lagdelt kollisionsarkitektur.

I denne omfattende, kodedrevne guide vil vi tage udgangspunkt i det absolutte grundlæggende i MonoGame-kollision (Rectangle.Intersects med ægte Bullet og Enemy sprites) og bygge op til avancerede cirkeltjek, mixed clamping, anti-tunneling raycasts, produktions-grade **Pixel-Zero-Per, All-Fact Collision Pixel-Zero og Grids optimeret til mobil GC-overlevelse.


1. The Foundation: A Simple MonoGame Sprite Hierarki

Før vi opdager kollisioner, har vi brug for rene spilenheder. I MonoGame besidder en enhed grundlæggende en position, en tekstur og et afgrænsende rektangel.

Her er den grundlæggende enhedsarkitektur, der bruges på tværs af vores spil:

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;

public class Sprite
{
    public Vector2 Position;
    public Texture2D Texture;
    public Color Tint = Color.White;
    public bool IsActive = true;

    // The raw Axis-Aligned Bounding Box (AABB)
    public virtual Rectangle Bounds => new Rectangle(
        (int)Position.X,
        (int)Position.Y,
        Texture != null ? Texture.Width : 0,
        Texture != null ? Texture.Height : 0
    );

    public virtual void Draw(SpriteBatch spriteBatch)
    {
        if (!IsActive || Texture == null) return;
        spriteBatch.Draw(Texture, Position, Tint);
    }
}

Lad os nu skabe konkrete "Player", "Enemy" og "Bullet" klasser:

public class Bullet : Sprite
{
    public Vector2 Velocity;
    public int Damage = 25;

    public void Update(GameTime gameTime)
    {
        Position += Velocity * (float)gameTime.ElapsedGameTime.TotalSeconds;
    }
}

public class Enemy : Sprite
{
    public int Health = 100;

    public void TakeDamage(int damage)
    {
        Health -= damage;
        if (Health <= 0)
        {
            IsActive = false;
        }
    }
}

public class Player : Sprite
{
    public int Health = 100;
    public bool IsInvulnerable = false;

    public void TakeDamage(int damage)
    {
        if (IsInvulnerable) return;
        Health -= damage;
    }
}

Med disse entiteter på plads, lad os undersøge, hvordan man opdager kollisioner mellem dem, startende fra den enkleste metode.


2. Niveau 1: The Simplest Collision – Rectangle.Intersects (AABB)

Det mest grundlæggende 2D-kollisionstjek i MonoGame er Axis-Aligned Bounding Box (AABB)-testen. Udtrykket "akse-justeret" betyder ganske enkelt, at rektanglets kanter er fuldstændig parallelle med skærmens \(X\)- og \(Y\)-akser (ingen rotation).

MonoGame giver en hurtig, indbygget metode: Rectangle.Intersects(Rectangle value).

Hvordan Rectangle.Skærer virker under motorhjelmen

Under overfladen udfører MonoGame fire heltals sammenligninger:

public bool Intersects(Rectangle value)
{
    return value.Left < this.Right &&
           this.Left < value.Right &&
           value.Top < this.Bottom &&
           this.Top < value.Bottom;
}

Hvis alle fire betingelser er opfyldt, overlapper rektanglerne. Hvis selv en betingelse mislykkes, adskiller en tom akse dem, og ingen kollision er mulig.

Ægte gameplay-kode: 'Bullet' vs. 'Enemy' i 'Game1.Update'

Sådan kontrollerer du kollisioner mellem en liste over aktive kugler og en liste over aktive fjender inde i din primære MonoGame Update-løkke:

public class Game1 : Game
{
    private List<Bullet> _bullets = new List<Bullet>();
    private List<Enemy> _enemies = new List<Enemy>();

    protected override void Update(GameTime gameTime)
    {
        // 1. Update bullet and enemy positions
        foreach (var bullet in _bullets) bullet.Update(gameTime);

        // 2. Collision Check: Bullets vs Enemies
        for (int b = _bullets.Count - 1; b >= 0; b--)
        {
            var bullet = _bullets[b];
            if (!bullet.IsActive) continue;

            for (int e = _enemies.Count - 1; e >= 0; e--)
            {
                var enemy = _enemies[e];
                if (!enemy.IsActive) continue;

                // The AABB check!
                if (bullet.Bounds.Intersects(enemy.Bounds))
                {
                    // Collision occurred!
                    enemy.TakeDamage(bullet.Damage);
                    bullet.IsActive = false;

                    // Remove inactive bullet immediately
                    _bullets.RemoveAt(b);
                    
                    if (!enemy.IsActive)
                    {
                        _enemies.RemoveAt(e);
                    }

                    // A bullet can only hit one enemy; break the inner loop
                    break;
                }
            }
        }

        base.Update(gameTime);
    }
}

Tip til ydeevne: Bemærk, at vi itererer baglæns (for (int i = list.Count - 1; i >= 0; i--))! Hvis du bruger foreach og prøver at kalde _bullets.Remove(bullet), kaster C# en InvalidOperationException: Collection was modified. Gentagelse baglæns muliggør sikker fjernelse af elementer uden problemer med genindeksering af hukommelsen.

The Arcade Secret: "Fair Hitboxes" via Inflate

I retrospil som Blocked: Pixel Panzer inkluderer sprite-teksturer ofte gennemsigtige marginer eller antennespidser. Hvis spillerens tank eksploderer, fordi en kugle rørte ved et tomt gennemsigtigt hjørne af dens tekstur, vil spilleren føle sig snydt.

For at få kollisionen til at føles responsiv og retfærdig, bruger spil en mindre Hitbox inde i spriten ved hjælp af 'Rectangle.Inflate':

public class EnemyTank : Enemy
{
    // Shrink the bounding box by 6 pixels on all sides for fair collision
    public override Rectangle Bounds
    {
        get
        {
            Rectangle raw = base.Bounds;
            raw.Inflate(-6, -6); // Reduces width and height by 12px
            return raw;
        }
    }
}

3. Niveau 2: Cirkel-til-cirkel-kollision (rotationsimmunitet)

Rektangler fungerer godt til statiske blokke og gitterfliser, men de fejler, når sprites roterer. Når et ikke-firkantet rumskib roterer i Paint Trek, skal en aksejusteret afgrænsningsboks udvide sig for at omslutte de roterende hjørner, hvilket resulterer i frustrerende "fantomkollisioner" i tom luft.

Til cirkulære asteroider, målsøgende energikugler og roterende rumfartøjer er Bounding Circles den ideelle løsning.

Firkantrodsfælden

To cirkler støder sammen, når afstanden mellem deres centre er mindre end eller lig med summen af deres radier:

\(\tekst{Afstand}(C_A, C_B) \le r_A + r_B\)

I kode bruger beregning af euklidisk afstand Math.Sqrt (eller Vector2.Distance). Men at beregne kvadratrødder i en loop med 200 projektiler forbrænder hundredvis af unødvendige CPU-cyklusser!

Ved at sammenligne kvadratafstand med kvadratradiussum eliminerer vi kvadratroden fuldstændigt:

\(\text{DistanceSquared} \le (r_A + r_B)^2\)

MonoGame-implementering: Circle vs. Circle

public struct Circle
{
    public Vector2 Center;
    public float Radius;

    public Circle(Vector2 center, float radius)
    {
        Center = center;
        Radius = radius;
    }

    public bool Intersects(Circle other)
    {
        float radiusSum = this.Radius + other.Radius;
        
        // MonoGame built-in Vector2.DistanceSquared
        return Vector2.DistanceSquared(this.Center, other.Center) <= (radiusSum * radiusSum);
    }
}

Integrer nu dette direkte i en enhed:

public class PaintTrekFighter : Sprite
{
    public float CollisionRadius = 18f;
    public Vector2 Center => Position + new Vector2(Texture.Width * 0.5f, Texture.Height * 0.5f);

    public Circle BoundingCircle => new Circle(Center, CollisionRadius);

    public bool CollidesWith(PaintTrekFighter other)
    {
        return this.BoundingCircle.Intersects(other.BoundingCircle);
    }
}

Nul kvadratrødder, immun over for sprite-rotation og lynhurtig.


4. Niveau 3: Blandede former – Circle vs. Box (MathHelper.Clamp)

Hvad sker der, når et cirkulært rumskib i Paint Trek navigerer gennem en tæt labyrint af rektangulære forsvarsbarrierer, eller når en rund kugle rammer en firkantet blok i Blocked: Pixel Panzer?

Vi har brug for Cirkel vs. Rektangel kollision.

Spændingsalgoritmen

Strategien er at finde det punkt på rektangelet, der er tættest på cirklens centrum, og derefter teste, om afstanden fra det nærmeste punkt til centrum er mindre end cirklens radius.

MonoGames MathHelper.Clamp gør dette trivielt:

public static class Collision2D
{
    public static bool CircleIntersectsRectangle(Circle circle, Rectangle rect)
    {
        // Find the closest point on the rectangle to the circle center
        float closestX = MathHelper.Clamp(circle.Center.X, rect.Left, rect.Right);
        float closestY = MathHelper.Clamp(circle.Center.Y, rect.Top, rect.Bottom);

        // Vector from closest point to circle center
        float distanceX = circle.Center.X - closestX;
        float distanceY = circle.Center.Y - closestY;

        // Check squared distance against squared radius
        float distanceSquared = (distanceX * distanceX) + (distanceY * distanceY);
        return distanceSquared <= (circle.Radius * circle.Radius);
    }
}

Du kan nu teste spillerskjolde mod rektangulære klodser med nul tildelinger og høj nøjagtighed!


5. Niveau 4: Continuous Collision Detection (CCD) & Swept Rays

Har du nogensinde affyret en snigskytterunde med hyperhastighed eller en railgun-laser i dit spil, kun for at se kuglen på magisk vis passere lige gennem et tyndt fjendtligt skib uden at gøre skade?

Denne fejl er kendt som tunneling.

Fordi diskrete spil opdateres i tidstrin (\(\Delta t = 16,6\text{ms}\) ved 60 FPS), bevæger et objekt sig med 1.800 pixels i sekundet 30 pixels i et enkelt billede. Hvis fjendens skrog kun er 15 pixels tykt, var kuglen foran fjenden på Frame 1 og helt bag fjenden på Frame 2.

Frame 1:  [ Bullet ]  --->       | Enemy Wall |
Frame 2:                          | Enemy Wall |       --->  [ Bullet ]
                               (NO HIT DETECTED!)

Løsningen: Swept Segment vs. Box (Slab-metode)

I stedet for at teste et enkelt punkt, tester vi hele linjesegmentet, der forbinder kuglens position på Frame 1 (forrige Position) til Frame 2 (currentPosition).

Her er produktions-raycasting slab-skæringsmetoden fra vores ledsagertitel SpiralWar:

public static class ContinuousCollision
{
    public static bool IntersectsSweptRay(Vector2 rayStart, Vector2 rayEnd, Rectangle box, out Vector2 hitPoint)
    {
        hitPoint = Vector2.Zero;

        Vector2 direction = rayEnd - rayStart;
        float tMin = 0f;
        float tMax = 1f;

        // Clip against X slabs
        if (MathF.Abs(direction.X) > 1e-6f)
        {
            float invX = 1f / direction.X;
            float t1 = (box.Left - rayStart.X) * invX;
            float t2 = (box.Right - rayStart.X) * invX;
            if (t1 > t2) (t1, t2) = (t2, t1);

            tMin = MathF.Max(tMin, t1);
            tMax = MathF.Min(tMax, t2);
            if (tMin > tMax) return false;
        }
        else if (rayStart.X < box.Left || rayStart.X > box.Right)
        {
            return false;
        }

        // Clip against Y slabs
        if (MathF.Abs(direction.Y) > 1e-6f)
        {
            float invY = 1f / direction.Y;
            float t1 = (box.Top - rayStart.Y) * invY;
            float t2 = (box.Bottom - rayStart.Y) * invY;
            if (t1 > t2) (t1, t2) = (t2, t1);

            tMin = MathF.Max(tMin, t1);
            tMax = MathF.Min(tMax, t2);
            if (tMin > tMax) return false;
        }
        else if (rayStart.Y < box.Top || rayStart.Y > box.Bottom)
        {
            return false;
        }

        hitPoint = rayStart + direction * tMin;
        return true;
    }
}

I Blocked: Pixel Panzer bruger spillerens kontinuerlige Laser Beam-færdighed denne nøjagtige raycast til at skære gennem rækker af faldende blokke uden at gå glip af en eneste kollision.


6. Niveau 5: Produktionsgrad Pixel-Perfekt kollisionsdetektion

Nu når vi det ultimative niveau af 2D-nøjagtighed: Pixel-Perfect Collision.

I et retro-tankskydespil eller rumskibsdogkamp er uregelmæssige former (tanktønder, vinger, cockpitcockpits) omgivet af gennemsigtige pixels i sprite-teksturen. Når et fjendtligt missil rammer det gennemsigtige rum, bemærker spillerne det med det samme.

Pixel-perfekt kollision inspicerer de faktiske alfa-kanaler (gennemsigtighed) af de overlappende teksturer. Hvis to ikke-gennemsigtige pixels overlapper på den samme verdenskoordinat, er der opstået et ægte fysisk hit.

Den fatale fejl: GetData inde i Update()

Mange tutorials instruerer begyndere til at gøre dette:

// DO NOT DO THIS!
Color[] dataA = new Color[textureA.Width * textureA.Height];
textureA.GetData(dataA); // STALLS GPU, CREATES MASSIVE GC LAG!

Kaldning af Texture2D.GetData() under kørsel tvinger CPU'en til at vente på, at GPU-pipelinen skylles ud, hvilket trækker teksturhukommelsen tilbage over bussen. Hvis du gør dette for snesevis af sprites, vil hver frame fylde dit spil fra 60 FPS til 5 FPS og udløse Android ANR'er med det samme!

Den kamptestede løsning fra Blocked: Pixel Panzer

I Blocked: Pixel Panzer implementerer vores 'Sprite.cs' en ultraoptimeret 'IntersectsPixel'-rutine bygget op omkring 5 afgørende regler:

  1. Static Color Array Cache: GetData kaldes nøjagtigt én gang, når teksturen indlæses, og gemmes i en Dictionary<Texture2D, Color[]>.
  2. AABB Early Exit Guard: Hvis Bounds.Intersects(other.Bounds) er falsk, redder vi straks. 99 % af kontrollerne er elimineret før inspektion af en enkelt pixel.
  3. Teksturatlas & 'SourceRectangle'-understøttelse: Håndterer sprites pakket ind i teksturatlas ved hjælp af kilderektangelforskydninger.
  4. Beregnet overlapningsvindue: Vi sløjfer kun over det nøjagtige skæringsrektangel mellem begge sprites (Math.Max(a.Top, b.Top) osv.).
  5. Alpha Short-Cuiting: Hvis Sprite A's pixel er transparent (A <= 20), springes Sprite B helt over.
  6. Property Hoisting: Adgang til virtuelle egenskaber ('Bounds') i indlejrede loops skaber tusindvis af struct-kopier. Vi gemmer dem i lokale stakvariabler, før vi går ind i løkken.

Her er den komplette, produktionsklare kode:

using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;

public class AdvancedSprite : Sprite
{
    private static readonly Dictionary<Texture2D, Color[]> _textureDataCache = new();

    public Rectangle? SourceRectangle { get; set; }

    public static Color[] GetCachedTextureData(Texture2D texture)
    {
        if (!_textureDataCache.TryGetValue(texture, out var data))
        {
            data = new Color[texture.Width * texture.Height];
            texture.GetData(data);
            _textureDataCache[texture] = data;
        }
        return data;
    }

    public bool IntersectsPixel(AdvancedSprite other)
    {
        // 1. Hoist bounds into local stack variables
        Rectangle a = this.Bounds;
        Rectangle b = other.Bounds;

        // 2. Fast AABB Broad-Phase check
        if (!a.Intersects(b)) return false;

        // Fallback to rectangle check if textures are missing
        if (this.Texture == null || other.Texture == null) return true;

        // 3. Retrieve pre-cached color arrays (Zero GPU latency)
        Color[] dataA = GetCachedTextureData(this.Texture);
        Color[] dataB = GetCachedTextureData(other.Texture);

        int aTexW = this.Texture.Width;
        int bTexW = other.Texture.Width;

        // Atlas / SourceRectangle offset mapping
        bool aHasSrc = this.SourceRectangle.HasValue;
        Rectangle aSrc = aHasSrc ? this.SourceRectangle.Value : Rectangle.Empty;
        int aOriginX = aHasSrc ? aSrc.X : 0;
        int aOriginY = aHasSrc ? aSrc.Y : 0;
        int aSpanX   = aHasSrc ? aSrc.Width  : this.Texture.Width;
        int aSpanY   = aHasSrc ? aSrc.Height : this.Texture.Height;

        bool bHasSrc = other.SourceRectangle.HasValue;
        Rectangle bSrc = bHasSrc ? other.SourceRectangle.Value : Rectangle.Empty;
        int bOriginX = bHasSrc ? bSrc.X : 0;
        int bOriginY = bHasSrc ? bSrc.Y : 0;
        int bSpanX   = bHasSrc ? bSrc.Width  : other.Texture.Width;
        int bSpanY   = bHasSrc ? bSrc.Height : other.Texture.Height;

        // 4. Calculate the overlapping sub-rectangle
        int top    = Math.Max(a.Top, b.Top);
        int bottom = Math.Min(a.Bottom, b.Bottom);
        int left   = Math.Max(a.Left, b.Left);
        int right  = Math.Min(a.Right, b.Right);

        // 5. Scan only overlapping pixels
        for (int y = top; y < bottom; y++)
        {
            // Row offsets hoisted outside the inner X loop!
            int colorA_Y = aOriginY + (int)((y - a.Y) / (float)a.Height * aSpanY);
            int colorB_Y = bOriginY + (int)((y - b.Y) / (float)b.Height * bSpanY);
            int rowA = colorA_Y * aTexW;
            int rowB = colorB_Y * bTexW;

            for (int x = left; x < right; x++)
            {
                int colorA_X = aOriginX + (int)((x - a.X) / (float)a.Width * aSpanX);

                // Early Bailout: If Pixel A is transparent, skip B entirely!
                if (dataA[colorA_X + rowA].A <= 20) continue;

                int colorB_X = bOriginX + (int)((x - b.X) / (float)b.Width * bSpanX);

                // If Pixel B is also opaque, we have confirmed contact!
                if (dataB[colorB_X + rowB].A > 20)
                {
                    return true;
                }
            }
        }

        return false;
    }
}

Denne rutine udføres på under 0,1 millisekunder på moderne Android-hardware og desktop-systemer.


7. Niveau 6: Broad-Phase Spatial Grid & Zero-Allocation Mobile GC

Forestil dig, at dit spil har 100 blokke, 60 kugler og 20 fjender. Hvis du tester hver enhed mod hver anden enhed ved hjælp af indlejrede 'for'-løkker, udfører du:

\(\frac{180 \times 179}{2} = 16.110 \text{ checks pr. frame!}\)

Ved 60 FPS er det næsten 1.000.000 kollisionstjek hvert sekund.

For at skalere dit spil skal du implementere Bred-Phase Spatial Partitioning.

Det ensartede rumlige hash-gitter

Vi opdeler skærmen i et 2D-gitter af ensartede celler (i Blocked: Pixel Panzer er hver celle \(150 x 150\) pixels). En enhed tester kun kollisioner mod andre enheder, der ligger i de samme gitterceller.

Mobilhukommelsesproblemet: GC Thrashing

Hvis dit SpatialGrid opretter ny liste<Punkt>() eller ny liste<Blok>() hver frame, allokerer du megabytes affald hvert minut på den administrerede heap.

På Androids Mono-runtime udløser dette hyppige Gen-0-affaldsindsamlinger, hvilket forårsager: "mono runtime: Native lock-konflikt (mono_class_is_subclass_)".

Spillet hakker, og Google Play markerer dit spil med ANR-advarsler!

Løsningen: Genanvendelige skrabebuffere

Her er Zero-Allocation Spatial Hash Grid fra CollisionManager.cs i Blocked: Pixel Panzer:

public class SpatialGridManager
{
    private const int CELL_SIZE = 150;
    private readonly Dictionary<Point, List<Sprite>> _grid = new();

    // Zero-GC: Reusable scratch buffers allocated ONCE at startup
    private readonly List<Point> _scratchCells = new();
    private readonly HashSet<Sprite> _scratchCheckedEntities = new();

    public void BuildGrid(List<Sprite> entities)
    {
        // Clear lists without re-allocating new List objects
        foreach (var list in _grid.Values)
        {
            list.Clear();
        }

        foreach (var entity in entities)
        {
            if (!entity.IsActive) continue;

            GetOccupiedCells(entity.Bounds, _scratchCells);
            foreach (var cell in _scratchCells)
            {
                if (!_grid.TryGetValue(cell, out var list))
                {
                    list = new List<Sprite>();
                    _grid[cell] = list;
                }
                list.Add(entity);
            }
        }
    }

    private void GetOccupiedCells(Rectangle bounds, List<Point> outCells)
    {
        outCells.Clear();

        int startX = bounds.Left / CELL_SIZE;
        int startY = bounds.Top / CELL_SIZE;
        int endX = bounds.Right / CELL_SIZE;
        int endY = bounds.Bottom / CELL_SIZE;

        for (int x = startX; x <= endX; x++)
        {
            for (int y = startY; y <= endY; y++)
            {
                outCells.Add(new Point(x, y));
            }
        }
    }

    public void CheckCollisions(Player player)
    {
        if (player.IsInvulnerable) return;

        GetOccupiedCells(player.Bounds, _scratchCells);
        _scratchCheckedEntities.Clear();

        foreach (var cell in _scratchCells)
        {
            if (_grid.TryGetValue(cell, out var entitiesInCell))
            {
                foreach (var other in entitiesInCell)
                {
                    if (other == player || _scratchCheckedEntities.Contains(other)) continue;
                    _scratchCheckedEntities.Add(other);

                    // 1. Broad-phase AABB test
                    if (player.Bounds.Intersects(other.Bounds))
                    {
                        // 2. High-precision Pixel-Perfect test
                        if (player is AdvancedSprite advPlayer && other is AdvancedSprite advOther)
                        {
                            if (advPlayer.IntersectsPixel(advOther))
                            {
                                player.TakeDamage(10);
                            }
                        }
                    }
                }
            }
        }
    }
}

Ved at genbruge _scratchCells og _scratchCheckedEntities, kører hele kollisionstrinnet med 0 bytes heap-allokering pr. frame.


8. Sammenfattende sammenligningstabel

Teknik Matematiske omkostninger Rotationsstøtte Sikker tunnelkørsel? Best Use Case i MonoGame
AABB ('Rektangel.Skærer') Ultrahurtig (~4 heltal sammenligninger) Nej Nej Gitterblokke, kuglebredfase, UI
Cirkel (DistanceSquared) Meget hurtig (3 muls, 0 sqrt) Ja (Invariant) Nej Runde skibe, ildkugler, energikugler
Circle vs Box (Clamp) Hurtig (Local MathHelper.Clamp) Ja Nej Cirkulær spiller navigerer tætte vægge
Swept Ray (Slab CCD) Moderat (Parametrisk Raycast) Ja Ja Hurtige snigskyttekugler, railguns, lasere
Pixel-Perfect (Skæringspixel) Selektiv (Restricted Sub-rect) Ja Nej Uregelmæssige spritekonturer, fair hitboxes
Rumligt gitter (Nul-GC) Bredfase (\(O(N^2) \to O(N)\)) N/A N/A Tætte bølger, kuglehelvede, Android 60 FPS

Real-World Production Showcases: Arar Games

Disse kollisionsarkitekturer er ikke teoretiske eksperimenter – de er det egentlige ingeniørgrundlag, der driver vores kommercielt udgivne titler:

  • Blokeret: Pixel Panzer: Vores retro-tankoverlevelsesarkadespil på Google Play og Microsoft Store. Den byder på vores komplette to-tiers kollisionssystem: et nul-allokering rumligt hash-gitter, der filtrerer faldende blokke, og pixel-perfekt kontaktkontrol for kampvogne, jagerfly, tårnskaller og ColorWheel elementær ammunition.
  • Paint Trek: Vores hurtige rumskyder med roterende cirkelkollision, kontinuerlig raycasting og swept-volume missilforsvarssystemer.

Konklusion og næste trin

MonoGame giver dig kraften til at designe kollisionsdetektion, der passer perfekt til dit spils behov. Ved at lukke dyre Pixel-Perfect-tjek bag hurtige AABB-tests, bruge Squared Distances til cirkler og eliminere affaldsopsamling med Genanvendelige Scratch-buffere, kan du levere konsolglat 60/120 FPS-ydeevne på både desktop- og mobilplatforme.

Tjek vores spil i app-butikkerne for at se disse kollisionssystemer i aktion, og begynd at implementere disse mønstre i dine egne MonoGame-projekter i dag!



SEO søgeord og Hashtags

Søgeord: MonoGame 2D-kollisionsdetektion, C# spiludvikling, Rectangle.Skærer MonoGame, pixel perfekt kollision C#, spatial hash-gitter MonoGame, swept ray continuous collision, indie-spilydelsesoptimering, nulallokeringsspilsløjfe, Android MonoGame-optimering, Blocked, Arzerzer-spil.

#MonoGame #CSharp #GameDev #IndieDev #GamePhysics #DotNet #2DGameDev #MobileGameDev #PerformanceOptimization #CleanCode #BlockedPixelPanzer `##GameGame``

21 Eylül 2026 Pazartesi

Fra CPI-fælden til loyale spillere: Skalering af mobilspil med Firebase In-App Actions og Google Ads (UAC 2.0)

Fra CPI-fælden til loyale spillere: Skalering af mobilspil med Firebase In-App Actions og Google Ads (UAC 2.0)

Enhver indie-spiludvikler, der lancerer en mobiltitel, står før eller siden over for Cost-Per-Install (CPI)-paradokset:

Du lancerer en betalt brugeranskaffelseskampagne (User Acquisition), glæder dig over en bølge af billige downloads og ser installationstælleren på Google Play stige støt. Men når du åbner dit analyse-dashboard 48 timer senere, rammer den barske virkelighed:

1.000 installationer ──► 500 øjeblikkelige afinstallationer (< 2 min.) ──► 50 gennemførelser af niveau 1 ──► 5 fastholdte spillere

Over 50 % af de erhvervede brugere slettede spillet, inden de overhovedet havde gennemført det allerførste niveau.

Hvis du kører en standard Google Ads-appkampagne til installationer (UAC 1.0) med grundlæggende CPI-budgivning, gør Googles maskinlæringsalgoritme præcis, hvad du har bedt den om: find de billigst mulige brugere, som trykker på "Installer" på Google Play. Desværre er de billigste brugere på annoncenetværket ofte belønningsannonce-jægere, tilfældige klikkere eller konti tæt på botnetværk, som aldrig har til hensigt at engagere sig i dit spil.

I denne artikel deler vi vores virkelige udviklings- og markedsføringsarkitektur fra vores tempofyldte retro-kampvognsactionspil, Blocked: Pixel Panzer (udviklet med MonoGame og C# af Arar Games). Vi vil undersøge, hvordan vi forbandt telemetri på enhedsniveau med Google Analytics 4 (GA4) og Google Ads-appkampagner til handlinger i appen (UAC 2.0) for at stoppe med at købe tomme installationer og i stedet tiltrække dedikerede spillere med høj fastholdelse.


1. Diagnosticering af tragten: Hvad live-analyse lærte os

Før du bruger en eneste krone på annoncenetværk, er du nødt til at vide, hvor spillerne falder fra.

I Blocked: Pixel Panzer logger vores telemetri spillets centrale livscyklusser asynkront til Firebase:

  • first_open
  • screen_view (sporing af mere end 40 tilpassede MonoGame-skærme)
  • level_completed (med parametrene level_number, score, duration_seconds)
  • skill_upgraded
  • boss_fight_result
  • app_remove (operativsystemets indbyggede afinstallationssignal)

Da vi analyserede afinstallerede sessioner i GA4 Path Exploration, var dataene entydige:

  • Ud af 63 undersøgte afinstallerede enheder havde ~30 brugere afinstalleret spillet, før de overhovedet gennemførte Niveau 1.
  • 14 brugere faldt fra umiddelbart efter at have opgraderet deres første færdigheder i spillets kerneløkke.
  • Det længste fremskridt blandt afhoppede brugere var Niveau 10 (den første verdensboss).
[ Brugerinstallationer ] 
       │
       ├──► 48 % frafald: Forlader spillet inden gennemførelse af Niveau 1 <── (CPI-spildzonen)
       │
       ├──► 30 % engagement: Opgraderer færdigheder og når Niveau 2-3
       │
       └──► 22 % kernefastholdelse: Når bossen (Niveau 10) og metaspillet

Læren var uomtvistelig: At betale for rå installationer er at kaste penge direkte i spildzonen.

For at opbygge en bæredygtig spiløkonomi var vores budmodel nødt til at optimere mod brugere, hvis adfærdssignatur matcher spillere, der rent faktisk overvinder Niveau 1 og fortsætter fremad mod Niveau 3.


2. Mekanikken i UAC 2.0: Optimering til in-app-handlinger

Google Ads App-kampagner (Universal App Campaigns) findes i distinkte optimeringsvarianter:

Kampagnemodel Budfokus Hvad algoritmen lærer Bedst egnet til
UAC 1.0 (Installationer) Maksimer installationer / Mål-CPI Profiler på brugere, der ofte installerer nye apps Tidlig lancering i butikken, hurtig volumen
UAC 2.0 (Handlinger i appen) Target CPA (Pris pr. handling) Profiler på brugere, der sandsynligvis gennemfører en bestemt milepæl i spillet Bæredygtig fastholdelse, vækst i metaspillet
UAC 2.5 (Værdi / ROAS) Target ROAS Profiler på brugere med højt forbrug (hvaler) Spil med massiv indtægtsgenerering via køb i appen

Under UAC 2.0 (Handlinger i appen) fastsætter du en Target Cost Per Action (Target CPA) – eksempelvis 0,20 $ pr. gennemførelse af Niveau 1.

Googles neurale netværk for Smart Bidding analyserer derefter hundredvis af adfærdssignaler på tværs af Google Play, YouTube, Google Søgning og millioner af apps på Google Displaynetværket (AdMob). Det byder aggressivt kun, når en annoncevisning matcher en bruger, hvis historiske vaner indikerer, at vedkommende vil downloade spillet, spille forbi introduktionen og fuldføre Niveau 1.


3. Trin-for-trin implementering: Fra C#-telemetri til Google Ads

Her er den nøjagtige tekniske pipeline, vi brugte til at omdanne fremskridt i spillet til handlekraftige konverteringsmål for annoncering.

Trin 1: Telemetri-krog på enheden (MonoGame / C#)

I Blocked.Shared/Managers/AnalyticsManager.cs beregner spillet score, skade og tid, hver gang spilleren rydder en kampzone, og sender derefter level_completed uden at blokere 60 FPS gengivelsestråden:

public static void LogLevelCompleted(int level, int score, int totalScore, string rank,
                                     int durationSeconds, int damageTaken,
                                     int blocksBroken, int enemiesKilled, int suppliesCollected)
{
    try
    {
        _payload.Clear();
        _payload[AnalyticsParams.LevelNumber] = level;
        _payload[AnalyticsParams.Score] = score;
        _payload[AnalyticsParams.TotalScore] = totalScore;
        _payload[AnalyticsParams.DurationSeconds] = durationSeconds;
        _payload[AnalyticsParams.IsBoss] = IsBossLevel(level) ? 1 : 0;
        
        // Sendes til Android Firebase SDK via JNI (ikke-blokerende SQLite-buffer)
        _service.LogEvent(AnalyticsEvents.LevelCompleted, _payload);
    }
    catch (Exception ex)
    {
        // Telemetri må aldrig få spillet til at gå ned
        Warn(ex);
    }
}

Trin 2: Syntetisering af GA4-hændelser (Ingen appopdatering påkrævet)

Google Ads kan ikke byde direkte på parameterunderforespørgsler såsom level_completed WHERE level_number == 1 som standard. Men Google Analytics 4 giver dig mulighed for at syntetisere tilpassede hændelser i realtid ud fra indkommende telemetri uden at udrulle en opdatering i appbutikken:

  1. I GA4-konsollen, gå til Admin ➔ Data display ➔ Events.
  2. Klik på Create event og konfigurer:
    • Custom event name: level_1_completed
    • Matching conditions:
      • event_name equals level_completed
      • level_number equals 1
    • Markér "Copy parameters from the source event" for at bevare scores og sessionsmålinger.
  3. Gem konfigurationen.
  4. På fanen Events eller Key events, find level_1_completed og slå "Mark as key event" til.

Nu udsender GA4 automatisk en dedikeret level_1_completed-konverteringshændelse, hver gang en spiller et vilkårligt sted i verden gennemfører Niveau 1.

Trin 3: Import af konverteringshandlinger til Google Ads

  1. Åbn Google Ads (ads.google.com).
  2. Gå til Mål ➔ Konverteringer ➔ Oversigt.
  3. Klik på Ny konverteringshandling ➔ Import ➔ Google Analytics 4-ejendomme (Firebase).
  4. Vælg level_1_completed fra listen.
  5. Sæt kategorien til Engagement eller Opnået niveau.
  6. Indstil konverteringshandlingen som Primær, så Smart Bidding aktivt optimerer mod den.
[ Mobilspil: Niveau 1 gennemført ]
                │
                │  LogEvent("level_completed", { level_number: 1 })
                ▼
[ Firebase Analytics Engine ]
                │
                │  GA4-regel: Syntetiser "level_1_completed"
                ▼
[ GA4 Key Event (Konvertering) ]
                │
                │  Google Ads forbundet integration
                ▼
[ Google Ads Smart Bidding Engine (UAC 2.0 Target CPA) ]

4. Sådan overvindes koldstartsproblemet: Tragtstrategien

En almindelig fejl, som indie-udviklere begår ved overgangen til UAC 2.0, er at sigte for dybt ind i spillet for tidligt.

Læringsvolumen for maskinlæring

Googles algoritmer for Smart Bidding kræver en statistisk minimumstærskel for at træne effektivt:

  • Den gyldne regel: En kampagne bør generere mindst 10 til 20 målkonverteringer i appen om dagen for at forlade "Læringsfasen" og opnå optimal budgivningseffektivitet.

I vores tidlige analysegennemgang oprettede vi en målgruppe ved navn Quality_Players_Level_3 (spillere, der havde gennemført Niveau 3 eller højere), som havde akkumuleret 17 spillere.

Hvis vi havde lanceret en kampagne med direkte målretning mod Niveau 3 med det samme:

  • Ville det have krævet massive annonceudgifter på forhånd at generere 15 Niveau 3-konverteringer dagligt.
  • Med kun 17 historiske datamønstre var algoritmens sammenligningsgrundlag for snævert, hvilket risikerede uregelmæssige budstigninger eller fuldstændig standsning af visninger ("Too small to serve").

Den to-trins tragtløsning

I stedet for en alt-eller-intet-tilgang valgte vi en faset tragtmodel:

flowchart TD
    A["Fase 1: Skaler Level 1 Completed"] --> B["Hverver engagerede spillere til lav tCPA"]
    B --> C["Filtrerer 100 % af ikke-spillende installationsafhoppere fra"]
    C --> D["Fyld 'Quality_Players_Level_3' målgruppen (100+ spillere)"]
    D --> E["Fase 2: Skift Target CPA til Niveau 3 / Færdighedsopgraderinger"]
  1. Trin 1 (Volumen og hygiejne): Optimer til level_1_completed. Da ca. 50–60 % af ægte spillere kan klare Niveau 1, sikrer kampagnen nemt 20+ konverteringer dagligt til en lav Target CPA (~0,15 \(– 0,25\)). Dette renser øjeblikkeligt ud i lavkvalitets installationsfarme og opbygger en aktiv brugerbase.
  2. Trin 2 (Finjustering): Efterhånden som hundredvis af spillere gennemfører Niveau 1, bevæger en stabil procentdel sig naturligt videre til Niveau 3, 5 og 10. Når puljen Quality_Players_Level_3 overstiger 100–200 aktive brugere, kan kampagnens målsætning sikkert overgå til Niveau 3 eller metaspilsmilepæle (skill_upgraded) for maksimal levetidsværdi.

5. Markedsudvælgelse og geografisk arbitrage

At byde på Tier-1-markeder (USA, Storbritannien, Tyskland) med en ukalibreret kampagne kan opbruge små indie-budgetter på få timer (1,50 \(– 4,00\) pr. handling).

Til vores indledende UAC 2.0-testkørsel for Blocked: Pixel Panzer udvalgte vi en strategisk klynge af fire højvolumen, omkostningseffektive og spilleglade markeder:

  1. Tyrkiet (TR): Stærk lokal spillerbase, stort engagement i arkade-/actiontitler og lave anskaffelsesomkostninger.
  2. Brasilien (BR): Et af verdens største Android-spilmarkeder. Brasilianske mobilspillere har en enorm passion for retroæstetik og arkadekampe.
  3. Mexico (MX): Det næststørste latinamerikanske spilknudepunkt med tæt volumen og loyale spillerfællesskaber.
  4. Polen (PL): Et ideelt europæisk testmarked – markant højere AdMob eCPM-indtægter end Sydamerika, men væsentligt lavere brugeranskaffelsesomkostninger end Vesteuropa.

Indbygget lokalisering: Den hemmelige multiplikator

At køre annoncer på udenlandske markeder konverterer kun, hvis oplevelsen inde i spillet matcher butikkens præsentation. I Blocked: Pixel Panzer understøttes alle fire markeder med fuld indbygget lokalisering fra dag ét:

  • tr.json (Tyrkisk)
  • pt-BR.json (Brasiliansk portugisisk)
  • es-419.json (Latinamerikansk spansk)
  • pl.json (Polsk)

Når en spiller fra São Paulo eller Warszawa trykker på en annonce, installerer spillet og straks møder sit modersmål, sker indlevelsen med det samme – hvilket øger gennemførelsesprocenten for Niveau 1 markant.

Tip til geografisk målretning: Under placeringsindstillingerne i Google Ads skal du altid ændre standardindstillingen "Tilstedeværelse eller interesse" til "Tilstedeværelse: Personer, der befinder sig i eller regelmæssigt besøger dine inkluderede lokationer". Dette sikrer, at dit budget udelukkende bruges på brugere, der fysisk befinder sig i dine mållande, og udelukker trafik fra udenlandske VPN-forbindelser eller uvedkommende søgninger.


6. Kreative elementer: Hvorfor video forstærker og aldrig kannibaliserer

Når udviklere sammensætter annonceaktiver i Google Ads, spørger de ofte: “Hvis jeg tilføjer gameplay-videoer, vil det så overskygge mine flotte screenshots?”

Svaret er et rungende nej. Google Ads arbejder på tværs af særskilte multikanal-opgørelser:

  • Overskrifter og skærmbilleder: Dominerer Google Play-søgeresultater, sektioner for anbefalede apps og display-bannernetværk.
  • Video: Åbner op for det enorme inventar på YouTube Shorts, instream-prerolls og belønnede videoplaceringer (rewarded video) i andre populære mobilspil.

I tempofyldte spil som Blocked: Pixel Panzer kan statiske skærmbilleder vise pixelgrafikken, men kun video kan demonstrere følelsen af at undvige sværme af kugler (bullet-hell), udløse eksplosive kædereaktioner og ødelægge fjendtlige jagerfly.

Spillere, der installerer efter at have set autentisk gameplay-video, forstår allerede styringen og mekanikken. Følgelig er deres konverteringsrate på level_1_completed markant højere end hos brugere, der udelukkende er erhvervet via statisk grafik.


7. Tjekliste for indie-udviklere

Før du lancerer din næste mobilspils-annoncekampagne, bør du gennemgå denne tjekliste:

  • Bekræft spillets telemetri: Sørg for, at level_completed og niveaumilepæle logges med rene parametre og nul GC-allokeringer på hovedtråden.
  • Syntetiser GA4-nøglehændelser: Opret dedikerede hændelser (f.eks. level_1_completed) og markér dem som nøglehændelser i GA4.
  • Importer til Google Ads: Forbind Firebase og Google Ads, importer handlingen, og marker den som Primær.
  • Budfokus: Vælg Handlinger i appen (Target CPA) frem for ren installationsvolumen.
  • Kalibrer start-CPA: Fastsæt realistiske mål-CPA'er (0,15 \(– 0,30\) afhængigt af landets niveau) og giv algoritmen mindst 3-4 dages uforstyrret maskinlæring.
  • Faseopdel din tragt: Start med en tidlig, opnåelig milepæl (Niveau 1) for at opbygge konverteringshastighed, og gå derefter gradvist over til dybere progressionsmål.

Ved at flytte dit fokus fra at købe downloads til at købe spilmæssige milepæle, forvandler du annoncering fra en uforudsigelig udgift til en gentagelig vækstmotor med høj spillerfastholdelse.


Skrevet af ingeniørteamet hos Arar Games / Mayhemco. Udforsk Blocked: Pixel Panzer på Google Play for at opleve vores hæsblæsende retro-arcadeaction på første hånd.


Hashtags & SEO Topics

#BlockedPixelPanzer #ArarGames #Mayhemco #PaintTrek #MonoGame #GoogleAds #FirebaseAnalytics #GA4 #MobileGameDev #UserAcquisition #GameMarketing #SmartBidding #TargetCPA #InAppActions #IndieGameDev #GameAnalytics #AndroidDev