[TIL] 타일 맵 구현하기(1)
![[TIL] 타일 맵 구현하기(1)](/_next/image?url=https%3A%2F%2Fdata.develog.develrocket.com%2Fupload%2Fdevelog%2Fuser_1777093328305%2F1777380586472-dre57h%2F_____2026-04-28_212238.png&w=3840&q=75)
개요
타일맵이란?
바둑판처럼 격자로 나눈 화면에 미리 만들어둔 작은 이미지(타일)을 배치해서 맵을 구성하는 게임 제작 기법
대표적인 게임
- 스타듀밸리
- 포켓몬 시리즈
- 테라리아
- 코어키퍼
작성 계기

약 2달 전에 C#으로 콘솔 게임 프로젝트에서 코어키퍼 모작을 했는데, 오늘 배운 실습을 통해 그때의 타일맵 구현보다 업그레이드 된 버전으로 구현할 수 있을 것 같아 글을 쓰게 됐다.
Tile.cs
타일 하나에 대한 정보를 갖는 클래스
Sides 열거형
public enum Sides
{
None = -1,
Top, // 3
Left, // 2
Right, // 1
Bottom, // 0
}
맵이 서로 연결되어있는지 확인하기 위해 방향에 대한 열거형을 선언해준다
이때 방향 정보는 열거형으로 관리할 것이다
예) 1000 이면 Top만 연결, 1100이면 Top, Left가 연결, 1111이면 Top, Left, Right, Bottom 모두 연결 이라는 뜻
필드 & 프로퍼티
public int id;
public Tile[] adjacents = new Tile[4];
public int autoTileId;
public int autoFowTileId;
// 맵이 열려있는지 판단
public bool isVisited = false;
public bool CanMove => autoTileId != (int)TileTypes.Empty;
- id: 각 타일이 가지는 id 정보 (타일마다 id가 다르며 행과 열을 통해 개별적으로 부여)
- adjacents: 현재 타일의 인접 타일들 정보(Top, Left, Right, Bottom)
- autoTileId: 비트 시프트를 통해 스프라이트 배열 안에 적절한 스프라이트를 고르기 위한 Id
- autoFowTileId: 마찬가지로 비트 시프트를 통해 적절한 스프라이트를 고르기 위한 Id
- isVisited: 해당 타일이 방문된 타일인지 판단
- CanMove: 해당 타일이 방문할 수 있는 타일인지 판단
autoTileId는 isVisited가 true인 타일을 보여주는 타일 Id
autoFowTiled는 isVisited가 false인 타일을 보여주는 타일 Id
메서드
public void UpdateAutoFowTileId()
{
autoFowTileId = 0;
for (int i = 0; i < adjacents.Length; i++)
{
if (adjacents[i] == null || !adjacents[i].isVisited)
{
autoFowTileId |= 1 << i;
}
}
}
public void UpdateAutoTileId()
{
autoTileId = 0;
for (int i = 0; i < adjacents.Length; i++)
{
if (adjacents[i] != null)
{
autoTileId |= 1 << i;
}
}
}
public void RemoveAdjacents(Tile tile)
{
for (int i = 0; i < adjacents.Length; i++)
{
if (adjacents[i] == null) continue;
if (adjacents[i].id == tile.id)
{
adjacents[i] = null;
UpdateAutoTileId();
UpdateAutoFowTileId();
break;
}
}
}
public void ClearAdjacents()
{
for (int i = 0; i < adjacents.Length; i++)
{
if (adjacents[i] == null) continue;
adjacents[i].RemoveAdjacents(this);
adjacents[i] = null;
}
UpdateAutoTileId();
UpdateAutoFowTileId();
}
- UpdateAutoFowTileId(): 인접 타일들을 살펴보며 적절한 스프라이트 Id 고르기
- UpdateAutoTileId(): 인접 타일들을 살펴보며 적절한 스프라이트 Id 고르기
- RemoveAdjacent(): 매개변수로 넘겨주는 타일이 인접 타일이라면 그 타일을 지우기
- ClearAdjacents(): 자기 타일을 지울 때 인접 타일의 인접 타일(=자기 타일)을 지우기
Map.cs
Tile 클래스를 기반으로 Tile배열을 만들어 전체적인 맵을 만드는 클래스
TileTypes 열거형
public enum TileTypes
{
Empty = -1,
// 0 ~ 14
Grass = 15,
Tree,
Hill,
Mountain,
Towns,
Castle,
Monster,
}
Empty는 갈 수 없는 Tile이고 0 ~ 15는 맵이 연결된 모양의 개수이다.
Tile에서 autoTile을 계산할때 4비트로 총 16개의 타일 형태를 만들 수 있으므로 0 ~ 15이다
아래 흰 선이 막혀있는 길을 표현한다

