28.10.2026 18 мин чтения Просмотров:0

Поле зрения на сетке в Unity: shadowcasting и туман войны

Считаем видимость клеток в пошаговой игре симметричным shadowcasting на C# и строим поверх него туман войны на Tilemap с изведанными и видимыми клетками.

Поле зрения на сетке в Unity: shadowcasting и туман войны

В этой статье соберём на C# поле зрения для пошаговой игры на сетке: алгоритм симметричного shadowcasting, который честно считает, какие клетки видит персонаж из-за углов и колонн, и поверх него туман войны на Tilemap с тремя состояниями клеток: «не видел», «видел раньше», «вижу сейчас». В конце будет тест, который ловит самую неприятную ошибку FOV-алгоритмов — асимметрию, когда игрок не видит монстра, а монстр игрока видит.

Зачем считать видимость, если есть Physics2D.Raycast

Первое, что приходит в голову: для каждой клетки в радиусе пустить луч от игрока и посмотреть, упёрся ли он в стену. В реальном времени с коллайдерами так и делают. Но в рогалике, тактике или пошаговой стратегии мир — это сетка, и у такого подхода сразу три проблемы.

  • Цена. При радиусе 12 в круге около 450 клеток, и каждый луч проходит до 12 клеток. Это тысячи проверок на каждый шаг игрока, а если поле зрения нужно ещё и двадцати врагам, получается заметная нагрузка на ровном месте.
  • Артефакты. Луч, проведённый по алгоритму Брезенхэма, на диагоналях «проскальзывает» между двумя стенами, стоящими уголком, или, наоборот, цепляется за угол и прячет клетку, которую игрок очевидно должен видеть.
  • Асимметрия. Линия из A в B и линия из B в A на дискретной сетке проходят через разные клетки. Значит, игрок может видеть врага, а враг игрока нет. Для игры, где ИИ решает «атаковать или нет» по видимости, это источник странных багов.

Shadowcasting решает все три. Он обходит каждую клетку в области видимости ровно один раз, а стены отбрасывают «тени» — диапазоны углов, в которые дальше смотреть не нужно. В варианте, который описал Альберт Форд (статья «Symmetric Shadowcasting» на albertford.com), алгоритм вдобавок симметричен для клеток пола: если A видит B, то и B видит A. Этот вариант мы и реализуем.

Идея алгоритма на пальцах

Разделим пространство вокруг наблюдателя на четыре квадранта: север, юг, восток и запад. Каждый — треугольник, расширяющийся от наблюдателя. В квадранте «север» клетки идут рядами: ряд 1 — соседи сверху, ряд 2 — следующая полоса и так далее. Ряд на глубине d содержит клетки со столбцами от −d до d.

Видимую часть ряда задают два наклона: начальный и конечный. Наклон — отношение столбца к глубине. Для пустого квадранта это −1 и 1: смотрим по всей ширине треугольника. Сканируя ряд слева направо, алгоритм следит за переходами:

  • если предыдущая клетка была стеной, а текущая — пол, тень закончилась, и начальный наклон ряда сдвигается к краю этой клетки;
  • если предыдущая была полом, а текущая — стена, начинается тень. Кусок ряда до неё порождает следующий ряд со своим конечным наклоном;
  • если ряд кончился на клетке пола, следующий ряд продолжает оставшийся диапазон.

Так квадрант разбивается на «конусы» света между стенами, и каждый конус сканируется дальше независимо. Клетки, которые попали в тень, просто никогда не посещаются, поэтому сложность пропорциональна числу видимых клеток, а не площади круга.

Ключевой момент симметрии: клетка пола считается видимой, только если её центр лежит внутри диапазона наклонов. Стены же открываются, если в диапазон попала любая их часть. Благодаря этому стены комнаты видны целиком («расширяющиеся стены»), а видимость пола остаётся симметричной.

Наклоны без float

В оригинале на Python наклоны хранятся в Fraction, и это не прихоть. Наклон краёв клеток выражается дробями вида (2·col − 1) / (2·depth), и при float-арифметике граничные случаи вроде 0.5 превращаются в 0.49999 и 0.50001. В итоге клетка на границе тени то видна, то нет, и симметрия ломается ровно там, где игрок её заметит — у дверных проёмов.

Нам хватит целочисленной дроби: знаменатель всегда положительный, а числа маленькие.

using UnityEngine;

