見出し画像

『人生の大切なことをゲームから学ぶ展』:「Dead or Alive」の技術的なおはなし

はじめに

たきコーポレーション[ ZERO ]テクニカルディレクター兼プログラマーの石丸と申します。デジタルコンテンツの企画・開発を行う「テックラボ」に所属しています。

現在、弊社が企画から開発までを手掛けた、『人生の大切なことをゲームから学ぶ展』が、GOOD DESIGN MARUNOUCHIにて2024年3月15日(金)から4月14日(日)まで開催されています。

展覧会では8つのゲームが展示されています。本記事では「Dead or Alive」のアウトラインと、技術トピックを一部抜粋してご紹介します。

「Dead or Alive」について

ゲームの概要

  • ジャンル:2D見下ろし型アクション

  • 舞台:ジャングル奥地の遺跡

  • 目的:できるだけ多くの財宝を手にいれ、遺跡から脱出すること

主題は「リスク&リターン」。遺跡に長くとどまれば、敵とコインが増え続け、ハイリスクハイリターンになる。

タイトル画面とプレイ画面

ゲームの特徴

  • 「決断UI」
    プレイヤーはプレイ中、繰り返し「挑戦」「脱出」の判断をせまられます。

  • 「敗者のランキング」
    ベストとワースト、二種類のランキングを用意し、勝者だけでなく敗者にフォーカスを当てています。

ベストランキング、ワーストランキング画面

技術のおはなし

今回は「マップ管理」についてお話しします。

開発環境

  • ゲームエンジン:Unity

  • ライブラリ:UniRx, UniTask, VContainer, Json.NET, DoTween

マップ管理の仕組み

ステージ作成には、UnityのTilemap機能を用いています。

はじめに、マップ管理の要件を整理しました。

  • アイテム&エネミーは、マップへ均一に配置したい

  • 一つのセルに二つ以上のアイテム&エネミーを配置しない

  • アイテム&エネミーは、プレイヤーから離れたセルに出現する

  • 死亡時、プレイヤーに近いセルから順番にコインをばらまく

必要なクラスと、各クラスの機能を検討しました。

  • セルクラス:自身の状態や座標を保持する

  • グリッドクラス:複数のセルを管理する

  • グリッドマネージャクラスグリッドを任意数に分割して管理する

  • Cell.cs

    • メンバはType, Id, Position, IsLocked

    • Typeは「Empty, Item, Enemy, NotWalkable」いずれかの状態を保持

  • Grid.cs

    • メンバはId, Width, Height, Cells

    • コンストラクタでTilemapを受け取り、Cellの2次元配列を生成する

    • セルを任意の状態へ更新する

    • 状態ごとに、セルの総数を返す

    • 空セルの座標をランダムに返す

    • 指定した座標の周囲の空セルのリストを返す

    • 指定した座標の周囲のセルをロックする

    • セルをアンロックする

    • CellをGizmoに描画する。デバッグ用途

  • GridManager.cs

    • メンバはColumn, Row, Grid

    • コンストラクタでTilemapを受け取り、Gridの2次元配列を生成する

    • マップを任意のグリッド数に分割する

    • 状態ごとに各グリッドに含まれるセルの平均数を算出して返す

    • セルが少ないグリッドから優先的に空セルを取得し、ポジションのリストを返す

    • セルの状態を更新する

    • 指定の座標からの距離で、ポジションのリストをソートする

    • ポジションのリストをシャッフルする

次のスクリーンショットは、実装後のシーンビューです。
プレイ中、セルの状態を定期的に更新し、アイテムやエネミーを配置する座標を決定しています。

赤丸:空のセル, 緑丸:アイテムのセル, 青丸:エネミーのセル, 黄丸:NotWalkableなセル 白矩形:ロックされたセル(プレイヤー周囲のセル)


最後に、コードを記載しますが、リファクタリングが不十分な箇所もあるため、参考までに留めていただければと思います。

using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using Items;
using UnityEngine;
using UnityEngine.Tilemaps;

namespace Stages
{
    public class GridManager
    {
        private int _column = 2;
        private int _row = 2;
        private Grid[,] _grids;

        public GridManager(Tilemap map)
        {
            _grids = new Grid[_column, _row];
            
            Initialized(map);
        }