이후 16~21은 스프라이트 순서에 맞게 해당 열거형을 선언해주었다.
필드 & 프로퍼티
public int rows = 0;
public int cols = 0;
public Tile[] tiles;
public Tile[] CoastTiles => tiles.Where(t => t.autoTileId >= 0
&& t.autoTileId < (int)TileTypes.Grass).ToArray();
public Tile[] LandTiles => tiles.Where(t => t.autoTileId == (int)TileTypes.Grass).ToArray();
public Tile startTile;
public Tile castleTile;
- rows / cols: 맵의 행, 열 크기
- tiles: 전체 tile의 정보
- CoastTiles: 맵 주변의 해안선 타일 (Linq로 인접 타일에 막힌 부분이 있는 타일 배열을 가져옴)
- LandTiles: 맵 안쪽의 땅바닥 타일 (Linq로 인접 타일이 없는 Grass 타일 배열을 가져옴)
- startTile / castleTile: 플레이어 시작 타일과 성 타일에 대한 정보
처음에 CoastTiles와 LandTiles를 나누는 이유를 몰랐는데
아래 사진의 경우에서 Tree, Hill, Mountain 등 다양한 건물이나 장애물 등을 배치할때 해안선(맵의 끝부분)에 배치하면 자연스럽게 보이기 위해 그 부분마다 해당하는 sprite가 필요하다.(예: 위아래가 막힌 타일에 Tree가 있는 타일과 좌우가 막힌 타일에 Tree가 있는 타일이 따로 존재해야함)
그래서 LandTiles를 따로 구해 사방에 타일이 존재하는 타일에 건물이나 장애물을 배치하는 것이 자연스럽게 구현된다.
아래 사진은 Land 부분에만 건물들이 있는 것을 볼 수 있다.(상하좌우에 타일이 있는 타일 위)