// Наклон хранится дробью Num/Den: float даёт ошибки на границах теней
public readonly struct Slope
{
    public readonly int Num;
    public readonly int Den; // всегда больше нуля

    public Slope(int num, int den)
    {
        Num = num;
        Den = den;
    }

    // Наклон левого края клетки (depth, col)
    public static Slope OfTileEdge(int depth, int col) => new Slope(2 * col - 1, 2 * depth);
}

public struct Row
{
    public int Depth;
    public Slope Start;
    public Slope End;

    public Row(int depth, Slope start, Slope end)
    {
        Depth = depth;
        Start = start;
        End = end;
    }

    // Первый и последний столбец ряда, которые хотя бы частично в диапазоне
    public int MinCol => FovMath.RoundTiesUp(Depth * Start.Num, Start.Den);
    public int MaxCol => FovMath.RoundTiesDown(Depth * End.Num, End.Den);

    public Row Next() => new Row(Depth + 1, Start, End);

    // Центр клетки внутри диапазона наклонов: условие симметрии для пола
    public bool IsSymmetric(int col) =>
        col * Start.Den >= Depth * Start.Num &&
        col * End.Den <= Depth * End.Num;
}

public static class FovMath
{
    // floor(n/d + 1/2) для d > 0
    public static int RoundTiesUp(int n, int d) => FloorDiv(2 * n + d, 2 * d);

    // ceil(n/d - 1/2) для d > 0
    public static int RoundTiesDown(int n, int d) => -FloorDiv(-(2 * n - d), 2 * d);

    // Целочисленное деление с округлением вниз и для отрицательных чисел
    public static int FloorDiv(int a, int b)
    {
        int q = a / b;
        if (a % b != 0 && (a < 0) != (b < 0))
            q--;
        return q;
    }
}

Обратите внимание на FloorDiv. Оператор деления в C# округляет к нулю, то есть −3 / 2 даёт −1, а нам нужно −2. Без этой поправки левая половина каждого квадранта (отрицательные столбцы) будет считаться со сдвигом на клетку, и вы получите несимметричный конус обзора, который долго будете списывать на «так задумано».

Почему округление «половинки вверх» для начала ряда и «половинки вниз» для конца? Если край диапазона проходит ровно по границе двух клеток, клетка за границей в него не попадает. Именно эти два правила дают аккуратные, предсказуемые края теней.

Интерфейс карты

Алгоритму не нужно знать ни про Tilemap, ни про GameObject. Ему достаточно двух операций: узнать, непрозрачна ли клетка, и отметить клетку видимой.

public interface IFovMap
{
    // Стена, закрытая дверь или клетка за пределами карты
    bool IsOpaque(int x, int y);

    void MarkVisible(int x, int y);
}

За пределами карты клетки должны считаться непрозрачными. Иначе алгоритм на открытом краю уровня уйдёт сканировать пустоту до самого радиуса, а без радиуса — бесконечно.

Сам shadowcasting

Оригинальная реализация рекурсивная. В C# рекурсию легко заменить своим стеком: порядок обработки рядов на результат не влияет, потому что каждый ряд несёт свои наклоны с собой. Стек выделяем один раз и переиспользуем, чтобы расчёт поля зрения не создавал мусора.

using System.Collections.Generic;
using UnityEngine;

public sealed class ShadowcastFov
{
    readonly Stack<Row> _rows = new Stack<Row>(64);

    public void Compute(IFovMap map, Vector2Int origin, int radius)
    {
        map.MarkVisible(origin.x, origin.y);

        // +radius делает круг чуть «полнее», без одиночных клеток на осях
        int radiusSq = radius * radius + radius;

        for (int quadrant = 0; quadrant < 4; quadrant++)
        {
            _rows.Clear();
            _rows.Push(new Row(1, new Slope(-1, 1), new Slope(1, 1)));

            while (_rows.Count > 0)
            {
                Row row = _rows.Pop();
                if (row.Depth > radius)
                    continue;

                bool hasPrev = false;
                bool prevWall = false;
                int minCol = row.MinCol;
                int maxCol = row.MaxCol;

                for (int col = minCol; col <= maxCol; col++)
                {
                    ToWorld(quadrant, origin, row.Depth, col, out int x, out int y);
                    bool wall = map.IsOpaque(x, y);
                    bool inRadius = row.Depth * row.Depth + col * col <= radiusSq;

                    if (inRadius && (wall || row.IsSymmetric(col)))
                        map.MarkVisible(x, y);

                    // Тень кончилась: сужаем начало ряда
                    if (hasPrev && prevWall && !wall)
                        row.Start = Slope.OfTileEdge(row.Depth, col);

                    // Тень началась: свет до стены уходит в следующий ряд
                    if (hasPrev && !prevWall && wall)
                    {
                        Row next = row.Next();
                        next.End = Slope.OfTileEdge(row.Depth, col);
                        _rows.Push(next);
                    }

                    hasPrev = true;
                    prevWall = wall;
                }

                // Ряд закончился полом: продолжаем оставшийся конус
                if (hasPrev && !prevWall)
                    _rows.Push(row.Next());
            }
        }
    }

