画面をもう 1 つ増やし、「初期状態へ戻す」を関数にまとめる
タイトル画面を足して3 画面にし、何度でもあそべるようにします。 これで step1 のゲームとしての形が完成します。 残るフェーズ 8〜10 は、お化けを増やす・音をつける・公開する、という上積みです。
画面の切りかえ自体はフェーズ 6 と同じ形をもう 1 本増やすだけです。 新しいのは「やり直すときに、変数を最初の状態へ戻す」という話のほうです。
scene に入る値が 1 つ増え、update() と draw() の
枝が 1 本増えるだけです。フェーズ 6 で
「値そのものに意味はない」と書いたとおり、番号を振り直す必要はありません。
SCENE_PLAY = 0 # あそんでいる画面
SCENE_GAMEOVER = 1 # ゲームオーバーの画面
SCENE_TITLE = 2 # タイトル画面 ← 足すのはこの 1 行
2 回目をあそぶとき、スコアが前回の 12 のままだったり、 お化けが男の子の目の前にいるままだったりしては困ります。
「やり直し」に特別な機能はありません。変数を最初の値に代入し直すだけです。
そしてその「最初の値」は、すでに __init__() に書いてあります。
__init__() から、初期化だけを取り出す
いまの __init__() は、性質の違う 2 つの仕事をしています。
| 仕事 | いつやるか |
|---|---|
pyxel.init() / pyxel.load() / pyxel.run() |
起動のとき 1 回だけ。2 回目はない |
| 男の子の位置・お化けの位置・スコア・お菓子 | あそび始めるたび毎回 |
混ざっているので、後半だけを reset_game() という名前で取り出します。
やることが変わるわけではありません。置き場所を変えて、
あとから呼び直せるようにするだけです。
__init__() は 1 回だけ、reset_game() は始めるたびbtnp() を使う(btn() ではない)フェーズ 2 で出てきた区別が、ここで効いてきます。
| 意味 | ここで使うと | |
|---|---|---|
btn() | 押されている(毎フレーム True) | Enter を押した指を離す前に、タイトル → ゲーム → …と一気に進んでしまう |
btnp() | 押された瞬間だけ True | 1 回押したら 1 回だけ進む。こちらを使います |
逃げているときは矢印キーを連打しています。その勢いで Enter を押していると、 GAME OVER の文字を見る前に、次のゲームが始まってしまいます。
フェーズ 6 で入れた「0.5 秒待ってから文字を出す」のと同じ待ち時間を、 キーの受付にも使います。文字が出ていない間は、押しても何も起きないようにします。
15 が、
「文字を出す判定」と「キーを受け付ける判定」の 2 か所に必要になりました。
フェーズ 4 で決めた基準どおり、GAMEOVER_WAIT = 15 という名前を付けます。
点滅の 30 と 20 も、タイトルとゲームオーバーの 2 か所で使うので同じように格上げします。
「もう一度あそぶ」には、行き先が 2 通り考えられます。
| 戻り先 | 操作の回数 | 向いている場合 |
|---|---|---|
| タイトルへ戻す | Enter を 2 回(タイトルでもう 1 回押す) | タイトルにゲームを始める以外の道があるとき。 難易度を選ぶ、あそび方を読む、ハイスコアを見る、など |
| そのままゲームへ戻す | Enter を 1 回 | タイトルが「始める」ボタンでしかないとき。いまはこちら |
いまのタイトル画面には、ゲームへ進む以外の道がありません。 そこを経由させるとプレイヤーは意味のない Enter を 1 回よけいに押すことになります。 だからゲームオーバー → Enter → すぐ再挑戦にします。 アーケードゲームでよくある形です。
def update_gameover(self):
"""ゲームオーバー画面。少し待ってから、Enter でもう一度始める"""
if pyxel.frame_count - self.gameover_frame < GAMEOVER_WAIT:
return
if pyxel.btnp(pyxel.KEY_RETURN):
self.reset_game() # ← タイトルへ行かないので、ここで初期化する
self.scene = SCENE_PLAY
reset_game() の呼び忘れに注意
タイトルへ戻す形なら初期化は update_title() の 1 か所で済みますが、
直接ゲームへ戻すならこちらにも書く必要があります。
忘れるとスコアが前回のまま、お化けが目の前にいる状態で再開してしまいます。
新しい API は pyxel.btnp() だけで、これもフェーズ 2 で一度出ています。
| 機能 | 役割 |
|---|---|
pyxel.btnp() | キーが押された瞬間を拾う |
pyxel.KEY_RETURN | Enter キー |
| メソッドの切り出し | reset_game() — 初期化を呼び直せる形にする |
if / elif / else | 3 つの画面へ振り分ける |
値を書かない return | その先を実行せずに関数を抜ける(フェーズ 6 で登場) |
pyxel.COLOR_YELLOW / COLOR_GRAY | 色の番号 10 と 13 |
SCENE_PLAY = 0 # あそんでいる画面
SCENE_GAMEOVER = 1 # ゲームオーバーの画面
SCENE_TITLE = 2 # タイトル画面
GAMEOVER_WAIT = 15 # 捕まってから文字を出すまでのフレーム数(0.5 秒)
BLINK_CYCLE = 30 # 点滅 1 周期のフレーム数(1 秒)
BLINK_ON = 20 # そのうち文字が見えているフレーム数
draw_center() の下に置きます。class の外です。
self を使わないので外に出せる——フェーズ 6 と同じ基準です。
def is_blink_on():
"""点滅の「見えている」タイミングなら True を返す"""
return pyxel.frame_count % BLINK_CYCLE < BLINK_ON
__init__() から初期化を取り出す
いまの __init__() のまん中の 6 行を、そのまま
reset_game() へ移します。中身は 1 文字も変えません。
def __init__(self):
"""起動時の設定"""
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Candy Hunt")
pyxel.load("candy_hunt.pyxres")
self.scene = SCENE_TITLE # ← 起動したらタイトルから
self.reset_game()
pyxel.run(self.update, self.draw)
def reset_game(self):
"""ゲームの中身を、最初の状態に戻す"""
self.boy_x = SCREEN_WIDTH // 2 - BOY_SIZE // 2
self.boy_y = SCREEN_HEIGHT // 2 - BOY_SIZE // 2
self.ghost_x = 0
self.ghost_y = 0
self.score = 0
self.place_candy()
update() の枝を 3 本にする def update(self):
"""フレーム毎の更新処理"""
if self.scene == SCENE_TITLE:
self.update_title()
elif self.scene == SCENE_PLAY:
self.update_play()
else:
self.update_gameover()
def update_title(self):
"""タイトル画面。Enter でゲームを始める"""
if pyxel.btnp(pyxel.KEY_RETURN):
self.reset_game()
self.scene = SCENE_PLAY
def update_gameover(self):
"""ゲームオーバー画面。少し待ってから、Enter でもう一度始める"""
if pyxel.frame_count - self.gameover_frame < GAMEOVER_WAIT:
return
if pyxel.btnp(pyxel.KEY_RETURN):
self.reset_game()
self.scene = SCENE_PLAY
どちらも reset_game() を呼んでから SCENE_PLAY にします。
入口が 2 つ(タイトルから/ゲームオーバーから)あっても、
始めるときにやることは同じだからです。
関数に切り出しておいた効きめが、ここで出ます。
draw() の枝を 3 本にするタイトルは盤面を描きません。そこだけフェーズ 6 と形が変わります。
def draw(self):
"""フレーム毎の描画処理"""
pyxel.cls(pyxel.COLOR_BLACK)
if self.scene == SCENE_TITLE:
self.draw_title()
return
self.draw_play()
if self.scene == SCENE_GAMEOVER:
self.draw_gameover()
def draw_title(self):
"""タイトル画面を描く"""
draw_center("CANDY HUNT", 44, pyxel.COLOR_YELLOW)
if is_blink_on():
draw_center("PRESS ENTER", 68, pyxel.COLOR_WHITE)
すでに書いてある draw_gameover() を、
定数を使う形に直して、案内の 1 行を足します。
def draw_gameover(self):
"""ゲームオーバーの文字を重ねて描く"""
if pyxel.frame_count - self.gameover_frame < GAMEOVER_WAIT:
return
if is_blink_on():
draw_center("GAME OVER", 52, pyxel.COLOR_RED)
draw_center(f"SCORE {self.score}", 66, pyxel.COLOR_WHITE)
draw_center("PRESS ENTER TO RETRY", 80, pyxel.COLOR_GRAY)
GAME OVER は点滅、SCORE と PRESS ENTER は出しっぱなしです。
いくつも点滅すると、どれを見ればいいのか分からなくなります。
目立たせたいものを 1 つ選ぶ、というのが点滅の使い方です。
ここまでを反映した全体です。行が増えるより、置き場所が変わるのが中心です。
# candy_hunt.py
# step1 フェーズ7:タイトル画面を足して、もう一度あそべるようにする
import random
import pyxel
SCREEN_WIDTH = 160
SCREEN_HEIGHT = 120
BOY_SIZE = 8 # 男の子の絵の大きさ(縦横とも 8 ドット)
BOY_SPEED = 2 # 1 フレームで進むドット数
CANDY_SIZE = 8 # お菓子の絵の大きさ
GHOST_SIZE = 8 # お化けの絵の大きさ
GHOST_SPEED = 1 # 1 フレームで進むドット数(男の子の半分)
HIT_SIZE = 4 # 当たり判定に使う四角の大きさ(絵の中央だけを見る)
HIT_OFFSET = (BOY_SIZE - HIT_SIZE) // 2 # 絵の左上から、判定の四角までの距離(= 2)
SCENE_PLAY = 0 # あそんでいる画面
SCENE_GAMEOVER = 1 # ゲームオーバーの画面
SCENE_TITLE = 2 # タイトル画面
GAMEOVER_WAIT = 15 # 捕まってから文字を出すまでのフレーム数(0.5 秒)
BLINK_CYCLE = 30 # 点滅 1 周期のフレーム数(1 秒)
BLINK_ON = 20 # そのうち文字が見えているフレーム数
def is_hit(x1, y1, size1, x2, y2, size2):
"""2 つの四角が重なっていれば True を返す"""
return (x1 < x2 + size2 and x2 < x1 + size1 and
y1 < y2 + size2 and y2 < y1 + size1)
def draw_center(s, y, col):
"""文字列を画面の横中央に描く"""
x = (SCREEN_WIDTH - len(s) * 4) // 2 # 4 は 1 文字分のフォント幅
pyxel.text(x, y, s, col)
def is_blink_on():
"""点滅の「見えている」タイミングなら True を返す"""
return pyxel.frame_count % BLINK_CYCLE < BLINK_ON
class App:
def __init__(self):
"""起動時の設定"""
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT, title="Candy Hunt")
pyxel.load("candy_hunt.pyxres")
self.scene = SCENE_TITLE
self.reset_game()
pyxel.run(self.update, self.draw)
def reset_game(self):
"""ゲームの中身を、最初の状態に戻す"""
self.boy_x = SCREEN_WIDTH // 2 - BOY_SIZE // 2
self.boy_y = SCREEN_HEIGHT // 2 - BOY_SIZE // 2
self.ghost_x = 0
self.ghost_y = 0
self.score = 0
self.place_candy()
def update(self):
"""フレーム毎の更新処理"""
if self.scene == SCENE_TITLE:
self.update_title()
elif self.scene == SCENE_PLAY:
self.update_play()
else:
self.update_gameover()
def update_title(self):
"""タイトル画面。Enter でゲームを始める"""
if pyxel.btnp(pyxel.KEY_RETURN):
self.reset_game()
self.scene = SCENE_PLAY
def update_play(self):
"""あそんでいる間の更新"""
self.move_boy()
self.move_ghost()
self.check_candy()
self.check_ghost()
def update_gameover(self):
"""ゲームオーバー画面。少し待ってから、Enter でもう一度始める"""
if pyxel.frame_count - self.gameover_frame < GAMEOVER_WAIT:
return
if pyxel.btnp(pyxel.KEY_RETURN):
self.reset_game()
self.scene = SCENE_PLAY
def move_boy(self):
"""矢印キーで男の子を動かす"""
if pyxel.btn(pyxel.KEY_LEFT):
self.boy_x -= BOY_SPEED
if pyxel.btn(pyxel.KEY_RIGHT):
self.boy_x += BOY_SPEED
if pyxel.btn(pyxel.KEY_UP):
self.boy_y -= BOY_SPEED
if pyxel.btn(pyxel.KEY_DOWN):
self.boy_y += BOY_SPEED
self.boy_x = max(0, min(self.boy_x, SCREEN_WIDTH - BOY_SIZE))
self.boy_y = max(0, min(self.boy_y, SCREEN_HEIGHT - BOY_SIZE))
def move_ghost(self):
"""お化けを男の子に近づける"""
if self.ghost_x < self.boy_x:
self.ghost_x += GHOST_SPEED
elif self.ghost_x > self.boy_x:
self.ghost_x -= GHOST_SPEED
if self.ghost_y < self.boy_y:
self.ghost_y += GHOST_SPEED
elif self.ghost_y > self.boy_y:
self.ghost_y -= GHOST_SPEED
def place_candy(self):
"""男の子と重ならない場所へ、お菓子を置きなおす"""
while True:
self.candy_x = random.randint(0, SCREEN_WIDTH - CANDY_SIZE)
self.candy_y = random.randint(0, SCREEN_HEIGHT - CANDY_SIZE)
if not is_hit(self.boy_x, self.boy_y, BOY_SIZE,
self.candy_x, self.candy_y, CANDY_SIZE):
break
def check_candy(self):
"""お菓子にしっかり重なったら、スコアを増やして次の場所に置きなおす"""
if is_hit(self.boy_x + HIT_OFFSET, self.boy_y + HIT_OFFSET, HIT_SIZE,
self.candy_x + HIT_OFFSET, self.candy_y + HIT_OFFSET, HIT_SIZE):
self.score += 1
self.place_candy()
def check_ghost(self):
"""お化けに捕まったらゲームオーバーにする"""
if is_hit(self.boy_x + HIT_OFFSET, self.boy_y + HIT_OFFSET, HIT_SIZE,
self.ghost_x + HIT_OFFSET, self.ghost_y + HIT_OFFSET, HIT_SIZE):
self.scene = SCENE_GAMEOVER
self.gameover_frame = pyxel.frame_count # 捕まった時刻を覚えておく
def draw(self):
"""フレーム毎の描画処理"""
pyxel.cls(pyxel.COLOR_BLACK)
if self.scene == SCENE_TITLE:
self.draw_title()
return
self.draw_play()
if self.scene == SCENE_GAMEOVER:
self.draw_gameover()
def draw_title(self):
"""タイトル画面を描く"""
draw_center("CANDY HUNT", 44, pyxel.COLOR_YELLOW)
if is_blink_on():
draw_center("PRESS ENTER", 68, pyxel.COLOR_WHITE)
def draw_play(self):
"""あそんでいる画面を描く"""
pyxel.blt(self.candy_x, self.candy_y, 0, 16, 0,
CANDY_SIZE, CANDY_SIZE, pyxel.COLOR_BLACK) # お菓子
pyxel.blt(self.ghost_x, self.ghost_y, 0, 8, 0,
GHOST_SIZE, GHOST_SIZE, pyxel.COLOR_BLACK) # お化け
pyxel.blt(self.boy_x, self.boy_y, 0, 0, 0,
BOY_SIZE, BOY_SIZE, pyxel.COLOR_BLACK) # 男の子
pyxel.text(4, 4, f"SCORE {self.score}", pyxel.COLOR_WHITE) # スコア
def draw_gameover(self):
"""ゲームオーバーの文字を重ねて描く"""
if pyxel.frame_count - self.gameover_frame < GAMEOVER_WAIT: # 0.5 秒は文字を出さない
return
if is_blink_on():
draw_center("GAME OVER", 52, pyxel.COLOR_RED)
draw_center(f"SCORE {self.score}", 66, pyxel.COLOR_WHITE)
draw_center("PRESS ENTER TO RETRY", 80, pyxel.COLOR_GRAY)
App()
PRESS ENTER が点滅するreset_game() を呼ぶ理由
最初はタイトル画面なので、男の子もお菓子もまだ描きません。
それでも __init__() で 1 回呼んでおきます。
self.scene = SCENE_TITLE
self.reset_game() # ← タイトルなのに呼ぶ
呼ばないと、self.boy_x などの変数がまだ 1 度も作られていない状態になります。
Python では 代入したときに初めて変数ができるので、
うっかりどこかで読もうとすると
AttributeError になります。置き場所を先に作っておくための 1 行です。
place_candy() は男の子の位置を必要とします
reset_game() の中で place_candy() を最後に呼んでいるのは、
お菓子を「男の子と重ならない場所」に置くために、
先に男の子の位置が決まっていないといけないからです。
順番を入れかえると、前回の位置を基準にしてしまいます。
draw() の途中で return する if self.scene == SCENE_TITLE:
self.draw_title()
return # ← ここで終わり。この下は実行されない
self.draw_play()
タイトルのときだけ「盤面を描かずに終わる」ので、return で抜けています。
else を使って下をまるごと囲んでも同じ動きになりますが、
インデントが 1 段深くなるぶん読みにくくなります。
「例外的なケースを先に片づけて、あとは本筋だけ書く」——
これは return のよくある使い方で、
フェーズ 6 の draw_gameover() でも同じ形を使いました。
else にしてよい場所、いけない場所
update() の 3 本目は else で受けています。
if self.scene == SCENE_TITLE:
self.update_title()
elif self.scene == SCENE_PLAY:
self.update_play()
else: # = SCENE_GAMEOVER のとき
self.update_gameover()
scene には3 つの値しか入らないと分かっているので、
残りは 1 つに決まります。フェーズ 5 の move_ghost() で
else が使えなかったのとは事情が違います。あちらは
「小さい・大きい・等しい」の 3 通りがあり、
else にすると「等しい」が紛れこんでしまうからでした。
elif self.scene == SCENE_GAMEOVER: と書いてもかまいません。
3 つ並べたほうが対称に見えて分かりやすいと感じるなら、そちらでも正解です。
同じ GAMEOVER_WAIT を、描くほうと受け付けるほうの 2 か所で見ています。
| 場所 | していること |
|---|---|
draw_gameover() | まだ文字を出さない |
update_gameover() | まだ Enter を受け付けない |
この 2 つがそろっていることが大事です。 「見えていないものは押せない」—— 画面に出ていない案内を押せてしまうと、プレイヤーは 「何が起きたのか分からないうちに画面が変わった」と感じます。
いまの点滅は pyxel.frame_count をそのまま見ているので、
捕まったタイミングによって、文字が出るまでの間がずれます。
実際に測ると 0.50 秒のときも 0.83 秒のときもあります
(待ちの 0.5 秒が明けた瞬間、たまたま点滅の「消えている側」だと、そのぶん延びるため)。
毎回きっちりそろえたいなら、捕まった時刻からの経過で点滅させます。
def draw_gameover(self):
"""ゲームオーバーの文字を重ねて描く"""
elapsed = pyxel.frame_count - self.gameover_frame # 捕まってからの経過
if elapsed < GAMEOVER_WAIT:
return
if (elapsed - GAMEOVER_WAIT) % BLINK_CYCLE < BLINK_ON: # 待ち明けを起点に点滅
draw_center("GAME OVER", 52, pyxel.COLOR_RED)
draw_center("PRESS ENTER TO RETRY", 80, pyxel.COLOR_GRAY)
draw_center(f"SCORE {self.score}", 66, pyxel.COLOR_WHITE)
elapsed という変数に入れておくと、同じ引き算を 2 回書かずに済み、
式の意味も読み取りやすくなります。
- GAMEOVER_WAIT を引くところが肝心です
ただ elapsed % BLINK_CYCLE と書くと、間の長さは毎回 0.50 秒でそろいますが、
最初のひと光りが 5 フレーム(0.17 秒)しか続きません。
待ちが明けた瞬間、点滅の周期はすでに 15 フレームぶん進んでいて、
「見えている 20 フレーム」の残り 5 フレームに飛びこむからです。● が見えているフレーム)。
elapsed % BLINK_CYCLE ・・・・・・・・・・・・・・・●●●●●・・・・・・・・・・●●●●●●●●●●
(elapsed - GAMEOVER_WAIT) % ... ・・・・・・・・・・・・・・・●●●●●●●●●●●●●●●●●●●●・・・・・
PRESS ENTER TO RETRY を if の中に入れると、
GAME OVER と息を合わせて光ります。2 つが同時に動くぶんには
「どちらを見ればいいか分からない」にはならず、ひとかたまりの合図として読めます。
外に出して出しっぱなしにすれば、案内はいつでも読めます。好みで選んでください。
reset_game() で消してはいけない値、という例になります。
def __init__(self):
...
self.high_score = 0 # ← reset_game ではなく __init__ に置く
self.scene = SCENE_TITLE
self.reset_game()
def check_ghost(self):
"""お化けに捕まったらゲームオーバーにする"""
if is_hit(...):
self.scene = SCENE_GAMEOVER
self.gameover_frame = pyxel.frame_count
self.high_score = max(self.high_score, self.score) # 最高記録を更新
def draw_gameover(self):
"""ゲームオーバーの文字を重ねて描く"""
if pyxel.frame_count - self.gameover_frame < GAMEOVER_WAIT:
return
if is_blink_on():
draw_center("GAME OVER", 52, pyxel.COLOR_RED)
draw_center(f"SCORE {self.score}", 66, pyxel.COLOR_WHITE)
draw_center(f"HI-SCORE {self.high_score}", 76, pyxel.COLOR_YELLOW)
draw_center("PRESS ENTER TO RETRY", 90, pyxel.COLOR_GRAY)
どこに変数を置くかが、そのまま「いつ消えるか」を決めます。
reset_game() に入れれば毎回 0 に戻り、
__init__() に入れればアプリを閉じるまで残ります。
open() で書いて読む)だけでできます。
| 症状 | 見るところ |
|---|---|
| 起動するといきなりゲームが始まる | __init__() の self.scene が
SCENE_PLAY のままです。SCENE_TITLE にします |
| Enter を押すとタイトルとゲームを行き来してしまう | btn() になっています。btnp() にします |
| 2 回目のスコアが 0 から始まらない | update_gameover() で self.reset_game() を
呼んでいるか確認します。self.scene を変えるだけでは戻りません。
update_title() と 2 か所あります |
| 2 回目にお化けが目の前から始まる | 同じく reset_game() の呼び忘れです。
ghost_x / ghost_y が前回のまま残っています |
| タイトル画面に男の子やお化けが出ている | draw() の return を書き忘れています。
draw_title() のあと、そのまま draw_play() まで進んでいます |
| ゲームオーバーで Enter が効かない | update() の else の中で
self.update_gameover() を呼んでいるか確認します。
あるいは 0.5 秒の待ち時間中に押しています(仕様どおりです) |
AttributeError: ... 'gameover_frame' |
フェーズ 6 の check_ghost() に
self.gameover_frame = pyxel.frame_count を書き忘れています |
AttributeError: ... 'boy_x' |
__init__() の self.reset_game() を書き忘れています。
定義しただけでは呼ばれません |
| タイトルの文字が中央からずれる | draw_center() ではなく pyxel.text() を
直接呼んでいませんか |
これでゲームとして一周する形ができました。あそんで、捕まって、 Enter 1 回でまた始められます。
次のフェーズ 8 ではお菓子を集めるほどお化けが増えます(最大 4 体)。
ここでリストが登場します。ghost_x / ghost_y という
変数 2 つで 1 体を持つやり方は、4 体になると破綻するからです。
「同じものがいくつもある」ときはリストにする——step1 最後の山場です。
GHOST_SPEED = 1 は1 体でも全力で逃げて 5 秒という速さです。
4 体になったら明らかに厳しいので、フェーズ 8 で
速さ・増えるペース・出てくる場所をまとめて調整します。