        private void Initialized(Tilemap map)
        {
            var bounds = map.cellBounds;
            var gridWidth = bounds.size.x / _column;
            var gridHeight = bounds.size.y / _row;
            
            for (var y = 0; y < _row; y++)
            {
                for (var x = 0; x < _column; x++)
                {
                    int width = 0;
                    var isLastColumn = x == _column - 1;
                    
                    if (isLastColumn)
                    {
                        width = gridWidth +  bounds.size.x % gridWidth;
                        
                    }
                    else
                    {
                        width = gridWidth;
                    }
                    
                    var height = 0;
                    var isLastRow = y == _row - 1;
                    if (isLastRow)
                    {
                        height = gridHeight + bounds.size.y % gridHeight;
                    }
                    else
                    {
                        height = gridHeight;
                    }
                    
                    var id = x + y * _column;
                    var beginX = gridWidth * x;
                    var beginY = gridHeight * y;
                    
                    _grids[x, y] = new Grid(map, id, beginX, beginY, width, height);                    
                }
            }
        }
        
        private int GetAverageCount(CellType type)
        {
            var count = 0;
            foreach (var grid in _grids)
            {
                count += grid.GetCellCount(type);
            }

            return count / _grids.Length;
        }
        
        private async UniTask<List<Vector2>> TakeEmptyPositions(CellType type, int count, bool isLocked = false, bool isUpdate = true)
        {
            var positions = new List<Vector2>();
            var averageCount = GetAverageCount(type);
            
            var grids = new List<Grid>();
            foreach (var grid in _grids)
            {
                grids.Add(grid);
            }
            
            grids.Sort((a, b) => a.GetCellCount(type).CompareTo(b.GetCellCount(type)));
            
            foreach (var grid in grids)
            {
                var cellCount = grid.GetCellCount(type);
                var diffCount = averageCount - cellCount;
                var takeCount = Mathf.Max(0, diffCount);
                var takePositions = grid.TakeEmptyRandomPositions(takeCount, isLocked);
                positions.AddRange(takePositions);
            }
            
            if (positions.Count < count)
            {
                var diffCount = count - positions.Count;
                
                var shuffleGrids = 
                    grids
                        .OrderBy(a => Guid.NewGuid())
                        .ToList();
                
                for (var i = 0; i < diffCount; i++)
                {
                    var grid = shuffleGrids[i % grids.Count];
                    var takePositions = grid.TakeEmptyRandomPositions(1, isLocked);
                    positions.AddRange(takePositions);
                }
            }
            
            var shuffledPositions = ShufflePositions(positions);
            
            shuffledPositions = shuffledPositions.GetRange(0, count);
            
            if (isUpdate)
            {
                foreach (var grid in _grids)
                {
                    await grid.UpdateCellType(shuffledPositions, type);
                }
            }

            return shuffledPositions;
        }
        
        public async UniTask UpdateGrid(List<Vector2> itemPositions, List<Vector2> enemyPositions, Vector2 position, float radius)
        {
            foreach (var grid in _grids)
            {
                await grid.Unlock();
                await grid.LockAroundPosition(position, radius);
                await grid.UpdateCellType(itemPositions, enemyPositions);
            }
        }

        public UniTask<List<Vector2>> TakeEmptyPositionsForItem(int count, bool isLocked = false, bool isUpdate = true)
        {
            return TakeEmptyPositions(CellType.Item, count, isLocked, isUpdate);
        }
        
        public UniTask<List<Vector2>> TakeEmptyPositionsForEnemy(int count, bool isLocked = false, bool isUpdate = true)
        {
            return TakeEmptyPositions(CellType.Enemy, count, isLocked, isUpdate);
        }
        
        public List<Vector2> TakePositionsAroundPoint(Vector2 point, int count, float radius)
        {
            var normalizedPoint = NormalizePosition(point);
            
            var allPositions = new List<Vector2>();
            foreach (var grid in _grids)
            {
                var takePositions = grid.TakeAroundPositions(normalizedPoint, radius);
                allPositions.AddRange(takePositions);
            }
            
            var sortedPositions = SortByDistance(normalizedPoint, allPositions);
            var maxCount = Mathf.Min(count, sortedPositions.Count);
            var positions = sortedPositions.GetRange(0, maxCount);

            return positions;
        }
        
        public Vector3 TakeEmptyRandomPosition(bool isLocked = false)
        {
            var randomGrid = _grids[UnityEngine.Random.Range(0, _column), UnityEngine.Random.Range(0, _row)];
            var position = randomGrid.TakeEmptyRandomPosition(isLocked);
            return (Vector3) position;
        }
        