    // Переводит (глубина, столбец) квадранта в координаты сетки. Y в Unity растёт вверх
    static void ToWorld(int quadrant, Vector2Int o, int depth, int col, out int x, out int y)
    {
        switch (quadrant)
        {
            case 0: x = o.x + col; y = o.y + depth; break;   // север
            case 1: x = o.x + col; y = o.y - depth; break;   // юг
            case 2: x = o.x + depth; y = o.y + col; break;   // восток
            default: x = o.x - depth; y = o.y + col; break;  // запад
        }
    }
}

Несколько неочевидных мест.

Изменение row.Start посреди цикла. Границы minCol и maxCol посчитаны до цикла, поэтому сужение диапазона не меняет набор обходимых клеток текущего ряда. Но проверка симметрии для следующих клеток уже идёт по новому начальному наклону — так и должно быть: клетки сразу за стеной частично в тени, и пол в них виден, только если центр не затенён.

Диагонали посещаются дважды. Клетки со столбцом, равным ±глубине, принадлежат сразу двум квадрантам. Это нормально, нужно лишь, чтобы MarkVisible был идемпотентным — в следующем разделе так и сделаем.

Радиус. Отсечка по глубине обрывает скан, а проверка по квадрату расстояния делает границу круглой. Стены за кругом при этом всё равно участвуют в построении теней, просто не помечаются видимыми.

Хранение видимости без очистки массива

Каждый ход нужно «забыть» старое поле зрения и посчитать новое. Наивно — обнулить массив bool размером с карту. На карте 200×200 это 40 тысяч записей на каждый шаг, хотя видимых клеток от силы пара сотен. Стандартный приём — метки поколений: вместо bool храним номер расчёта, в котором клетка была видна. Чтобы «очистить» всё, достаточно увеличить счётчик.

using System.Collections.Generic;
using UnityEngine;

public sealed class GridVisibility : IFovMap
{
    public readonly int Width;
    public readonly int Height;

    readonly bool[] _opaque;
    readonly bool[] _explored;
    readonly int[] _visibleStamp;
    int _stamp = 1;

    readonly List<Vector2Int> _visibleNow = new List<Vector2Int>(512);
    readonly List<Vector2Int> _visibleBefore = new List<Vector2Int>(512);
    readonly ShadowcastFov _fov = new ShadowcastFov();

    public IReadOnlyList<Vector2Int> VisibleNow => _visibleNow;
    public IReadOnlyList<Vector2Int> VisibleBefore => _visibleBefore;

    public GridVisibility(int width, int height)
    {
        Width = width;
        Height = height;
        _opaque = new bool[width * height];
        _explored = new bool[width * height];
        _visibleStamp = new int[width * height];
    }

    public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height;

    public void SetOpaque(int x, int y, bool value) => _opaque[y * Width + x] = value;

    public bool IsOpaque(int x, int y) => !InBounds(x, y) || _opaque[y * Width + x];

    public bool IsVisible(int x, int y) => InBounds(x, y) && _visibleStamp[y * Width + x] == _stamp;

    public bool IsExplored(int x, int y) => InBounds(x, y) && _explored[y * Width + x];

    public void MarkVisible(int x, int y)
    {
        if (!InBounds(x, y))
            return;

        int i = y * Width + x;
        if (_visibleStamp[i] == _stamp)
            return; // уже отмечена, например на диагонали двух квадрантов

        _visibleStamp[i] = _stamp;
        _explored[i] = true;
        _visibleNow.Add(new Vector2Int(x, y));
    }

