見出し画像

Pyxel(Python)を使ったRPGの作り方

Pyxelというレトロゲームエンジンをご存じでしょうか。
初心者にも扱いやすいプログラミング言語Pythonのライブラリで、小規模なゲームから本格的なゲームまで低学習コストで制作ができる最高にクールな日本発のゲームエンジンです。

Pyxelの紹介は、作者のkitaoさんご自身による以下のQiitaの記事が読みやすいです。

本記事ではこのPyxelを作って、シンプルな(といっても、一応は30分くらいしっかり遊べる)2DダンジョンRPGを作ります。
よくあるこの手の記事の中には「とりあえずフィールドをキャラクターが移動するだけ」くらいにとどめたものも多い印象ですが、この記事ではアセット準備、フィールド処理、バトル、セーブ&ロード…などなど、シンプルながらもひととおり揃った完成品を題材にしています。
その分文章の量も多いですが、ご容赦くださいませ。


1.はじめに

本記事の想定読者

この記事を開いた方であれば、まずRPGの制作に興味をお持ちだと思います。

初心者がRPGの制作を目指す場合、RPGツクールシリーズなどがまず有力な候補になると思いますが、「プログラムはできる or できるようになりたい」「制作ツールの制約やクセに縛られず、自由に制作したい」といった理由でプログラムを自作して実現したい方も多いでしょう。
もしくは私と同じように「レトロRPGが好きなので、自分でそういうものを作ってみたい」という方もいらっしゃるかもしれません。

本記事は、主に上記のような方に向けた記事となります。

もちろん、万能ゲームエンジンであるUnityやゲームエンジンを使わずフルスクラッチでの制作など、他にもいろいろな選択肢はありますが、少なくとも手軽さという観点でPyxelはかなり強力です。

なぜ「2DダンジョンRPG」なの?

「いや、俺が作りたいのはドラクエやFFのような広大な世界を旅するJRPGなんだけど」「そもそも2DダンジョンRPGって何やねん、ダンジョンRPGいうたらWizardryや世界樹の迷宮みたいな3Dやろかい」と思われた方もいるかもしれませんが、これには理由があります。

まず、ダンジョンRPGを作る場合はワールドマップを用意する必要がないので、マップチップやマップ本体の制作労力が著しく小さくなります。

さらに、ワールドマップがあるオーソドックスなJRPGの場合、通常は「街」や「街の中にある店」なども必要になってくるので、作業量がどんどん膨れ上がってきます。
もちろん、マップを作ったり街のNPCのセリフを考えたりするのが何より楽しい、という方もいると思いますが、まずはミニマルであっても完成させることを重視するなら、こういった要素は後回しになるでしょう。

また「2D」ダンジョンである理由は、3Dは苦手と感じるプレイヤーも多いこと、拡張すれば前述のようなワールドマップありのJRPGにもできること、そしてPyxelの機能(タイルマップ、後述)を活かしやすいこと、などです。

「Pyxel」の使い方、覚えていいことあるの?

「そうは言っても、いまさらそんな時代遅れのゲーム作ってもなあ」と思われる方も多いかもしれません。

実際のところそうとも限らず、ファミコン風のレトロゲームは2025年現在なお当時(35~40年ほど前)子供だった層には人気がありますし、また、何か1つの言語やゲームエンジンの使い方を習得すれば、新しい言語や他のゲームエンジンを学習しようとする際の労力はぐっと下がります。
(ちなみに私はPyxelを触るまではPythonは使ったことがありませんでしたが、Javascriptなど他の言語に習熟していたため、かなり容易にPythonも使えるようになりました。)

そして、「最初の何か」を学ぶ際に、その対象の学習ハードルが低いというのはかなり重要なことです。
Pythonは初心者にも使いやすい人気の言語で、Pyxelはシンプルかつかゆいところに手が届くように設計されており、学習ハードルがかなり低い部類のゲームエンジンです。

で、お前は何者なの?

記事を書いている私ですが、Pyxelで「Dungeon Antiqua」というRPGを制作して2024年下期にSteam, App Store, Google Playで販売した実績があり、メインのSteamでは18,000本売れています。
・・といったマウントの取り方は下品なのであまりしたくないのですが、「記事を書いたやつはちゃんと実績あるんだよな?」という疑念を拭うために添えさせていただきました!

2. 本記事のサンプル作品と制作方針

前置きが長くなりましたが、そんなわけでPyxelを使った2DダンジョンRPG、作っていきましょう。

本記事のためにサンプル作品を制作したので、以降はこのサンプルを題材に「どのように設計し、アセットを準備し、プログラムを作っていったか」をできるだけ詳しく紐解いていく形で進めていきます。

実際に触ってみたい方は、こちらのリンクからお開きください。(Pyxelは標準でWebアプリをビルドできる機能がついているんですよ!)
スマホでプレイする場合は「Aボタン」がバーチャルパッドの下ボタン、「Bボタン」が右ボタンになるので、その点だけ覚えておいてください。

ただ、このゲームは面白いんか?と問われますと、率直に申し上げてそうでもございません。
・・だってほら、何せミニマルなのでね?
あくまで学習用サンプルだと思ってくださいね!

プレイ時間はクリアまで遊ぶと30分くらいでしょうか。
「この記事でどれくらいのものが作れるか見たい」という目的なら5分くらいのプレイで十分です。

以下は、本サンプルの大まかな制作方針(自分で決めた要件)です。

画面サイズ128x128 + 3色のみ

Pyxelでは画面サイズを自由に指定できるので、私は普段ファミコンに近しい256x240などのサイズで制作しているのですが、今回はミニマルというコンセプトでもあり、128x128とかなり小さいサイズで制作しました。

ちなみに、Pyxelと同種のレトロゲームエンジンであるPICO-8の画面サイズは128x128ですので、全く同じ内容でPICO-8向けに作ることもできそうですね。

作ってみた実感としては、128x128はかなり狭かったです。
以下画面例ですが、フィールド画面では7個x7個(112x112)を上部中央に、最下部16ピクセルに簡易ステータス表示を入れました。
また、何かとメッセージを表示するための領域が必要になりますので、下側48ピクセルをメッセージウィンドウにして、たとえばメニューを開いたときであれば上側80ピクセルに必要な情報を表示するようにしました。
枠含めて48ピクセルだと、昔のゲーム機っぽさを出すために行の高さは16ピクセルにしているため(後述)、2行しかメッセージが入りません。

後述しますが、必然的に管理できるステータスなども最小限になります

また、画面はすべて「白」「グレー」「黒」の3色だけで表現しているのですが、これは別に労力の削減にはつながりません。
ただのミニマルっぽさの演出ですが、ダンジョンRPGというジャンルとは相性がいい感じがしますね。

ちなみに画面性能が低スペックでも大ヒットした名機、初代ゲームボーイの画面サイズは160x144、色数は4色です。
横が32ピクセル違うと幅8ピクセルのフォントで4文字多く入れられるようになりますが、これだけ狭いとこの4文字分の違いは大きいなと感じました。
「160x144x4色」で制作してゲームボーイ風にするというのもいいかもしれませんね。

ソースコード1本 + アセットファイル1個 + フォントファイル1個

本作を構成するファイルは以下の3つのみです。
GitHubでリポジトリ公開しています。

  • ソースコード:1本のPythonソースファイル

  • アセット:1個のPyxel標準アセット(.pyxres)ファイル

  • フォント:1個のBDF(ビットマップフォント)ファイル

ソースコードは、一般的には保守性等の観点で適宜分割した方が良いとされますが、本作はすべて合わせて977行(空行およびコメントだけの行を除いた実質の行数は795 行)とそれなりにコンパクトなので、あえて1ソースに収めました。

