本稿は ACCESS Advent Calendar 2024 19日目の記事としてお送りします
はじめに
みなさん、こんにちは。新卒1年目が終わりそうで震えています。とりあえずなんか残すか...となり、アドベントカレンダーに参加することにしました。
今回はPyxelというPython向けのゲームエンジンの紹介記事を見て気になったので、ゲームを作って記事を書いてみました。
ただひたすらに地下深くに潜っていくローグライク......のようなゲームを簡易的に作ります。戦闘などはないです。
一応、チュートリアルというか、私が作ったときの考え方をなぞっていきますが、プログラムの説明をガッツリするわけではありません。Pythonがある程度わかっている人向けです。
Pyxelってなんぞ?
Pyxelは、Python向けのレトロゲームエンジンです。
開発の中に日本人の方がいるため、日本語ドキュメントが充実しているので触りやすいですし、シンプルなのでゲームプログラミング初心者です!と言った方におすすめです。
ゲーム内容
マップ
- ランダムで壁が生成される(今回、BSPなどでマップ生成せずに完全ランダムとします)
- 床の場所にランダムで階段が生成される
プレイヤー
- キー入力を行うと1マス進む
- エネミーに当たるとHPが減る
- HPが0になったらゲームオーバー
エネミー
- プレイヤーが行動を行うとランダムに1マス動く
というわけで、ざっとこんな感じでゲームを作っていきます。
ほかにも罠とか宝箱とかあってもいいかな?と思ったのですが、200行未満で作ると最初に決めていたので、これくらいの規模のゲームにしておきました。(もしこの記事をみて作った人がいたら教えてね!喜びます)
マップをウィンドウに写してみよう
Mapクラスの作成
ウィンドウとマップとタイルのサイズを決める
まず最初に、諸々のサイズを決めていきましょう。適当で大丈夫ですが、これくらいがちょうどよかったです。グローバル変数で設定していきます。
# ウィンドウサイズ
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
# タイルサイズ
TILE_SIZE = 8
# マップサイズ
MAP_WIDTH = 20
MAP_HEIGHT = 15
壁と床を分ける
次に、壁と床を認識できるようにしましょう。
タイルのタイプを床を0、壁を1としてグローバル変数で設定します。これがあると、PlayerやEnemyが壁の上を走らないようにできます。
# タイルタイプ
TILE_FLOOR = 0
TILE_WALL = 1
マップを生成する
とりあえずめんどくさいのでマップ全体を壁、内側を床で埋めていきます。
その後、床の場所にランダムで壁を生成していきましょう。もちろん、外側と内側でちゃんとわけても構いませんよ。
class Map:
def __init__(self):
self.data = self.generate()
# マップ生成関数
def generate(self):
# マップ全体を壁で埋める
map_data = [[TILE_WALL for _ in range(MAP_WIDTH)] for _ in range(MAP_HEIGHT)]
# 内側を床で埋める
for y in range(1, MAP_HEIGHT - 1):
for x in range(1, MAP_WIDTH - 1):
map_data[y][x] = TILE_FLOOR
# ランダムで壁を生成する
for _ in range(40):
x = random.randint(1, MAP_WIDTH - 2)
y = random.randint(1, MAP_HEIGHT - 2)
map_data[y][x] = TILE_WALL
return map_data # 生成されたマップデータを返す
もちろんですが、本当にランダムで生成するのでゲームスタート時点で壁に囲まれ、いしのなかにいる状態で動けなくなることもあります。階段も同様です。あと壁が重複して配置されることもある。
ガバガバだけどまあいいでしょう。そういうこともある。
ローグライクのマップ自動生成は、上記で挙げたBSPなど、いろいろなアルゴリズムがあるので気になった人は調べてみてください。結構おもしろいですよ。
階段を生成する
次に、次のフロアに行くための階段を作りましょう。
床の場所をピックアップして、その場所をランダムで選べばOKです。
class Map:
def __init__(self):
self.data = self.generate()
self.next_floor = self.set_next_floor)
"""
省略
"""
def set_next_floor(self):
floor_tiles = [(x, y) for y in range(MAP_HEIGHT) for x in range(MAP_WIDTH) if self.data[y][x] == TILE_FLOOR]
return random.choice(floor_tiles)
描写する
最後に、ウィンドウへ描写していきます。
pyxelではpyxel.rect()
を使うと矩形の描画ができるようです。便利だ。
class Map:
def __init__(self):
self.data = self.generate()
self.next_floor = self.set_next_floor)
"""
省略
"""
def draw(self):
# 壁と床の描写
for y in range(MAP_HEIGHT):
for x in range(MAP_WIDTH):
color = 7 if self.data[y][x] == TILE_FLOOR else 11
pyxel.rect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
# 階段の描写
pyxel.rect(self.next_floor[0] * TILE_SIZE, self.next_floor[1] * TILE_SIZE, TILE_SIZE, TILE_SIZE, 12)
色はPyxelにあらかじめ用意されている、カラーパレット(全16色)を使用しました。
床は7なので白色、壁は11なので緑色、階段は12なので青色ですね。
Mapクラス全体のコード
class Map:
def __init__(self):
self.data = self.generate()
self.next_floor = self.set_next_floor()
def generate(self):
# マップ全体を壁で埋める
map_data = [[TILE_WALL for _ in range(MAP_WIDTH)] for _ in range(MAP_HEIGHT)]
# 内側の部分を床に変える
for y in range(1, MAP_HEIGHT - 1):
for x in range(1, MAP_WIDTH - 1):
map_data[y][x] = TILE_FLOOR
# ランダムに壁を配置
for _ in range(40):
x = random.randint(1, MAP_WIDTH - 2)
y = random.randint(1, MAP_HEIGHT - 2)
map_data[y][x] = TILE_WALL
return map_data # 生成されたマップデータを返す
def set_next_floor(self):
floor_tiles = [(x, y) for y in range(MAP_HEIGHT) for x in range(MAP_WIDTH) if self.data[y][x] == TILE_FLOOR]
return random.choice(floor_tiles)
def draw(self):
for y in range(MAP_HEIGHT):
for x in range(MAP_WIDTH):
color = 7 if self.data[y][x] == TILE_FLOOR else 11
pyxel.rect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
pyxel.rect(self.next_floor[0] * TILE_SIZE, self.next_floor[1] * TILE_SIZE, TILE_SIZE, TILE_SIZE, 12)
ある程度実装できたので、ここまできたら一回実行して確認してみたいですよね。
次はGameクラスを追加してウィンドウに表示してみましょう。
Gameクラスの作成
Gameクラスではゲーム全体の主な流れを書いていきます。ウィンドウを作成したり、ゲームのメインループを開始したりといった内容です。
まずpyxel.init()
を呼び出してウィンドウの初期化とMapクラスのインスタンスを作成して設定します。
class Game:
def __init__(self):
# ウィンドウの初期化
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="ローグライクゲーム")
# マップの初期化
self.map = Map()
次にメインループを開始しましょう。Pyxelでは、pyxel.run
を呼び出すことでメインループを開始することができます。
update関数とdraw関数を作り、描写します。今は、update関数にはなにも書かなくて大丈夫です。あとでPlayerを操作したり、Enemyを動かしたりするときに使います。
class Game:
def __init__(self):
"""
省略
"""
pyxel.run(self.update, self.draw)
def update(self):
# あとで使うので、今は返すだけ
return
def draw(self):
# 画面を黒色で消去
pyxel.cls(0)
# マップを描写
self.map.draw()
ここまできたら、あとは以下のようにGameクラスを呼び出して実行しましょう。わくわく。
if __name__ == "__main__":
Game()
全体のコード
import pyxel
# ウィンドウサイズ
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
# タイルサイズ
TILE_SIZE = 8
# マップサイズ
MAP_WIDTH = 20
MAP_HEIGHT = 15
# タイルタイプ
TILE_FLOOR = 0
TILE_WALL = 1
class Map:
def __init__(self):
self.data = self.generate()
self.next_floor = self.set_next_floor()
def generate(self):
# マップ全体を壁で埋める
map_data = [[TILE_WALL for _ in range(MAP_WIDTH)] for _ in range(MAP_HEIGHT)]
# 内側の部分を床に変える
for y in range(1, MAP_HEIGHT - 1):
for x in range(1, MAP_WIDTH - 1):
map_data[y][x] = TILE_FLOOR
# ランダムに壁を配置
for _ in range(40):
x = random.randint(1, MAP_WIDTH - 2)
y = random.randint(1, MAP_HEIGHT - 2)
map_data[y][x] = TILE_WALL
return map_data # 生成されたマップデータを返す
def set_next_floor(self):
floor_tiles = [(x, y) for y in range(MAP_HEIGHT) for x in range(MAP_WIDTH) if self.data[y][x] == TILE_FLOOR]
return random.choice(floor_tiles)
def draw(self):
for y in range(MAP_HEIGHT):
for x in range(MAP_WIDTH):
color = 7 if self.data[y][x] == TILE_FLOOR else 11
pyxel.rect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
pyxel.rect(self.next_floor[0] * TILE_SIZE, self.next_floor[1] * TILE_SIZE, TILE_SIZE, TILE_SIZE, 12)
class Game:
def __init__(self):
# ウィンドウの初期化
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="ローグライクゲーム")
# マップの初期化
self.map = Map()
pyxel.run(self.update, self.draw)
def update(self):
# あとで使うので、今は返すだけ
return
def draw(self):
# 画面を黒色で消去
pyxel.cls(0)
# マップを描写
self.map.draw()
if __name__ == "__main__":
Game()
実行すると、こんな感じで出てきます。どうでしょうか?出てきましたか?
出てきたら、とりあえずひと段落ですね。おめでとうございます!
ここまできたらこのマップを自由に動き回りたいですよね。次はPlayerクラスを作りましょう。
プレイヤーをウィンドウに写してみよう
Playerクラスの作成
プレイヤーを操作する
まずPlayerを表示したいな〜となったとき、どこの座標に存在するかを考えますよね。というわけで、Playerのx座標とy座標を定義しちゃいましょう。
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
ここからキーボードの矢印ボタンを使ってx座標とy座標を増やしたり減らしたりすることによって、Playerの動きを表現していきます。それらを定義するmove関数を作成しましょう。
操作する方法は簡単です。右矢印キーを押したらx座標に+1、左矢印キーならx座標に-1、......というようにしていけばいいだけです。キーが押されたかどうかを判定するにはpyxel.btnp()
を使います。
xとyの増加量dx, dyを定義して、キーがおされたらself.x、あるいはself.yをdx, dy分増やしたり減らしたりすればいいです。
つまり、以下のようになります。
class Player:
"""
省略
"""
def move(self):
dx, dy = 0, 0
if pyxel.btnp(pyxel.KEY_UP):
dy = -1
elif pyxel.btnp(pyxel.KEY_DOWN):
dy = 1
elif pyxel.btnp(pyxel.KEY_LEFT):
dx = -1
elif pyxel.btnp(pyxel.KEY_RIGHT):
dx = 1
# プレイヤーが動かしたあとの新しい位置
new_x = self.x + dx
new_y = self.y + dy
ただ、これだけだと不十分ですね。そう、このままだと壁も歩けてしまいます。なので、self.x += dx
とはしませんでした。新しい位置が、床なのかどうかの判定をしてから位置を更新してあげましょう。
class Player:
"""
省略
"""
# 引数にgame_mapを追加してあげる
def move(self, game_map):
dx, dy = 0, 0
if pyxel.btnp(pyxel.KEY_UP):
dy = -1
elif pyxel.btnp(pyxel.KEY_DOWN):
dy = 1
elif pyxel.btnp(pyxel.KEY_LEFT):
dx = -1
elif pyxel.btnp(pyxel.KEY_RIGHT):
dx = 1
# プレイヤーが動かしたあとの新しい位置
new_x = self.x + dx
new_y = self.y + dy
# 新しい位置が床かどうか判定してから更新
if game_map.data[new_y][new_x] == TILE_FLOOR:
self.x = new_x
self.y = new_y
こうすれば、最低限動く動作はできるようになります。次はMapクラスのときと同様、Player自身の描写をしてあげましょう。
描写する
ここは改めて説明する必要はないですよね。Mapクラスと同様、矩形で表現します。今回のカラーは橙色です。
class Player:
"""
省略
"""
def draw(self):
color = 9
pyxel.rect(self.x * TILE_SIZE, self.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
これで描写定義は終わりです。次はまたGameクラスを編集して、Playerを描写してあげましょう!今回はすぐに動かせますよ。
Gameクラスの編集
まず、Mapクラスと同様に、Playerクラスをインスタンス化しましょう。初期位置は(x, y) = (1, 1)とします。
class Game:
def __init__(self):
# ウィンドウの初期化
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="ローグライクゲーム")
# マップの初期化
self.map = Map()
# プレイヤーの初期化
self.player = Player(1, 1)
pyxel.run(self.update, self.draw)
で、次はupdate関数ですね。これは先ほどなにも書きませんでした。ここに書くのはずーっと監視したいものだと考えればいいです。Playerの動き(=キーボード入力)はずっと監視したいですよね。なのでここに突っ込みます。
class Game:
"""
省略
"""
def update(self):
self.player.move(self.map)
return
最後に描写です。Mapを描写したのと同様にPlayerも描写してみましょう。
class Game:
"""
省略
"""
def draw(self):
# 画面を黒色で消去
pyxel.cls(0)
# マップを描写
self.map.draw()
# プレイヤーを描写
self.player.draw()
これでOKです。念の為、全体のコードをまた確認しておきましょう。
全体のコード
import pyxel
import random
# ウィンドウサイズ
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
# タイルサイズ
TILE_SIZE = 8
# マップサイズ
MAP_WIDTH = 20
MAP_HEIGHT = 15
# タイルタイプ
TILE_FLOOR = 0
TILE_WALL = 1
class Map:
def __init__(self):
self.data = self.generate()
self.next_floor = self.set_next_floor()
def generate(self):
# マップ全体を壁で埋める
map_data = [[TILE_WALL for _ in range(MAP_WIDTH)] for _ in range(MAP_HEIGHT)]
# 内側の部分を床に変える
for y in range(1, MAP_HEIGHT - 1):
for x in range(1, MAP_WIDTH - 1):
map_data[y][x] = TILE_FLOOR
# ランダムに壁を配置
for _ in range(40):
x = random.randint(1, MAP_WIDTH - 2)
y = random.randint(1, MAP_HEIGHT - 2)
map_data[y][x] = TILE_WALL
return map_data # 生成されたマップデータを返す
def set_next_floor(self):
floor_tiles = [(x, y) for y in range(MAP_HEIGHT) for x in range(MAP_WIDTH) if self.data[y][x] == TILE_FLOOR]
return random.choice(floor_tiles)
def draw(self):
for y in range(MAP_HEIGHT):
for x in range(MAP_WIDTH):
color = 7 if self.data[y][x] == TILE_FLOOR else 11
pyxel.rect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
pyxel.rect(self.next_floor[0] * TILE_SIZE, self.next_floor[1] * TILE_SIZE, TILE_SIZE, TILE_SIZE, 12)
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
def move(self, game_map):
dx, dy = 0, 0
if pyxel.btnp(pyxel.KEY_UP):
dy = -1
elif pyxel.btnp(pyxel.KEY_DOWN):
dy = 1
elif pyxel.btnp(pyxel.KEY_LEFT):
dx = -1
elif pyxel.btnp(pyxel.KEY_RIGHT):
dx = 1
# プレイヤーが動かしたあとの新しい位置
new_x = self.x + dx
new_y = self.y + dy
# 新しい位置が床かどうか判定してから更新
if game_map.data[new_y][new_x] == TILE_FLOOR:
self.x = new_x
self.y = new_y
def draw(self):
color = 9
pyxel.rect(self.x * TILE_SIZE, self.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
class Game:
def __init__(self):
# ウィンドウの初期化
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="ローグライクゲーム")
# マップの初期化
self.map = Map()
# プレイヤーの初期化
self.player = Player(1, 1)
pyxel.run(self.update, self.draw)
def update(self):
self.player.move(self.map)
return
def draw(self):
# 画面を黒色で消去
pyxel.cls(0)
# マップを描写
self.map.draw()
# プレイヤーを描写
self.player.draw()
if __name__ == "__main__":
Game()
これで実行してみましょう。Playerが表示されて、キーボードで動かせるはずです。お疲れ様でした。
敵をウィンドウに写してみよう
Enemyクラスの作成
それでは、次にEnemyクラスを作成しましょう。EnemyクラスもPlayerクラスと同様に考えれば楽です。x座標とy座標を定義していきます。
class Enemy:
def __init__(self, x, y):
self.x = x
self.y = y
では次に、Enemyに自由に動いてもらいましょう。
動かす
ここもPlayerと同様に考えてくれれば大丈夫です。xとyの増加量dx, dyを考えて、今いる場所から増やしたり減らしたりすれば問題ないです。問題はランダムに動くという点ですね。これはPythonのrandom.choice()
を使えば簡単にできます。
class Enemy:
"""
省略
"""
def move(self, game_map):
# ランダムにエネミーを動かす
dx, dy = random.choice([(0, -1), (0, 1), (-1, 0), (1, 0)])
new_x, new_y = self.x + dx, self.y + dy
if game_map.data[new_y][new_x] == TILE_FLOOR:
self.x = new_x
self.y = new_y
描写する
こちらもPlayerクラスとほぼ同様に書けますね。注意点は色をPlayerと被らないようにすることくらいでしょうか(わからなくなっちゃうからね......)。
class Enemy:
"""
省略
"""
def draw(self):
color = 8
pyxel.rect(self.x * TILE_SIZE, self.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
次は今までの流れと同様にGameクラスを編集していきます。Enemyはここがちょっと複雑になるかもしれません。
Gameクラスの編集
まずEnemyは複数作りたいですよね。とりあえず5体作るとしましょう。self.enemy
......ではなくself.enemies
で初期化していきます。(x, y)でつくるのではなく[(x1, y1), (x2, y2)...]と作っていきましょう。
class Game:
def __init__(self):
# ウィンドウの初期化
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="ローグライクゲーム")
# マップの初期化
self.map = Map()
# プレイヤーの初期化
self.player = Player(1, 1)
# エネミーの初期化
self.enemies = [Enemy(random.randint(1, MAP_WIDTH - 2), random.randint(1, MAP_HEIGHT - 2)) for _ in range(5)]
pyxel.run(self.update, self.draw)
次に、Enemyのupdateの部分を作りたいわけですが、そのままenemy.move(self.map)
を突っ込むだけではダメです。やってみればわかりますが、爆速で移動します。そら1フレームごとに動くんだからそりゃそうよ。
じゃあどうすればいいんだよ、と思った人は最初のゲームの内容のところに戻りましょう。プレイヤーが行動を行うとランダムに1マス動く、とかいてますね。これは言い換えると、キーボード入力があったら1マス動く、ということと同じ意味です。なので、キーボード入力があるかどうかを判定してから動かすようにしましょう。
class Game:
"""
省略
"""
# 矢印キーのいずれかが押されているか確認
def is_player_moving(self):
return any(pyxel.btnp(key) for key in [pyxel.KEY_UP, pyxel.KEY_DOWN, pyxel.KEY_LEFT, pyxel.KEY_RIGHT])
def update_enemies(self):
for enemy in self.enemies:
enemy.move(self.map)
def update(self):
if self.is_player_moving():
self.player.move(self.map)
self.update_enemies()
このあとは、同様にエネミーを描写してあげましょう。
class Game:
"""
省略
"""
def draw(self):
# 画面を黒色で消去
pyxel.cls(0)
# マップを描写
self.map.draw()
# プレイヤーを描写
self.player.draw()
# エネミーを描写
for enemy in self.enemies:
enemy.draw()
全体のコード
import pyxel
import random
# ウィンドウサイズ
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
# タイルサイズ
TILE_SIZE = 8
# マップサイズ
MAP_WIDTH = 20
MAP_HEIGHT = 15
# タイルタイプ
TILE_FLOOR = 0
TILE_WALL = 1
class Map:
def __init__(self):
self.data = self.generate()
self.next_floor = self.set_next_floor()
def generate(self):
# マップ全体を壁で埋める
map_data = [[TILE_WALL for _ in range(MAP_WIDTH)] for _ in range(MAP_HEIGHT)]
# 内側の部分を床に変える
for y in range(1, MAP_HEIGHT - 1):
for x in range(1, MAP_WIDTH - 1):
map_data[y][x] = TILE_FLOOR
# ランダムに壁を配置
for _ in range(40):
x = random.randint(1, MAP_WIDTH - 2)
y = random.randint(1, MAP_HEIGHT - 2)
map_data[y][x] = TILE_WALL
return map_data # 生成されたマップデータを返す
def set_next_floor(self):
floor_tiles = [(x, y) for y in range(MAP_HEIGHT) for x in range(MAP_WIDTH) if self.data[y][x] == TILE_FLOOR]
return random.choice(floor_tiles)
def draw(self):
for y in range(MAP_HEIGHT):
for x in range(MAP_WIDTH):
color = 7 if self.data[y][x] == TILE_FLOOR else 11
pyxel.rect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
pyxel.rect(self.next_floor[0] * TILE_SIZE, self.next_floor[1] * TILE_SIZE, TILE_SIZE, TILE_SIZE, 12)
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
def move(self, game_map):
dx, dy = 0, 0
if pyxel.btnp(pyxel.KEY_UP):
dy = -1
elif pyxel.btnp(pyxel.KEY_DOWN):
dy = 1
elif pyxel.btnp(pyxel.KEY_LEFT):
dx = -1
elif pyxel.btnp(pyxel.KEY_RIGHT):
dx = 1
# プレイヤーが動かしたあとの新しい位置
new_x = self.x + dx
new_y = self.y + dy
# 新しい位置が床かどうか判定してから更新
if game_map.data[new_y][new_x] == TILE_FLOOR:
self.x = new_x
self.y = new_y
def draw(self):
color = 9
pyxel.rect(self.x * TILE_SIZE, self.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
class Enemy:
def __init__(self, x, y):
self.x = x
self.y = y
def move(self, game_map):
# ランダムにエネミーを動かす
dx, dy = random.choice([(0, -1), (0, 1), (-1, 0), (1, 0)])
new_x, new_y = self.x + dx, self.y + dy
if game_map.data[new_y][new_x] == TILE_FLOOR:
self.x = new_x
self.y = new_y
def draw(self):
color = 8
pyxel.rect(self.x * TILE_SIZE, self.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
class Game:
def __init__(self):
# ウィンドウの初期化
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="ローグライクゲーム")
# マップの初期化
self.map = Map()
# プレイヤーの初期化
self.player = Player(1, 1)
# エネミーの初期化
self.enemies = [Enemy(random.randint(1, MAP_WIDTH - 2), random.randint(1, MAP_HEIGHT - 2)) for _ in range(5)]
pyxel.run(self.update, self.draw)
# 矢印キーのいずれかが押されているか確認
def is_player_moving(self):
return any(pyxel.btnp(key) for key in [pyxel.KEY_UP, pyxel.KEY_DOWN, pyxel.KEY_LEFT, pyxel.KEY_RIGHT])
def update_enemies(self):
for enemy in self.enemies:
enemy.move(self.map)
def update(self):
if self.is_player_moving():
self.player.move(self.map)
self.update_enemies()
def draw(self):
# 画面を黒色で消去
pyxel.cls(0)
# マップを描写
self.map.draw()
# プレイヤーを描写
self.player.draw()
# エネミーを描写
for enemy in self.enemies:
enemy.draw()
if __name__ == "__main__":
Game()
これを実行すると、以下のようになります。キーボードを入力するとPlayerが動き、それと同時にEnemyがランダムに動くはずです。一気にゲームっぽくなりましたね。お疲れ様でした。
エネミーに当たったらHPを減らしてみよう
次は、プレイヤーのHPを設定して、エネミーに当たったらHPが減る処理を作りましょう。ついでにHPをUIに表示することと、ゲームスタート画面とゲームオーバー画面への画面遷移もやってみます。
Playerクラスの編集
まず最初に、プレイヤーのHPをPlayerクラスに設定していきます。今回はHPを5にします。
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
self.hp = 5
ダメージをくらう処理
次に、エネミーに当たったらHPを減らす処理を作りましょう。これは簡単で、プレイヤーの位置 = エネミーの位置のときにHPを減らしていけばOKです。
class Player:
"""
省略
"""
def damage(self, enemy):
if (self.x, self.y) == (enemy.x, enemy.y):
self.hp -= 1
あとはこの関数をGameクラスで呼び出せばOKです。簡単ですね。
class Game:
"""
省略
"""
def update(self):
if self.is_player_moving():
self.player.move(self.map)
self.update_enemies()
for enemy in self.enemies:
self.player.damage(enemy)
ただ、これだけだと今の自分のHPがわからないので、UIとして表示していきましょう。
今のHPを表示する
HPを表示するdraw_ui関数を作って、draw関数で呼び出せばOKです。場所は好きなところにどうぞ。
class Game:
"""
省略
"""
def draw_ui(self):
pyxel.text(1, 1, f"HP: {self.player.hp}", 7)
def draw(self):
# 画面を黒色で消去
pyxel.cls(0)
# マップを描写
self.map.draw()
# プレイヤーを描写
self.player.draw()
# エネミーを描写
for enemy in self.enemies:
enemy.draw()
self.draw_ui()
実行すると、左上にHPが表示されて、敵に当たるとHPが減ると思います。
ただ、敵に当たり続けてたらわかると思うのですがこのままだとマイナスまでいってしまいますね。なので、0になったらゲームオーバーの画面へ遷移するようにしましょう。ついでにゲームタイトル画面もつくります。
画面遷移をしてみよう
プレイ画面タイプの定義
まず最初に、プレイ画面タイプを設定していきましょう。今回はゲームタイトル画面、ゲームプレイ画面、ゲームオーバー画面も3つが必要となります。グローバル変数に以下のように設定していきましょう。
# プレイ画面タイプ
SCENE_GAME_TITLE = 0
SCENE_PLAY = 1
SCENE_GAME_OVER = 2
Gameクラスの編集
次に、最初の画面を設定していきましょう。最初にSCENE_GAME_TITLW
を設定します。
class Game:
def __init__(self):
# ウィンドウの初期化
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="ローグライクゲーム")
# 最初のゲーム画面
self.scene = SCENE_GAME_TITLE
# マップの初期化
self.map = Map()
# プレイヤーの初期化
self.player = Player(1, 1)
# エネミーの初期化
self.enemies = [Enemy(random.randint(1, MAP_WIDTH - 2), random.randint(1, MAP_HEIGHT - 2)) for _ in range(5)]
pyxel.run(self.update, self.draw)
次に、それぞれのゲームシーンでupdate関数を設定していきます。今のupdate関数では、ゲームプレイ画面をそのままベタ書きでしたが、以下のように分けていきます。ついでにHPが0以下になったらゲームオーバー画面へと遷移するようにします。
class Game:
"""
省略
"""
# ゲームタイトル画面のupdate関数
def update_game_title_scene(self):
# スペースを押したらゲームプレイ画面へ
if pyxel.btnp(pyxel.KEY_SPACE):
self.scene = SCENE_PLAY
return
# ゲームプレイ画面のupdate関数
def update_play_scene(self):
if self.is_player_moving():
# HPが0以下になったらゲームオーバー画面へ
if self.player.hp <= 0:
self.scene = SCENE_GAME_OVER
return
self.player.move(self.map)
self.update_enemies()
for enemy in self.enemies:
self.player.damage(enemy)
# ゲームオーバー画面のupdate関数
def update_game_over_scene(self):
# スペースを押したらゲームタイトル画面へ
if pyxel.btnp(pyxel.KEY_SPACE):
self.scene = SCENE_GAME_TITLE
# シーンによってupdate関数を切り替える
def update(self):
if self.scene == SCENE_GAME_TITLE:
self.update_game_title_scene()
elif self.scene == SCENE_PLAY:
self.update_play_scene()
elif self.scene == SCENE_GAME_OVER:
self.update_game_over_scene()
先ほど設定したself.scene
で場合分けをして、シーンそれぞれのupdate関数を実行していきましょう。こうすると、画面遷移ができます。
また、draw関数も修正が必要ですね。draw関数も3つの場合に分けていきます。ゲームオーバーになったらゲームをリセットするために、一部を切り出して新しくreset_game関数も作成します。
class Game:
def __init__(self):
# ウィンドウの初期化
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="ローグライクゲーム")
self.reset_game()
pyxel.run(self.update, self.draw)
# ゲームリセット
def reset_game(self):
# 最初のゲーム画面
self.scene = SCENE_GAME_TITLE
# マップの初期化
self.map = Map()
# プレイヤーの初期化
self.player = Player(1, 1)
# エネミーの初期化
self.enemies = [Enemy(random.randint(1, MAP_WIDTH - 2), random.randint(1, MAP_HEIGHT - 2)) for _ in range(5)]
"""
省略
"""
# ゲームタイトル画面のdraw関数
def draw_title_scene(self):
pyxel.text(SCREEN_WIDTH // 2 - 20, SCREEN_HEIGHT // 2 - 10, "GAME START", pyxel.COLOR_LIGHT_BLUE)
pyxel.text(SCREEN_WIDTH // 2 - 35, SCREEN_HEIGHT // 2 + 10, "Press SPACE to Pray", pyxel.COLOR_WHITE)
# ゲームプレイ画面のupdate関数
def draw_play_scene(self):
self.map.draw()
self.player.draw()
for enemy in self.enemies:
enemy.draw()
self.draw_ui()
# ゲームオーバー画面のupdate関数
def draw_game_over_scene(self):
pyxel.text(SCREEN_WIDTH // 2 - 20, SCREEN_HEIGHT // 2 - 10, "GAME OVER", pyxel.COLOR_RED)
pyxel.text(SCREEN_WIDTH // 2 - 40, SCREEN_HEIGHT // 2 + 10, "Press SPACE to Retry", pyxel.COLOR_WHITE)
# シーンによってdraw関数を切り替える
def draw(self):
# 画面を黒色で消去
pyxel.cls(0)
if self.scene == SCENE_GAME_TITLE:
self.draw_title_scene()
elif self.scene == SCENE_PLAY:
self.draw_play_scene()
elif self.scene == SCENE_GAME_OVER:
self.draw_game_over_scene()
全体のコード
import pyxel
import random
# ウィンドウサイズ
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
# タイルサイズ
TILE_SIZE = 8
# マップサイズ
MAP_WIDTH = 20
MAP_HEIGHT = 15
# タイルタイプ
TILE_FLOOR = 0
TILE_WALL = 1
# プレイ画面タイプ
SCENE_GAME_TITLE = 0
SCENE_PLAY = 1
SCENE_GAME_OVER = 2
class Map:
def __init__(self):
self.data = self.generate()
self.next_floor = self.set_next_floor()
def generate(self):
# マップ全体を壁で埋める
map_data = [[TILE_WALL for _ in range(MAP_WIDTH)] for _ in range(MAP_HEIGHT)]
# 内側の部分を床に変える
for y in range(1, MAP_HEIGHT - 1):
for x in range(1, MAP_WIDTH - 1):
map_data[y][x] = TILE_FLOOR
# ランダムに壁を配置
for _ in range(40):
x = random.randint(1, MAP_WIDTH - 2)
y = random.randint(1, MAP_HEIGHT - 2)
map_data[y][x] = TILE_WALL
return map_data # 生成されたマップデータを返す
def set_next_floor(self):
floor_tiles = [(x, y) for y in range(MAP_HEIGHT) for x in range(MAP_WIDTH) if self.data[y][x] == TILE_FLOOR]
return random.choice(floor_tiles)
def draw(self):
for y in range(MAP_HEIGHT):
for x in range(MAP_WIDTH):
color = 7 if self.data[y][x] == TILE_FLOOR else 11
pyxel.rect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
pyxel.rect(self.next_floor[0] * TILE_SIZE, self.next_floor[1] * TILE_SIZE, TILE_SIZE, TILE_SIZE, 12)
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
self.hp = 5
def move(self, game_map):
dx, dy = 0, 0
if pyxel.btnp(pyxel.KEY_UP):
dy = -1
elif pyxel.btnp(pyxel.KEY_DOWN):
dy = 1
elif pyxel.btnp(pyxel.KEY_LEFT):
dx = -1
elif pyxel.btnp(pyxel.KEY_RIGHT):
dx = 1
# プレイヤーが動かしたあとの新しい位置
new_x = self.x + dx
new_y = self.y + dy
# 新しい位置が床かどうか判定してから更新
if game_map.data[new_y][new_x] == TILE_FLOOR:
self.x = new_x
self.y = new_y
def damage(self, enemy):
if (self.x, self.y) == (enemy.x, enemy.y):
self.hp -= 1
def draw(self):
color = 9
pyxel.rect(self.x * TILE_SIZE, self.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
class Enemy:
def __init__(self, x, y):
self.x = x
self.y = y
def move(self, game_map):
# ランダムにエネミーを動かす
dx, dy = random.choice([(0, -1), (0, 1), (-1, 0), (1, 0)])
new_x, new_y = self.x + dx, self.y + dy
if game_map.data[new_y][new_x] == TILE_FLOOR:
self.x = new_x
self.y = new_y
def draw(self):
color = 8
pyxel.rect(self.x * TILE_SIZE, self.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
class Game:
def __init__(self):
# ウィンドウの初期化
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="ローグライクゲーム")
self.reset_game()
pyxel.run(self.update, self.draw)
def reset_game(self):
# 最初のゲーム画面
self.scene = SCENE_GAME_TITLE
# マップの初期化
self.map = Map()
# プレイヤーの初期化
self.player = Player(1, 1)
# エネミーの初期化
self.enemies = [Enemy(random.randint(1, MAP_WIDTH - 2), random.randint(1, MAP_HEIGHT - 2)) for _ in range(5)]
# 矢印キーのいずれかが押されているか確認
def is_player_moving(self):
return any(pyxel.btnp(key) for key in [pyxel.KEY_UP, pyxel.KEY_DOWN, pyxel.KEY_LEFT, pyxel.KEY_RIGHT])
# エネミーのupdate関数
def update_enemies(self):
for enemy in self.enemies:
enemy.move(self.map)
# ゲームタイトル画面のupdate関数
def update_game_title_scene(self):
# スペースを押したらゲームプレイ画面へ
if pyxel.btnp(pyxel.KEY_SPACE):
self.scene = SCENE_PLAY
# ゲームプレイ画面のupdate関数
def update_play_scene(self):
if self.is_player_moving():
# HPが0以下になったらゲームオーバー画面へ
if self.player.hp <= 0:
self.scene = SCENE_GAME_OVER
return
self.player.move(self.map)
self.update_enemies()
for enemy in self.enemies:
self.player.damage(enemy)
# ゲームオーバー画面のupdate関数
def update_game_over_scene(self):
# スペースを押したらゲームタイトル画面へ
if pyxel.btnp(pyxel.KEY_SPACE):
self.reset_game()
# シーンによってupdate関数を切り替える
def update(self):
if self.scene == SCENE_GAME_TITLE:
self.update_game_title_scene()
elif self.scene == SCENE_PLAY:
self.update_play_scene()
elif self.scene == SCENE_GAME_OVER:
self.update_game_over_scene()
def draw_ui(self):
pyxel.text(1, 1, f"HP: {self.player.hp}", 7)
# ゲームタイトル画面のdraw関数
def draw_title_scene(self):
pyxel.text(SCREEN_WIDTH // 2 - 20, SCREEN_HEIGHT // 2 - 10, "GAME START", pyxel.COLOR_LIGHT_BLUE)
pyxel.text(SCREEN_WIDTH // 2 - 35, SCREEN_HEIGHT // 2 + 10, "Press SPACE to Pray", pyxel.COLOR_WHITE)
# ゲームプレイ画面のupdate関数
def draw_play_scene(self):
# マップを描写
self.map.draw()
# プレイヤーを描写
self.player.draw()
# エネミーを描写
for enemy in self.enemies:
enemy.draw()
# UIを描写
self.draw_ui()
# ゲームオーバー画面のupdate関数
def draw_game_over_scene(self):
pyxel.text(SCREEN_WIDTH // 2 - 20, SCREEN_HEIGHT // 2 - 10, "GAME OVER", pyxel.COLOR_RED)
pyxel.text(SCREEN_WIDTH // 2 - 40, SCREEN_HEIGHT // 2 + 10, "Press SPACE to Retry", pyxel.COLOR_WHITE)
# シーンによってdraw関数を切り替える
def draw(self):
# 画面を黒色で消去
pyxel.cls(0)
if self.scene == SCENE_GAME_TITLE:
self.draw_title_scene()
elif self.scene == SCENE_PLAY:
self.draw_play_scene()
elif self.scene == SCENE_GAME_OVER:
self.draw_game_over_scene()
if __name__ == "__main__":
Game()
実行してみたら以下のようにゲーム画面が順番に切り替わりますか?とてもゲームらしくなってきましたね。お疲れ様でした。
次のフロアに行ってみよう
とうとう最後は、プレイヤーが階段の場所を踏んだら次のフロアに行く処理と、今どのフロアにいるかわかるようにUIを作ってみましょう。そのままGameクラスを編集します。
Gameクラスの編集
まず、最初にフロアの初期値を決めましょう。とりあえず1で。
class Game:
"""
省略
"""
def reset_game(self):
# 最初のゲーム画面
self.scene = SCENE_GAME_TITLE
# マップの初期化
self.map = Map()
# プレイヤーの初期化
self.player = Player(1, 1)
# エネミーの初期化
self.enemies = [Enemy(random.randint(1, MAP_WIDTH - 2), random.randint(1, MAP_HEIGHT - 2)) for _ in range(5)]
# フロアの初期化
self.floor = 1
次に、プレイヤーが階段を踏んでいるかをチェックしていきましょうか。これは何度もやった処理ですよね。階段のx座標とy座標がプレイヤーのx座標とy座標と一致しているかチェックしていきます。その後、回数を増やして、マップを再生成、プレイヤーの位置を適当に決めます。今回のスタートは(1, 1)で固定にします。そして作ったcheck_next_floor関数をゲームプレイのupdate関数に追加しましょう。
class Game:
"""
省略
"""
def update_play_scene(self):
if self.is_player_moving():
# HPが0以下になったらゲームオーバー画面へ
if self.player.hp <= 0:
self.scene = SCENE_GAME_OVER
return
self.player.move(self.map)
self.update_enemies()
self.check_next_floor()
for enemy in self.enemies:
self.player.damage(enemy)
def check_next_floor(self):
if (self.player.x, self.player.y) == self.map.next_floor:
self.floor += 1
self.map = Map()
self.player.x, self.player.y = 1, 1
また、今何回かを知りたいですよね。これもHPの時と同様、draw_ui関数に書いていきましょう。
class Game:
"""
省略
"""
def draw_ui(self):
pyxel.text(1, 1, f"HP: {self.player.hp}", 7)
pyxel.text(30, 1, f"B{self.floor}F", 7)
##全体のコード
import pyxel
import random
# ウィンドウサイズ
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
# タイルサイズ
TILE_SIZE = 8
# マップサイズ
MAP_WIDTH = 20
MAP_HEIGHT = 15
# タイルタイプ
TILE_FLOOR = 0
TILE_WALL = 1
# プレイ画面タイプ
SCENE_GAME_TITLE = 0
SCENE_PLAY = 1
SCENE_GAME_OVER = 2
class Map:
def __init__(self):
self.data = self.generate()
self.next_floor = self.set_next_floor()
def generate(self):
# マップ全体を壁で埋める
map_data = [[TILE_WALL for _ in range(MAP_WIDTH)] for _ in range(MAP_HEIGHT)]
# 内側の部分を床に変える
for y in range(1, MAP_HEIGHT - 1):
for x in range(1, MAP_WIDTH - 1):
map_data[y][x] = TILE_FLOOR
# ランダムに壁を配置
for _ in range(40):
x = random.randint(1, MAP_WIDTH - 2)
y = random.randint(1, MAP_HEIGHT - 2)
map_data[y][x] = TILE_WALL
return map_data # 生成されたマップデータを返す
def set_next_floor(self):
floor_tiles = [(x, y) for y in range(MAP_HEIGHT) for x in range(MAP_WIDTH) if self.data[y][x] == TILE_FLOOR]
return random.choice(floor_tiles)
def draw(self):
for y in range(MAP_HEIGHT):
for x in range(MAP_WIDTH):
color = 7 if self.data[y][x] == TILE_FLOOR else 11
pyxel.rect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
pyxel.rect(self.next_floor[0] * TILE_SIZE, self.next_floor[1] * TILE_SIZE, TILE_SIZE, TILE_SIZE, 12)
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
self.hp = 5
def move(self, game_map):
dx, dy = 0, 0
if pyxel.btnp(pyxel.KEY_UP):
dy = -1
elif pyxel.btnp(pyxel.KEY_DOWN):
dy = 1
elif pyxel.btnp(pyxel.KEY_LEFT):
dx = -1
elif pyxel.btnp(pyxel.KEY_RIGHT):
dx = 1
# プレイヤーが動かしたあとの新しい位置
new_x = self.x + dx
new_y = self.y + dy
# 新しい位置が床かどうか判定してから更新
if game_map.data[new_y][new_x] == TILE_FLOOR:
self.x = new_x
self.y = new_y
def damage(self, enemy):
if (self.x, self.y) == (enemy.x, enemy.y):
self.hp -= 1
def draw(self):
color = 9
pyxel.rect(self.x * TILE_SIZE, self.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
class Enemy:
def __init__(self, x, y):
self.x = x
self.y = y
def move(self, game_map):
# ランダムにエネミーを動かす
dx, dy = random.choice([(0, -1), (0, 1), (-1, 0), (1, 0)])
new_x, new_y = self.x + dx, self.y + dy
if game_map.data[new_y][new_x] == TILE_FLOOR:
self.x = new_x
self.y = new_y
def draw(self):
color = 8
pyxel.rect(self.x * TILE_SIZE, self.y * TILE_SIZE, TILE_SIZE, TILE_SIZE, color)
class Game:
def __init__(self):
# ウィンドウの初期化
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="ローグライクゲーム")
self.reset_game()
pyxel.run(self.update, self.draw)
def reset_game(self):
# 最初のゲーム画面
self.scene = SCENE_GAME_TITLE
# マップの初期化
self.map = Map()
# プレイヤーの初期化
self.player = Player(1, 1)
# エネミーの初期化
self.enemies = [Enemy(random.randint(1, MAP_WIDTH - 2), random.randint(1, MAP_HEIGHT - 2)) for _ in range(5)]
# フロアの初期化
self.floor = 1
# 矢印キーのいずれかが押されているか確認
def is_player_moving(self):
return any(pyxel.btnp(key) for key in [pyxel.KEY_UP, pyxel.KEY_DOWN, pyxel.KEY_LEFT, pyxel.KEY_RIGHT])
# エネミーのupdate関数
def update_enemies(self):
for enemy in self.enemies:
enemy.move(self.map)
# ゲームタイトル画面のupdate関数
def update_game_title_scene(self):
# スペースを押したらゲームプレイ画面へ
if pyxel.btnp(pyxel.KEY_SPACE):
self.scene = SCENE_PLAY
# ゲームプレイ画面のupdate関数
def update_play_scene(self):
if self.is_player_moving():
# HPが0以下になったらゲームオーバー画面へ
if self.player.hp <= 0:
self.scene = SCENE_GAME_OVER
return
self.player.move(self.map)
self.update_enemies()
for enemy in self.enemies:
self.player.damage(enemy)
# ゲームオーバー画面のupdate関数
def update_game_over_scene(self):
# スペースを押したらゲームタイトル画面へ
if pyxel.btnp(pyxel.KEY_SPACE):
self.reset_game()
# シーンによってupdate関数を切り替える
def update(self):
if self.scene == SCENE_GAME_TITLE:
self.update_game_title_scene()
elif self.scene == SCENE_PLAY:
self.update_play_scene()
elif self.scene == SCENE_GAME_OVER:
self.update_game_over_scene()
def check_next_floor(self):
if (self.player.x, self.player.y) == self.map.next_floor:
self.floor += 1
self.map = Map()
self.player.x, self.player.y = 1, 1
def draw_ui(self):
pyxel.text(1, 1, f"HP: {self.player.hp}", 7)
pyxel.text(30, 1, f"B{self.floor}F", 7)
# ゲームタイトル画面のdraw関数
def draw_title_scene(self):
pyxel.text(SCREEN_WIDTH // 2 - 20, SCREEN_HEIGHT // 2 - 10, "GAME START", pyxel.COLOR_LIGHT_BLUE)
pyxel.text(SCREEN_WIDTH // 2 - 35, SCREEN_HEIGHT // 2 + 10, "Press SPACE to Pray", pyxel.COLOR_WHITE)
# ゲームプレイ画面のupdate関数
def draw_play_scene(self):
# マップを描写
self.map.draw()
# プレイヤーを描写
self.player.draw()
# エネミーを描写
for enemy in self.enemies:
enemy.draw()
# UIを描写
self.draw_ui()
# ゲームオーバー画面のupdate関数
def draw_game_over_scene(self):
pyxel.text(SCREEN_WIDTH // 2 - 20, SCREEN_HEIGHT // 2 - 10, "GAME OVER", pyxel.COLOR_RED)
pyxel.text(SCREEN_WIDTH // 2 - 40, SCREEN_HEIGHT // 2 + 10, "Press SPACE to Retry", pyxel.COLOR_WHITE)
# シーンによってdraw関数を切り替える
def draw(self):
# 画面を黒色で消去
pyxel.cls(0)
if self.scene == SCENE_GAME_TITLE:
self.draw_title_scene()
elif self.scene == SCENE_PLAY:
self.draw_play_scene()
elif self.scene == SCENE_GAME_OVER:
self.draw_game_over_scene()
if __name__ == "__main__":
Game()
これで実行してみて、階段を踏んでみましょう!
B2Fに行って、マップが再生成されるはずです。
これで延々と地下深くに潜ることができます! やったね。
もちろんですが、自分でこの階まで行けたらゴール画面に遷移...とかしてもいいですよ!自分ですきに弄ってください。
おわりに
今回は、Pyxelを使用して200行未満でローグライクっぽいゲームを作ってみました。少ない行数でこういったゲームが作れる時代になったんだなあと感無量です。ウィンドウを表示するだけで100行以上書く時代は終わったんだ。
私も今回初めてPyxelを使用しましたが、かなり使いやすいと思います!おすすめです!製作者が日本人なのも嬉しいポイントですね。
UnityやUnrealEngineみたいなリッチなゲームエンジンを使うのもいいですが、たまにはこういったレトロなゲームを作ってみるのはどうでしょうか?
それではさよなら〜