    public void Recompute(Vector2Int origin, int radius)
    {
        // Старый список становится «предыдущим» без копирования данных
        _visibleBefore.Clear();
        _visibleBefore.AddRange(_visibleNow);
        _visibleNow.Clear();

        _stamp++;
        _fov.Compute(this, origin, radius);
    }
}

Списки VisibleNow и VisibleBefore пригодятся отрисовке: обновлять нужно только клетки, у которых изменилось состояние, а не всю карту. Переполнение int у счётчика при одном пересчёте на ход наступит через два миллиарда ходов, так что об этом можно не думать.

Туман войны на Tilemap

Самый простой и быстрый способ нарисовать туман — отдельный Tilemap поверх уровня с более высоким Order in Layer. В нём два тайла: непрозрачно-чёрный для неизведанных клеток и полупрозрачный тёмный для клеток, которые игрок уже видел, но сейчас не видит. Видимые клетки в тумане просто пустые.

Карту непрозрачности строим из Tilemap стен: всё, где есть тайл, блокирует обзор. Чтобы индексы массива начинались с нуля, запоминаем левый нижний угол границ.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;

public sealed class FogOfWar : MonoBehaviour
{
    [SerializeField] Tilemap _walls;
    [SerializeField] Tilemap _fog;
    [SerializeField] TileBase _unknownTile;
    [SerializeField] TileBase _rememberedTile;
    [SerializeField] int _radius = 10;

    GridVisibility _visibility;
    Vector3Int _origin;

    readonly List<Vector3Int> _positions = new List<Vector3Int>(1024);
    readonly List<TileBase> _tiles = new List<TileBase>(1024);

    public GridVisibility Visibility => _visibility;

    void Awake()
    {
        _walls.CompressBounds();
        BoundsInt b = _walls.cellBounds;
        _origin = b.min;
        _visibility = new GridVisibility(b.size.x, b.size.y);

        var all = new TileBase[b.size.x * b.size.y];
        for (int y = 0; y < b.size.y; y++)
        for (int x = 0; x < b.size.x; x++)
        {
            var cell = new Vector3Int(_origin.x + x, _origin.y + y, 0);
            _visibility.SetOpaque(x, y, _walls.HasTile(cell));
            all[y * b.size.x + x] = _unknownTile;
        }

        // Заливаем весь уровень туманом одним вызовом
        _fog.SetTilesBlock(new BoundsInt(_origin, new Vector3Int(b.size.x, b.size.y, 1)), all);
    }

    public Vector2Int ToGrid(Vector3Int cell) => new Vector2Int(cell.x - _origin.x, cell.y - _origin.y);
    public Vector3Int ToCell(Vector2Int g) => new Vector3Int(g.x + _origin.x, g.y + _origin.y, 0);

    public void Refresh(Vector3Int playerCell)
    {
        _visibility.Recompute(ToGrid(playerCell), _radius);

        _positions.Clear();
        _tiles.Clear();

        // Ушли из поля зрения: становятся «воспоминанием»
        foreach (Vector2Int g in _visibility.VisibleBefore)
        {
            if (_visibility.IsVisible(g.x, g.y))
                continue;
            _positions.Add(ToCell(g));
            _tiles.Add(_rememberedTile);
        }

        // Видимые сейчас: убираем туман
        foreach (Vector2Int g in _visibility.VisibleNow)
        {
            _positions.Add(ToCell(g));
            _tiles.Add(null);
        }

        _fog.SetTiles(_positions.ToArray(), _tiles.ToArray());
    }
}

Пакетный SetTiles заметно быстрее, чем сотни отдельных SetTile: Tilemap обновляет меш и коллайдеры один раз. Два вызова ToArray создают небольшие массивы на каждый ход. Для пошаговой игры это незаметно; если пересчитывать каждый кадр, держите собственные массивы с запасом и используйте перегрузку SetTiles с TileChangeData или обновляйте через SetTile только изменившиеся клетки.

Альтернатива отдельному Tilemap — красить клетки самого уровня через SetColor. Тогда перед покраской нужно снять с клетки флаг TileFlags.LockColor (метод SetTileFlags), иначе цвет молча не применится: большинство тайлов по умолчанию запирают цвет. Вариант с отдельным слоем тумана проще, к тому же он прячет и спрайты врагов, стоящих под туманом, если поставить слой тумана выше них.

Когда пересчитывать

Поле зрения меняется в двух случаях: наблюдатель переместился или изменилась непрозрачность клеток (открыли дверь, взорвали стену). Самый надёжный вариант — флаг «грязно», который выставляют оба события, и один пересчёт в конце хода.