アセットファイルはPyxel指定のフォーマットで、以下のリソースを1ファイルにパッケージングした形式です。
詳しくは公式ドキュメントを参照ください。

  • 画像(スプライト)

  • タイルマップ

  • サウンド

  • ミュージック

BGMや効果音は、ゲームとしてはなくても成り立つので省いてもよかったのですが、Pyxelの特性を活かすため本サンプルでは入れています。

フォントファイルはPyxelで日本語を表示するために必要で、k8x12というフォントを使っています(選定理由などは後述)。
正確には、文字を画像として表示することでフォントファイルを使わない実装もできるのですが、Pyxelでサポートしているビットマップフォント表示の機能を使ったほうが圧倒的に楽です。

キーボードとゲームパッド両対応、スマホプレイも可

あなたが何らかのゲームを作ってWeb上で公開するとします。
現代であればSNSなどを使って告知する方が多いでしょう。

その場合、あなたのゲームを遊ぼうと思った方は、ゲームパッドを接続したPCからアクセスするかもしれませんし、SNSを見ていたスマホからそのままアクセスする人もいるでしょう。

「このゲームはPC、キーボード専用です」などと割り切ることもできますが、ゲームデザイン上無理がないようなら、少し労力が増えても間口は広くしておくに越したことはありません。

幸いPyxelにはキーボードや標準的なゲームパッドの入力受付機能もありますし、Webアプリ化したページをスマホで開くとバーチャルパッドを表示する機能もあります。

なので、本サンプルでも「キーボードとゲームパッド両対応、スマホプレイも可」としました。

スマホプレイでの画面イメージ

RPGなのでセーブ・ロードは必須

本サンプルは30分程度でクリアできるゲームなので、セーブ・ロードができなくてもそこまで悪質ではないのですが(逆にそういうゲーム性にする設計もありですね)、小さいながらもRPGの基本形として、セーブ・ロード機能は実装しておくことにします。

主にWeb上でプレイすることを想定しているため、PCローカルだけでなくブラウザのデータとしてセーブデータを扱う必要があります。
ブラウザにはローカルストレージ(localStorage)という小規模データ保持の仕組みがあるので、これを使って実装します。
(やり方は5章で説明します。)

3. 設計(ゲームデザイン)しよう

それでは制作にとりかかっていきましょう。

最初にすべきことはゲーム全体の設計(ゲームデザイン)です。
ゲームをちゃんと完成させようと思うなら、よほど持続力に自信がない限り、場当たり的に作っては広げて、とするべきではなく、最初に仕様を決めてしまったほうがいいと私は思います。

そのため本記事でも、まずこの章で詳しくゲームの仕様やその意図について説明していきます。

純粋な実装工程にのみ興味があるという方は、次の「4. アセットを準備しよう」まで読み飛ばしてください。

フィールドでは「自分の周囲3マスしか見えない」

画面サイズの設定と密接に関係しますが、画面上のマップ表示は112x112なので、正方形サイズで表示すれば7x7(自分の周辺3マス四方)が表示できます。

本サンプルはダンジョンRPGということで、その狭さを「(スペック上の制約ではなく)ダンジョンの暗さによる視界の不自由さの演出」と位置付けるため、「自分の周辺のわずかな円形部分しか見えない」という画面デザインにしました。

リアリティを考えると、壁の向こう側が見えるのはおかしいのですが、ポジティブに解釈すると、下の画面のように「うわ、上の部屋宝箱めっちゃある、どうやったら行けるんだ?」とプレイヤーに感じせるようなマップ設計ができるメリットもあります。

本当は自分のいる部屋しか見えないはずだが…

ターン制バトル、1:1

ミニマルなRPGということで、初代ドラクエなどと同様、バトルは自分と敵の1:1にします。

戦闘画面は「モンスター絵、自分のHPMP、3行メッセージ」という構成

また、純粋なコマンド方式のターン制バトルです。
ターンはバトルの最初に素早さによって自分とモンスターのどちらが先行するかを決め、あとは交互に(毎ターン判定ではなく)行動するという、これまた初代ドラクエと同じ方式にしました。

実際のところ、令和の現代においては、この1:1ターン制バトルはシンプルすぎて物足りなさがあります。
たとえばバフデバフ、状態異常、相性、あるいはアクション性(UNDERTALEのような)を入れるなどの工夫次第で、1:1でも面白いバトルシステムを作り上げることも十分できると思いますが、本サンプルではここは追及しません。

もしこのサンプルを土台に実際にプレイヤーに楽しんでもらえるゲームを作るとしたら、バトル設計については優先的に見直すべきだと思います。

アイテムは「鍵」のみ、装備なし、レベルなし、お金で育成

ダンジョンRPGといえばお宝探しが醍醐味・・ですが、本サンプルでは装備やアイテムの要素は原則カットし、ダンジョンの中に配置された「扉」を開けるためのアイテムとして「鍵」だけがある、という仕様としました。

つまり宝箱から得られるものは「鍵」と「お金」だけになります。
当然、味気なくはなりますが、おかげで実装としてはだいぶ省力化できます。

また「敵を倒してレベルアップしていく」というシステムもRPGの基本中の基本の要素ですが、本サンプルでは敵を倒しても経験値は得られず、お金が得られるだけにしました。
これによってレベルアップ処理や所要経験値テーブル(or計算式)の準備などが不要になります。

そしてアイテムも存在しないため、お金の唯一の使い道は「ショップでお金を払うことで好きなパラメータを強化できる」としています。
パラメータはHP・MP・力・速さの4種で、力はイコール攻撃力、速さは命中率・回避率・ダメージを受けたときの軽減量を兼ねることとします。

パラメータがあがるほど、必要なお金も増えていく

まとめると「バトルに勝利したり宝箱を発見することでキャラクタの強化につながっていく」というRPGの醍醐味は最低限味わえるようにした、というゲームデザインです。

呪文は4種類、ゲーム進行にあわせて習得

1:1のコマンドバトルでアイテムや装備もないとなると、さすがに呪文・魔法・スキルといった要素がないと寂しすぎるので、呪文を4種類だけ用意しました。

最初期のコンピューターRPGである1975年制の「pedit5(これも2DダンジョンRPG!)」すら全部で16もの呪文が用意されているので、シンプルにもほどがありますが、「ゲームとして一応成立させる」ことが目的なので目をつぶってくださいませ。

  • ファイア:消費MP2、敵に中程度のダメージを与える。最初から使える。基本的に必ず当たるが、特定の敵には効かない。

  • リターン:消費MP6、ダンジョンのセーフエリア(後述)にテレポートする。戦闘中は使えない。

  • ヒール:MPx5の分だけHPを回復する。たとえばHPが20で最大HPが70なら、MP10を使ってHPをフル回復できる。

  • バースト:すべてのMPを使って敵に大ダメージを与える。

ファイアを除く3つの呪文は、ダンジョン内で特定のNPCと会話したりバトルに勝ったりすることで習得できるようにします。

ヒールとバーストは、消費MPが固定ではなくHPの減少量に依存したり現在MPをすべて使用したり、とクセのある仕様になっていますが、これはあまり深い意図はなく、ちょっとだけ作品を個性的にしてみた、という遊び心です。

この説明文で理解してもらえるかはちょっと自信がない

バトルのルール(計算式)を決める

キャラクタのステータスや呪文など、バトルに影響する要素が決まったので、バトルのルールを決めていきます。

「防御力」というパラメータすら犠牲にしたので、前述のように「はやさ」をいろいろな計算に用います。
以下、やや記述が細かいですが、実際には太字部を決めて、それに合うように計算式を作った、というアプローチでした。

①先行/後行判定
自分と敵の速さそれぞれに1~2の乱数を乗じて、自分が大きければ自分が先行、小さければ敵が先行とする。
乱数の範囲が1~2なので、自分の速さが敵の倍以上なら必ず先行する。