메서드
public void Init(int rows, int cols)
{
this.rows = rows;
this.cols = cols;
tiles = new Tile[rows * cols];
for (int i = 0; i < tiles.Length; i++)
{
tiles[i] = new Tile();
tiles[i].id = i;
}
for (int r = 0; r < rows; r++)
{
for (int c = 0; c < cols; c++)
{
int index = r * cols + c;
var adjacents = tiles[index].adjacents;
if ((r - 1) >= 0)
{
adjacents[(int)Sides.Top] = tiles[index - cols];
}
if ((c + 1) < cols)
{
adjacents[(int)Sides.Right] = tiles[index + 1];
}
if ((c - 1) >= 0)
{
adjacents[(int)Sides.Left] = tiles[index - 1];
}
if ((r + 1) < rows)
{
adjacents[(int)Sides.Bottom] = tiles[index + cols];
}
}
}
for (int i = 0; i < tiles.Length; i++)
{
tiles[i].UpdateAutoTileId();
}
}
public void ShuffleTiles(Tile[] tiles)
{
for (int i = tiles.Length - 1; i > 0; i--)
{
int rand = Random.Range(0, i + 1);
(tiles[rand], tiles[i]) = (tiles[i], tiles[rand]);
}
}
public void DecorateTiles(Tile[] tiles, float percent, TileTypes tileTypes)
{
ShuffleTiles(tiles);
int total = Mathf.FloorToInt(tiles.Length * percent);
// Debug.Log($"{tileTypes}: {total}/{tiles.Length}");
for (int i = 0; i < total; i++)
{
if (tileTypes == TileTypes.Empty)
{
tiles[i].ClearAdjacents();
}
tiles[i].autoTileId = (int)tileTypes;
}
}
public bool CreateIsland(
float erodePercent,
int erodeIterations,
float lakePercent,
float treePercent,
float hillPercent,
float mountainPercent,
float townPercent,
float monsterPercent)
{
for (int i = 0; i < erodeIterations; i++)
{
DecorateTiles(CoastTiles, erodePercent, TileTypes.Empty);
}
DecorateTiles(LandTiles, lakePercent, TileTypes.Empty);
DecorateTiles(LandTiles, treePercent, TileTypes.Tree);
DecorateTiles(LandTiles, hillPercent, TileTypes.Hill);
DecorateTiles(LandTiles, mountainPercent, TileTypes.Mountain);
DecorateTiles(LandTiles, townPercent, TileTypes.Towns);
DecorateTiles(LandTiles, monsterPercent, TileTypes.Monster);
var towns = tiles.Where(x => x.autoTileId == (int)TileTypes.Towns).ToArray();
ShuffleTiles(towns);
startTile = towns[0];
castleTile = towns[1];
castleTile.autoTileId = (int)TileTypes.Castle;
return true;
}
- Init(): rows와 cols를 전달받아 맵 전체를 초기화 하는 메서드(처음엔 상하좌우 끝부분을 제외하고 연결시켜준다)
- ShuffleTiles(): 피셔-예이츠 셔플을 이용하여 타일을 섞는 메서드
- DecorateTiles(): tileTypes를 percent만큼 남아있는 tiles에 할당해주는 함수(Empty면 타일을 지운다)
- CreateIsland(): 실제 맵을 만드는 함수
- Coast에서는 Empty를 통해 타일을 지워 불규칙적인 해안가 모습을 만들어줌
- Land에서는 각 타일의 percent와 tile 종류를 할당해줌
Stage.cs
맵을 어떻게 생성할지 스테이지를 만드는 함수
필드 & 프로퍼티
public GameObject tilePrefabs;
private GameObject[] tileObjs;
public PlayerMovement playerPrefab;
private PlayerMovement player;
public int mapWidth = 20;
public int mapHeight = 20;
private float startX;
private float startY;
[Range(0f, 0.9f)]
public float erodePercent = 0.5f;
public int erodeIterations = 2;
public float lakePercent = 0.01f;
public float treePercent = 0.2f;
public float hillPercent = 0.2f;
public float mountainPercent = 0.1f;
public float townPercent = 0.1f;
public float monsterPercent = 0.1f;
public Vector2 tileSize = new Vector2(16, 16);
public Sprite[] islandSprites;
public Sprite[] isFowSprites;
private Map map;
public Map Map => map;
private int lastHoveredTileId = -1;
- 타일 프리펩, 그것을 담은 오브젝트 배열
- 플레이어 프리펩, 그것을 표현하는 인스턴스
- 맵에 필요한 여러 정보들
- 맵 인스턴스
메서드
private void Start()
{
startX = transform.position.x - mapWidth * tileSize.x * 0.5f + tileSize.x * 0.5f;
startY = transform.position.y + mapHeight * tileSize.y * 0.5f - tileSize.y * 0.5f;
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
ResetStage();
}
}
private void ResetStage()
{
map = new Map();
map.Init(mapHeight, mapWidth);
//map.CreateIsland(coastErodePercent, coastErodeIterations);
map.CreateIsland(erodePercent, erodeIterations, lakePercent,
treePercent, hillPercent, mountainPercent, townPercent, monsterPercent);
CreateGrid();
CreatePlayer(); // 여기서 오픈해주는걸 필요함
}
private void CreateGrid()
{
if (tileObjs != null)
{
foreach (var tile in tileObjs)
{
Destroy(tile.gameObject);
}
}
tileObjs = new GameObject[mapWidth * mapHeight];
var position = new Vector3(startX, startY, 0f);
for (int i = 0; i < mapHeight; i++)
{
for (int j = 0; j < mapWidth; j++)
{
var tileId = i * mapWidth + j;
var newGo = Instantiate(tilePrefabs, transform);
newGo.transform.position = position;
position.x += tileSize.x;
tileObjs[tileId] = newGo;
DecorateTile(tileId);
}
position.x = startX;
position.y -= tileSize.y;
}
}
public void DecorateTile(int tileId)
{
var tile = map.tiles[tileId]; // 데이터
var tileGo = tileObjs[tileId]; // 게임 오브젝트
var ren = tileGo.GetComponent<SpriteRenderer>();
tile.UpdateAutoFowTileId();
// 여기서 분기해서 fow 설정
if (tile.isVisited)
{
if (tile.autoTileId != (int)TileTypes.Empty)
{
ren.sprite = islandSprites[tile.autoTileId];
}
else
{
ren.sprite = null;
}
}
else
{
ren.sprite = isFowSprites[tile.autoFowTileId];
}
}
- Start(): 현재 stage좌표를 기준으로 첫 타일이 생성될 x,y 좌표를 구한다
- Update(): 스페이스바를 누르면 맵이 리셋된다
- ResetStage(): Map을 새로 초기화하여 다시 생성되도록 한다
- CreateGrid(): 이미 tileObj가 있으면 없애버리고 타일들을 배치해주는 메서드
- DecorateTile(): 타일 id를 받아 해당 타일이 어디가 막혀있는지 autoTIleId를 통해 확인 후 해당 sprite로 할당
테스트


스페이스바를 누를때마다 맵이 계속 재생성된다
캐릭터 배치와 방문하지 않은 타일에 대한 처리는 구현은 해놨지만 일단 다음 포스트에 다뤄보겠음..


