27 Eylül 2026 Pazar

MonoGame 中的 2D 碰撞偵測:從基本矩形到零分配像素完美系統




MonoGame 中的 2D 碰撞偵測:從基本矩形到零分配像素完美系統

在 Unity 或 Godot 等視覺引擎中建立 2D 遊戲時,碰撞偵測通常感覺像是一系列檢查器複選框:您點擊“BoxCollider2D”或“CircleCollider2D”,附加“Rigidbody”,並希望內部實體步驟不會在行動裝置上卡頓。

然而,在 MonoGame 和 C# 中,您擁有完全的控制權。沒有隱藏的物理開銷,沒有不需要的旋轉慣性,也沒有神秘的垃圾收集 (GC) 峰值竊取您的幀預算。

在 Arar Games,當我們建造 Blocked: Pixel Panzer 和 Paint Trek 時,我們的街機遊戲循環需要在 Windows PC 和 Android 設備上以 60 至 120 FPS 的速度處理數百顆高速敵人子彈、爆炸的磚塊網格、旋轉坦克砲塔、戰鬥機飛越和粒子護盾。通用實體引擎是不可能的-我們需要一個專門建構的、分層的碰撞架構。

在這個全面的、代碼驅動的指南中,我們將從 MonoGame 碰撞的絕對基礎知識(“矩形.相交”與真正的“子彈”和“敵人”精靈)開始,並構建高級圓形檢查、混合夾緊、反隧道光線投射、生產級像素完美碰撞和針對移動 GC 生存優化的零分配空間網格。


1. 基礎:一個簡單的 MonoGame Sprite 層次結構

在偵測碰撞之前,我們需要清理遊戲實體。在 MonoGame 中,實體基本上擁有位置、紋理和邊界矩形。

以下是我們在遊戲中使用的基準實體架構:

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

現在讓我們建立具體的「Player」、「Enemy」和「Bullet」類別:

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

有了這些實體,讓我們從最簡單的方法開始探索如何偵測它們之間的碰撞。


2. 第 1 級:最簡單的碰撞 – 矩形.相交 (AABB)

MonoGame 中最基本的 2D 碰撞檢查是 軸對齊邊界框 (AABB) 測試。術語「軸對齊」僅表示矩形的邊緣完全平行於螢幕的 \(X\) 和 \(Y\) 軸(無旋轉)。

MonoGame 提供了一個快速的內建方法:「Rectangle.Intersects(Rectangle value)」。

Rectangle.Intersects 的底層運作原理

在表面之下,MonoGame 執行四個整數比較:

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

如果滿足所有四個條件,則矩形重疊。即使其中一個條件失敗,它們也會被一根空軸分開,不可能發生碰撞。

真實遊戲代碼:“Game1.Update”中的“Bullet”與“Enemy”

以下是在主 MonoGame「更新」循環中檢查活動子彈清單和活動敵人清單之間的碰撞的方法:

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

**效能提示:**注意我們向後迭代(for (int i = list.Count - 1; i >= 0; i--))!如果您使用“foreach”並嘗試呼叫“_bullets.Remove(bullet)”,C# 會拋出“InvalidOperationException:集合已修改”。向後迭代可以安全地刪除元素,而不會出現記憶體重新索引問題。

街機秘密:透過“Inflate”實現“Fair Hitboxes”

在像 Blocked: Pixel Panzer 這樣的復古遊戲中,精靈紋理通常包括透明邊緣或天線尖刺。如果玩家的坦克因為子彈觸及其紋理的空透明角而爆炸,玩家會感到被欺騙。

為了使碰撞感覺響應靈敏且公平,遊戲使用「矩形.膨脹」在精靈內使用較小的 Hitbox:

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. 2 級:圓與圓碰撞(旋轉免疫)

矩形非常適合靜態區塊和網格圖塊,但當精靈旋轉時它們就會失敗。當非方形太空船在 Paint Trek 中旋轉時,軸對齊的邊界框必須擴展以包圍旋轉的角,從而導致在空曠的空氣中產生令人沮喪的「幻影碰撞」。

對於圓形小行星、尋的能量球和旋轉太空船,邊界圓是理想的解決方案。

平方根陷阱

當兩個圓的圓心之間的距離小於或等於它們的半徑總和時,它們就會發生碰撞:

\(\text{距離}(C_A, C_B) \le r_A + r_B\)