②「逃げる」の判定
「1+自分の速さ/敵の速さ>乱数(0~2)」であれば逃走成功とする。
つまり最低でも50%成功、自分の速さ≧敵の速さなら100%成功となる。

③命中判定とダメージ判定
攻撃者の速さ/対象者の速さにより「命中係数」を求める(0.25~1.5の範囲に収める)
次に、命中係数から0~1の乱数を引いたものを「命中判定値」とする(〜1の範囲に収める)
命中判定値が0以下ならミス扱いとし、当たった場合「攻撃者の力 x (1 + 命中判定値) / 2」をダメージとする。
→少しややこしいが、自分の速さ≧敵の速さなら必ずヒットし、速さの値が大きいと回避しやすくなる上に被弾した場合でもダメージを軽減できるという結果になる。

④呪文のダメージ判定
ファイアのダメージは24~30(敵のファイアの場合は12~18)の乱数とする。
バーストのダメージは「8~12の乱数」をMP値の回数分合算して求める。たとえばMP40なら8~12(平均10)を40回合算するため、平均400ダメージとなる。

ダンジョンのサイズは32x32x5階層

冒険の舞台となるダンジョンは地下5階まで、うち最初の階はセーフエリア(回復したりキャラクタの強化ができる場所)としました。

マップサイズは32x32です。視界が自分の周辺3マスなので、東西南北のマップの端を考慮すると実質26x26になっています。
かなり小さいようですが、視界が狭すぎるせいか、テストプレイした感覚としては意外と小さく感じませんでした。

また、マップには通路や壁のほか、以下のチップを用意しました。

  • 宝箱:鍵かお金が入っている

  • 扉:鍵があれば鍵を1つ使って開けることができる

  • 回復の泉:HPとMPを全回復できる

ミニマルな作りということで、罠やギミック(Wizardryのような隠し扉や一方通行など)は入れていません。

HPの回復手段を考える

ここで一度整理すると、HPの回復手段は「ヒールの呪文」と「回復の泉」の2つだけになります。
しかもそのうち「ヒールの呪文」は3つ目の呪文と位置付けており、ゲーム序盤では使えません。

もちろんこれを前提としたバランス設計にするのも良いのですが、本サンプルでは「ダンジョンを歩くと2歩ごとにHPが1回復する」という仕様にしました。

ゲーム序盤は自動回復の恩恵が大きく(HP自体が小さいため)、後半はヒールへの依存度が高くなるということになります。

モンスターは8種類

各フロアで登場するモンスターは基本的に固定で、1/4の確率で1つ上のランク(次のフロア)のモンスターが出るという仕様としました。

地下1階はセーフエリアなので、地下2階〜5階までで4階層、1ランク上の敵を考慮すると4+1=5種類のザコモンスターが必要ということになります。

さらに、イベント用のモンスター(ボスキャラ)も3種類用意し、合計で5+3=8種類となりました。

シンプルなバトルルールとはいえ、「HPと攻撃力が違うだけじゃん」てなことにならないよう、モンスターごとに特徴づけをしています。

  1. かぼちゃ:最弱の敵。シンプルに弱い。

  2. こおに:少しタフで攻撃力が高いが、ファイア一発で必ず倒れる。

  3. おにび:ファイアの呪文を1度だけ使え、こちらのファイアは効かない。

  4. ゆうれい:速さの値が高いので、強化をしていないとよく回避される。

  5. にんじゃ:雑魚モンスター最強。ゆうれいの倍の攻撃力がある。

  6. まどうし:小ボス1。ファイアの呪文を2度使えるが、打撃は弱い。

  7. だてんし:小ボス2。全体的に高性能な上にファイアが効かない。

  8. めがみ:ラスボス。普通に殴り合ってもまず勝てない。

このモンスター構成はゼロから考えたのではなく、あらかじめ用意してあったモンスター画像(後述します)から8つをチョイスしつつ、「こいつを最弱のザコにしよう」などと決めていきました。

個人制作ゲームの場合、このように素材の範囲でモンスターやキャラクター、マップのギミックなどを用意していくことも現実的には多いと思います。

4. アセットを準備しよう

仕様を決めたので、続いて画像などのアセットを準備していきます。

2章でも触れたように、アセットはすべてPyxel標準のアセットファイル1つに収める形で用意していきます。
まだPyxelに触れたことのない方のために、具体的な制約の内容も触れるようにします。

画像

Pyxelの画像データは、256x256のイメージバンクを3つまで登録することができます。

本サンプルで使用したバンクは1つだけで、その1バンクもフルに使わずに済んでいます。

使用したイメージバンクの画像

左上64x64ピクセルはマップやキャラクタ、ウィンドウなどのスプライト。

その横の1/4の円は視界を円形に切り取るための画像で、上下反転や左右反転はPyxelの機能で簡単にできるので、左上1/4部分だけバンク上に配置しました。
(バンクの容量は余っているので、まるごと配置しても良かったのですが。)

その下の8個のイラストはモンスターグラフィックです。
今回はモンスターグラフィックをオリジナルで用意するほどの作品でなかったので、Noto Emojiからモンスター画像を生成するというヘンテコな方法で賄っています。詳しくは以下の記事にて。

色に関してですが、本来Pyxelでは以下の16色が使えます。

Pyxelのデフォルト16色パレット

今回は表示色として黒(0)、灰色(13)、白(7)の3色を、また透過色として暗い青(1)を便宜上使っています(未使用の残り13色ならなんでも良いです)。

視界を切り取るための1/4円の画像がこの青色(1)なのは、「黒+透過色」の画像をマップにかぶせることで、透過色になっている円の部分だけ見えるようにしている、ということです。

左上64x64のスプライトは、エリむーとさん(X @Emt_mezzo)に提供いただいたものです(人型アイコンやウィンドウなどの簡単なパーツは自分で描いたもの)。

タイルマップ

マップデータはPyxelのタイルマップエディタで制作しました。

ちなみに白い人型は主人公、灰色の人型はNPCです

Pyxelのタイルマップ機能としては256x256(マップチップが16x16ドットの場合は128x128)のサイズのマップを8個まで保存できるのですが、本サンプルでは左上64x64(32x32の倍)だけを使ったタイルマップを5個登録したことになります。

Pyxelのスプライトのベースの大きさは8x8なのですが、エディタとしてはスプライトの実質単位が16x16でもマップを作れるように配慮して作られているので、少しコツを覚えれば標準エディタでも十分作れます。

ただ画面に一度に表示できる量が8x8マス分しかないので、より大規模なRPGを作る場合は自前のエディタを作ったり、TiledなどでTiled Map File (TMX)を作って読み込ませる、といったやり方のほうが効率的かもしれません。

BGM

BGMも標準エディタを使って制作しました。
Pyxelサウンドの仕様をある程度理解しないと、ここはちょっとわかりづらい部分になるかもしれません。

VOLの「42」の記述は、3音目以降「4」「2」「4」「2」と繰り返すことを意味する

たとえば上の画像が1つのサウンドアセット(短音の短い演奏データ)で、このアセットだと「ミ♭ ソ ファ# ソ ミ♭ ソ ファ# ソ…」と8部音符で演奏しているデータになるのですが、何も知らない人が見たら「ミ♭ミ♭ ソソ ファ#ファ# ソソ …」という譜面に見えると思います。

が、Pyxelでは連続した2つのブロックの音は、間に休符などを挟まず連続した音として再生されるため、これで「ミ♭ ソ ファ# ソ ミ♭ ソ ファ# ソ…」になっているのです。

