#自己紹介
こんにちは、42tokyo Advent Calendar 2021 の9日目を担当する、在校生のtkanzakiです。
マップが "so long" でも動く2Dゲームを作るという課題があったので、Unityで作ってみました。
#やりたいこと
巨大なマップを読み取って一定範囲内のマップを生成する
いっぺんにオブジェクトを生成すると時間がかかってしまうのでプレイヤーの回りだけ生成されるようにする
#実装手順
1.マップをテキストファイルから読み取る
2.マップのサイズに合わせて背景を出す
3.プレイヤーを出す
4.プレイヤーを動かす
5.プレイヤーの一定範囲にゲームオブジェクトを生成する
上の2つの画像を使うのでダウンロードしてください
画像は自作したものなのでフリー素材として使っていただいて大丈夫です
Unityのバージョンは何でもいいです
UnityHubから2Dゲームのテンプレートでプロジェクトを新規作成してください
##1.マップをテキストファイルから読み取る
・MapCreater.cs:マップを生成する
・BlockData.cs:マップの1文字(ブロック)に対応する要素
・BlockCharacter.cs:ブロックの文字の列挙型
・map.txt:マップのテキストファイル
この4つのファイルをAssets下にメニューバーから Assets > Create で作ってください
テキストファイルなどはドラッグアンドドロップすればインポートできます
コピペで使えます
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
public class MapCreater : MonoBehaviour
{
[SerializeField] private string mapFilePath; // マップファイルのパス
private List<List<BlockData>> mapBlocks = new List<List<BlockData>>(); // BlockDataクラスの二次元リスト
void Start()
{
// マップを読み込み
ReadMap(mapFilePath);
}
private void ReadMap(string filePath)
{
try
{
using (var stream = new StreamReader(filePath))
{
InitBlocks(stream);
// マップの情報をコンソールに出力
PutDebugLog();
}
}
catch (System.Exception e)
{
Debug.Log($"Error!{e}");
}
}
// マップデータを読み込んでブロックリストに格納
private void InitBlocks(StreamReader stream)
{
var count = 0;
var line = stream.ReadLine();
while (line != null)
{
mapBlocks.Add(new List<BlockData>());
InitMapLine(line, count);
line = stream.ReadLine();
count++;
}
}
// 一行分の読み込んだマップデータをブロックリストに格納
private void InitMapLine(string line, int index)
{
var count = 0;
foreach (var item in line)
{
var character = (BlockCharacter)item;
var block = new BlockData(character);
mapBlocks[index].Add(block);
count++;
}
}
// コンソール出力
private void PutDebugLog()
{
foreach (var line in mapBlocks)
{
foreach (var block in line)
{
Debug.Log($"block char is {block.GetCharacter()}");
}
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BlockData
{
private BlockCharacter character;
public BlockData(BlockCharacter character)
{
this.character = character;
}
public BlockCharacter GetCharacter()
{
return (character);
}
}
public enum BlockCharacter
{
Empty = '0',
Wall = '1',
Player = 'P'
}
11111
10P01
11111
Hierarchy から Create Empty で MapCreater を作成
AddComponent で MapCreater を追加します
Map File Path にマップファイルのパスを入力します
Unity Editor 上部の実行ボタンを押してしてコンソールを確認します
map.txt と同じ数出力されていれば成功です
##2.マップのサイズに合わせて背景を出す
MapCreater に CreateBG を追加しました
マップを読み込んだ時に縦と横の拡大率が決まります
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
public class MapCreater : MonoBehaviour
{
[SerializeField] private string mapFilePath; // マップファイルのパス
[SerializeField] private GameObject bG; // 背景のゲームオブジェクト
[SerializeField] private int bGImageWidth; // 背景画像の幅
[SerializeField] private int bGImageHeight; // 背景画像の高さ
private float bgScaleX; // 背景画像のXの拡大率
private float bgScaleY; // 背景画像のYの拡大率
private List<List<BlockData>> mapBlocks = new List<List<BlockData>>(); // BlockDataクラスの二次元リスト
private int mapBlocksWidth; // マップ内の横のブロックの数
private int mapBlocksHeight; // マップ内の縦のブロックの数
// Start is called before the first frame update
void Start()
{
// マップを読み込み
ReadMap(mapFilePath);
// 背景を作成
CreateBg();
}
// 省略
// マップデータを読み込んでブロックリストに格納
private void InitBlocks(StreamReader stream)
{
var count = 0;
var line = stream.ReadLine();
while (line != null)
{
mapBlocks.Add(new List<BlockData>());
InitMapLine(line, count);
line = stream.ReadLine();
count++;
}
// マップの幅をセット
this.mapBlocksHeight = count;
// 背景の画像に対する大きさをセット
this.bgScaleY = (float)this.mapBlocksHeight / (float)this.bGImageHeight;
}
// 一行分の読み込んだマップデータをブロックリストに格納
private void InitMapLine(string line, int index)
{
var count = 0;
foreach (var item in line)
{
var character = (BlockCharacter)item;
var block = new BlockData(character);
mapBlocks[index].Add(block);
count++;
}
// マップの幅をセット
this.mapBlocksWidth = count;
// 背景の幅をセット
this.bgScaleX = (float)this.mapBlocksWidth / (float)this.bGImageWidth;
}
private void CreateBg()
{
var bGObject = Instantiate(this.bG);
bGObject.transform.position = new Vector3(0.5f, 1f, 0f);
bGObject.transform.localScale = new Vector3(this.bgScaleX, this.bgScaleY, 1);
}
}
背景画像をAssets下に置いたら、
Pixels Per Unit を 1
Pivot を Top Left
に変更して、Apply してください
Pivot を左上にするのはマップを読み取るのが左上からだからです
Assets から Hierarchy にドラッグアンドドロップするとプレハブ化されるので、プレハブ化した後にAssetsに戻しましょう
MapCreaterにプレハブを割り当てて幅と高さを入力します
##3.プレイヤーを出す
スプライト画像をAssets下に置いたら、
Sprite Mode をMultiple
Pixels Per Unit を 128
に変更して、Apply してください
そして、Sprite Editor を開き、
Type を Grid By Cell Size
Pixel Size を 128 x 128
Pivot を Bottom
で Slice して Apply してください
Sprite Editor を閉じたら、BG と同じ要領で、
sprite_0 を Hierarchy にドラッグアンドドロップしてプレハブ化してください
名前はわかりやすいように Player にリネームしてください
Player の Sprite Renderer の Order in Layer を 1 にして、BG より上に描画するようにします
MapCreater に CreatePlayer を追加しました
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
public class MapCreater : MonoBehaviour
{
[SerializeField] private string mapFilePath; // マップファイルのパス
[SerializeField] private GameObject bG; // 背景のゲームオブジェクト
[SerializeField] private int bGImageWidth; // 背景画像の幅
[SerializeField] private int bGImageHeight; // 背景画像の高さ
private float bgScaleX; // 背景画像のXの拡大率
private float bgScaleY; // 背景画像のYの拡大率
[SerializeField] private GameObject player; // プレイヤーのゲームオブジェクト
private int playerStartPosX; // プレイヤーのXの開始位置
private int playerStartPosY; // プレイヤーのYの開始位置
private List<List<BlockData>> mapBlocks = new List<List<BlockData>>(); // BlockDataクラスの二次元リスト
private int mapBlocksWidth; // マップ内の横のブロックの数
private int mapBlocksHeight; // マップ内の縦のブロックの数
// Start is called before the first frame update
void Start()
{
// マップを読み込み
ReadMap(mapFilePath);
// 背景を作成
CreateBg();
// プレイヤーを作成
CreatePlayer();
}
// 省略
// 一行分の読み込んだマップデータをブロックリストに格納
private void InitMapLine(string line, int index)
{
var count = 0;
foreach (var item in line)
{
var character = (BlockCharacter)item;
var block = new BlockData(character);
mapBlocks[index].Add(block);
count++;
// プレイヤーの開始ポジションをセット
if (character == BlockCharacter.Player)
{
playerStartPosX = count;
playerStartPosY = (-1) * index;
}
}
// マップの幅をセット
this.mapBlocksWidth = count;
// 背景の幅をセット
this.bgScaleX = (float)this.mapBlocksWidth / (float)this.bGImageWidth;
}
// 省略
private void CreatePlayer()
{
var player = Instantiate(this.player);
player.transform.position = new Vector3(this.playerStartPosX, this.playerStartPosY, 1);
}
}
MapCreater に Player を入れる
実行してマップと同じ位置にプレイヤーが出たら成功です
##4.プレイヤーを動かす
まずは、Player を動かすので Player.cs を作り Player に Add Component しましょう
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour
{
[SerializeField] private Animator animator;
[SerializeField] private float moveSpeed;
private MapCreater mapCreater;
void Start()
{
mapCreater = GameObject.FindWithTag("MapCreater").GetComponent<MapCreater>();
}
void Update()
{
// アニメーション切り替え
AnimationChange();
// キャラクター移動
Move();
}
private void Move()
{
var xPos = this.transform.position.x + Input.GetAxis("Horizontal") * Time.deltaTime * moveSpeed;
var yPos = this.transform.position.y + Input.GetAxis("Vertical") * Time.deltaTime * moveSpeed;
transform.position = new Vector3(xPos, yPos, 0f);
}
private void AnimationChange()
{
// 反転
SetDirection();
}
private void SetDirection()
{
var horizon = Input.GetAxis("Horizontal");
var scale = this.transform.localScale;
if (horizon > 0.1f)
{
scale.x = 1;
}
else if (horizon < -0.1f)
{
scale.x = -1;
}
else
{
return;
}
transform.localScale = scale;
}
}
Move Speed を 3 くらいにします
Add Tag から MapCreater というタグを作って MapCreater に付けます
こうすることで Player から FindWithTag で参照できるようになります
Player を Open Prefab してカメラを付けることでプレイヤーに追従してくれます
camera の transform.position.z を -4 にして手前から写るようにします
##5.プレイヤーの一定範囲にオブジェクトを生成する
MapCreater に CreateArround と DeleteArround を追加し、Player から呼び出します
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
public class MapCreater : MonoBehaviour
{
[SerializeField] private string mapFilePath; // マップファイルのパス
[SerializeField] private GameObject bG; // 背景のゲームオブジェクト
[SerializeField] private int bGImageWidth; // 背景画像の幅
[SerializeField] private int bGImageHeight; // 背景画像の高さ
private float bgScaleX; // 背景画像のXの拡大率
private float bgScaleY; // 背景画像のYの拡大率
[SerializeField] private GameObject player; // プレイヤーのゲームオブジェクト
private int playerStartPosX; // プレイヤーのXの開始位置
private int playerStartPosY; // プレイヤーのYの開始位置
private List<List<BlockData>> mapBlocks = new List<List<BlockData>>(); // BlockDataクラスの二次元リスト
private int mapBlocksWidth; // マップ内の横のブロックの数
private int mapBlocksHeight; // マップ内の縦のブロックの数
[SerializeField] private float enableRange; // ブロックの有効範囲
[SerializeField] private float disableRange; // ブロックを無効範囲
[SerializeField] private GameObject wall; // 壁のゲームオブジェクト
// 省略
public void CreateAround(float posX, float posY)
{
posY = Mathf.Abs(posY);
var startX = (posX - this.enableRange) > 0f ? (posX - this.enableRange) : 0f;
var endX = (posX + this.enableRange) < (float)this.mapBlocksWidth ? (posX + this.enableRange) : (float)this.mapBlocksWidth;
var startY = (posY - this.enableRange) > 0f ? (posY - this.enableRange) : 0f;
var endY = (posY + this.enableRange) < (float)this.mapBlocksHeight ? (posY + this.enableRange) : (float)this.mapBlocksHeight;
for (int i = (int)startY; i < (int)endY; i++)
{
for (int j = (int)startX; j < (int)endX; j++)
{
if (mapBlocks[i][j].IsAlreadyRead == true)
{
continue;
}
if (mapBlocks[i][j].GetCharacter() == BlockCharacter.Wall)
{
Debug.Log($"{j}, {i}, {mapBlocks[i][j].GetCharacter()} is created.");
mapBlocks[i][j].GameObject = Instantiate(this.wall);
mapBlocks[i][j].GameObject.transform.position = new Vector3(j + 1, -i, 0);
}
mapBlocks[i][j].IsAlreadyRead = true;
}
}
}
public void DeleteAround(float posX, float posY)
{
posY = Mathf.Abs(posY);
var startX = (posX - this.disableRange) > 0f ? (posX - this.disableRange) : 0f;
var endX = (posX + this.disableRange) < (float)this.mapBlocksWidth ? (posX + this.disableRange) : (float)this.mapBlocksWidth;
var startY = (posY - this.disableRange) > 0f ? (posY - this.disableRange) : 0f;
var endY = (posY + this.disableRange) < (float)this.mapBlocksHeight ? (posY + this.disableRange) : (float)this.mapBlocksHeight;
for (int i = 0; i < this.mapBlocksHeight; i++)
{
for (int j = 0; j < mapBlocksWidth; j++)
{
// 一定範囲内なら削除しない
if ((i >= startY && j >= startX && i <= endY && j <= endX) || mapBlocks[i][j].IsAlreadyRead == false)
{
continue;
}
if (mapBlocks[i][j].GetCharacter() == BlockCharacter.Wall)
{
Debug.Log($"{j}, {i}, {mapBlocks[i][j].GetCharacter()} is deleted.");
Destroy(mapBlocks[i][j].GameObject);
}
mapBlocks[i][j].IsAlreadyRead = false;
}
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class BlockData
{
private BlockCharacter character;
public bool IsAlreadyRead { get; set; }
public GameObject GameObject { get; set; }
public BlockData(BlockCharacter character)
{
this.character = character;
IsAlreadyRead = false;
}
public BlockCharacter GetCharacter()
{
return (character);
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour
{
[SerializeField] private float moveSpeed;
private MapCreater mapCreater;
// 省略
void FixedUpdate()
{
// プレイヤーの一定範囲のマップを読み込む
mapCreater.CreateAround(this.transform.position.x, this.transform.position.y);
// プレイヤーの一定範囲外のマップを消す
mapCreater.DeleteAround(this.transform.position.x, this.transform.position.y);
}
// 省略
}
わかりやすいようにマップを大きくします
111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
1000000P0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101101
100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001
111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
sprite_9 を Hierarchy にドラッグアンドドロップして Wall のプレハブを作ります
Sprite Renderer の Order in Layer も 1 にします
MapCreater の
Enable Range に 10 を入力
Disable Range に 15 を入力
Wall にプレハブを割り当て
プレイヤーにあたり判定を付与します
Rigidbody 2D を Add Component
Gravity Scale を 0
Constrains の Freeze Rotation の Z を有効化
Box Collider 2D を Add Component
壁も同様にあたり判定を付与します
壁は動かないので Freeze Position の X, Y を 有効にします
見やすいように Unity Editor のレイアウトを 2 by 3 に変更して実行すると
うまくいきました!
長すぎると見づらいのでアニメーションは省略しました
プレイヤーにアニメーションを付けるスクリプトはGithubで公開しときます
Animator や Animation は自分で作らないと動かないので調べてみてください
敵や、ゴールを実装するとゲームらしくなるのでよかったら自分なりに拡張してみてください
#終わりに
42ではminilibXというライブラリを使ってC言語で作るのですが、めちゃくちゃ大変でした。
画像ファイルから色データをpixel単位で読み取ったり、キーの状態もシグナルハンドラで制御したりと、低レイヤーなところから自分で実装していくので、普段ゲームエンジンを使ってると意識しないようなことを調べ実装しなくてはなりませんでした。
特に、あたり判定などの物理挙動はUnityだと10秒くらいで実装できるので、「Unityってほんとに偉大だなー。」と改めて認識することができました。
明日は、機械学習エンジニアのKotablogさんが経路探索超入門について書いてくれる予定ですので、そちらの記事もお楽しみに!