フェーズ 9 効果音と BGM

チャンネルを分けて、鳴らしたい瞬間に 1 行足す

トップページstep1 概要 / フェーズ 9

ゴール

効果音 3 つ(お菓子を取った・捕まった・お化けが増えた)と BGM をつけます。

手ざわりがいちばん変わるフェーズです。 「取った感じが薄い」で 「効果音がいちばん効く」と書いたとおり、 当たり判定を縮めたときよりも、点滅を足したときよりも、はっきり変わります。

コードで足すのは 6 行だけです。時間がかかるのは音を作るほうで、 そちらはリソースエディタでの作業になります。

1. 考え方

1-1. 音は 2 種類ある

効果音BGM
長さ0.2〜0.4 秒くらい数秒〜。ずっとくり返す
鳴らし方pyxel.play(ch, 番号)pyxel.playm(番号, loop=True)
いつ呼ぶできごとが起きた瞬間に 1 回ゲームを始めるときに 1 回
作る場所どちらも .pyxres(リソースエディタ)
playplaym の違い play「音」を 1 つ鳴らします。playmmmusic で、複数のチャンネルぶんの音をまとめて鳴らします。 BGM は「メロディ」と「ベース」を重ねるので playm です。

1-2. チャンネルは 4 本。ぶつからないように割り当てる

Pyxel は同時に 4 つまで音を出せます。この 4 本をチャンネルと呼びます。

大事なのは、同じチャンネルで新しい音を鳴らすと、前の音は止まるということです。 BGM と効果音を同じチャンネルにすると、お菓子を取るたびに BGM が途切れます

Pyxel のチャンネル(同時に鳴らせるのは 4 つまで) ch 0 BGM のメロディ playm() が自動で使う ch 1 BGM のベース playm() が自動で使う ch 2 お菓子を取った音 / 捕まった音 CH_SE = 2 ch 3 お化けが増えた音 CH_EVENT = 3 5 個目のお菓子を取った瞬間は「取った音」と「増えた音」が同時に鳴る。 同じチャンネルに入れると、あとから鳴らしたほうが前の音を打ち消してしまう。
BGM は ch0・ch1 を使うので、効果音は ch2・ch3 にまわす
なぜ効果音を 2 本に分けるのか 5 個目のお菓子を取った瞬間を考えてください。 「取った音」と「お化けが増えた音」が同じフレームで鳴ります。 1 本にまとめると、あとに書いたほうが前の音を消してしまいます。 いちばん伝えたい「増えた」が、いちばん消えやすい場所にあるわけです。

1-3. 鳴らす場所は、もう作ってある

「音を鳴らすタイミング」は、すでにコードの中にあります。 スコアを増やしている行、お化けを足している行、 ゲームオーバーにしている行——そのすぐ隣に 1 行足すだけです。

ゲーム開始 start_game() pyxel.playm(MUSIC_BGM, loop=True) BGM が流れはじめる お菓子を取った pyxel.play(CH_SE, SOUND_CANDY) ピロン 5 個目 → 1 体増える pyxel.play(CH_EVENT, SOUND_GHOST) ↑ と同時に鳴る 捕まった pyxel.stop() pyxel.play(CH_SE, SOUND_CAUGHT) BGM を止めてから 下降音を鳴らす
できごとの隣に 1 行ずつ。順番だけ気をつける(止めてから鳴らす)
stop() を先、play() をあとに書きます pyxel.stop()引数なしだと 4 本すべて止めます。 逆に書くと、せっかく鳴らした「捕まった音」まで消えてしまいます

1-4. 音の長さは speed で決まる

リソースエディタの SPEED1 音の長さを決めます。実測すると 1 音の秒数 = SPEED ÷ 120 でした。

SPEED1 音の長さ4 音ならぶと用途
80.067 秒0.27 秒効果音(テンポよく鳴る)
120.100 秒0.40 秒効果音(少し重い)
200.167 秒0.67 秒BGM のメロディ
400.333 秒1.33 秒BGM のベース
効果音は「短く」が鉄則です お菓子は1 秒に何度も取ることがあります。 効果音が 1 秒もあると、次の音が前の音を打ち消してブツブツ切れて聞こえます0.3 秒より短く作っておくと、連続して取っても気持ちよく鳴ります。

2. このフェーズで使う機能

機能役割
pyxel.play(ch, snd)チャンネル ch で音 snd を 1 回鳴らす
pyxel.playm(msc, loop=True)ミュージック msc をくり返し鳴らす
pyxel.stop()すべてのチャンネルを止める(stop(2) なら 1 本だけ)
リソースエディタの SOUND効果音と BGM のパートを作る
リソースエディタの MUSICSOUND を組み合わせて BGM にする

3. 手順