ではなんでわざわざ2つ重ねているの?という話ですが、電子音というのは1つの音の中で音量(VOL)の変化をつけないとベタっとした音になってしまうので、発音が大きく、少し時間経過したら音が小さくなるように(シンセ的に表現すると、アタック感を出すように)「4」→「2」と打ち込んでいます。
(なおVOLは7が最大になります。)

以前にサウンド制作についての簡単な説明動画を作ったので、興味がある方はあわせてご覧ください。

Pyxelのミュージックデータは、このサウンドアセットをチャンネルごとに横につなげることで完成させていくのですが、上記のような方法で作っていくと、1つのサウンドアセットで表現できる小節数が2〜4くらいになるので、長い曲を打ち込もうとすると枠をすぐ使い果たしてしまいます。

たとえば以下の画像は地下1階セーフエリアの曲で、CH0とCH1は2小節で1つ、CH2(ベース)は4小節で1つのアセット枠を使っているので、合計8小節の曲で10個の枠を消費しています。
(アセット枠の上限は64個です。)

青が使用済みのサウンドアセット。32〜はSE(次項)で使っています。

本サンプルでは、以下の3曲をそれぞれ8小節で制作し、合計28個のサウンドアセットを登録しました。

  • タイトル画面兼ダンジョンのメイン曲

  • バトル曲

  • セーフエリアの曲(一般的に言うと町っぽい曲)

このように書くと制約が厳しそうに感じるかもしれませんが、現在のバージョンのPyxelはこのエディタでの打ち込みのほかにMML形式のサポートも行っています。
今回はできるだけPyxelとしてのベーシックな作りを重視しましたが、現状ではMMLを使うのが一番実用的かと思います。

また「そもそもBGMを入れない」も十分に現実的な選択肢です。
特に本サンプルのようなモノクロのダンジョンRPGの場合、BGMをなくすことでかえって無機質で不気味な雰囲気を演出できるかもしれませんね。

SE

PyxelではBGMもSEも同じ形式で扱うので、同様に標準エディタでSEも作っていきます。

SE制作はPyxelのサウンドエディタがすこぶる使いやすいです。
以下は「階段の上り下り」のSE例ですが、「だっだっだっだっ」という音をノイズ音色(n)を短く切った音(F)で、少しずつ音量を下げて(77665544)表現しています。

EFXにはn(ノーマル)、S(スライド)、F(フェードアウト)、V(ビブラート)などがある

ただ、↑はわかりやすい例を挙げたのですが、ある程度ピコピコ音の知識(経験)がないと意図した音がなかなか出せないはずなので、BGMよりSE作りのほうが難しいかもしれません。

以下は私が公開しているPyxel用効果音素材で、あまりバリエーションはないのですが、自分で作れる自信はないという方はよければ使ってください。

また同様に、BOOTHでサウンド素材を無料公開してくださっている方もいらっしゃいました。ありがたいですね!

フォント

フォントはk8x12というビットマップフォントのうち「k8x12s」というタイプを使っています。

8x12というのは「横幅8ドット、縦幅12ドット」を意味しています。
縦長のフォントにように見えますが、この「k8x12s」の場合、ひらがなやカタカナは下寄せ8x8ドットで構成されており、「が」「ぱ」など濁点・半濁点がつく文字は、その上の4ドットに濁点・半濁点部分が描かれます。

フレームの位置がちょっとおかしいことに気が付きましたが、文字部分だけ着目してくださいね!

これはファミコンやゲームボーイをイメージさせる画面を作る上ではかなり都合がよく、上の画像のように文字の行間を8ドットにして、濁音や半濁音だけがその間の行に表示されるようにすると、ファミコンやゲームボーイでよく使われていたテキストの表示スタイルを簡単に再現できます。

5. プログラムを書こう

ゲームデザインが決まり、アセットも揃ったのでコーディングに入ります。

ソースコード全文はここにあります。
ミニマルとはいえ約1,000行あるので、この章ではプログラム上重要な部分やPyxel独自の機能を使っている処理をピックアップして説明していきます。

PythonやPyxelの基本については、この記事では解説しません。
現在は良質なPyxelの公式入門書も出ているので、そちらで学習したり、公式ドキュメントとサンプルで勉強するなり(少しプログラミングの知識がある方ならこれでも十分)してみてくださいね!

Pyxelアプリの起動

import pyxel as px
# (略)
BDF = None
# (略)

class App:
    def __init__(self):
        global BDF
        px.init(
            128, 128, title="Pyxel Tiny DRPG", quit_key=px.KEY_NONE, display_scale=2
        )
        px.load("assets.pyxres")
        BDF = px.Font("k8x12S.bdf")  # フォントファイル
        # 障害物(ドア、宝箱、NPC)データ
        self.obstacles = {
            # (略)
        }
        # 会話イベントデータ
        self.talks = {
            # (略)
        }
        # モンスターデータ
        self.monsters = (
            # (略)
        )
        # 呪文データ
        self.spells = [
            # (略)
        ]
        self.cur = None
        self.wait = False
        self.bgm = None
        self.welcome_show()
        px.run(self.update, self.draw)

# (略)
App()

冒頭の import pyxel as px でPyxelライブラリを呼び出し、px.で記述できるようにします。
頻繁に使うので簡素に書けるようにしたいですからね。

そして、Pyxel公式で推奨されている形に従い、Appクラスを定義して__init__関数で初期化を行います。
初期化処理ではPyxelアプリの初期化関数(px.init)によるゲーム画面の生成、アセットファイルの読み込み、ゲームデータの定義、変数の初期化などを行なっています。

ゲームデータは、量が大きくなるとjsonやcsvファイルなどに外出ししてプログラムから読み込むほうがスマートですが、今回はプログラム内で直接定義しました。

welcome_showはゲーム開始直後の起動画面を表示するための関数で、その後の px.run(self.update, self.draw) で30fpsでのupdate関数とdraw関数呼び出し(メインプログラムのループ)がスタートします。

クラス定義

# ウィンドウオブジェクト
class Window:
    all = {}

    def __init__(self, key, x1, y1, x2, y2, texts):
        # (略)


# カーソル(選択肢の ▶︎)
class Cursor:
    def __init__(self, key, list_x, y, cancel_pos=None):
        # (略)

# フィールド用障害物(kind = 0:ドア 1:宝箱 2:人)
class Obstacle:
    def __init__(self, x, y, z, kind=0, val=0):
        # (略)

# 戦闘用キャラクタ(自分とモンスター)
class Actor:
    def __init__(self, name, hp, mp, atk, spd, resist=0, img=None, gold=0):
        # (略)

# 呪文
class Spell:
    def __init__(self, name, mp, on_menu, desc):
        # (略)

本サンプルでは「ウィンドウ」「カーソル」「障害物(ドア、宝物、NPCを指す)」「呪文」「戦闘用キャラクタ(自分とモンスターを指す)」をクラス化しました。

何をクラス化するか、にはいろいろな考え方がありますが、私は「同じ性質を持つものが複数存在する場合、または繰り返し生成と消滅する場合」にクラス化しています。

たとえば呪文は4種類存在しますし、カーソルは「メニューを開いた時」「バトルでコマンドを選ぶ時」などに繰り返し生まれては消える、という理由で選定しました。

ユーティリティ(共通)関数を定義する

プログラム内で複数のクラスから呼び出されたり、ゲームクラス(App)のプロパティに実行結果が影響しない関数は、ユーティリティ関数としてまとめてグローバル定義しておきます。

本来はこういった関数をまとめて1ソースで管理してモジュール化し、他のプログラムからimportして使いたい関数を呼び出す、といった形にするほうが望ましいですが(他のプロジェクトでの再利用もできますし)、今回は1ソースに収めてさっと実装できる形を選びます。

記事構成の都合で、一部ここでの説明を省略している関数があります。

### ユーティリティ関数 ###

