見出し画像

【Pyxel】レトロRPG制作メモ(2023/3/5)

1.概要

 今までゲームを作ることだけに集中していましたが、「完成したものを公開しやすい開発環境無いかな~」と思い始めた頃、以前にこちらのマガジンを見つけ、Pyxelで試しに何か作ってみようと思いました。

 この方はゲーム開発のノウハウについて記事やtwitterなどで情報を発信しておられる方で、とても参考にさせて頂いてます。
 将棋制作の記事で一度紹介させて頂いたYoutubeのゲーム数学チャンネルの方もですが、世の中には本当に凄い人たちがいますね。

 新たな開発環境の勉強ということで、モチベーションの維持も含めメジャーなレトロゲームを思い浮かべたところ、最初に聖剣伝説が出てきました。この作品も以前から作ってみたかったものの1つで、これに決めました。
 ですが、本当は以前からJavaで作っていたオリジナルゲームを完成させたい意向があるので、ある程度Pyxelの制作に慣れてきたらオリジナルゲームの制作(Java→Pyxelへの移植)を優先して進める予定でいます^^;

 また、Pyxelを初めて触る身としてつまづいたところを自分なりの備忘録として残したく、自分以外にもPyxelを始めたての方の制作のヒントになれば幸いです。
 記事の最後にソースを載せておきます。正直書き方が合っているかは分かりませんが…(;'∀')

2.Pyxelを触ってみる

 まずはPyxelの使い方やPythonの構文から勉強し始めました。
 以下のPyxelのリファレンスを見ながら、サンプルコードを参考に進めました。

3.今回できたこと

3-1.できたもの

 ドット絵、キャラクターの移動、マップの当たり判定ができました。


3-2.付属のエディタでドット絵作成

 Pyxel付属のエディタで、キャラクターやタイルマップを作りました。
ヒーローや地面・木などのグラフィックは1つ1つが縦横16×16ドットですが、エディタを触った感じでは1タイルを8×8ドットで作ることを基本にしている印象でした。
 これはコーディングの際に使うtilemapクラスのpget()関数でも、1タイル8×8ドットで扱っているようでした。φ(..)メモメモ

キャラクターグラフィック(ヒーロー)の作成

 ↑↑Pyxelでは画面描画時に透過色を設定することができるため、あまり使うことが無さそうな紫色で背景部分を塗り潰しています。
 ソースでは以下のように定数を定義しました。

TRANSPARENT_COLOR = pyxel.COLOR_PURPLE  # 透過色
タイルマップの作成

 ↑↑のちに実装する壁などの当たり判定を作るために、
  1段目を「通行可能なタイル」(赤枠部分)、
  2段目を「通行できないタイル」(青枠部分)
としました。
 タイルの種類が増えればエディタやソースでの修正が入りそうですが、とりあえずはこの方法としました。

3-3.障害物との当たり判定

 以下はマップ上の当たり判定を行う関数です。

'''
当たり判定を行う
@param tileX 移動先X座標(単位:タイル)
@param tileY 移動先Y座標(単位:タイル)
@return 判定結果(True:判定あり/False:判定なし)
'''
def isHit(self, tileX, tileY):

    # 確認用
    # print("tileX = " + str(tileX) + " / tileY = " + str(tileY) + " / chipY = " + str(pyxel.tilemap(0).pget(tileX, tileY)[1]))

    if pyxel.tilemap(0).pget(tileX, tileY)[1] > CommonConst.PASSABLE_TILE_HEIGHT:
        return True
    else:
        return False

 pyxelをimportしておけば、恐らくどこからでもドット絵などのリソースを参照できそうでした。

if pyxel.tilemap(0).pget(tileX, tileY)[1] > CommonConst.PASSABLE_TILE_HEIGHT:

 tilemap(0)は、以下の図の「タイルマップのバンク」を指定します。
 pget(X, Y)は、マップ上の指定したX、Y座標(単位は1タイル)で使われているタイルの座標を取得し、タプル型で返します。(タプル型って初めて知りました)
 例えばpget(0, 0)を指定した場合、X = 0、Y = 2 のタイルを使用しているため、(0, 2)という値が取得できます。
 pget(X, Y)[1]とすることで(0, 2)のうち「2」の要素を取り出し、この値が定数「PASSABLE_TILE_HEIGHT:1」より大きい場合、True(壁などの障害物に当たっている)を返しています。
 → 1段目のタイル(値が0~1)は通行可能、2段目のタイル(値が2以上)は通行不可のため。本制作では1マス分を2×2タイルで表現しているため、少しややこしくなっています…。