3-1. リソースエディタを開く

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 / PageDownOCTAVE を上げ下げ(0 〜 3 の 4 段だけ
/ TON・VOL・EFX の行へ移る
SPACE / L再生して確かめる / ループ再生
CtrlZ取り消し(打ち間違えたら戻せる)
CtrlS保存

3-1b. どのキーがどの音か

このフェーズの音は、OCTAVE 3OCTAVE 1 の 2 つで全部打てます。 まずは OCTAVE 3 のときの割り当てを見てください。

下段 = OCTAVE 3(C3 〜 B3) 上段 = その 1 つ上(C4 〜 B4) S D G H J 2 3 5 6 7 Z X C V B N M Q W E R T Y U C3 D3 E3 F3 G3 A3 B3 C4 D4 E4 F4 G4 A4 B4 キー 音名 黄色い鍵 = このフェーズで使う音 キー → Enter で 1 音置く
OCTAVE を 1 に下げると、同じキーの並びがそのまま 2 オクターブ低い音(C1 〜 B2)になります

黒鍵も含めた早見表です。OCTAVE がいくつでも、この並びは変わりません。

音名CC#DD#EF F#GG#AA#B
下段
(OCTAVE の高さ)
ZSXDCV GBHNJM
上段
(その 1 つ上)
Q2W3ER 5T6Y7U
OCTAVE に 4 はありません OCTAVE は 0・1・2・3 の 4 段だけです。 「C4 を入れたいから OCTAVE を 4 にする」——これはできません。 C4 より上は、上段のキーで出します。 OCTAVE 3 のとき、上段の Q がちょうど C4 です。
キーで届く範囲はいつも 2 オクターブぶんで、その 2 オクターブを 音域のどこに当てるかを決めているのが OCTAVE の数字、と考えてください。
今の OCTAVE は画面に数字で出ません 画面の左右にある細いバーの、明るい帯の位置で読み取ります (図は step0 フェーズ 7 にあります)。 確実に合わせたいときは、PageDown4 回以上押していちばん下(0)にしてから、 PageUp3 回で OCTAVE 3 です。
打ち込む順のおすすめ OCTAVE を行ったり来たりしないで済むように、高さでまとめて作ると楽です。
① OCTAVE 3 のまま SOUND 0 → 2 → 3 → 4 → ② PageDown を 2 回押して OCTAVE 1 にし、SOUND 1 → 5

3-2. 効果音を 3 つ作る

SOUND 0・1・2 に、それぞれ次の内容を入れます。

番号音符TONEVOLUMEEFFECTSPEED長さ
0
お菓子
C3 E3 G3 C4
ドミソド(上がる) OCTAVE 3 / Z C B Q
P6N8 0.27 秒
1
捕まった
G2 E2 C2 A1
ソミドラ(下がる) OCTAVE 1 / T E Q N
S7F12 0.40 秒
2
お化け出現
C3 G3
ドソ(2 音だけ) OCTAVE 3 / Z B
P6N10 0.17 秒
打鍵の読み方 1 音ごとに「キー → Enterの 2 打です。 Z C B Q と書いてあれば、 Z Enter C Enter …… と 4 回くり返します。
SOUND 1 だけ OCTAVE 1 なのは、いちばん低い A1 を出すためです。 OCTAVE 1 なら A1 は下段の N、G2・E2・C2 は上段の T E Q で届きます。
TONE(音色)の 4 種類 T = 三角波(やわらかい・ベース向き)/ S = 矩形波(はっきりした電子音)/ P = パルス(軽くて明るい・効果音向き)/ N = ノイズ(ザーッという音・打撃や爆発向き)。
EFFECTN=なし、S=スライド、 V=ビブラート、F=フェードアウト。 捕まった音に F を入れているのは、音の終わりを自然に消すためです。
音の高さは「上がる/下がる」で意味が伝わります 上がる音=よいこと下がる音=わるいこと——これはほぼ世界共通の感覚です。 お菓子はドミソド(上がる)、捕まったのはソミドラ(下がる)にしてあるのは、 音を聞いただけでどちらか分かるようにするためです。

3-3. BGM のパートを 3 つ作る

SOUND 3・4 がメロディ(前半・後半)、SOUND 5 がベースです。 イ短調で、少しあやしい雰囲気にしています。

番号音符(16 音/8 音)TONEVOLEFFECTSPEED
3
メロディ前半
A3 C4 E4 C4 A3 C4 E4 C4
F3 A3 C4 A3 F3 A3 C4 A3 OCTAVE 3 / N Q E Q N Q E Q
V N Q N V N Q N
T5N20
4
メロディ後半
G3 B3 D4 B3 G3 B3 D4 B3
A3 C4 E4 C4 A3 E4 C4 A3 OCTAVE 3 / B M W M B M W M
N Q E Q N E Q N
T5N20
5
ベース
A1 A1 F1 F1 G1 G1 A1 A1 OCTAVE 1 / N N V V B B N N S3N40
長さがそろうように SPEED を選んであります メロディは 16 音 × 20 ÷ 120 = 2.67 秒が 2 つで 5.33 秒。 ベースは 8 音 × 40 ÷ 120 = 2.67 秒です。
短いほうは自動でくり返されるので、ベースは 2 周してちょうど合います。 ベースを 16 音ぶん打ち込む必要はありません。
ベースが「棒読み」に聞こえたら、EFFECT を F にする ベースは A1 A1 のように同じ音が 2 つ続きます。 EFFECT が N(なし)だと、この 2 音は切れ目なくつながって、 1 つの長い音に聞こえます。8 音打ったのに 4 音に聞こえる、という状態です。
EFFECT を F(フェードアウト)にすると、1 音ごとに音量が落ちて 音の切れ目=拍が聞こえるようになります。拍が聞こえると「のり」が出ます。 このプロジェクトのベースは F を採用しました。
表の N は「まず素直な状態から始める」ための初期値です。 聞いてみて直したくなったら、そちらが正解です。

3-4. MUSIC 0 に組み立てる

MUSIC タブに移り、MUSIC 0 に次のように入れます。

チャンネル入れる SOUND 番号
ch 03, 4(前半 → 後半の順)
ch 15
ch 2空のまま
ch 3空のまま

ch2・ch3 を空にしておくのが大事です。 ここを埋めると、効果音のチャンネルと取り合いになります。 L キーでループ再生して、つながりを確かめてください。 確認できたら CtrlS で保存します。

3-5. 定数を足す

CORNERSに置きます。

SOUND_CANDY = 0         # お菓子を取った音
SOUND_CAUGHT = 1        # 捕まった音
SOUND_GHOST = 2         # お化けが増えた音
MUSIC_BGM = 0           # BGM(チャンネル 0 と 1 を使う)

CH_SE = 2               # 効果音を鳴らすチャンネル
CH_EVENT = 3            # お化けが増えた音だけ別のチャンネルにする

3-6. 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()

3-7. 音を鳴らす行を足す(3 か所)

    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

4. できあがりのコード

# 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()
動かして確かめること

5. コードの解説

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 回だけ処理する書き方」と同じ考え方です。

6. もう一歩(やらなくても大丈夫)

エディタを開かずに、その場で音を試す

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 音目そのもの)で、しかも短い—— 埋もれる条件がそろっています。差をつけた候補です。