# 全角化
def zen(val):
    h2z = str.maketrans(
        " 1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ /+-:*#()[]",
        " 1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ /+-:*#()[]",
    )
    return str(val).translate(h2z)

半角英数字が含まれる文字列を全て全角の文字列に変換する関数です。
数字型の変数をそのまま文字列変換すると「1」のようになりますが、使用しているBDFフォントでは「1」だと4ドット幅になってしまうので、「あ」などと同じ8ドット幅の全角文字にする用途です。

# テキスト描画
def draw_text(x, y, t):
    px.text(x * 8, y * 8 + 4, zen(t), 7, BDF)

Pyxelのテキスト関数を使って文字を画面に表示します。
(x座標, y座標, 文字, 色番号, BDFフォント)の形式です。

文字を配置する座標は8ドット単位とするため、x, y座標は引数としては1/8の単位(0~15の値)で受け取り、関数内で8倍します(y座標に+4しているのは、フォントの縦幅が12ドットなのでその調整)。

また、先ほどのzen関数を使って表示する文字は全角化します。
色番号は7、BDFフォントはinit関数で読み込んだk8x12sフォントを常に指定します。

# ボタン取得
def get_btn_state():
    btn = {
        "u": px.btn(px.KEY_UP) or px.btn(px.GAMEPAD1_BUTTON_DPAD_UP),
        "d": px.btn(px.KEY_DOWN) or px.btn(px.GAMEPAD1_BUTTON_DPAD_DOWN),
        "l": px.btn(px.KEY_LEFT) or px.btn(px.GAMEPAD1_BUTTON_DPAD_LEFT),
        "r": px.btn(px.KEY_RIGHT) or px.btn(px.GAMEPAD1_BUTTON_DPAD_RIGHT),
        "a": px.btnp(px.KEY_Z, 10, 2) or px.btnp(px.GAMEPAD1_BUTTON_A, 10, 2),
        "b": px.btnp(px.KEY_X, 10, 2) or px.btnp(px.GAMEPAD1_BUTTON_B, 10, 2),
    }
    return btn

ゲームパッドのボタンまたはキーボードのキー状態を取得します。
たとえば十字キーを右上に入れながらAボタンを押した状態なら
{ "u": True, "d": False, "r": True, "l": False, "r": True, "a": True, "b": False}
と返ってきます。

これだけでゲームパッドとキーボードの両方に対応できますし、スマホのバーチャルパッドもGAMEPAD1_BUTTONが発火する仕組みになっているので、スマホ版の動作もカバーできます。素敵ですね。

また「btnp(px.GAMEPAD1_BUTTON_A, 10, 2)」というコードは「10フレーム以上押したら、以降2フレーム間隔で押したものと判定する」という意味となり、これだけでボタン押しっぱなしによるメッセージの早送りなどが実現できます。

基本はこれで十分なので本サンプルはこれで完成としますが、実はユーザビリティを向上させようと思うとまだ不十分な点が少しあります。
1つはこれではアナログスティックに反応しないという点、もう1つはゲームパッドによってはBボタンが「Aボタン」と認識されてしまう点です。

後者の問題は以下の記事で詳しく触れていますので、気になる方はご参照ください。

# パディング左よせ
def spacing(val, length):
    return zen(val).ljust(length)[-length:]

# パディング右よせ
def pad(val, length, fill=" "):
    return zen(val).rjust(length, fill)[-length:]

たとえばHPを表示するとき「1」も「10」も「999」も3桁分のスペースに右寄せして表示したいことがよくあると思います。

こういう場合にこの関数を「f"HP {pad(hp,3)}"」などと呼び出すことで、HPの量によらず3桁分のスペースで表示することができます。

UIのベースである「ウィンドウ」を作る

# ウィンドウオブジェクト
class Window:
    all = {}

    def __init__(self, key, x1, y1, x2, y2, texts):
        self.key = key
        self.x1 = x1
        self.y1 = y1
        self.x2 = x2
        self.y2 = y2
        self.texts = texts

    def draw(self):
        x1 = self.x1 * 8
        y1 = self.y1 * 8
        x2 = self.x2 * 8
        y2 = self.y2 * 8
        px.blt(x1, y1, 0, 0, 48, 8, 8)
        px.blt(x2 - 8, y1, 0, 8, 48, 8, 8)
        px.blt(x1, y2 - 8, 0, 0, 56, 8, 8)
        px.blt(x2 - 8, y2 - 8, 0, 8, 56, 8, 8)
        x = self.x1 + 1
        while x < self.x2 - 1:
            px.blt(x * 8, y1, 0, 16, 48, 8, 8)
            px.blt(x * 8, y2 - 8, 0, 16, 56, 8, 8)
            x += 1
        y = self.y1 + 1
        while y < self.y2 - 1:
            px.blt(x1, y * 8, 0, 24, 48, 8, 8)
            px.blt(x2 - 8, y * 8, 0, 24, 56, 8, 8)
            y += 1
        px.rect(x1 + 8, y1 + 8, x2 - x1 - 16, y2 - y1 - 16, 0)
        for pos, text in enumerate(self.texts):
            if pos >= 0 and pos < (self.y2 - self.y1 - 2) // 2:
                draw_text(self.x1 + 1, self.y1 + 1 + pos * 2, text)

    @classmethod
    def open(cls, key, x1, y1, x2, y2, texts=[]):
        if key in cls.all:
            cls.all[key].texts = texts
        else:
            cls.all[key] = cls(key, x1, y1, x2, y2, texts)
        return cls.all[key]

    @classmethod
    def close(cls):
        windows_copy = copy.deepcopy(cls.all)
        for key in windows_copy:
            del cls.all[key]
        return

  # pyxel drawメイン
    def draw(self):
        px.cls(0)
        # (略)
        # ウィンドウ
        for key in Window.all:
            Window.all[key].draw()

# (略)

class App:
    # (略)

    # メニュー用ウィンドウ生成(Window.openの使用例)
    def menu_show(self):
        pl = self.pl
        t = [
            f"HP {pad(pl.hp,3)}/{pad(pl.mhp,3)}",
            f"MP  {pad(pl.mp,2)}/ {pad(pl.mmp,2)}",
            f"ちから {pad(pl.atk,2)}  はやさ {pad(pl.spd,2)}",
            f" {pad(self.gold,4)}G  カギ {pad(self.keys,2)}こ",
        ]
        Window.open("menu_stat", 0, 0, 16, 10, t)

これまでのスクショで見ていただいたとおり、マップチップやモンスターを表示している箇所以外の画面はほぼ全て「ウィンドウのフレーム+フレーム内のテキスト」で構成されています。

これを実現しているのがWindowクラスです。
それぞれの関数や使い方をざっと説明します。

  • Appクラス側から使いやすいように、ウィンドウ開閉時は「Window.open(キー名, 座標など…)」と「Window.close()」で呼び出せるクラス関数とした。また現在どのウィンドウが開かれているかは辞書型のWindow.all[]で取得できるようにして、openとclose関数を呼び出すことで更新されるようにした。

  • ウィンドウの表示内容の更新タイミングとして「最初に開いたとき」「プレイヤーのアクションにより情報を更新するとき」があるが、open関数だけで両方を処理できるようにした(データベース風に言うとinsertとupdateを両方兼ねるupsert関数なイメージ)。

  • Appクラスのdraw関数から「for key in Window.all」→「Window.all[key].draw()」で個々のウィンドウのdraw関数を呼び出すことで現在開いているウィンドウを全て表示させている。

  • ウィンドウのdraw関数ではフレームはpx.blt関数でスプライトを、draw_text関数(前述のユーティリティ関数)で文字を表示する。

カーソル処理を作る

