Ga naar inhoud

Eenheden Systeem

Afbeelding van de grid met op elke tile units, op elke tile dezelfde kleur en type units en verder van het midden lineair een groter aantal.

Doelstelling

Het doel was om eenheden te hebben op tiles. Die dan ook gedeeld worden met elke speler en getekend worden op de tiles, op een manier dat er meerdere tiles tegelijk zichtbaar zijn.

Toegepaste oplossing

UnitGrid

public class UnitGrid(int radius = 4, float tileRadius = 20f, int layer = 0, string id = "") : BaseGrid<UnitGroup>(radius, tileRadius, layer, id)
{
    public HexVector MousePos { get; protected set; }
    public PublicUnit? MouseUnit { get; protected set; }
    internal Vector2 MouseUnitPos { get; set; }

    public override void HandleInput(InputHelper inputHelper)
    {
        base.HandleInput(inputHelper);

        MousePos = grid.PosToHex((
            (inputHelper.MousePosition - GlobalPosition) / CellSize
        ).ToNumerics());

Vanaf hier begint het anders te worden dan de TileGrid. Hier word er met de berekende muis positie op de tile onder de muis gekeken naar de eenheden. Dan word er door geloopt om te kijken naar de specifieke eenheid waar de muis op staat. Met een afstand check in plaats van een simpelere vierkant check, omdat de eenheden qua uiterlijk meer rond zijn dan vierkant. En het verminderd ook de kans dat één eenheid net zo over een andere staat zodat het moeilijker word om de onderste te selecteren.

        MouseUnit = null;

        if (!IsInRange(MousePos))
            return;

        var mouseGroup = Get(MousePos);
        if (mouseGroup is null)
            return;

        var units = mouseGroup.ArrangeUnits(mouseGroup.GetGroupedUnits());
        foreach (var unit in units.units)
        {
            if (
                (unit.pos - (inputHelper.MousePosition - GetGlobalPosAtHex(MousePos))).LengthSquared()
                <= Math.Pow(units.unitSize / 2, 2)
            ) {
                MouseUnit = unit.unit;
                MouseUnitPos = unit.pos;
                break;
            }
        }
    }

Hier heb ik ook extra algemene methods gemaakt om enkele units toe te voegen en te verwijderen. Want anders zou je handmatig elke keer moeten checken of er wel een groep op een plek staat en die dan aanmaken.

    public bool RemoveUnit(HexVector pos, PublicUnit unit)
    {
        var group = Get(pos);
        if (group == null) return false;

        var removed = group.RemoveUnit(unit);

        if (group.UnitCount <= 0)
            Remove(pos);

        return removed;
    }

    public void AddUnit(HexVector pos, PublicUnit unit)
    {
        var group = Get(pos);
        if (group == null)
        {
            group = new UnitGroup(pos, new(1));
            Add(group);
        }

        group.AddUnit(unit);
    }
}

UnitGroup

public class UnitGroup(HexVector pos, List<PublicUnit>? units = null, float? rotationOffset = null, int layer = 0, string id = "") : BaseCell(pos, layer, id)
{
    protected List<PublicUnit> units = units ?? new();

    public int UnitCount => units.Count;

    public int unitGroupingLimit = 7;
    public int sunflowerSeedOffset = 5;
    public float maxUnitSizeScale = .5f;

    public float rotationOffset = rotationOffset ?? (App.Random.NextSingle() * MathF.Tau);

    public void AddUnit(PublicUnit unit)
        => units.Add(unit);

    public bool RemoveUnit(PublicUnit unit)
        => units.Remove(unit);

Het is mij gelukt om dit goed op te delen in meerdere methods. Dit was ook vereist omdat de posities en andere informatie over individuele eenheden nodig is om te checken welke word geselecteerd.

    public override void Draw(GameTime gameTime, SpriteBatch spriteBatch)
    {
        DrawMultiple(spriteBatch, ArrangeUnits(GetGroupedUnits()));
    }

Hier maak ik gelijk gebruik van twee speciale features van C#.

Eerst een record om gemakkelijk een type te definiëren dat alleen hier word gebruikt voor iets kort. Normaal is een record een immutable class, maar ik heb er een struct van gemaakt zodat het in de stack kan opgeslagen worden in plaats van de heap. Wat sneller is en een klein beetje minder memory gebruikt. Het enigste minpunt is dat het nu niet meer immutable is, maar het was toch niet nodig en als het wel zo zou zijn kan het gemakkelijk readonly gemaakt worden.

Daarna gebruik ik de speciale query syntax waarmee ik eigenlijk gewoon methods van Linq op een andere manier kan oproepen. Het is wel korter maar tegelijk is het ook moeilijk om te begrijpen.

Deze syntax gebruik om als er te veel eenheden op een tile staan ze te groeperen op hun team en type. Zodat ze dan met een klein getalletje getekend kunnen worden, in plaats van mogelijk honderden kleine eenheden.