        private List<Vector2> SortByDistance(Vector2 point, List<Vector2> positions, bool isFar = false)
        {
            positions.Sort((a, b) => 
                Vector3.Distance(point, a).CompareTo(Vector3.Distance(point, b)));
            
            if (isFar) positions.Reverse();
            
            return positions;
        }

        private List<Vector2> ShufflePositions(List<Vector2> positions)
        {
            var shuffledPositions =
                 positions
                     .OrderBy(a => Guid.NewGuid())
                     .ToList();

            return shuffledPositions;
        }
        
        private Vector2 NormalizePosition(Vector2 position, float size = 1f)
        {
            return new Vector2(
                Mathf.Floor(position.x) + size * 0.5f,
                Mathf.Floor(position.y) + size * 0.5f
            );
        }
        
        
        public void DrawGizmos()
        {
            if (_grids == null) return;
            
            foreach (var grid in _grids)
            {
                grid.DrawGizmos();
            }
        }
    }
}
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Tilemaps;

namespace Stages
{
    public class Grid
    {
        private readonly int _id;
        private readonly int _width;
        private readonly int _height;
        private Cell[,] _cells;
        
        public Grid(
            Tilemap map,
            int id,
            int beginX,
            int beginY,
            int width,
            int height)
        {
            _id = id;
            _width = width;
            _height = height;
            
            _cells = new Cell[_width, _height];
            
            Initialized(map, beginX, beginY);
        }
        
        private void Initialized(Tilemap map, int beginX, int beginY)
        {
            var bounds = map.cellBounds;
            var allTiles = map.GetTilesBlock(bounds);
            var anchor = map.tileAnchor;

            for (var y = 0; y < _height; y++)
            {
                for (var x = 0; x < _width; x++)
                {
                    var tileX = beginX + x;
                    var tileY = beginY + y;
                    var tileIndex = tileX + tileY * bounds.size.x;
                    TileBase tile = allTiles[tileIndex];
                    Vector3Int localPlace = (new Vector3Int(tileX, tileY, (int)map.transform.position.z));
                    Vector3 place = map.CellToWorld(localPlace);
                    Vector3 center = place + anchor;
                    Vector2 position = center;
                    
                    if (tile != null)
                    {
                        _cells[x, y] = new Cell(tileIndex, position, CellType.Empty);
                    }
                    else
                    {
                        _cells[x, y] = new Cell(tileIndex, position, CellType.NotWalkable);
                    }
                }
            }
        }
        
        public async UniTask UpdateCellType(List<Vector2> positions, CellType type)
        {
            var normalizedPositions = positions.Select(pos => NormalizePosition(pos)).ToList();
            
            foreach (var cell in _cells)
            {
                if (normalizedPositions.Contains(cell.Position))
                {
                    cell.SetType(type);
                }
            }
            
            await UniTask.CompletedTask;
        }
        
        public async UniTask UpdateCellType(List<Vector2> itemPositions, List<Vector2> enemyPositions)
        {
            var normalizedItemPositions = itemPositions.Select(pos => NormalizePosition(pos)).ToList();
            var normalizedEnemyPositions = enemyPositions.Select(pos => NormalizePosition(pos)).ToList();
            
            foreach (var cell in _cells)
            {
                if (cell.Type == CellType.NotWalkable) continue;
                
                if (normalizedItemPositions.Contains(cell.Position))
                {
                    cell.SetItem();
                }
                else if (normalizedEnemyPositions.Contains(cell.Position))
                {
                    cell.SetEnemy();
                }
                else
                {
                    cell.SetEmpty();
                }
            }
            
            await UniTask.CompletedTask;
        }
        
        private Vector2 NormalizePosition(Vector2 position, float size = 1f)
        {
            return new Vector2(
                Mathf.Floor(position.x) + size * 0.5f,
                Mathf.Floor(position.y) + size * 0.5f
            );
        }
        
        public int GetCellCount(CellType type)
        {
            var count = 0;
            
            foreach (var cell in _cells)
            {
                if (cell.Type == type)
                {
                    count++;
                }
            }
            
            return count;
        }

        private List<Vector2> TakeCellPositions(CellType type, bool isLocked)
        {
            var positions = new List<Vector2>();
            
            foreach (var cell in _cells)
            {
                if (cell.Type == type && cell.IsLocked == isLocked)
                {
                    positions.Add(cell.Position);
                }
            }
            
            return positions;
        }
        
        private List<Vector2> TakeItemPositions(bool isLocked)
        {
            return TakeCellPositions(CellType.Item, isLocked);
        }
        
        public List<Vector2> TakeEnemyPositions(bool isLocked)
        {
            return TakeCellPositions(CellType.Enemy, isLocked);
        }
        