# カーソル(選択肢の ▶︎)
class Cursor:
    def __init__(self, key, list_x, y, cancel_pos=None):
        self.key = key
        self.list_x = list_x
        self.y = y
        self.pos = 0
        self.cancel_pos = cancel_pos
        self.moved = False

    def draw(self):
        x = self.list_x[self.pos]
        px.blt(x * 8, self.y * 8, 0, 32, 48, 8, 8)

    def update(self, btn):
        if btn["r"] or btn["l"]:
            if self.moved:
                return
            dist = 1 if btn["r"] else -1
            self.pos = (self.pos + dist) % len(self.list_x)
            self.moved = True
        else:
            self.moved = False
        if btn["a"]:
            px.play(3, 35)
            return self.pos
        elif btn["b"]:
            return self.cancel_pos
        return None

シンプルな2DのRPGというと、まずは「十字キーでキャラクタをフィールド上で動かす」という操作が思い浮かびますが、同じくらい頻繁に行う操作が「選択肢からカーソルで1つを選ぶ」だったりします。
たとえば戦闘時のコマンド選択、メニューを開いた時、イベント時の「はい」「いいえ」の選択、起動直後の画面でニューゲームかコンティニューを選ぶ、などですね。

本サンプルではこの実装をシンプルにするため、カーソルは常に横方向の選択しかできない仕様にしました。

アイテムがないのでアイテムや装備を選択する画面はなく、戦闘時のコマンドも「たたかう」「じゅもん」「にげる」の3択。
呪文も4つしかないので、横一列に収められるくらいの分量です。
(が、実際には画面横幅もかなり小さいので、「ファ」「リタ」など最初の2文字だけを表示することでごまかしました。)

init関数のkeyは「何の選択肢か」を識別するためのキー文字列、list_xは選択肢に応じたx方向の座標の配列、yはy座標(横にしか動かないので固定)、cancel_posはBボタンを押した時にどの選択肢を押したことにするかを指定します。

インスタンスを生成すれば、あとは1フレームごとにdrawを呼び出すことでカーソルを表示し、updateを呼び出してキーボードやパッドに応じて左右に移動したり、選択肢を決定したりできます。

Aボタンを押した時の px.play(3, 35) は「チャンネル3でサウンドアセット35番を鳴らす」という意味で、Aボタンを押したときだけ「ポッ」という音が鳴るようにしています。
なぜ「チャンネル3」かというと、チャンネル0~2はBGMで使っているので、空いているチャンネル3を指定しているわけですね。

        self.message(["じゅんびは よいか?", " はい  いいえ"])
        self.cur = Cursor("boss1", [1, 5], 14, 1)

これはカーソル生成箇所の例で、messageは2行のテキストを表示するウィンドウを生成する関数で、次の行のCursor()で「はい」「いいえ」のテキストの位置にあわせた2択のカーソルを生成してself.curに保持させています。

この場合はBボタンを押すと「いいえ」を選んだことにしたいので、2番目のインデックスを示す「1」をcancel_posとして指定しています。
また、今選択したものが何の選択肢だったのかを後のフレームで判別できるように、キー値として"boss1"を設定しています。

ゲーム中では唐突にこう聞かれるので「なんの?」となること必至

メイン処理の骨格を作る

    def update(self):
        # ゲーム時間カウント
        if self.scene != "welcome":
            self.frames += 1
        btn = get_btn_state()
        # 十字キー押しっぱなし防止
        if self.wait and (btn["u"] or btn["d"] or btn["r"] or btn["l"]):
            return
        self.wait = False
        # カーソルがある場合カーソル処理を優先
        if self.cur:
            cur = self.cur
            ret = cur.update(btn)
            # (略)
            # ABボタンを押してなければ終了
            if ret is None:
                return
            # 起動画面の選択肢
            if cur.key == "welcome":
                # (略)
            # メニューの選択肢
            elif cur.key == "menu":
                # (略)
            # (略)
        # フィールド用update処理
        elif self.scene == "field":
             # (略)
        # バトル用update処理
        elif self.scene == "battle":
             # (略)
        # ゲームオーバー
        elif self.scene == "gameover":
             # (略)

下地が整ったので、メインのupdate処理の骨格を作っていきます。

init関数のくだりで触れた通り、px.runを実行すると、以降は指定した関数(関連としてupdateとdrawで固定)が1/30秒ごとに呼び出されます。

update関数のベースとなるのは「入力の受付」→「現在のゲーム内のシーンに応じた入力内容の処理」です。
うち、入力に使うのは前述のユーティリティ関数のget_btn_stateですね。

ゲームのシーンとしては「起動画面(welcome)」「フィールド(field)」「バトル(battle)」「メニュー(menu)」「ゲームオーバー(gameover)」を定義しました。
これをAppクラスの変数sceneに保持して、適宜この値に応じて分岐したり更新することにします。

ただし、どのシーンにおいても、現在カーソルが表示されている場合はカーソルの処理を優先します。
たとえばフィールド上で会話が発生して「はい」「いいえ」を選択する場面になった場合、回答をするまで移動は続行できない、といった挙動を実現するためです。

そこで、カーソルを生成した時は常にAppクラスのcurプロパティにカーソルのインスタンスを持たせておいて、メインのupdate関数内では「インスタンスが存在すればカーソルのupdate関数を呼び出し、カーソル以外の処理は行わない」ようにします。

また、「経過フレームを加算する処理(クリア時に所要時間を表示するのに使う)」「メッセージウィンドウが表示されたときにキーを押しっぱなしにしていてもウィンドウが一瞬で消えないようにするためのボタンリリース待ち処理」などもここに入れています。

画面表示処理を作る

    def draw(self):
        px.cls(0)
        # 起動画面用draw処理
        if self.scene == "welcome":
            draw_text(3, 2, "Pyxel Tiny")
            draw_text(6, 4, "DRPG")
        # フィールド用draw処理
        elif self.scene == "field":
            # (略、次項で説明)
        # バトル用draw処理(モンスターグラフィック表示)
        elif self.scene == "battle":
            u = self.ms.img % 4 * 64
            v = self.ms.img // 4 * 64 + 64
            px.blt(0, 0, 0, u, v, 64, 64)
        # ウィンドウ
        for key in Window.all:
            Window.all[key].draw()
        # カーソル
        if self.cur:
            self.cur.draw()

もう1つのコア機能であるdraw関数も作っていきます。

まず、冒頭のpx.cls(0)で画面を初期化します。
0は黒を示す色番号です。

次にシーンごとの画面を描画します。
起動画面(welcome)では、本来ならかっこいいロゴなんかを表示したいところですが、本サンプルではテキストでタイトルを表示するだけにします。

画面が極小なおかげで、これでも意外とむなしい印象を受けない(気がする)

フィールドのシーン(field)については次項で説明します。

バトルのシーン(battle)の画面は、「左上にモンスター画像」「右上に自分の状況」「下部にメッセージ」という構成とします。

ドラクエ1とFC版Wizを足して2で割ったようなバトル画面(か?)

このうち右上と下は共通のウィンドウ機能なので、バトルのシーン独自に書くべき表示処理としてはモンスター画像の表示のみです。
コード中のu, vでイメージバンク上の位置を指定しているのですが、モンスタークラスで登録した画像番号から対応した座標を算出しています。

最後にウィンドウとカーソルの表示処理で、ここはすでに説明した通りになります。

ちなみに全滅(gameover)の分岐がありませんが、全滅時は以下のように真っ暗な画面にメッセージウィンドウだけの画面としているため、何も記述する必要がありません。

死ぬわけではない、ただ意識を失うだけなのだ

メニュー(menu)も同様、共通機能であるウィンドウの表示以外の画面表示が必要ないため何も定義しません。

フィールドの処理を作る(draw)

