『人生の大切なことをゲームから学ぶ展』:「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次元配列を生成する
マップを任意のグリッド数に分割する
状態ごとに各グリッドに含まれるセルの平均数を算出して返す
セルが少ないグリッドから優先的に空セルを取得し、ポジションのリストを返す
セルの状態を更新する
指定の座標からの距離で、ポジションのリストをソートする
ポジションのリストをシャッフルする
次のスクリーンショットは、実装後のシーンビューです。
プレイ中、セルの状態を定期的に更新し、アイテムやエネミーを配置する座標を決定しています。
最後に、コードを記載しますが、リファクタリングが不十分な箇所もあるため、参考までに留めていただければと思います。
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;
}
}
}