在程式碼中,計算歐幾里德距離使用“Math.Sqrt”(或“Vector2.Distance”)。然而,在一個包含 200 個彈頭的循環中計算平方根會消耗數百個不必要的 CPU 週期!

透過將距離平方與半徑平方和進行比較,我們完全消除了平方根:

\(\text{距離平方} \le (r_A + r_B)^2\)

MonoGame 實作:Circle 與 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);
    }
}

現在將其直接整合到一個實體中:

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

零平方根,不受精靈旋轉影響,速度快如閃電。


4. 第 3 級:混合形狀 – 圓形與方框(MathHelper.Clamp)

當 Paint Trek 中的圓形太空船穿過矩形防禦障礙的緊密迷宮時,或者當Blocked: Pixel Panzer 中的圓形子彈擊中方塊時會發生什麼?

我們需要圓形與長方形碰撞。

箝位演算法

策略是找出矩形上距離圓心最近的點,然後測試該最近點到圓心的距離是否小於圓的半徑。

MonoGame 的 MathHelper.Clamp 使這變得微不足道:

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

現在您可以以零分配和高精度測試玩家盾牌對矩形磚的影響!


5. 第 4 級:連續碰撞偵測 (CCD) 和掃描射線

您是否曾經在遊戲中發射超高速狙擊彈或軌道炮激光,卻發現子彈神奇地直接穿過薄薄的敵艦而沒有造成任何傷害?

此錯誤稱為隧道。

由於離散遊戲按時間步長更新(\(\Delta t = 16.6\text{ms}\),60 FPS),因此以每秒 1,800 像素的速度移動的物件在單幀中移動 30 像素。如果敵人的船體只有 15 像素厚,則子彈在第 1 格上位於敵人前面,而在第 2 格上完全位於敵人後方。

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

解決方案:掃描線段與框(板法)

我們不是測試單一點,而是測試連接第 1 幀 (previousPosition) 上子彈位置到第 2 幀 (currentPosition) 的整個線段。

以下是來自我們的姊妹篇 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;
    }
}

在 Blocked: Pixel Panzer 中,玩家的連續 雷射光束技能 使用這種精確的光線投射來切穿一排排下降的方塊,而不會錯過任何一次碰撞。


6. 第 5 級:生產級完美像素碰撞偵測

現在我們達到了 2D 精度的終極水平:像素完美碰撞。

在復古坦克射擊遊戲或太空船混戰中,不規則形狀(坦克桶、機翼、駕駛艙駕駛艙)被精靈紋理中的透明像素包圍。當敵方導彈擊中透明空間時,玩家會立即註意到。

像素完美碰撞檢查重疊紋理的實際 Alpha(透明度)通道。如果兩個非透明像素在同一世界座標重疊,則發生了真正的物理撞擊。

致命錯誤:Update() 中的 GetData

許多教程指導初學者這樣做:

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

在運行時呼叫「Texture2D.GetData()」會強制 CPU 等待 GPU 管道刷新,透過總線將紋理記憶體拉回。每幀對數十個精靈執行此操作將使您的遊戲速度從 60 FPS 降低到 5 FPS,並立即觸發 Android ANR!

Blocked: Pixel Panzer 中經過實戰考驗的解決方案

在 Blocked: Pixel Panzer 中,我們的 Sprite.cs 實作了一個圍繞 5 個關鍵規則構建的超優化的 IntersectsPixel 例程:

  1. 靜態顏色數組快取: GetData 在紋理載入時僅呼叫一次,並儲存在 Dictionary<Texture2D, Color[]> 中。
  2. AABB Early Exit Guard: 如果 Bounds.Intersects(other.Bounds) 為 false,我們立即退出。在檢查單一像素之前就消除了 99% 的檢查。
  3. 紋理圖集和SourceRectangle支援: 使用來源矩形偏移處理打包到紋理圖集中的精靈。
  4. 計算的重疊視窗: 我們只循環兩個精靈之間的精確交叉矩形(Math.Max(a.Top, b.Top)等)。
  5. Alpha 短路: 如果 Sprite A 的像素是透明的 (A <= 20),則完全跳過 Sprite B。
  6. 屬性提升: 在巢狀循環中存取虛擬屬性(「Bounds」)會建立數千個結構副本。在進入循環之前,我們將它們儲存在本地堆疊變數中。

這是完整的、可用於生產的程式碼:

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

該例程在現代 Android 硬體和桌面系統上的執行時間不到 0.1 毫秒。