続いて、2DRPGとして必須となるフィールド関連の処理を作ります。
まずは画面表示(draw関数)の処理です。

    def draw(self):
        # (略)
        # フィールド用draw処理
        elif self.scene == "field":
            # マップ
            x, y = (self.x * 16 + self.dx, self.y * 16 + self.dy)
            px.bltm(8, 0, self.z, x - 48, y - 48, 112, 112)

後で実行した描画処理が前の描画内容の上にかぶさっていくので、背面に表示する要素から順に記述していきます。
そのため最初に書くべきはマップチップ本体の表示処理です。

この実装はシンプルで、コード中の「px.bltm」が、指定した範囲のタイルマップに紐づくスプライトをまとめて表示する強力な関数となっています。
つまり、この1文で112(16x7)ドット四方のマップ画像が表示されます。

引数の補足として、座標の計算に用いているself.xとself.yは16ドット単位の座標、self.dxとself.dyは移動途中の1ドット単位の座標になります。
またself.zは現在の階層を示しており、地下1階が0、地下2階が1・・としています。これがタイルマップの番号と同じになるので、px.bltmのタイルマップ番号の引数としてそのまま指定しています。

            # 障害物(NPC含む)
            for key in self.obstacles:
                if not key in self.flags:
                    ob = self.obstacles[key]
                    ob.draw(x, y, self.z)

次に、マップ上に扉・宝箱・NPCなどの障害物を表示します。

表示処理はObstacleオブジェクトのdraw関数にゆだねていますが、内容としてはプレイヤーの座標をもとに画面上の座標を計算し、表示範囲内にあれば描画する処理です。

扉や宝箱は一度開けるとマップから消滅させる必要がありますが、それぞれ"1-2"のようなキー値をもっているので、開けた時にflagsにキー値を追加することで、その後は非表示となるようにしています。

            # マスク
            px.blt(0, -8, 0, 64, 0, 64, 64, 1)
            px.blt(64, -8, 0, 64, 0, -64, 64, 1)
            px.blt(0, 56, 0, 64, 0, 64, -64, 1)
            px.blt(64, 56, 0, 64, 0, -64, -64, 1)

