皆さんこんにちは。ボンタンの連打で勝敗が決まるようなゲームでは、1つのボタンを両手の親指で交互に押す小幡です。みんなで遊べるパーティー系のゲームで見かけるゲーム性だと思いますが、「ボタンを押す」というとても単純なゲームなのに、どうしてあんなに楽しいのか不思議に思います。
前回の記事は以下リンクからどうぞ。
前回のおさらいです。
- プレイヤー”P”に位置情報を持たせる
- キー入力を受け付け、押されたキーに応じてプレイヤーを移動させる
前回から二次元配列の要素を置換する作業などが始まり、少し難易度が上がってきたかと思います。今回もプレイヤーの動きに関する処理を追加していきますので少し複雑なコードに見えるかもしれません。たとえば、「下に移動」だけ実装しましたが、「上・右・左」も追加しますし、「もし移動先が”壁”だったら移動しない」や「”ゴール”にたどり着いたら”ゲームクリア”を表示する」などです。わからないところをそのままにせず、ゆっくりと読み解いていけば理解できると思いますので、あきらめずに頑張りましょう。
「上・下・左・右」とその他のキー入力を受け付ける
前回は”s”のみの入力を想定して実装を行いましたが、実際問題としてプレイヤーの入力は一種類ではありません。”s”だけでも、半角小文字、半角大文字、全角小文字、全角大文字の入力が考えられます。まずはその様な複数の入力を想定した実装に修正してみます。
新しくprocess.pyというファイルを作り、その中にProcessクラスを作成します。このクラスはあまり良いクラスとは言えないかもしれませんが、オブジェクト指向の習得の練習だと考えて貰えればと思います。(processは「処理する」という想定で作成しています。)
ちなみに現段階でのファイル構造は以下です。
python_rpg
┣ main.py
┣ map.py
┣ player.py
┗ process.py
新しく作ったprocess.pyのコードは、キー入力で受け取る可能性がある文字列を定数に置き換えて言語としてわかりやすくするイメージです。中身は以下です。
class Process:
UP = 'w'
DOWN = 's'
LEFT = 'a'
RIGHT = 'd'
ESC = 'q'
ITEM = 'e'
STATUS = 'z'
DECISION = 'x'
HELP = 'c'
EMPTY = ''
KEY_LIST = {
UP: ['w', 'W', 'w', 'W'],
DOWN: ['s', 'S', 's', 'S'],
LEFT: ['a', 'A', 'あ', 'A'],
RIGHT: ['d', 'D', 'd', 'D'],
ESC: ['q', 'Q', 'q', 'Q'],
ITEM: ['e', 'E', 'え', 'E'],
STATUS: ['z', 'Z', 'z', 'Z'],
DECISION: ['x', 'X', 'x', 'X'],
HELP: ['c', 'C', 'c', 'C'],
EMPTY: ['', ' ', ' '],
}
この構造は少し複雑になってしまいましたが、まずは大枠をとらえてください。
上から順番に、定数として「上・下・左・右」が「UP・DOWN・LEFT・RIGHT」のように定義されていることがわかるかと思います。これは「キーボードの”w”」は「DOWN(上へ)」という意味を持つという表現をしています。
この流れで見ると「キーボードの”q”」を押すと「ESC(エスケープ)」なので、「ゲームから抜ける」というイメージが湧くかと思います。もっと丁寧に作りたい場合は”q”ではなく”Esc”キーになるかと思います。
最後に”KEY_LIST”が定義されています。これは波括弧”{}”で代入されていますので、辞書型の変数であることがわかります。さらに辞書型のkeyが先ほど定義した定数になっています。そしてvalueがリストの配列になっています。一見するととても複雑なので、理解するのに時間がかかるポイントです。
これは結論から言うと、先ほど作成した「上へ」は「UP」という定義に、プラスして「上へ」として入力される値は「”半角小文字”、”半角大文字”、”全角小文字”、”全角大文字”」も含めるというような記述になっています。こうすることで全角大文字の”W”が入力されても「上へ」の処理ができるようにする準備が整いました。(今回の記事では”KEY_LIST”を使う実装まで至りません。)
「上・下・左・右」に移動する
それでは前回作成したmap.pyのmoveメソッドを改良して「上・下・左・右」に移動できるようにしていきます。先日作成したmoveメソッドは一旦削除し、新しくmoveメソッドを作成したコードが以下です。
from process import Process
# ~~ 中略 ~~
def move(self, input_key):
"""マップを移動
"""
next_height = self.now_h
next_width = self.now_w
# 下へ
if input_key in Process.DOWN:
next_height += 1
# 左へ
elif input_key in Process.LEFT:
next_width -= 1
# 右へ
elif input_key in Process.RIGHT:
next_width += 1
# 上へ
elif input_key in Process.UP:
next_height -= 1
self.change_field(next_height, next_width)
self.counter += 1
1行目で先ほど作成したprocess.pyのProcessクラスをインポートしています。
9行目・10行目でプレイヤーの位置座標を保存しています。これは後程使います。
13行目からそれぞれ押されたキーの条件判定と処理内容です。押されたキーに対応して移動したい方向へ数値が変動していることがわかるかと思います。「下へ」移動したい場合はProcess.DOWNに対応する文字列だった場合にTrueとなります。そしてTrueだった場合は、二次元配列で表示したマップでいう所の1つしたの段に移動したいので、行を1つプラスしています。(今回はまず半角小文字にのみ対応した実装です。次の記事で”KEY_LIST”を使う実装を行います。)
それぞれ処理されたこの値”next_height, next_width”を保持した状態で、次の処理28行目の”self.change_field”を行います。
これはメソッドの”change_field”という名前からわかる通り、フィールドマップの変更処理をします。前回でいう所の、「元いた場所を空地にし、移動先に”P”を表示する」というような処理です。
こうして、「上・下・左・右」のキー入力によってプレイヤーを移動させる準備ができました。
最後の29行目は、プレイヤーの移動が完了した後に「カウンター」に1をプラスすることで、「モンスターにエンカウントするかどうか」の判定に処理をつなげています。「移動が終わったら次はモンスターとの戦闘処理を作るんだなぁ」くらいに考えておいてもらえればと思います。
フィールドマップ更新判定
さて、続けて”change_field”メソッドを作成していきましょう。
次に行うのは「元いた場所を空地にし、移動先を”P”にする」処理なのですが、そのまえに、「移動先」の状況を確認しなければなりません。
例えば「移動先が”壁”の場合」と「移動先が”空地”の場合」では処理が異なります。もちろん、壁の場合は移動しないように処理する必要がありますし、もしもゴールだったらゴールの処理をしなければなりません。このように移動先に応じた処理を期日したコードが以下です。先ほど作った”move”メソッドのすぐ下に記述しています。
def change_field(self, height, width):
"""フィールドを更新
"""
# ゴールの場合
if self.map_lists[height][width] == MapItem.GOAL.value:
self.field = MapItem.GOAL.value
# 空地の場合
elif self.map_lists[height][width] == MapItem.EMPTY.value:
self._change_field(height, width)
self.field = MapItem.EMPTY.value
# 剣の場合
elif self.map_lists[height][width] == MapItem.WEAPON.value:
self._change_field(height, width)
self.field = MapItem.WEAPON.value
# 盾の場合
elif self.map_lists[height][width] == MapItem.SIELD.value:
self._change_field(height, width)
self.field = MapItem.SIELD.value
# 薬の場合
elif self.map_lists[height][width] == MapItem.HERBS.value:
self._change_field(height, width)
self.field = MapItem.HERBS.value
# 壁の場合
elif self.map_lists[height][width] == MapItem.BLOCK.value:
self.field = MapItem.BLOCK.value
それぞれの処理を分解して説明していきます。
# ゴールの場合
if self.map_lists[height][width] == MapItem.GOAL.value:
self.field = MapItem.GOAL.value
これはゴールの場合の処理です。最初のif文なので純粋にifで始まっています。
self.map_lists[height][width]というのは、前回同様に二次元配列の要素を指しています。しかし、ここの[height][width]というのはメソッドの引数で渡ってきた、加減算された結果が入っています。つまり移動先の値です。そのためこのif文は「移動先の値が、GOALの場合」と読み解くことができます。
最後に処理内容ですが、「self.fieldというメンバにGOALを入れているんだなぁ」ということがわかれば十分です。後で使用しますが、これはself.fieldの値を使用して、「もしGOALの値の場合、エンディングの処理をする」というような条件分岐で使用します。
「すぐにやりたいことをやればいいのでは?」という疑問が浮かぶかもしれません。もちろん、そのように記述しても構いません。今回の記述が最適解ではないとも思いますので、もっと良いコードが見つかりそうな場合はどんどん改良してみましょう。あくまでも今回は「フィールドを変更する」処理だけを書きたいので、「ゴールした時の処理」は他で行うことにしています。
今回はゴールでなかった場合を想定して、if文の次の処理を見てみましょう、elif文に入っていきます。
# 空地の場合
elif self.map_lists[height][width] == MapItem.EMPTY.value:
self._change_field(height, width)
self.field = MapItem.EMPTY.value
次は空地だった場合の処理です。elif文の条件判定と、末業のself.fieldに値を代入しているのは、先ほどの処理と同じです。違うのは真ん中のメソッドを呼び出している処理が追加されている事です。ここでもう1回”change_field”を呼び出しているというイメージです。
self._change_field(height, width)の中身が気になりますが、その前に、どんどんelif文を見ていきましょう。すると、薬の場合も県の場合も盾の場合も、構造は同じだということに気が付くと思います。”self.field”に値を入れて、”_change_field”を行うという構造です。
そして最後の壁の場合だけ、”_change_field”を呼び出していません。これは「壁にぶつかったのだから、そのままの状態が維持される」というイメージです。「フィールドを更新する必要がない」とも言い換えることができると思います。
元の場所を空地にし、移動先を”P”にする
さて、何度も繰り返し説明してきました「元の場所を空地西、移動先を”P”にする」処理を再び記述します。コードは以下です。”change_field”メソッドの下に記述しています。
def _change_field(self, height, width):
"""現在位置を空地へ
"""
self.map_lists[self.now_h][self.now_w] = MapItem.EMPTY.value
# 移動先をPへ
if height > self.now_h:
self.map_lists[self.now_h + 1][self.now_w] = MapItem.PLAYER.value
self.now_h += 1
elif height < self.now_h:
self.map_lists[self.now_h - 1][self.now_w] = MapItem.PLAYER.value
self.now_h -= 1
elif width > self.now_w:
self.map_lists[self.now_h][self.now_w + 1] = MapItem.PLAYER.value
self.now_w += 1
elif width < self.now_w:
self.map_lists[self.now_h][self.now_w - 1] = MapItem.PLAYER.value
self.now_w -= 1
最初の記述は見覚えがあるかもしれません。
self.map_lists[self.now_h][self.now_w] = MapItem.EMPTY.value
上の処理は、前回記述したコードと同じです。[self.now_h][self.now_w]これらは、プレイヤーの元の位置の値を表しているのがわかるかとおもいます。加減算した値ではないので注意です。その元の場所に空地の値を代入していますので「元の位置を空地にする」という処理は簡単に実現できました。
つぎに、4つの条件分岐が続いています。
# 移動先をPへ
if height > self.now_h:
self.map_lists[self.now_h + 1][self.now_w] = MapItem.PLAYER.value
self.now_h += 1
これは「移動先を”P”にする」ための条件分岐ですが、移動先の”height”と元の位置の”self.now_h”を比較しています。これは、この処理に至る前にheightかwidthを加減算していることがポイントです。
例えば「下へ」キーが押された場合、二次元配列の1つ下の行に移動するために”height”が1つ加算されているはずです。元の位置が[1, 1](height, width)の場合はheightが1足されることで2になっています。すると、この条件式では「if 2 > 1:」という事になり、答えはTrueです。
このようにして、それぞれのキーが入力された値に応じて、条件分岐し処理を行うという流れです。処理の中身は前回と同様に「移動先の値を”P”にする」という処理と、最終的に”P”が表示される位置と現在位置の値(self)を一致させるための処理を行っています。
ここも少し複雑に思えるかもしれません。heightとwidthは、二次元配列を操作するために作られた値であって、この処理を抜けると値は使えなくなります。その前にselfの値を”P”の位置へと修正することで値がインスタンスに保持されるということです。これで少しインスタンスの持つ力を体験できたかと思います。
単純な移動処理が完成!
大変お疲れ様でした。これでようやく「単純な移動処理」が完成しました!
実際に動作確認をしてみましょう。「上・下・左・右」がキーボードの「w・s・a・d」キーに対応しているのが確認できると思います。(まだ他の全角などには対応していません。)
さらに壁である”#”にぶつかった場合は移動していないことも確認できるかと思います。前回はここにエラーがあったので、大きな前進と言えるかもしれません。無事に”G”までたどり着ければ、移動処理は大丈夫と言えそうです。
薬や剣はぶつかると、そこに移動した後にそれらは消滅しているという状態になると思います。ここから想像すると「アイテムに乗ったら、アイテム取得処理を実行すればいい」という事が実現できそうですね。また”G”にたどり着いたときもエンディングに移行させれば、スタートからゴールまでを実現できそうです。
まとめ
今回は現在位置と移動先の座標を使い「単純な移動処理」を実現させることができました。
また次に実装すると楽しくなりそうな要素も見えてきたのではないでしょうか?一歩ずつではありますが、確実に前進しています。ここで紹介している2次元配列の値の取得や代入は、慣れるまで難しいと思います。資格レベルで言うと、基本情報技術者試験の問題として出てくるレベルだと思います。確か後期のJava問題を選択すると似たような2次元配列を使った問題があったかと思います。
初心者向けと言いつつも、なかなかのレベルになりつつあるかと思いますが、ここからはアイテムを取得・使用したり、モンスターとのバトルの処理も待っていますので楽しく学ぶことができると思います。諦めずに頑張りましょう。
ここまで読んで頂きありがとうございました。また次回も皆さんにお会いできることを楽しみにしております。
以下のDiscordサーバでも質問を受け付けています。お気軽に声をかけて頂けると幸いです!