紹介
Pythonのことは何も知らない人です。
今回の記事はPythonという言語に対してこうなのかななどの無駄な感想も適宜入っています。間違えて解釈してしまっている箇所もあるかと思いますのでご容赦ください…。
対象
-
Pythonのことが分からない人
-
pyxelを触ったことがないので概要を掴みたい人
なんでをゲーム作りたい?
少し前にPyxelがQiitaで盛り上がっていたのを見て、一度はゲームを作ってみたい気持ちになってしまった
Pythonについてどのくらいの知識か
- AIとか機械学習にいいらしい
- 一番人気ありそうなイメージ
- 図書館に行くとPythonの本は大体貸出中
- 型ないらしい(型ヒント的なのはあるらしい?)
→ 無知
完成品
有識者の方々に本記事をお読みいただくのは心苦しいため完成品だけでもご覧ください
- 移 動:十字キー
- ジャンプ:SPACE
早速ドキュメントを読んでみる
これを見ても最初はよくわからなかったのでハンズオンでとりあえず触ってみる。(初心者が躓かずにハンズオンできるので分かりやすかったです)
何をベースにクリスマスっぽいゲームを作るのかを決めてみる
READMEに様々な作例が載っていましたが、私が人生で初めて遊んだゲームはスーパーマリオなので横スクロールアクション的なのをベースとして作りたいと思います。
READMEに書いてることをやってみる前に
pythonはMacには標準で入っているらしいですが念の為確認
python --version
# pythonっていうコマンド使えないよエラー
python3 --version
Python 3.9.6
過去に何かのパッケージを使用する為にPythonの新しめのバージョンが必要になって、標準で入っているPythonと自分で入れたバージョンのPythonで標準搭載Pythonが優先的に使用されてしまう問題を解決したような記憶もありますが正直覚えていないです、すみません。
よし、早速やってみよう
ディレクトリ作って、そこに移動
mkdir practice-pyxel && cd $_
ドキュメントに従い、Macなのでbrewでインストールして色々する
brew install pipx
pipx ensurepath
pipx install pyxel
実際のサンプルから実際のコードや色々を確認するのは大事なのでサンプルを持ってくる
pyxel copy_examples
実際に動くか確認
cd pyxel_examples
pyxel run 01_hello_pyxel.py
pyxel play 30sec_of_daylight.pyxapp
helloが出て、ゲームが始まった。ウィンドウを閉じると終了されるっぽい。
自分で描いたファイルを実行してみる
ChatGPTに聞くとPythonではmain.pyというファイルが一般的らしいのでファイルを作る。
# ルートに戻る
cd ../
touch main.py
ドキュメントのコードを貼り付けて実行してみる
python3 main.py
# import pyxelしてるけど"pyxel"が見つからないよエラー
npm installみたいに、パッケージをインストールしないで使おうとしている状態か、調べるとpipというのを使用するらいいのでインストールしてみる。
pip install pyxel
# command not found: pip | pipが見つからないよエラー
でも調べるとpython3には標準で入っているっぽい?
意味がないけど標準で入っているなら念の為もう一回
pip install pyxel
# command not found: pip | pipが見つからないよエラー
クリティカルな記事を書いてくれている方がいましたので、倣って実行してみます。
python3 -m pip install pyxel
# インストールできた
# でもパス通ってないよ警告
nano ~/.zshrc
開かれるエディターの一番下に↓をコピペ(警告文に出ていたPath)
export PATH="$HOME/Library/Python/3.9/bin:$PATH"
- controll + X で抜ける
- YESで進む
- Enterで閉じる
pathを通す
source ~/.zshrc
再度自分のファイルを実行してみる
python3 main.py
無事、四角が出ている画面が表示されましたー!これで準備完了なはず
一旦どうやって表示されているのか確認してみる
# pyxelをimportする
import pyxel
# initでwindowサイズを決定する
pyxel.init(160, 120)
# なんかQを押すと起きるっぽい
# Qを押したら閉じたので、pyxel.quit()はwindow閉じるやつっぽい
# updateという名前からして、定期的に実行したい処理はここで実装しそう
def update():
if pyxel.btnp(pyxel.KEY_Q):
pyxel.quit()
# rectって書いてるのでここで四角書いてるっぽい drawだし
def draw():
# 画面をまっさらにする(0は黒)
pyxel.cls(0)
# 値を変えてみたところ最後のは色、左から頂点座標っぽい
pyxel.rect(10, 10, 20, 20, 11)
pyxel.run(update, draw)
実際のコードではクラスでラップした方がいいらしいので下記に書き換える。
import pyxel
class App:
# 上からの流れ的に多分最初に実行される(initだし…
# __とかついてるので多分VueのonMounted,Reactの空配列useEffect的な決まった記法かな
# フロントエンドの感覚とは違うだろうけど
def __init__(self):
pyxel.init(160, 120)
self.x = 0
pyxel.run(self.update, self.draw)
def update(self):
# 余りを計算しているっぽいが、self.xが増加する処理が見えないので謎
# updateというくらいなので、ある程度の間隔で定期実行されるのかな
# 実行したら動いたのでそういうことっぽい
self.x = (self.x + 1) % pyxel.width
def draw(self):
pyxel.cls(0)
# drawは初期状態の表示に使用してupdateで変更されるself.xの値で移動しているっぽい
pyxel.rect(self.x, 0, 8, 8, 9)
App()
実際に開発するときはpython file_name
ではなく、pyxel側で用意してくれているpyxel watch WATCH_DIR Pythonスクリプトファイル
を使用して開発したらとても便利そう(使ったことはない)
Pyxel Editorでマップやドット絵を作成していく
ドキュメント(README)の「リソースの作成方法」にあたる箇所になります。最初はPyxel Editorとはなんぞやだったのですが、ゲームに使用するドット絵や音楽が作れる場所っぽいので作成していきます。
現段階で何となくこういう流れかなと思ったPyxelでのゲームの作り方
- Pyxel Editorでアイテムをドット絵で作る
- Pyxel Editorでアイテムをマップに配置していく
- Pyxel Editorで作成したマップに対して、キャラクターの動きをコードに書いていく
違う可能性もありますが一旦この認識で実装を進めていきます。
Pyxel Editorを起動してみよう
pyxel edit
ファイル名を指定しなければmy_resource.pyxres
というファイル名になるらしいので、こだわりも無いためそのまま実行
おお!READMEに書いてあった画面が立ち上がった
あれ、ドット絵難しいぞ…
なんかいい方法ないのかな
あまりにも自分にセンスがなかったため、サンプルを見にいきます
サンプルを見学してみる
pyxel_examplesディレクトリに移動
cd pyxel_examples
鳥さんが横スクロールで移動していくやつがイメージに近かったため、そのサンプルを見学。
ファイル名 | 説明 | デモURL | コード |
---|---|---|---|
10_platformer.py | マップのある横スクロールアクションゲーム | デモ | コード |
10番のサンプルのソースを読んでみると、platformer.pyxresを読み込んでいたため確認してみる。
pyxel edit assets/platformer.pyxres
おー色んな素材が置いてある!
左上の四角が四つ集まっているアイコンを押すとマップにアイテムを自分で設置して遊ぶことが出来たので先程の作成手順の仮説はあっていそう。
ただ、ドット絵は自分のセンスでは無理だと思うので一旦フリー素材探しの旅に出かけてきます。🏃💨
30minutes later ...
8 × 8の画像じゃないと上手く読み込めないっぽい?
ダウンロードしても500 × 720とかになるので上手くいかない?
原因はよくわかっていませんが、いい感じに読み込めなかったです
ということで、無理でしたので自分で作ります。
適当に自分で作ってみた
一旦適当に作ってみた
ドット絵初心者にしては結構いい感じにできたのでは?
この状態で先程のドキュメントのサンプルコードで立ち上げても当然読み込まれないので一度load関数を使用して、作ったファイルを読み込んでみます。
import pyxel
class App:
def __init__(self):
pyxel.init(160, 120)
pyxel.load('my_resource.pyxres')
self.x = 0
pyxel.run(self.update, self.draw)
def update(self):
self.x = (self.x + 1) % pyxel.width
def draw(self):
pyxel.cls(0)
pyxel.rect(self.x, 0, 8, 8, 9)
App()
これでも読み込まれないので、一度ドキュメントを見返します。
🔸blt(x, y, img, u, v, w, h, [colkey], [rotate], [scale])
イメージバンクimg(0-2) の (u, v) からサイズ (w, h) の領域を (x, y) にコピーします。
w、hそれぞれに負の値を設定すると水平、垂直方向に反転します。
colkeyに色を指定すると透明色として扱われます。
rotate(度:Degree)、scale(1.0=100%)、またはその両方を指定すると対応する変換が適用されます。
ここが該当していそうなので、サンプルコードからImageBankの記述を使用していそうな箇所を探して今の自分のImageBankに調整して表示されるか確認してみます。
def draw(self):
# キャラクター画像の描画
pyxel.blt(self.x, self.y, 0, 8, 8, 16, 16, 0) # images[0] のキャラクター画像
ここがぽいので先程のコードに差し込んでみます。
import pyxel
class App:
def __init__(self):
pyxel.init(160, 120)
pyxel.load('my_resource.pyxres')
self.x = 0
pyxel.run(self.update, self.draw)
def update(self):
self.x = (self.x + 1) % pyxel.width
def draw(self):
pyxel.cls(0)
pyxel.blt(1, 1, 0, 8, 8, 16, 16, 0)
App()
x,yに1をそれぞれ指定しているので、左上に表示されていますが正しく表示されていそうです。
pyxel.blt(1, 1, 0, 8, 0, 8, 8, 0)
上のコードに変更したらお化けが表示されたので問題なさそうですね
タイルマップエディタで作成したMapを表示してみる
まずどうやるのか分からないので、サンプルとドキュメントを見比べてっぽいところを探します
ドキュメントでbltm
が怪しかったので、サンプルコードで検索をかけてみると、Appクラスのdrawメソッドがそのまま初期化して、タイルマップ反映して、プレイヤーと敵を置くという処理になっていたので、playerクラス作成前のためそれ以外を記述してみます。
def draw(self):
pyxel.cls(0)
# Draw level
pyxel.camera()
pyxel.bltm(0, 0, 0, (scroll_x // 4) % 128, 128, 128, 128)
pyxel.bltm(0, 0, 0, scroll_x, 0, 128, 128, TRANSPARENT_COLOR)
# Draw characters
pyxel.camera(scroll_x, 0)
player.draw()
for enemy in enemies:
enemy.draw()
pyxel.bltm(0, 0, 0, scroll_x, 0, 128, 128, TRANSPARENT_COLOR)のままの記述だとTRANSPARENT_COLORも設定されておらずにスクロールの設定もしていないため表示されないので、タイルマップを確認しつつ調整します。
def draw(self):
pyxel.cls(0)
pyxel.bltm(0, 0, 0, 0, 0, 128, 128)
うれしー!正直ここに30分かかりました…(謎)
お化けかわいい、適当ドット絵主人公も愛着湧いてきている
sampleのコードを見ながらplayerクラスを作成してみる
私がplayerクラスに求めること
- 十字キーで移動ができること
- SPACEでジャンプすること
- 自然な感じで落下すること
後から追加
- 地面に立てること(設定しないと落ちていってしまう)
- 将来的に左右に慣性が欲しい(ここでは一旦保留)
とりあえず、ドキュメントを見てるとボタンを押すとtrueを返してくれる部分が見つかりました。
- btnp(key, [hold], [repeat])
そのフレームにkeyが押されたらTrue、押されなければFalseを返します。
holdとrepeatを指定すると、holdフレーム以上ボタンを
押し続けた時にrepeatフレーム間隔でTrueが返ります。
import pyxel
class Player:
# Playerの初期化、x,yの引数で初期位置を与える
def __init__(self, x, y):
self.x = x
self.y = y
self.width = 8
self.height = 8
# 移動速度やそれぞれの値
self.speed = 2
self.jump_power = 6
self.gravity = 0.5
self.y_velocity = 0
# 名前的に空中か地面かの判定
self.is_jumping = False
self.on_ground = False
def update(self):
# 横方向の移動ロジック(直接数字にしがちだったけどマジックナンバーになるからちゃんと変数にした方がいい)
if pyxel.btn(pyxel.KEY_LEFT):
self.x -= self.speed
if pyxel.btn(pyxel.KEY_RIGHT):
self.x += self.speed
# ジャンプのロジック(直接じゃなくてvelocityだから速度の値を変えることで重力というか縦方向の落ちていく動作を再現するのかー)
if pyxel.btnp(pyxel.KEY_SPACE) and self.on_ground:
self.y_velocity = -self.jump_power
self.is_jumping = True
self.on_ground = False
# 滑らかに縦に落ちる処理(重力)
self.y_velocity += self.gravity
self.y += self.y_velocity
# 地面の高さを指定して、それ以下になると縦方向の落下を止める。= 地面に立つ的なことか
ground_level = 120
if self.y + self.height > ground_level:
self.y = ground_level - self.height
self.y_velocity = 0
self.is_jumping = False
self.on_ground = True
# 画面外に出ないようにする処理?
self.x = max(0, min(self.x, pyxel.width - self.width))
def draw(self):
pyxel.blt(self.x, self.y, 0, 8, 8, self.width, self.height, 0)
class App:
def __init__(self):
pyxel.init(160, 128)
pyxel.load('my_resource.pyxres')
# x,yの値を与えて初期位置を設定(px)
self.player = Player(10, 100)
pyxel.run(self.update, self.draw)
def update(self):
self.player.update()
def draw(self):
pyxel.cls(0)
# タイルマップの範囲指定
pyxel.bltm(0, 0, 0, 0, 0, 128, 128)
self.player.draw()
App()
サンプルコードを見てあまり理解できていないものの一旦流用してみるとキャラクターが動きました!
一旦AIに処理は書いてもらっちゃおう!
こちらが指定したオブジェクトに当たり判定を追加、特定のX座標時に到達したらゴール、敵は左右に動く、敵に触れたらゲームオーバーなどを指定してコードを書いてもらいました。
長いので省略
import pyxel
# 今回だとお化けに関するクラス
class Enemy:
def __init__(self, x, y):
self.x = x
self.y = y
self.dx = 1
self.direction = 1
self.is_alive = True
self.start_x = x
# 1マス8pxなので3マス範囲で移動するための値
self.move_range = 24
self.initial_direction = 1
def update(self):
# 右に移動
self.x += self.dx
# 初期位置から3マス分の範囲で往復
# 初期位置からxの値が範囲を超えたら座標を差し引き
if self.x >= self.start_x + self.move_range:
self.x = self.start_x + self.move_range
self.dx *= -1
self.direction *= -1
elif self.x <= self.start_x:
self.x = self.start_x
self.dx *= -1
self.direction *= -1
# 左右の向いている方向でお化けの向く向きも変える
def draw(self):
if self.direction == 1:
pyxel.blt(self.x, self.y, 0, 16, 8, 8, 8)
else:
pyxel.blt(self.x, self.y, 0, 8, 0, 8, 8)
class Player:
def __init__(self, x, y):
self.x = x
self.y = y
self.dx = 0
self.dy = 0
self.is_falling = False
self.on_ground = False
self.direction = 1
# ジャンプ関係の値
self.jump_count = 0
# 二段ジャンプさせたい
self.max_jumps = 2
# ジャンプの強さ
self.jump_strength = -5.5
# コードを読んだ感じHoldでジャンプの高さが変わるっぽいので最小値?
self.jump_cut_off = -2.0
self.gravity = 0.35
self.max_fall_speed = 6
self.is_dashing = False
self.dash_cooldown = 0
self.dash_duration = 0
self.acceleration = 0.5
self.max_speed = 3
self.friction = 0.85
# ゲームオーバーかどうかの判定値
self.is_alive = True
def check_tile_collision(self, x, y):
# タイルのサイズ(8x8)を考慮して、プレイヤーの四隅のタイル座標をチェック
tile_coords = [
(int(x / 8), int(y / 8)), # 左上
(int((x + 7) / 8), int(y / 8)), # 右上
(int(x / 8), int((y + 7) / 8)), # 左下
(int((x + 7) / 8), int((y + 7) / 8)) # 右下
]
for tile_x, tile_y in tile_coords:
# タイルマップの範囲内かチェック
if 0 <= tile_x < 45 and 0 <= tile_y < 16:
tile = pyxel.tilemap(0).pget(tile_x, tile_y)
# 地面として扱うタイル(X: 0-1, Y: 1-2のタイルと0-1, Y: 2-3のタイル)をチェック
if (tile[0] == 0 and tile[1] == 1) or (tile[0] == 0 and tile[1] == 2): # 地面とオブジェクトのタイル
return True, tile_y * 8 # 衝突したタイルのY座標も返す
return False, 0
def update(self):
# ダッシュのクールダウン
if self.dash_cooldown > 0:
self.dash_cooldown -= 1
if self.dash_duration > 0:
self.dash_duration -= 1
else:
self.is_dashing = False
# 左右移動
if pyxel.btn(pyxel.KEY_LEFT):
self.dx -= self.acceleration
self.direction = -1
elif pyxel.btn(pyxel.KEY_RIGHT):
self.dx += self.acceleration
self.direction = 1
else:
self.dx *= self.friction
# 速度制限
if not self.is_dashing:
self.dx = max(-self.max_speed, min(self.max_speed, self.dx))
# ダッシュ
if pyxel.btnp(pyxel.KEY_SHIFT) and self.dash_cooldown <= 0:
self.is_dashing = True
self.dash_duration = 10
self.dash_cooldown = 30
self.dx = self.direction * self.max_speed * 2
# ジャンプ
if pyxel.btnp(pyxel.KEY_SPACE):
if self.on_ground:
self.dy = self.jump_strength
self.is_falling = True
self.on_ground = False
self.jump_count = 1
elif self.jump_count < self.max_jumps:
self.dy = self.jump_strength
self.jump_count += 1
# ジャンプカットオフ(ジャンプボタンを離したときの処理)
if not pyxel.btn(pyxel.KEY_SPACE) and self.dy < self.jump_cut_off:
self.dy = self.jump_cut_off
# 重力の適用
if not self.on_ground:
self.dy += self.gravity
self.dy = min(self.dy, self.max_fall_speed) # 落下速度の制限
# 座標の更新(X方向)
next_x = self.x + self.dx
next_y = self.y
# X方向の当たり判定
has_collision, _ = self.check_tile_collision(next_x, self.y)
if not has_collision:
self.x = next_x
else:
# 壁にぶつかった場合
self.dx = 0
# Y方向の更新と当たり判定
next_y = self.y + self.dy
has_collision, ground_y = self.check_tile_collision(self.x, next_y)
if not has_collision:
self.y = next_y
# 下向きの移動中は地面との接触をチェック
if self.dy > 0:
# 次のフレームでの地面チェック
next_has_collision, next_ground_y = self.check_tile_collision(self.x, next_y + 1)
if next_has_collision:
# 地面との接触が近い場合、スナップする
self.y = next_ground_y - 8
self.dy = 0
self.is_falling = False
self.on_ground = True
self.jump_count = 0
else:
self.on_ground = False
self.is_falling = True
else:
self.on_ground = False
self.is_falling = True
else:
# 地面に着地した場合
if self.dy > 0: # 下向きの移動の場合
# タイルの上端に正確に位置を合わせる
self.y = ground_y - 8
self.dy = 0
self.is_falling = False
self.on_ground = True
self.jump_count = 0
else: # 上向きの移動の場合(天井にぶつかった)
self.y = ground_y + 8
self.dy = 0
# マップ全体での移動制限(45タイル=360px)
self.x = max(0, min(self.x, 360 - 8)) # マップの幅に合わせて制限
def check_collision(self, enemy):
# 敵との当たり判定
if (abs(self.x - enemy.x) < 8 and
abs(self.y - enemy.y) < 8):
self.is_alive = False
return True
return False
def draw(self):
# プレイヤーの向きに応じて描画
if self.direction == 1:
pyxel.blt(self.x, self.y, 0, 8, 8, 8, 8, 0)
else:
pyxel.blt(self.x, self.y, 0, 8, 8, -8, 8, 0) # 左向きの場合は反転
class App:
def __init__(self):
pyxel.init(160, 128)
pyxel.load('my_resource.pyxres')
self.init_game()
pyxel.run(self.update, self.draw)
def spawn_enemies(self):
enemies = []
# tilemapをスキャンして敵を配置
for y in range(16):
for x in range(45): # 45タイル分スキャン
tile = pyxel.tilemap(0).pget(x, y)
if tile == (1, 0): # 敵のタイル
enemies.append(Enemy(x * 8, y * 8))
# 敵の位置のタイルを透明なタイルに変更
pyxel.tilemap(0).pset(x, y, (0, 0))
return enemies
def init_game(self):
# タイルマップを初期状態に戻す
pyxel.load('my_resource.pyxres')
self.player = Player(0, 0)
self.enemies = self.spawn_enemies() # tilemapから敵を生成
self.game_over = False
self.game_clear = False # ゲームクリアフラグを追加
self.camera_x = 0 # カメラのX位置を追加
def update(self):
if not self.game_over and not self.game_clear:
self.player.update()
# ゲームクリア判定を追加(312px = 39タイル目)
if self.player.x >= 312:
self.game_clear = True
# カメラの位置をプレイヤーに追従(画面中央に表示)
target_camera_x = self.player.x - pyxel.width // 2
# カメラが0より左に行かないようにする(45タイル=360px)
self.camera_x = max(0, min(target_camera_x, 360 - pyxel.width))
# 敵の更新と当たり判定
for enemy in self.enemies:
# 画面から少し余裕を持った範囲の敵だけ更新する
if -64 <= enemy.x - self.camera_x <= pyxel.width + 64:
enemy.update()
if self.player.check_collision(enemy):
self.game_over = True
else:
# リスタート
if pyxel.btnp(pyxel.KEY_R):
self.init_game()
def draw(self):
pyxel.cls(0)
# カメラ位置を考慮してマップを描画(45タイル=360px)
pyxel.bltm(0, 0, 0, self.camera_x, 0, 360, 128)
if not self.game_over and not self.game_clear:
# プレイヤーの描画(実際の位置で描画)
real_x = self.player.x - self.camera_x
pyxel.blt(real_x, self.player.y, 0, 8, 8, 8 if self.player.direction == 1 else -8, 8, 0)
# 敵の描画(実際の位置で描画)
for enemy in self.enemies:
real_x = enemy.x - self.camera_x
# 画面内の敵だけ描画
if -8 <= real_x <= pyxel.width + 8:
if enemy.direction == 1:
pyxel.blt(real_x, enemy.y, 0, 16, 8, 8, 8)
else:
pyxel.blt(real_x, enemy.y, 0, 8, 0, 8, 8)
elif self.game_clear:
# ゲームクリア画面
pyxel.text(60, 64, "GAME CLEAR!", 11)
pyxel.text(45, 74, "PRESS R TO RESTART", 8)
else:
# ゲームオーバー画面
pyxel.text(60, 64, "GAME OVER", 8)
pyxel.text(45, 74, "PRESS R TO RESTART", 8)
App()
うわー、すごく長い。必要な機能をそれぞれ分解していけば自分が作りたいクリスマスっぽいゲームを作れそう。
とりあえず、サンプルが完成したので公開
お化けに当たらないように、ゴールを目指せ!
移動:十字 ジャンプ:SPACE ダッシュ:SHIFT
クリスマスっぽい装飾を加えていきます
上の方でも一度書いたドット絵を描くPyxel Editorを使用してクリスマスっぽくしていきます!
- キャラクターの慣性はあった方がリッチになるかと思いきや個人的にマイナス評価だったため削除
- ダッシュ機能もいらないので削除
- 雪降ってた方がいい感じになりそうなので雪が降る処理を追加
改めて完成品
🎊〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜🎊
🎊〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜🎊
作ってみた感想
完全初心者が数時間で作成できたので、とても作りやすい環境だなと感じました!それと同時に作り込もうと思ったら、とても作り込めそうな雰囲気を感じましたので今後も趣味の範囲で作っていくかもしれません。
自分のデザインセンスではクリスマス感を出し切れず、空にサンタさんを置いたりとかリース設置したりもっと赤を追加したかったのですが不完全燃焼感もあるので来年のクリスマスにはもっと凝ったゲームを作ったり作らなかったりとか…
最後まで読んでいただきありがとうございました
良いクリスマスを!🎄
参考記事(作ろうと思うきっかけになった記事)