次のマスク処理は、アセット準備の章で見た1/4の円弧の形状の画像を112x112のタイルマップ表示領域の左上、右上、左下、右下にかぶせる(円弧の外側を黒で塗りつぶす)ことで円形の視界を表現するためのものです。
「px.blt(64, -8, 0, 64, 0, -64, 64, 1)」などの「-64, 64」部分の引数は幅と高さを示すのですが、ここにはマイナス値を指定することで水平方向・垂直方向それぞれ反転した画像を表示することできます。

            # 主人公
            (u, v) = ((px.frame_count % 30) // 15 * 16, 2 * 16)
            px.blt(56, 48, 0, u, v, 16, 16, 1)
            # ステータス表示
            px.rect(0, 112, 128, 16, 0)
            t = f"HP{pad(self.pl.hp,3)} MP{pad(self.pl.mp,2)} {pad(self.gold,4)}G"
            draw_text(0, 14, t)

最後に主人公を中央に、簡易ステータスを下部16ピクセルに表示して終了です。

主人公のスプライト位置(u,v)を設定する際、「(px.frame_count % 30) // 15」という数式が入っていますが、px.frame_countは1フレームごとに1増えていくシステム変数です。
なので、これは15フレーム(0.5秒)ごとに2つのスプライトを切り替えて表示する簡単なアニメーション処理ということになります。

また、主人公の外観を抽象的なシンボルにしたので、初代ドラクエ1の「カニ歩き」のように、キャラクターの向きという概念は無視できます。

フィールドの処理を作る(update)

    # pyxel updateメイン
    def update(self):
        # (略)
        # フィールド用update処理
        elif self.scene == "field":
            # 操作受付
            if not self.moving:
                self.dy = btn["d"] - btn["u"]
                self.dx = btn["r"] - btn["l"] if not self.dy else 0
                if self.dy or self.dx:
                    self.move_start()
                elif btn["a"]:
                    Window.close()
                elif btn["b"]:  # メニュー呼び出し
                    self.menu_show()
                    self.scene = "menu"
            # 移動実処理
            else:
                self.dy += self.spd * ((self.dy > 0) - (self.dy < 0))
                self.dx += self.spd * ((self.dx > 0) - (self.dx < 0))
                # 移動終了
                if (self.dy % 16, self.dx % 16) == (0, 0):
                    self.move_end()

次は、操作を受け付けて移動可否判定および実移動処理を行うupdate関数の処理です。

まず、1マス移動している間に他の方向に移動指示を出したりメニューを呼び出せてはいけないので、変数movingで状態を保持します。

moving が False で操作受付しているときに方向キーが押されると(btn["u"]などで判定)、move_start関数(後述)を呼び出し、移動開始の判定を行います。
「self.dy = btn["d"] - btn["u"]」というコードは少しトリッキーですが、Boolean型の変数を数値として計算すると真のとき「1」として計算されるので、この記述で「下が押されたら1、上が押されたら-1」という結果が得られます。
move_start関数については後述します。

Bボタンによるメニューの呼び出し判定もここで行います。
(Aボタンを押したときは、画面にメッセージウィンドウが残存している場合に消す処理を行なっています。)

一方、moving が True=移動途中のときは、プレイヤーキャラクタの現在位置をフレームごとにずらしていく処理(dxまたはdyを加減算する処理)が必要になります。
1マスは16ドット単位なので、dxまたはdyが16で割り切れる数になったときにmove_end関数(後述)を呼び出し、移動終了を処理します。

    def move_start(self):
        # 移動先のイベントを取得
        evt = self.event
        if not evt or evt in (">", "<"):  # 階段
            self.dx *= self.spd
            self.dy *= self.spd
            self.moving = True
            Window.close()  # ウィンドウが表示されていれば閉じる
            return
        self.dx, self.dy = (0, 0)
        if evt == "@":  # 泉
            px.play(3, 32)
            self.message(["かいふくの いずみだ", "HP MP かいふく!"])
        # 略
    #略

    # 移動先のイベントorタイル状態取得
    @property
    def event(self):
        x = self.x + self.dx
        y = self.y + self.dy
        tm = px.tilemaps[self.z].pget(x * 2, y * 2)
        if tm == (0, 2):
            return "-"  # 壁
        elif tm == (2, 2):
            return "@"  # 泉
        elif tm == (4, 0):
            return "<"  # 上り階段
        elif tm == (6, 0):
            return ">"  # 下り階段
        for key in self.obstacles:
            ob = self.obstacles[key]
            if not key in self.flags and (ob.x, ob.y, ob.z) == (x, y, self.z):
                return key
        return ""

続いて移動判定処理です。

これから移動しようとしている先のマスが壁などの侵入不可能なマスか、扉・宝箱・NPCの障害物と重なる場合は移動はできないので、移動の方向を示すdxとdyを0にします。
同時に、移動先が回復の泉や宝箱などであれば、それに応じたイベントを発動させます。
そこで、上のコード内のeventプロパティにて「移動先に何があるかを取得する」処理を行なっています。

移動先のマスのタイルが何かは、px.tilemaps[マップ番号].pget(x座標, y座標)で取得することができます。
上記の処理ではx座標とy座標を2倍にしていますが、これは本サンプルのマップのマスの単位が16ドットx16ドットであるのに対して、Pyxelのタイルマップが8ドットx8ドットの最小スプライトの単位でデータを管理しているためです。

その後、移動先のマスに何もなかった場合は、扉・宝箱・NPCを管理しているAppクラスのobstaclesを回して、有効(フラグが立っている=消滅していない)かつ座標が一致するものがあればキー値を取得します。

これで、たとえばあるNPCに「1-1」というキー値を設定したとすると、そのNPCに向けて方向キーを入れた時に「1-1」がeventから返却されるので、移動はせずにイベントが発火する、という処理を実現できます。

セーブ・ロード機能を作る

セーブ・ロード機能を作る上で、まず考えておく必要があるのは「Webからの起動でもセーブ・ロードできるようにする」という点です。

また、仮にWeb上のみでプレイしてもらうことを想定していたとしても、開発・テスト中の利便性を考えると、ローカルでも動作するようにするべきでしょう。

そこで、まずソース冒頭に以下のようなコードを入れます。

IS_WEB = True

try:
    from js import window
except:
    IS_WEB = False

tryブロック中でimportしているjsというライブラリは、PythonからWeb APIを呼び出すためのライブラリです。
今回具体的に使いたいのはlocalStorageなので、windowオブジェクトをインポートします。

PyxelはPyodideというPythonをWeb上で実行できるライブラリを使ってWebアプリ機能を実現しているのですが、このPyodide環境でPythonを動かした場合、jsライブラリは標準的に使用できます。
一方、ローカルで動かした場合はこのimport処理は通常エラーになるはずなので、exceptブロックに入って「IS_WEB = False」が設定されます。

次に、ローカルでプレイする場合のセーブデータのフルパスを取得する関数を用意します。
この関数は、この章の最初のほうで説明したユーティリティ関数として位置付けておきます。

def get_data_file():
    return px.user_data_dir("shiromofu factory", "tinyDRPG") + "save.json"

px.user_data_dir関数は、OSに応じた適切なパス名を返す関数です。
そのため、たとえばWindowsマシンでget_data_file関数を実行すると、返却値は
C:\Users\(ユーザー名)\.pyxel\shiromofufactory\tinyDRPG\save.json
となります。

px.user_data_dir関数で取得したディレクトリが存在しない場合は自動的にディレクトリを作成してくれるので、初回実行時に自分でディレクトリ作成する処理などを入れるといった考慮は不要です。嬉しいですね。

ではこれで準備が整ったので、セーブとロード機能の本処理をコーディングします。

    def load_data(self):
        try:
            if IS_WEB:
                data_str = window.localStorage.getItem("pyxel-tiny-drpg")
            else:
                with open(get_data_file(), mode="r", encoding="utf-8") as f:
                    data_str = f.read()
            data = json.loads(data_str)
            self.x = data["x"]
            self.y = data["y"]
            self.z = data["z"]
            self.gold = data["gold"]
            self.keys = data["keys"]
            self.flags = data["flags"]
            self.enc = data["enc"]
            self.frames = data["frames"]
            self.pl = Actor(
                data["name"],
                data["mhp"],
                data["mmp"],
                data["atk"],
                data["spd"],
            )
            self.pl.hp = data["hp"]
            self.pl.mp = data["mp"]
            return True
        except Exception:
            # ロード失敗(初回プレイ)
            self.new_game()
            return False

こちらはロード処理で、ウェブ起動の場合はブラウザのlocalStorageから、ローカル起動の場合は前述の関数で取得したセーブファイルから、json形式のテキストデータを読み込みます。

このテキストデータをjson.loads関数(jsonはpythonの標準ライブラリ)で辞書形式に変換すれば、これをもとにAppクラスの変数を設定したり、プレイヤーキャラクタ(Actorクラス)を生成したりできます。

初回プレイ時はjsonデータが存在しないので、強制的にニューゲーム扱いとします。
new_game関数は、以下のように初期状態のプレイヤーキャラクタや空っぽのフラグ・お金などのデータをセットする処理を行います。

    def new_game(self):
        self.pl = Actor("あなた", 30, 6, 12, 12)
        self.go_start_location()
        self.gold = 0
        self.keys = 0  # カギの数
        self.flags = []  # フラグ(宝箱、扉などの判定用)
        self.enc = 0  # エンカウント
        self.frames = 0

ロードの次はセーブです。
これはロードの反対のことをするだけですね。

    def save_data(self):
        try:
            data = {
                "x": self.x,
                "y": self.y,
                "z": self.z,
                "gold": self.gold,
                "keys": self.keys,
                "flags": self.flags,
                "enc": self.enc,
                "frames": self.frames,
                "name": self.pl.name,
                "hp": self.pl.hp,
                "mhp": self.pl.mhp,
                "mp": self.pl.mp,
                "mmp": self.pl.mmp,
                "atk": self.pl.atk,
                "spd": self.pl.spd,
            }
            data_str = json.dumps(data)
            if IS_WEB:
                window.localStorage.setItem("pyxel-tiny-drpg", data_str)
            else:
                with open(get_data_file(), "w", encoding="utf_8") as f:
                    f.write(data_str)
        except Exception:
            pass

丁寧に作るなら、何らかの理由でセーブに失敗したときの例外処理を組み込む(「エラー発生 セーブできませんでした」とメッセージを表示するなど)必要がありますが、本サンプルでは省略してエラーを握りつぶしています。

BGMを演奏する

    def play_bgm(self, bgm):
        if bgm != self.bgm:
            px.playm(bgm, loop=True)
            self.bgm = bgm

BGMの演奏は、Pyxel標準アセットの形式でミュージックデータを制作した場合、上記のようにとても簡単な処理で実現できます。

関数play_bgmの引数bgmには0~2の値が入り、この値がエディタ上のミュージック番号に対応しています。

少し工夫しているポイントとして、「現在どの曲を演奏しているか」をAppクラスの変数bgmに保持しています。
たとえば、ダンジョン内では地下1階のセーフエリアのみ2番、以外は1番のBGMを使うのですが、階が変わるたびに以下のような処理を行うことで「地下2階から地下1階に移動したらBGMが切り替わる」「地下2階から地下3階に移動してもBGMはそのまま流れ続ける」という挙動が実現できます。

    def field_bgm(self):
        self.play_bgm(1 if self.z > 0 else 2)

その他の機能(バトル・メニュー・イベント処理など)

このほかにも、バトルやメニュー、NPCとの会話イベント、プレイヤーのパワーアップ(ショップ的な)機能などがありますが、ここまでの部分が理解できる状態であれば、あとはほぼPythonのコードを継ぎ足していくだけなので、それほど難しいポイントはないはずです。

3章で説明した仕様(もしくはプレイして把握するほうが早いかも)とソースコードのコメントをもとに、どこで何をやっているか必要に応じて分析してみてください!

6. おわりに

かなりの文字数になってしまいました。
お読みいただいた労力に見合う価値があるか不安もありますが、一箇所でもお役に立てるところがあったり「ちょっとやってみようかな?」という気になった方がいれば嬉しい限りです。

練習目的で本記事のサンプル作品を改変・拡張していただくのもいいと思います。コードも自由に利用いただいてかまいません。
ぱっと思いつくだけでも、以下のようにいろいろなアイデアがあります。

  • グラフィックをオリジナルのものに差し替えたり、タイトルロゴをつけたり、画面サイズを大きくしたりする

  • 演出が寂しいので、場面切り替え時にフェードアウト・インするようにしたり、ダメージを受けると画面が揺れる処理を入れたりする

  • ダンジョンに針山やテレポートの罠などのギミックを追加してよりスリリングにする

  • ダンジョンを自動生成にする

  • アイテムや装備、レベルアップ制度を追加してより本格的なRPGにする

  • いっそワールドマップを追加してJRPGにしてみる

  • 仲間やモンスターの数を増やし、1:1ではないバトルシステムに進化させる

もしいい感じの仕上がりになったら、そのまま公開していただいても大丈夫です。(その場合は、この記事がベースになっている点だけ言及していただけると嬉しいです。)

それでは、楽しいゲーム制作ライフを!

いいなと思ったら応援しよう!