    public record struct UnitWithCount(PublicUnit unit, int count);

    public IEnumerable<UnitWithCount> GetGroupedUnits()
        => units.Count > unitGroupingLimit
            ? from u in units
                group u by u into g
                select new UnitWithCount(g.Key, g.Count())
            : from u in units
                select new UnitWithCount(u, 1);


    public record struct UnitWithData(PublicUnit unit, int count, Vector2 pos);
    public record struct UnitsSetWithData(float unitSize, float textScale, int unitGroupCount, IEnumerable<UnitWithData> units);

    public UnitsSetWithData ArrangeUnits(IEnumerable<UnitWithCount> units)
    {
        var count = units.Count();
        var furthestSeed = Math.Max(1, MathExtras.SunflowerSeedArrangementPolar(count + sunflowerSeedOffset).Y);
        var tileWidth = Size.X;

        var unitSize = Math.Min(maxUnitSizeScale * tileWidth, tileWidth / furthestSeed);

        return new UnitsSetWithData(
            unitSize,
            unitSize/tileWidth,
            count,
            units.Select((unit, index) => {
                var sunflowerArrangement = MathExtras.SunflowerSeedArrangementPolar(index + 1 + sunflowerSeedOffset);
                sunflowerArrangement.X += rotationOffset;
                sunflowerArrangement = MathExtras.PolarToCartesian(sunflowerArrangement);

                return new UnitWithData(
                    unit.unit,
                    unit.count,
                    sunflowerArrangement / furthestSeed * (tileWidth/2 - unitSize/2)
                );
            }
        ));
    }

Dankzij de grootse opdeling van elke method zijn nu de draw methods zeer simpel geworden. De method om een enkele eenheid te tekenen is zelfs static, zodat het bijvoorbeeld in een inventory gebruikt kan worden.

Het enigste wat hier nog verbeterd kan worden is een of andere vorm van sorteren op de eenheden. Want op dit moment kan het gebeuren dat een eenheid, dat boven een andere staat, over de andere getekend word. Maar dit is niet een groot probleem want de eenheden staan meestal ver genoeg van elkaar en zijn te klein om dit zichtbaar te maken.

    public void DrawMultiple(SpriteBatch spriteBatch, UnitsSetWithData unitsSet)
    {
        foreach (var unit in unitsSet.units)
        {
            DrawSingle(
                spriteBatch,
                unit.unit,
                GlobalPosition + unit.pos,
                unitsSet.unitSize,
                unitsSet.textScale,
                Parent is UnitGrid grid
                    && grid.MouseUnit.Equals(unit.unit)
                    && unit.pos == grid.MouseUnitPos,
                unit.count
            );
        }
    }

    public static void DrawSingle(SpriteBatch spriteBatch, PublicUnit unit, Vector2 pos, float size, float textScale, bool highlighted = false, int count = 1)
    {
        var teamColour = unit.team.ToColour() * (highlighted ? .5f : 1f);

        spriteBatch.BetterDrawSheet(
            App.AssetManager.GetSprite("Images/Units@2x2"),
            new(2, 2),
            (int)unit.type,
            pos,
            Vector2.One * size,
            color: teamColour
        );

        if (count > 1)
            spriteBatch.BetterDrawStringWithOutline(
                App.AssetManager.Content.Load<SpriteFont>("Fonts/SpriteFont@20px"),
                $"x{count}",
                pos + Vector2.One * .3f * (size/2f),
                Vector2.One * textScale
            );
    }
}

Groeperen

Om het tekenen van de eenheden te testen heb ik het een klein beetje geprobeerd tot het limiet te krijgen. Maar ik kwam veel eerder bij het limiet dat het veel te klein word, dus dat is een goed teken dat het goede performance heeft.

Hier worden er exponentieel meer en meer eenheden geplaats op basis van hoe ver de tile is van het midden.

Met groepen
Zonder groepen

Zoals te zien is helpt het groeperen enorm. In de afbeelding zonder groepen zijn er zo veel eenheden op de uiterste tiles dat elke maar een paar pixels in beslag neemt. Absoluut niet te lezen.

Maar natuurlijk komen er normaal nooit zo veel tiles in het spel tegelijk. Hieronder is het te zien met meer normale aantallen eruit ziet. Dit is ook lineair in plaats van exponentieel.

Elke type en team
Dezelfde type en team

Dit zijn alsnog best veel eenheden, maar nu zijn ze zeer goed uit elkaar te halen. Wel is het zo dat in de afbeelding met verschillende teams op dezelfde de eenheden zeer rommelig eruit zien. Maar in een normaal spel vechten verschillende teams met elkaar zodra ze met hun eenheden op dezelfde tile komen. Dus zal de verdeling meer lijken op de andere afbeelding.

