チャンネルを分けて、鳴らしたい瞬間に 1 行足す
効果音 3 つ(お菓子を取った・捕まった・お化けが増えた)と BGM をつけます。
手ざわりがいちばん変わるフェーズです。 「取った感じが薄い」で 「効果音がいちばん効く」と書いたとおり、 当たり判定を縮めたときよりも、点滅を足したときよりも、はっきり変わります。
コードで足すのは 6 行だけです。時間がかかるのは音を作るほうで、 そちらはリソースエディタでの作業になります。
| 効果音 | BGM | |
|---|---|---|
| 長さ | 0.2〜0.4 秒くらい | 数秒〜。ずっとくり返す |
| 鳴らし方 | pyxel.play(ch, 番号) | pyxel.playm(番号, loop=True) |
| いつ呼ぶ | できごとが起きた瞬間に 1 回 | ゲームを始めるときに 1 回 |
| 作る場所 | どちらも .pyxres(リソースエディタ) | |
play と playm の違い
play は「音」を 1 つ鳴らします。playm の m は
music で、複数のチャンネルぶんの音をまとめて鳴らします。
BGM は「メロディ」と「ベース」を重ねるので playm です。
Pyxel は同時に 4 つまで音を出せます。この 4 本をチャンネルと呼びます。
大事なのは、同じチャンネルで新しい音を鳴らすと、前の音は止まるということです。 BGM と効果音を同じチャンネルにすると、お菓子を取るたびに BGM が途切れます。
「音を鳴らすタイミング」は、すでにコードの中にあります。 スコアを増やしている行、お化けを足している行、 ゲームオーバーにしている行——そのすぐ隣に 1 行足すだけです。
stop() を先、play() をあとに書きます
pyxel.stop() は引数なしだと 4 本すべて止めます。
逆に書くと、せっかく鳴らした「捕まった音」まで消えてしまいます。
speed で決まる
リソースエディタの SPEED は1 音の長さを決めます。実測すると
1 音の秒数 = SPEED ÷ 120 でした。
SPEED | 1 音の長さ | 4 音ならぶと | 用途 |
|---|---|---|---|
| 8 | 0.067 秒 | 0.27 秒 | 効果音(テンポよく鳴る) |
| 12 | 0.100 秒 | 0.40 秒 | 効果音(少し重い) |
| 20 | 0.167 秒 | 0.67 秒 | BGM のメロディ |
| 40 | 0.333 秒 | 1.33 秒 | BGM のベース |
| 機能 | 役割 |
|---|---|
pyxel.play(ch, snd) | チャンネル ch で音 snd を 1 回鳴らす |
pyxel.playm(msc, loop=True) | ミュージック msc をくり返し鳴らす |
pyxel.stop() | すべてのチャンネルを止める(stop(2) なら 1 本だけ) |
| リソースエディタの SOUND | 効果音と BGM のパートを作る |
| リソースエディタの MUSIC | SOUND を組み合わせて BGM にする |
pyxel edit step1/candy_hunt.pyxres
Alt+←/→ でタブを移動して、SOUND の画面にします。
操作のおさらいです。
| キー | できること |
|---|---|
Z S X D C V G B H N J M | 下の段の鍵盤(いまの OCTAVE の高さ) |
Q 2 W 3 E R 5 T 6 Y 7 U | 上の段の鍵盤(その 1 オクターブ上) |
Enter | カーソル位置に置いて 1 つ進む(音は 1 つずつこれで確定する) |
A | 休符(音名の「ラ」は N。混同しやすい) |
PageUp / PageDown | OCTAVE を上げ下げ(0 〜 3 の 4 段だけ) |
↓ / ↑ | TON・VOL・EFX の行へ移る |
SPACE / L | 再生して確かめる / ループ再生 |
Ctrl+Z | 取り消し(打ち間違えたら戻せる) |
Ctrl+S | 保存 |
このフェーズの音は、OCTAVE 3 と OCTAVE 1 の 2 つで全部打てます。 まずは OCTAVE 3 のときの割り当てを見てください。
黒鍵も含めた早見表です。OCTAVE がいくつでも、この並びは変わりません。
| 音名 | C | C# | D | D# | E | F | F# | G | G# | A | A# | B |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 下段 (OCTAVE の高さ) |
Z | S | X | D | C | V | G | B | H | N | J | M |
| 上段 (その 1 つ上) |
Q | 2 | W | 3 | E | R | 5 | T | 6 | Y | 7 | U |
Q がちょうど C4 です。PageDown を 4 回以上押していちばん下(0)にしてから、
PageUp を 3 回で OCTAVE 3 です。
PageDown を 2 回押して OCTAVE 1 にし、SOUND 1 → 5。
SOUND 0・1・2 に、それぞれ次の内容を入れます。
| 番号 | 音符 | TONE | VOLUME | EFFECT | SPEED | 長さ |
|---|---|---|---|---|---|---|
| 0 お菓子 |
C3 E3 G3 C4ドミソド(上がる) OCTAVE 3 / Z C B Q |
P | 6 | N | 8 |
0.27 秒 |
| 1 捕まった |
G2 E2 C2 A1ソミドラ(下がる) OCTAVE 1 / T E Q N |
S | 7 | F | 12 |
0.40 秒 |
| 2 お化け出現 |
C3 G3ドソ(2 音だけ) OCTAVE 3 / Z B |
P | 6 | N | 10 |
0.17 秒 |
Enter」の 2 打です。
Z C B Q と書いてあれば、
Z Enter C Enter …… と 4 回くり返します。N、G2・E2・C2 は上段の
T E Q で届きます。
T = 三角波(やわらかい・ベース向き)/
S = 矩形波(はっきりした電子音)/
P = パルス(軽くて明るい・効果音向き)/
N = ノイズ(ザーッという音・打撃や爆発向き)。N=なし、S=スライド、
V=ビブラート、F=フェードアウト。
捕まった音に F を入れているのは、音の終わりを自然に消すためです。
SOUND 3・4 がメロディ(前半・後半)、SOUND 5 がベースです。 イ短調で、少しあやしい雰囲気にしています。
| 番号 | 音符(16 音/8 音) | TONE | VOL | EFFECT | SPEED |
|---|---|---|---|---|---|
| 3 メロディ前半 |
A3 C4 E4 C4 A3 C4 E4 C4F3 A3 C4 A3 F3 A3 C4 A3
OCTAVE 3 / N Q E Q N Q E QV N Q N V N Q N |
T | 5 | N | 20 |
| 4 メロディ後半 |
G3 B3 D4 B3 G3 B3 D4 B3A3 C4 E4 C4 A3 E4 C4 A3
OCTAVE 3 / B M W M B M W MN Q E Q N E Q N |
T | 5 | N | 20 |
| 5 ベース |
A1 A1 F1 F1 G1 G1 A1 A1
OCTAVE 1 / N N V V B B N N |
S | 3 | N | 40 |
SPEED を選んであります
メロディは 16 音 × 20 ÷ 120 = 2.67 秒が 2 つで 5.33 秒。
ベースは 8 音 × 40 ÷ 120 = 2.67 秒です。F にする
ベースは A1 A1 のように同じ音が 2 つ続きます。
EFFECT が N(なし)だと、この 2 音は切れ目なくつながって、
1 つの長い音に聞こえます。8 音打ったのに 4 音に聞こえる、という状態です。F(フェードアウト)にすると、1 音ごとに音量が落ちて
音の切れ目=拍が聞こえるようになります。拍が聞こえると「のり」が出ます。
このプロジェクトのベースは F を採用しました。N は「まず素直な状態から始める」ための初期値です。
聞いてみて直したくなったら、そちらが正解です。
MUSIC タブに移り、MUSIC 0 に次のように入れます。
| チャンネル | 入れる SOUND 番号 |
|---|---|
| ch 0 | 3, 4(前半 → 後半の順) |
| ch 1 | 5 |
| ch 2 | 空のまま |
| ch 3 | 空のまま |
ch2・ch3 を空にしておくのが大事です。
ここを埋めると、効果音のチャンネルと取り合いになります。
L キーでループ再生して、つながりを確かめてください。
確認できたら Ctrl+S で保存します。
CORNERS の上に置きます。
SOUND_CANDY = 0 # お菓子を取った音
SOUND_CAUGHT = 1 # 捕まった音
SOUND_GHOST = 2 # お化けが増えた音
MUSIC_BGM = 0 # BGM(チャンネル 0 と 1 を使う)
CH_SE = 2 # 効果音を鳴らすチャンネル
CH_EVENT = 3 # お化けが増えた音だけ別のチャンネルにする
start_game() にまとめるBGM を鳴らしはじめる場所は「タイトルから始めるとき」と「やり直すとき」の 2 か所です。 どちらもやることは同じなので、1 つのメソッドにまとめます。
def start_game(self):
"""ゲームを始める。中身を初期化して BGM を鳴らしはじめる"""
self.reset_game()
self.scene = SCENE_PLAY
pyxel.playm(MUSIC_BGM, loop=True)
そして呼び出し側を、2 か所とも 1 行に減らします。
def update_title(self):
"""タイトル画面。Enter でゲームを始める"""
if pyxel.btnp(pyxel.KEY_RETURN):
self.start_game()
def update_gameover(self):
"""ゲームオーバー画面。少し待ってから、Enter でもう一度始める"""
if pyxel.frame_count - self.gameover_frame < GAMEOVER_WAIT:
return
if pyxel.btnp(pyxel.KEY_RETURN):
self.start_game()
def check_candy(self):
"""お菓子にしっかり重なったら、スコアを増やして次の場所に置きなおす"""
if is_hit(...):
self.score += 1
pyxel.play(CH_SE, SOUND_CANDY) # 取った音
self.place_candy()
if (self.score % GHOST_SPAWN_SCORE == 0
and len(self.ghosts) < len(GHOST_MOVE_EVERY)):
self.add_ghost()
pyxel.play(CH_EVENT, SOUND_GHOST) # 増えた音
def check_ghosts(self):
"""どれか 1 体にでも捕まったらゲームオーバーにする"""
for ghost in self.ghosts:
if is_hit(...):
self.scene = SCENE_GAMEOVER
self.gameover_frame = pyxel.frame_count
pyxel.stop() # BGM を止めてから
pyxel.play(CH_SE, SOUND_CAUGHT) # 捕まった音
return
# candy_hunt.py
# step1 フェーズ9:効果音と BGM をつける
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_MOVE_EVERY = [2, 4, 1, 3, 5] # 出てくる順に「何フレームに 1 回動くか」
GHOST_SPAWN_SCORE = 5 # お菓子を何個取るごとに 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 # そのうち文字が見えているフレーム数
SOUND_CANDY = 0 # お菓子を取った音
SOUND_CAUGHT = 1 # 捕まった音
SOUND_GHOST = 2 # お化けが増えた音
MUSIC_BGM = 0 # BGM(チャンネル 0 と 1 を使う)
CH_SE = 2 # 効果音を鳴らすチャンネル
CH_EVENT = 3 # お化けが増えた音だけ別のチャンネルにする
CORNERS = [ # お化けが出てくる 4 隅
(0, 0),
(SCREEN_WIDTH - GHOST_SIZE, 0),
(0, SCREEN_HEIGHT - GHOST_SIZE),
(SCREEN_WIDTH - GHOST_SIZE, SCREEN_HEIGHT - GHOST_SIZE),
]
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
def far_corner(x, y):
"""(x, y) からいちばん遠い隅を返す"""
best_x, best_y = CORNERS[0]
best_distance = -1
for corner_x, corner_y in CORNERS:
distance = abs(corner_x - x) + abs(corner_y -y)
if distance > best_distance:
best_x, best_y = corner_x, corner_y
best_distance = distance
return best_x, best_y
class Ghost:
"""お化け 1 体ぶんの場所と動き"""
def __init__(self, x, y, move_every):
"""1 体作るときに、置く場所と動く間隔を決める"""
self.x = x
self.y = y
self.move_every = move_every
self.born_frame = pyxel.frame_count # 生まれた時刻を覚えておく
def move(self, target_x, target_y):
"""自分の番のフレームだけ、目標へ 1 ドット近づく"""
if pyxel.frame_count % self.move_every != 0:
return
if self.x < target_x:
self.x += 1
elif self.x > target_x:
self.x -= 1
if self.y < target_y:
self.y += 1
elif self.y > target_y:
self.y -= 1
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.score = 0
self.ghosts = []
self.add_ghost()
self.place_candy()
def add_ghost(self):
"""男の子からいちばん遠い隅に、お化けを 1 体足す"""
move_every = GHOST_MOVE_EVERY[len(self.ghosts)]
x, y = far_corner(self.boy_x, self.boy_y)
self.ghosts.append(Ghost(x, y, move_every))
def update(self):
"""フレーム毎の更新処理"""
if self.scene == SCENE_TITLE:
self.update_title()
elif self.scene == SCENE_PLAY:
self.update_play()
else:
self.update_gameover()
def start_game(self):
"""ゲームを始める。中身を初期化して BGM を鳴らしはじめる"""
self.reset_game()
self.scene = SCENE_PLAY
pyxel.playm(MUSIC_BGM, loop=True)
def update_title(self):
"""タイトル画面。Enter でゲームを始める"""
if pyxel.btnp(pyxel.KEY_RETURN):
self.start_game()
def update_gameover(self):
"""ゲームオーバー画面。少し待ってから、Enter でゲームを再び始める"""
if pyxel.frame_count - self.gameover_frame < GAMEOVER_WAIT:
return
if pyxel.btnp(pyxel.KEY_RETURN):
self.start_game()
def update_play(self):
"""あそんでいる間の更新"""
self.move_boy()
self.move_ghosts()
self.check_candy()
self.check_ghosts()
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_ghosts(self):
"""お化けを男の子に近づける"""
for ghost in self.ghosts:
ghost.move(self.boy_x, self.boy_y)
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
pyxel.play(CH_SE, SOUND_CANDY) # 取った音
self.place_candy()
if (self.score % GHOST_SPAWN_SCORE == 0
and len(self.ghosts) < len(GHOST_MOVE_EVERY)):
self.add_ghost()
pyxel.play(CH_EVENT, SOUND_GHOST) # 増えた音
def check_ghosts(self):
"""どれか 1 体にでも捕まったらゲームオーバーにする"""
for ghost in self.ghosts:
if is_hit(self.boy_x + HIT_OFFSET, self.boy_y + HIT_OFFSET, HIT_SIZE,
ghost.x + HIT_OFFSET, ghost.y + HIT_OFFSET, HIT_SIZE):
self.scene = SCENE_GAMEOVER
self.gameover_frame = pyxel.frame_count # 捕まった時刻を覚えておく
pyxel.stop() # BGM を止めてから
pyxel.play(CH_SE, SOUND_CAUGHT) # 捕まった音
return
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) # お菓子
for ghost in self.ghosts: # お化けは何体でも
if pyxel.frame_count - ghost.born_frame < 30 and not is_blink_on():
continue # 出てきて 1 秒は、点滅の消える側で描かない
pyxel.blt(ghost.x, 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):
"""ゲームオーバーの文字を重ねて描く"""
elapsed = pyxel.frame_count - self.gameover_frame # 捕まってからの経過
if elapsed < GAMEOVER_WAIT:
return
if elapsed % 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)
App()
start_game() に切り出した理由
これまでは update_title() と update_gameover() に
同じ 2 行が並んでいました。そこに BGM の行が加わると3 行の重複になります。
self.reset_game()
self.scene = SCENE_PLAY
pyxel.playm(MUSIC_BGM, loop=True) # ← 2 か所に同じものが並ぶ
「同じことが 2 か所に出たらまとめる」——フェーズ 4 で定数について決めた基準を、 今度は処理に対して使っています。
playm() を書き忘れると、
「1 回目は BGM が鳴るのに、2 回目は無音」という、
原因の見えにくい不具合になります。
1 か所にまとめてしまえば、書き忘れようがありません。
loop=True を忘れると 1 回で終わる pyxel.playm(MUSIC_BGM, loop=True)
loop=True がないと、5.33 秒で BGM が止まって、そのまま無音になります。
効果音の play() にも同じ引数がありますが、
効果音は 1 回鳴れば十分なので付けません。
stop() の引数| 書き方 | 止まる範囲 | 使いどころ |
|---|---|---|
pyxel.stop() | 4 本すべて | ゲームオーバー(BGM を全部止める) |
pyxel.stop(2) | ch2 だけ | 効果音だけ止めたいとき |
ゲームオーバーでは pyxel.stop() を使っています。
BGM は ch0 と ch1 の2 本を使っているので、
stop(0) だけではベースが鳴り続けてしまうからです。
self.score += 1
pyxel.play(CH_SE, SOUND_CANDY) # ← すぐ隣に置く
update() のどこかで「スコアが増えていたら鳴らす」と書こうとすると、
前のフレームのスコアを覚えておく必要が出てきます。
変化を起こしている場所で鳴らせば、その手間がまるごといりません。
備忘録の「1 回だけ処理する書き方」と同じ考え方です。
Pyxel は play() に音符を文字で書いた文字列(MML)を渡せます。
リソースエディタを開かずに、コードだけで音を試せます。
# 番号のかわりに、音符を直接書ける
pyxel.play(CH_SE, "T120 @2 O3 L16 C E G >C")
| 書き方 | 意味 |
|---|---|
T120 | テンポ |
@2 | 音色(0=三角 1=矩形 2=パルス 3=ノイズ) |
O3 | オクターブ |
L16 | 音の長さ(16 分音符) |
C D E … | 音符。R は休符 |
> / < | オクターブを 1 つ上げる/下げる |
気に入った音が見つかったら、同じ内容をエディタに打ち込みます。
この学習プロジェクトでは音は .pyxres に持つ方針なので、
MML は試すための道具として使うのがおすすめです。
mml() を呼んだ音は「MML モード」から戻らなくなります
pyxel.sounds[0].mml("…") を一度呼ぶと、そのあと
pyxel.sounds[0].set("…") を呼んでも無視されます(実測で確認)。
もとに戻すには引数なしで mml() を呼びます。
pyxel.sounds[0].mml() # ← MML モードを抜ける
pyxel.play(ch, "…") のようにその場で文字列を渡すぶんには、
この問題は起きません。
連続して取ったときに音がだんだん上がると、勢いが出ます。 MML なら 1 行でできます。
self.score += 1
オクターブ = 3 + min(self.score // 10, 2) # 3 → 4 → 5
pyxel.play(CH_SE, f"T120 @2 O{オクターブ} L16 C E G >C")
min(…, 2) で頭打ちにしているのは、上がりすぎて聞こえなくなるのを防ぐためです。
チャンネルを分けても、「増えた感じ」がしないことがあります。 2 つの音が似すぎていると、耳はそれを1 つの音としてまとめて聞いてしまうからです (→ 備忘録 2 つの効果音が 1 つに聞こえる)。
表の SOUND 2 は、お菓子の音と同じ音色(P)・同じ音量(6)・
同じ高さ(C3 と G3 はお菓子の音の 1 音目と 3 音目そのもの)で、しかも短い——
埋もれる条件がそろっています。差をつけた候補です。
| 案 | 音符 | TONE | VOL | EFFECT | SPEED | 長さ |
|---|---|---|---|---|---|---|
| A 低くうなる | C2 G1OCTAVE 1 / Q B |
S | 7 | V | 20 |
0.33 秒 |
| B ノイズでザッ | C2 C1OCTAVE 1 / Q Z |
N | 7 | F | 18 |
0.30 秒 |
| C 低い音の連打 | E2 E2 E2OCTAVE 1 / E E E |
S | 7 | V | 12 |
0.30 秒 |
3 案とも、次の 4 点でお菓子の音とずらしてあります。
P ではなく S や N。別の楽器になる6 → 7。単純だが効くC3 G3 は上がる音で、意味としても
「お化けが増えた(わるいこと)」と食い違っていました。
低い音・下がる音にすると、聞き分けやすさと意味の両方がそろいます。
捕まったときに短いジングル(数秒の曲)を鳴らすと、ぐっとゲームらしくなります。
MUSIC 1 を作り、SOUND 6・7 あたりにメロディとベースを入れて、
playm で鳴らします。
loop=True を付けません
ゲームオーバー画面でずっと同じ曲がくり返されると、うるさく感じます。
1 回鳴って終わるのがふつうです。
素直に書くと、check_ghosts() の中はこうなります。
pyxel.stop() # BGM を止めてから
pyxel.play(CH_SE, SOUND_CAUGHT) # 捕まった音(ch2)
pyxel.playm(MUSIC_GAMEOVER) # ジングル(ch0・ch1)
これは鳴ります。チャンネルが違うので、 上書きで消える問題も起きません。 ところが実際に聞くとぐちゃっと団子になります。 捕まった音(0.4 秒)とジングルが、同じ瞬間に走り出すからです。
音を重ねないのが直し方です。捕まった音が鳴り終わってから、ジングルを始めます。
GAMEOVER_WAIT を流用できます。捕まった音は 0.4 秒なので、フェーズ 7 で決めた 15 フレーム(0.5 秒)でもちょうど鳴り終わります
check_ghosts() からは playm の行を消します。
pyxel.stop() # BGM を止めてから
pyxel.play(CH_SE, SOUND_CAUGHT) # 捕まった音だけ鳴らす
かわりに update_gameover() の先頭で鳴らします。
def update_gameover(self):
"""ゲームオーバー画面。少し待ってから、Enter でゲームを再び始める"""
elapsed = pyxel.frame_count - self.gameover_frame
if elapsed == GAMEOVER_WAIT: # ちょうどこのフレームだけ通る
pyxel.playm(MUSIC_GAMEOVER) # 捕まった音が終わってからジングル
if elapsed < GAMEOVER_WAIT:
return
if pyxel.btnp(pyxel.KEY_RETURN):
self.start_game()
== を >= にしてはいけません
update_gameover() は毎フレーム呼ばれます。
>= にすると条件がずっと真になり、
playm が毎フレーム「先頭から鳴らし直し」を続けて、
ジングルの出だしだけが延々とくり返される音になります。== で済むのは、frame_count が 1 ずつしか増えないからです。
時計の「分」のように値が飛ぶことがあるものを見張るときは、
前回の値を覚えておく書き方が必要です
(→ 備忘録 1 回だけ処理したい)。
SPEED 20(1 音 0.167 秒)と
ベース SPEED 40(1 音 0.333 秒)では、
同じ長さの無音を作れません。休符の数で合わせようとすると
2 つのパートの長さがそろわなくなり、こんどはずれて聞こえます。GAMEOVER_WAIT を見ているため)。
音と絵が同時に動くと、演出はそれだけで強くなります。
GAMEOVER_WAIT を変えれば、間の長さも 2 つまとめて変わります。| 症状 | 見るところ |
|---|---|
| まったく音が鳴らない | .pyxres を保存しましたか(Ctrl+S)。
エディタで鳴っていても、保存しないとゲームには入りません |
| BGM が 5 秒ほどで止まる | playm の loop=True が抜けています |
| お菓子を取ると BGM が途切れる | 効果音のチャンネルが 0 か 1 になっています。
CH_SE = 2 を確認します |
| お化けが増えたのに「増えた感じ」がしない 5 個目のときだけ、お菓子の音がいつもと違って聞こえる |
2 つとも CH_SE に鳴らしていませんか。
pyxel.play(CH_SE, SOUND_GHOST) と書いてあったら、それが原因です。
同じチャンネルに同じフレームで 2 回書くと、あとの 1 回が前を上書きし、
お菓子の音は 1 度も鳴りません。増えた音は CH_EVENT です
(→ 備忘録 2 つの効果音が 1 つに聞こえる) |
| チャンネルは分けたのに、それでも増えた音が弱い | 2 つの音が似すぎています。音色・音域・長さ・音量のどれかで差をつけます (もう一歩に候補あり) |
| 捕まった音が鳴らない | pyxel.stop() を play() のあとに書いています。
止めてから鳴らします |
| 2 回目に BGM が鳴らない | update_gameover() が start_game() を
呼んでいるか確認します |
| お菓子を連続で取ると音がブツブツ切れる | 効果音が長すぎます。SPEED を下げて
0.3 秒より短くします |
| BGM のメロディとベースがずれていく | 長さがそろっていません。 音数 × SPEED が整数倍の関係になるようにします |
| Web に公開したら音が鳴らない | ブラウザの仕様です。画面をクリックするまで音が出ません。 フェーズ 10 で扱います |
これでstep1 のゲームは完成です。 残る フェーズ 10 はパッケージ化して Web で公開—— step0 の Star Voyager でやった手順そのままなので、復習になります。
pyxel package step1 step1/candy_hunt.py
pyxel app2html step1.pyxapp
できた HTML を docs/site/step1/ に置けば公開されます。
Web 版は起動時に CLICK TO START が出ることと、
全画面ボタンを足し直す必要があることだけ、フェーズ 10 で確認します。