public sealed class FovController : MonoBehaviour
{
    [SerializeField] FogOfWar _fog;
    [SerializeField] Grid _grid;
    [SerializeField] Transform _player;

    Vector3Int _lastCell = new Vector3Int(int.MinValue, 0, 0);
    bool _dirty = true;

    public void SetDoorOpen(Vector3Int cell, bool open)
    {
        Vector2Int g = _fog.ToGrid(cell);
        _fog.Visibility.SetOpaque(g.x, g.y, !open);
        _dirty = true;
    }

    void LateUpdate()
    {
        Vector3Int cell = _grid.WorldToCell(_player.position);
        if (cell != _lastCell)
        {
            _lastCell = cell;
            _dirty = true;
        }

        if (!_dirty)
            return;

        _dirty = false;
        _fog.Refresh(cell);
    }
}

LateUpdate здесь выбран намеренно: к этому моменту все скрипты движения отработали, и туман не отстаёт от игрока на кадр.

Враги и симметрия

Вот где окупается симметричный вариант. Скрыть врагов, которых игрок не видит, — одна проверка:

foreach (Enemy enemy in _enemies)
{
    Vector2Int g = _fog.ToGrid(enemy.Cell);
    enemy.SetRendered(_fog.Visibility.IsVisible(g.x, g.y));
}

А раз для клеток пола видимость симметрична, тот же результат отвечает и на обратный вопрос: видит ли враг игрока. Не нужно считать отдельное поле зрения для каждого монстра — достаточно проверить, что клетка врага видна игроку и расстояние не больше радиуса обзора самого врага. Это превращает десятки вызовов FOV в одну проверку массива.

public bool EnemySeesPlayer(Enemy enemy, Vector3Int playerCell)
{
    Vector2Int e = _fog.ToGrid(enemy.Cell);
    if (!_fog.Visibility.IsVisible(e.x, e.y))
        return false;

    Vector3Int d = enemy.Cell - playerCell;
    return d.x * d.x + d.y * d.y <= enemy.SightRadius * enemy.SightRadius;
}

Тонкость: радиус игрока должен быть не меньше радиуса любого врага, иначе дальнозоркий снайпер за пределами круга игрока «ослепнет». Если у врагов обзор больше, считайте поле зрения игрока с максимальным радиусом, а для тумана отдельно фильтруйте клетки по радиусу игрока.

Ещё один момент: симметрия гарантирована для пола. Стена может быть видна игроку, а «взгляд со стены» не определён — но враги на стенах обычно не стоят. Если у вас есть существа в стенах (призраки, турели в нишах), заведите для их клетки отдельную проверку.

Когда хватит одной линии

Не каждой проверке видимости нужен полный FOV. Если вопрос разовый — «может ли турель выстрелить в эту клетку» — дешевле пройти одной линией. Чтобы она не противоречила туману, удобно считать её от центра клетки к центру клетки и проверять обе стороны:

public static bool HasLineOfSight(IFovMap map, Vector2Int a, Vector2Int b)
{
    return Walk(map, a, b) || Walk(map, b, a);
}

// Брезенхэм: идём по клеткам между a и b, концы не проверяем
static bool Walk(IFovMap map, Vector2Int a, Vector2Int b)
{
    int dx = Mathf.Abs(b.x - a.x), sx = a.x < b.x ? 1 : -1;
    int dy = -Mathf.Abs(b.y - a.y), sy = a.y < b.y ? 1 : -1;
    int err = dx + dy;
    int x = a.x, y = a.y;

    while (true)
    {
        if (x == b.x && y == b.y)
            return true;
        if ((x != a.x || y != a.y) && map.IsOpaque(x, y))
            return false;

        int e2 = 2 * err;
        if (e2 >= dy) { err += dy; x += sx; }
        if (e2 <= dx) { err += dx; y += sy; }
    }
}

Проверка «в обе стороны» делает функцию симметричной по построению, но результат всё равно иногда расходится с shadowcasting на диагоналях вблизи углов. Поэтому правило простое: если результат виден игроку на экране (туман, подсветка, скрытие врагов), берите shadowcasting; если это внутренняя механика без визуальной обратной связи — можно линией.

Тест на симметрию