音符TONEVOLEFFECTSPEED長さ
A 低くうなる C2 G1
OCTAVE 1 / Q B
S7V20 0.33 秒
B ノイズでザッ C2 C1
OCTAVE 1 / Q Z
N7F18 0.30 秒
C 低い音の連打 E2 E2 E2
OCTAVE 1 / E E E
S7V12 0.30 秒

3 案とも、次の 4 点でお菓子の音とずらしてあります。

音を「下げる」ほうが、お化けらしい 3-2 で書いたとおり上がる音=よいことです。 もとの C3 G3上がる音で、意味としても 「お化けが増えた(わるいこと)」と食い違っていました。 低い音・下がる音にすると、聞き分けやすさと意味の両方がそろいます。

ゲームオーバーの music を別に作る

捕まったときに短いジングル(数秒の曲)を鳴らすと、ぐっとゲームらしくなります。 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 秒)とジングルが、同じ瞬間に走り出すからです。

音を重ねないのが直し方です。捕まった音が鳴り終わってから、ジングルを始めます。

0.0 0.25 0.5 0.75 1.0 秒 × 同じフレームで両方鳴らす 捕まった音 0.4 秒 ch2 ゲームオーバー BGM 2.0 秒 — ch0/1 この 0.4 秒が団子になる ○ 捕まった音が終わってから鳴らす 捕まった音 0.4 秒 ch2 ゲームオーバー BGM — ch0/1 GAMEOVER_WAIT = GAME OVER が出るのと同時
待つ長さは 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 回だけ処理したい)。
「休符で待たせる」ではうまくいきません ジングルの先頭に休符を入れて 0.5 秒待たせる——これは思いつきますが、 今回はメロディとベースで SPEED が違うのがネックになります。 たとえばメロディ SPEED 20(1 音 0.167 秒)と ベース SPEED 40(1 音 0.333 秒)では、 同じ長さの無音を作れません。休符の数で合わせようとすると 2 つのパートの長さがそろわなくなり、こんどはずれて聞こえます。
「待つ」はコードの仕事、「鳴らす」は音データの仕事と分けて考えると迷いません。
この形にすると、おまけが 1 つ付いてきます ジングルが始まる瞬間と、GAME OVER の文字が出る瞬間がぴたりとそろいます (どちらも GAMEOVER_WAIT を見ているため)。 音と絵が同時に動くと、演出はそれだけで強くなりますGAMEOVER_WAIT を変えれば、間の長さも 2 つまとめて変わります。
このプロジェクトでは 15 → 20 フレーム(0.5 → 0.67 秒)に増やしました。 ジングルが入ると、捕まってから曲が始まるまでの間は 少し長いほうが落ち着きます。数フレーム変えるだけで印象が変わるので、 実際に鳴らして決めるのがいちばんです。

7. うまくいかないときは

症状見るところ
まったく音が鳴らない .pyxres を保存しましたか(CtrlS)。 エディタで鳴っていても、保存しないとゲームには入りません
BGM が 5 秒ほどで止まる playmloop=True が抜けています
お菓子を取ると BGM が途切れる 効果音のチャンネルが 01 になっています。 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 で扱います

8. 次のフェーズ

これで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 で確認します。