tilemap.pget()について

4.ソースコード

 最初はメインとなるクラスに処理を書いていましたが、後で複数のファイルに分けてソースを整理してみました。
 ファイルに分けたクラスを参照するにはどうすればいいかなど、悪戦苦闘しました…(T_T)

 1ファイル1クラスとして、以下の①~⑥のファイルを作成しました。

①mainGame.py

import pyxel

from map import Map
from input import Input
from commonConst import CommonConst

"""
メインゲームクラス
"""
class App:
    """
    初期化
    """
    def __init__(self):
        self.input = Input()
        self.map = Map()

        pyxel.init(CommonConst.SCREEN_WIDTH, CommonConst.SCREEN_HEIGHT, title="FF Gaiden", fps=60, display_scale=2)
        pyxel.load("assets/mainGame.pyxres")

        pyxel.run(self.update, self.draw)

    """
    更新処理
    """
    def update(self):
        self.map.update(self.input.update())

    """
    描画処理
    """
    def draw(self):
        pyxel.cls(pyxel.COLOR_BLACK)

        # マップ
        self.map.draw()

App()

②input.py

import pyxel

"""
入力クラス
"""
class Input:
    # 方向、決定、キャンセルの定数
    DOWN = 0
    UP = 1
    LEFT = 2
    RIGHT = 3
    DECISION = 4
    CANCEL = 5

    """
    初期化
    """
    def __init__(self):
        self.inputs = []

    """
    更新処理
    """
    def update(self):
        self.inputs.clear()

        if pyxel.btn(pyxel.KEY_UP) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_UP):
            self.inputs.append(self.UP)
        elif pyxel.btn(pyxel.KEY_DOWN) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_DOWN):
            self.inputs.append(self.DOWN)
        elif pyxel.btn(pyxel.KEY_LEFT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_LEFT):
            self.inputs.append(self.LEFT)
        elif pyxel.btn(pyxel.KEY_RIGHT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_RIGHT):
            self.inputs.append(self.RIGHT)
        
        if pyxel.btn(pyxel.KEY_Z) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_A):
            self.inputs.append(self.DECISION)
        elif pyxel.btn(pyxel.KEY_X) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_B):
            self.inputs.append(self.CANCEL)

        return self.inputs

③map.py

import pyxel

from sprite import Sprite
from input import Input
from commonConst import CommonConst

"""
マップクラス
"""
class Map:
    """
    初期化
    """
    def __init__(self):
        self.sprite = Sprite(2, 2)

    """
    更新処理
    @param inputs 入力
    """
    def update(self, inputs):
        self.sprite.update(inputs, self)

    """
    描画処理
    """
    def draw(self):
        # タイルマップ
        pyxel.bltm(0, 0, 0, 0, 0, pyxel.width, pyxel.height, 0)

        # スプライト
        self.sprite.draw()

    '''
    当たり判定を行う
    @param tileX 移動先X座標(単位:タイル)
    @param tileY 移動先Y座標(単位:タイル)
    @return 判定結果(True:判定あり/False:判定なし)
    '''
    def isHit(self, tileX, tileY):

        # 確認用
        # print("tileX = " + str(tileX) + " / tileY = " + str(tileY) + " / chipY = " + str(pyxel.tilemap(0).pget(tileX, tileY)[1]))

        if pyxel.tilemap(0).pget(tileX, tileY)[1] > CommonConst.PASSABLE_TILE_HEIGHT:
            return True
        else:
            return False

④sprite.py

import pyxel

from input import Input
from commonUtil import CommonUtil
from commonConst import CommonConst