FOV-код ломается тихо: ошибка в одном знаке даёт поле зрения, которое выглядит почти правильно. Поэтому стоит закрепить главное свойство тестом в Edit Mode. Карту удобно описывать строками: решётка — стена, точка — пол.

using NUnit.Framework;
using UnityEngine;

public class ShadowcastFovTests
{
    static readonly string[] Map =
    {
        "##########",
        "#....#...#",
        "#.##.#.#.#",
        "#....#...#",
        "#.#......#",
        "#...##.#.#",
        "##########",
    };

    static GridVisibility Build()
    {
        int h = Map.Length, w = Map[0].Length;
        var v = new GridVisibility(w, h);
        for (int y = 0; y < h; y++)
        for (int x = 0; x < w; x++)
            v.SetOpaque(x, y, Map[h - 1 - y][x] == '#');
        return v;
    }

    [Test]
    public void FloorVisibility_IsSymmetric()
    {
        const int radius = 20;
        var a = Build();
        var b = Build();

        for (int ay = 0; ay < a.Height; ay++)
        for (int ax = 0; ax < a.Width; ax++)
        {
            if (a.IsOpaque(ax, ay)) continue;
            a.Recompute(new Vector2Int(ax, ay), radius);

            for (int by = 0; by < b.Height; by++)
            for (int bx = 0; bx < b.Width; bx++)
            {
                if (b.IsOpaque(bx, by)) continue;
                b.Recompute(new Vector2Int(bx, by), radius);

                Assert.AreEqual(a.IsVisible(bx, by), b.IsVisible(ax, ay),
                    $"({ax},{ay}) и ({bx},{by}) видят друг друга по-разному");
            }
        }
    }

    [Test]
    public void OriginIsAlwaysVisible()
    {
        var v = Build();
        v.Recompute(new Vector2Int(1, 1), 5);
        Assert.IsTrue(v.IsVisible(1, 1));
    }
}

Тест квадратичный по числу клеток пола, поэтому карту держите маленькой, но с колоннами, нишами и диагональными проходами — именно там ломается симметрия. Отдельно полезно прогнать тест с float-наклонами вместо дробей: на этой карте он, скорее всего, упадёт, и вы своими глазами увидите, зачем понадобилась целочисленная арифметика.

Производительность и расширения

На карте с комнатами и коридорами расчёт с радиусом 10–15 занимает микросекунды и не выделяет памяти: ряды — структуры в переиспользуемом стеке, метки поколений избавляют от очистки массивов. Если нужно поле зрения для многих источников одновременно (например, видимость всех союзников в тактике), считайте их по очереди в один GridVisibility без увеличения счётчика между вызовами Compute: MarkVisible объединит результаты сам. Для этого вынесите увеличение _stamp и очистку списков в отдельный метод BeginFrame.

Ещё несколько идей, которые ложатся на ту же основу:

  • Освещение. Каждый факел — свой Compute с маленьким радиусом в отдельную карту освещённости. Клетка видна игроку, только если она в его FOV и освещена. Так получаются тёмные комнаты, в которые видно лишь пятна света.
  • Полупрозрачные препятствия. Кусты или туман можно сделать «стеной для обзора за ними, но не для самих себя»: клетка помечается видимой, а для сканирования считается непрозрачной.
  • Память о врагах. В клетку «воспоминания» можно записывать последнего увиденного врага и рисовать его призрачный силуэт — игроку это подсказывает, где он видел опасность.
  • Гексагональные сетки. Shadowcasting переносится и на гексы, но там шесть секторов вместо четырёх квадрантов и другие формулы наклонов. Если у вас гексы, подход тот же, а код ряда придётся переписать.

Итог

Поле зрения на сетке — задача, которую легко решить «почти правильно» лучами и потом долго ловить странности у дверей и колонн. Симметричный shadowcasting даёт честный результат за время, пропорциональное видимой области, а пара инженерных решений — целочисленные наклоны, свой стек вместо рекурсии и метки поколений вместо очистки массива — делают его быстрым и предсказуемым в Unity. Поверх него туман войны на отдельном Tilemap собирается за сотню строк, а симметрия бесплатно отвечает на вопрос, видит ли враг игрока.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также

Как импортировать .unitypackage в Unity: пошагово
31.08.2026

Как импортировать .unitypackage в Unity: пошагово

Пошаговая инструкция по импорту файлов .unitypackage в Unity. Разбираемся, как установить ассет в проект, где найти импортированные файлы и что делать при возникновении ошибок.