7. 第 6 級:寬相空間網格與零分配移動 GC

想像一下您的遊戲有 100 個方塊、60 顆子彈和 20 個敵人。如果您使用巢狀的「for」迴圈針對每個其他實體測試每個實體,您將執行:

\(\frac{180 \times 179}{2} = 16,110 \text{ 每幀檢查! }\)

在 60 FPS 下,幾乎每秒進行 1,000,000 次碰撞檢查**。

要擴展您的遊戲,您必須實施寬相空間分區。

統一空間哈希網格

我們將螢幕劃分為由均勻單元格組成的 2D 網格(在 Blocked: Pixel Panzer 中,每個單元格為 150 美元 × 150$ 像素)。實體僅測試與駐留在同一網格單元中的其他實體的碰撞。

行動記憶體問題:GC 抖動

如果您的“SpatialGrid”每幀建立“new List()”或“new List()”,則每分鐘都會在託管堆上分配兆位元組的垃圾。

在 Android 的 Mono 運行時,這會觸發頻繁的 Gen-0 垃圾收集,從而導致: mono 運作時:本機鎖定爭用 (mono_class_is_subclass_)

遊戲卡頓,Google Play 會用 ANR 警告標記您的遊戲!

解決方案:可重複使用的暫存緩衝區

這是來自 Blocked: Pixel Panzer 中 CollisionManager.cs 的 零分配空間哈希網格:

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

透過重複使用“_scratchCells”和“_scratchCheckedEntities”,整個衝突步驟以每幀 0 位元組的堆分配運行。


8. 總結比較表

技術 數學成本 旋轉支撐 隧道安全嗎? MonoGame 的最佳用例
AABB(矩形.相交) 超快(約 4 次整數比較) 沒有 沒有 網格塊、子彈寬相、UI
圓(DistanceSquared) 非常快(3 muls,0 sqrt) 是(不變) 沒有 圓船、火球、能量球
圓形與盒子(夾子) 快速(本地 MathHelper.Clamp) 是的 沒有 圓形播放器在緊密的牆壁上航行
掃描射線(平板 CCD) 中(參數光線投射) 是的 是的 快速狙擊子彈、軌道炮、雷射
像素完美(IntersectsPixel) 選擇性(受限子區域) 是的 沒有 不規則的精靈輪廓,公平的碰撞箱
空間網格(零GC) 寬相 (\(O(N^2) \to O(N)\)) 不適用 不適用 密集海浪、彈幕地獄、Android 60 FPS

真實世界製作展示:Arar Games

這些碰撞架構不是理論實驗——它們是為我們商業發布的遊戲提供動力的真正工程基礎:

  • 已封鎖:Pixel Panzer: 我們在 Google Play 和 Microsoft Store 上推出的復古坦克生存街機遊戲。它具有我們完整的兩層碰撞系統:零分配空間哈希網格過濾掉落的方塊,以及對坦克、戰鬥機、砲塔砲彈和 ColorWheel 元素彈藥進行像素完美的接觸檢查。
  • Paint Trek: 我們的快節奏太空射擊遊戲具有旋轉圓碰撞、連續光線投射和掃掠飛彈防禦系統。

結論與後續步驟

MonoGame 讓您能夠設計完全符合遊戲需求的碰撞偵測。透過在快速 AABB 測試後面進行昂貴的 Pixel-Perfect 檢查,利用 Squared Distances 進行圓,並透過 Reusable Scratch Buffers 消除垃圾收集,您可以在桌面和行動平台上提供控制台平滑的 60/120 FPS 效能。

在應用程式商店中查看我們的遊戲,了解這些碰撞系統的實際應用,並立即開始在您自己的 MonoGame 專案中實現這些模式!


商店連結和資源


SEO 關鍵字與標籤

關鍵字: MonoGame 2D 碰撞偵測、C# 遊戲開發、Rectangle.Intersects MonoGame、像素完美碰撞 C#、空間雜湊網格 MonoGame、掃掠射線連續碰撞、獨立遊戲效能最佳化、零分配遊戲循環、Android MonoGame 最佳化、Arar Games、Blocked Pixel Panzer、Paint Trek。

#MonoGame #CSharp #GameDev #IndieDev #GamePhysics #DotNet #2DGameDev #MobileGameDev #PerformanceOptimiz盤#GameProgramming`




Hiç yorum yok:

Yorum Gönder