"""
スプライトクラス
"""
class Sprite:
    """
    初期化
    @param tileX X座標(単位:タイル)
    @param tileY Y座標(単位:タイル)
    """
    def __init__(self, tileX, tileY):
        self.px = CommonUtil.tilesToPixels(tileX)
        self.py = CommonUtil.tilesToPixels(tileY)
        self.tileX = tileX
        self.tileY = tileY + 1  # 足元を基準に当たり判定を行わせるため1タイル分追加
        self.isMoving = False
        self.direction = Input.DOWN
        self.count = 0
        self.moveDist = 0

    """
    更新処理
    @param inputs 入力
    @map マップクラス
    """
    def update(self, inputs, map):

        if not self.isMoving:
            if Input.UP in inputs:
                self.direction = Input.UP
                if not map.isHit(self.tileX, self.tileY - 1) and not map.isHit(self.tileX + 1, self.tileY - 1):
                    self.isMoving = True
                    self.move()
            elif Input.DOWN in inputs:
                self.direction = Input.DOWN
                if not map.isHit(self.tileX, self.tileY + 1) and not map.isHit(self.tileX + 1, self.tileY + 1):
                    self.isMoving = True
                    self.move()
            elif Input.LEFT in inputs:
                self.direction = Input.LEFT
                if not map.isHit(self.tileX - 1, self.tileY):
                    self.isMoving = True
                    self.move()
            elif Input.RIGHT in inputs:
                self.direction = Input.RIGHT
                if not map.isHit(self.tileX + 2, self.tileY):
                    self.isMoving = True
                    self.move()
            else:
                self.count = 0
        else:
            self.move()

    """
    描画処理
    """
    def draw(self):
        pyxel.blt(self.px, self.py, 0, self.direction * 2 * CommonConst.TILE_WIDTH * 2 + self.count * CommonConst.TILE_WIDTH * 2,
                  0, CommonConst.TILE_WIDTH * 2, CommonConst.TILE_HEIGHT * 2, CommonConst.TRANSPARENT_COLOR)

        # 確認用
        # pyxel.text(0, 0, str(self.direction), pyxel.COLOR_BLACK)
        # pyxel.text(0, 0, "tileX = " + str(self.tileX) + " / tileY = " + str(self.tileY), pyxel.COLOR_BLACK)

    """
    スプライトの移動処理
    """
    def move(self):

        if self.direction == Input.UP:
            self.py -= 1
        if self.direction == Input.DOWN:
            self.py += 1
        if self.direction == Input.LEFT:
            self.px -= 1
        if self.direction == Input.RIGHT:
            self.px += 1

        self.moveDist += 1

        if (self.moveDist < CommonConst.MAX_MOVE_DIST / 2):
            self.count = 1
        else:
            self.count = 0

        # 一定距離移動したら停止
        if (self.moveDist >= CommonConst.MAX_MOVE_DIST):
            if self.direction == Input.UP:
               self.tileY -= 1
            if self.direction == Input.DOWN:
                self.tileY += 1
            if self.direction == Input.LEFT:
                self.tileX -= 1
            if self.direction == Input.RIGHT:
                self.tileX += 1

            self.isMoving = False
            self.moveDist = 0
            self.count = 0

    """
    スプライトの移動処理終了
    """
    def endMove(self):
        self.isMoving = False
        self.moveDist = 0
        self.count = 0

⑤commonUtil.py

import pyxel
import math

from commonConst import CommonConst

"""
共通ユーティリティクラス
"""
class CommonUtil:

    '''
    ピクセル単位をタイル単位に変換する
    @param pixels ピクセル単位
    @return タイル単位
    '''
    def pixelsToTiles(pixels):
        return math.floor(pixels / CommonConst.TILE_WIDTH)

    '''
    タイル単位をピクセル単位に変換する
    @param tiles タイル単位
    @return ピクセル単位
    '''
    def tilesToPixels(tiles):
        return tiles * CommonConst.TILE_WIDTH

⑥commonConst.py

import pyxel

"""
共通定数クラス
"""
class CommonConst:

    TRANSPARENT_COLOR = pyxel.COLOR_PURPLE  # 透過色
    SCREEN_WIDTH = 160      # 画面の幅
    SCREEN_HEIGHT = 144     # 画面の高さ
    TILE_WIDTH = 8          # タイルの幅
    TILE_HEIGHT = 8         # タイルの高さ
    PASSABLE_TILE_HEIGHT = 1    # 通過できるタイル(タイルマップチップの1~2段目は通行可能、3段目以降は通行不可)
    MAX_MOVE_DIST = TILE_WIDTH  # 1歩進む距離

5.終わりに

 とりあえず1画面分のマップができたので、次はステータス表示や攻撃について作ってみようかと思います。

 ご覧いただき、ありがとうございましたm(_ _)m

この記事が気に入ったらサポートをしてみませんか?