Zonnebloemzaad Arrangement

Voor het plaatsen van de eenheden heb ik ervoor gekozen om het zonnebloemzaad arrangement te gebruiken die ik online heb gevonden(J. de Jong, 2013). Want met dit arrangement staat elke eenheid even ver weg van elkaar, en met kleine aantallen eenheden ziet de verdeling natuurlijk rommelig eruit. Wat mooi past bij het idee van een fysiek bordspel.

Hier is de formule ervoor, als poolcoördinaat.

\[ \large \text{positie} = \begin{cases} \theta = {\LARGE \frac{2\pi}{\phi^2}}n \\ r = c\sqrt n \end{cases} \]

Waar \(\theta\) de hoek, \(r\) de straal, \(\phi\) de gulden snede en \(n\) het index van de zonnebloem zaad is. Ook is er een constante \(c\) waarvoor ik dezelfde constante \(1\) heb gekozen als in de bron. Eigenlijk kan het weggelaten worden.

Met simpele trigonometrie hieronder in de code kan het naar een cartesische coördinaat veranderd worden.

public static Vector2 SunflowerSeedArrangementPolar(int n, int arbitraryConstant = 1)
    => new(
        MathF.Tau / (GoldenRatioF*GoldenRatioF) * n,
        arbitraryConstant * MathF.Sqrt(n)
    );

public static Vector2 PolarToCartesian(Vector2 polar)
    => new(
        polar.Y * MathF.Cos(polar.X),
        polar.Y * MathF.Sin(polar.X)
    );

Publieke data

Voor nu zijn er nog geen nieuwe packets nodig om deze eenheden te delen, omdat ze nog niet veranderen. Maar hier heb ik het wel al toegevoegd als nieuwe shared structs

public struct PublicGameInfo(int range, PublicTile[] tiles, PublicUnitGroup[] units)
{
    public int range { get; set; } = range;
    public PublicTile[] grid { get; set; } = tiles;
    public PublicUnitGroup[] units { get; set; } = units;
}

Voor de publieke unit heb ik ook de Equal method overreden, zodat ik de unit direct kan gebruiken om op te groeperen.

public struct PublicUnit(UnitType type, Team team)
{
    public UnitType type { get; set; } = type;
    public Team team { get; set; } = team;

    public override bool Equals([NotNullWhen(true)] object? obj)
    {
        if (obj is not PublicUnit unit)
            return false;

        return unit.type == type && unit.team == team;
    }

    public override int GetHashCode()
        => HashCode.Combine(type, team);
}

public struct PublicUnitGroup(HexVector pos, List<PublicUnit> units)
{
    public HexVector pos { get; set; } = pos;
    public List<PublicUnit> units { get; set; } = units;
}

Klassendiagram

Onderdelen dat al stonden in de klassendiagram voor de tiles heb ik weggelaten. Omdat anders het veel minder overzichtelijk zou zijn.

classDiagram class GameObject { <<extern>> } BaseCell --|> GameObject BaseCell --> IPositioningGrid class BaseCell { <<abstract>> } class IPositioningGrid { <<interface>> } BaseGrid --|> GameObject BaseGrid --|> IPositioningGrid BaseGrid --o BaseCell class BaseGrid~T~ { <<abstract>> } Tile --|> BaseCell class Tile TileGrid --|> BaseGrid TileGrid --o Tile class TileGrid { + HexVector MousePos } class Unit { <<struct>> + UnitType type + Team team } UnitWithCount --* Unit class UnitWithCount { <<record struct>> + Unit unit + int count } UnitWithData --* Unit class UnitWithData { <<record struct>> + Unit unit + int count + Vector2 pos } UnitsSetWithData --o UnitWithData class UnitsSetWithData { <<record struct>> + float unitSize + float textScale + int unitGroupCount + IEnumerable~UnitWithData~ units } UnitGroup --|> BaseCell UnitGroup --o Unit UnitGroup --o UnitWithCount UnitGroup --o UnitsSetWithData class UnitGroup { # List~Unit~ units + int unitGroupingLimit + int sunflowerSeedOffset + float maxUnitSizeScale + AddUnit(hex, unit) + RemoveUnit(hex, unit) bool + GetGroupedUnits() IEnumerable~UnitWithCount~ + ArrangeUnits(groupedUnits) UnitsSetWithData + DrawMultiple(spriteBatch, unitsSet) + DrawSingle(spriteBatch, unit, pos, size, textScale, highlighted)$ } UnitGrid --|> BaseGrid UnitGrid --o UnitGroup class UnitGrid { + HexVector MousePos + Unit? MouseUnit + Vector2 MouseUnitPos + AddUnit(hex, unit) + RemoveUnit(hex, unit) bool }

Bronnen


Laatst geüpdatet: March 26, 2024
Gecreëerd: March 26, 2024