        private List<Vector2> TakeEmptyPositions(bool isLocked)
        {
            return TakeCellPositions(CellType.Empty, isLocked);
        }
        
        public List<Vector2> TakeEmptyRandomPositions(int count, bool isLocked)
        {
            var takePositions = TakeEmptyPositions(isLocked);
            var positions = new List<Vector2>();

            var totalCount = 0;
            while (totalCount < count && takePositions.Count > 0)
            {
                var index = Random.Range(0, takePositions.Count);
                positions.Add(takePositions[index]);
                takePositions.RemoveAt(index);
                totalCount++;
            }
            
            return positions;
        }
        
        public Vector2 TakeEmptyRandomPosition(bool isLocked)
        {
            var positions = TakeEmptyPositions(isLocked);
            positions = positions.Count > 0 ? positions : TakeItemPositions(isLocked);
            positions = positions.Count > 0 ? positions : TakeEmptyPositions(!isLocked);
            positions = positions.Count > 0 ? positions : TakeItemPositions(!isLocked);
            
            var index = Random.Range(0, positions.Count);
            return positions[index];
        }
        
        public List<Vector2> TakeEmptyAroundPositions(Vector2 position, float radius)
        {
            var positions = new List<Vector2>();
            
            foreach (var cell in _cells)
            {
                if (cell.Type != CellType.Empty) continue;
                
                if (Vector2.Distance(cell.Position, position) <= radius)
                {
                    positions.Add(cell.Position);
                }
            }
            
            return positions;
        }
        
        public List<Vector2> TakeAroundPositions(Vector2 position, float radius)
        {
            var positions = new List<Vector2>();
            
            foreach (var cell in _cells)
            {
                if (cell.Type == CellType.NotWalkable || cell.Type == CellType.Enemy) continue;
                
                if (Vector2.Distance(cell.Position, position) <= radius)
                {
                    positions.Add(cell.Position);
                }
            }
            
            return positions;
        }
        
        
        public async UniTask LockAroundPosition(Vector2 position, float radius)
        {
            foreach (var cell in _cells)
            {
                if (Vector2.Distance(cell.Position, position) <= radius)
                {
                    cell.Lock();
                }
            }
            
            await UniTask.CompletedTask;
        }
        
        public async UniTask Unlock()
        {
            foreach (var cell in _cells)
            {
                cell.Unlock();
            }

            await UniTask.CompletedTask;
        }
        
        public void DrawGizmos()
        {
            if (_cells == null) return;
            
            var sphereSize = 0.2f;
            var cubeSize = Vector3.one * 0.5f;
            
            foreach (var cell in _cells)
            {   
                if (cell.IsLocked)
                {
                    Gizmos.color = Color.white;
                    Gizmos.DrawCube(cell.Position, cubeSize);
                }
                
                if (cell.Type == CellType.Empty)
                {
                    Gizmos.color = Color.red;
                    Gizmos.DrawSphere(cell.Position, sphereSize);
                }
                else if (cell.Type == CellType.Item)
                {
                    Gizmos.color = Color.green;
                    Gizmos.DrawSphere(cell.Position, sphereSize);
                }
                else if (cell.Type == CellType.Enemy)
                {
                    Gizmos.color = Color.blue;
                    Gizmos.DrawSphere(cell.Position, sphereSize);
                }
                else if (cell.Type == CellType.NotWalkable)
                {
                    Gizmos.color = Color.yellow;
                    Gizmos.DrawSphere(cell.Position, sphereSize);
                }
            }
        }
    }
}
using UnityEngine;

namespace Stages
{
    public enum CellType
    {
        None,
        Empty,
        Item,
        Enemy,
        NotWalkable,
    }

    public class Cell
    {
        public CellType Type { get; private set; }
        public bool IsLocked { get; private set; }
        public Vector2 Position { get; private set; }
        public int Id { get; private set; }
        
        public Cell(int id, Vector2 position, CellType type = CellType.None)
        {
            Id = id;
            Position = position;
            Type = type;   
        }
        
        public void SetType(CellType type)
        {
            Type = type;
        }
        
        public void SetItem()
        {
            Type = CellType.Item;
        }
        
        public void SetEnemy()
        {
            Type = CellType.Enemy;
        }
        
        public void SetEmpty()
        {
            Type = CellType.Empty;
        }
        
        public void Lock()
        {
            IsLocked = true;
        }
        
        public void Unlock()
        {
            IsLocked = false;
        }
    }
}