0%

Make a Music Game 101

大家都喜歡打音遊,那有要不要自己來做一個音遊呢:D

會用到的東西:

  • python3
  • pygame - python 的一個套件
  • 數學 - 算座標
  • 耐心 - 有時候一些數字是反覆測試出來的

因為這不是 unity,所以做這遊戲滿土炮的www
除了使用一些沒辦法自己寫的東西外 (如: 載入圖片、畫面顯示)
剩下就真的用手算w (如: 物件顯示座標),沒有 GUI QQQQ

Former Knowledge

先來看看一個基本的音遊要有什麼東西

  • 音樂
    • 譜面列表
    • 時間列表
  • 音符 (note)
    • 對上節拍
    • 等速落下
    • 在對應時刻如果玩家有按到他就消失
  • 按鍵
    • 按下時會有反應
  • Combo 數

音樂

譜面列表代表音符出現的順序,而時間列表裡面就有對應的音符要在什麼時候出現。

首先處理譜面列表與時間列表,把他們讀檔進來後存在陣列裡。並且按下 start 後就開始播放音樂,並且按照列表顯示音符

這兩個列表是用 “行” 來定位的。
第一行代表第 4.000 秒的時候會有一顆音符出現在 448 位置。
第二行也代表第 4.000 秒也會有一顆音符在 64 位置。
第三行就代表第 4.500 秒音符出現在 448 位置。

譜面列表 時間列表
1 448 4000
2 64 4000
3 448 4500
4 320 5000

這邊的 64 跟 448 位置不代表真的出現在 x=64 & x=448
因為這些列表是用 osu 產生出來的
所以這些是 osu 自己的編碼
之後會對這些數字再處理
編譜感謝: (・∀・)#6879

音符

因為不同時間會有不同的音符存在於鍵盤上,所以我們可以用超毒瘤的一個陣列 showing_array 來儲存應該遊在棋盤上的音符們 (code 部分會提到)

按鍵 & Combo 數

讀取現在鍵盤的狀態,並且在畫面畫上一個看起來很棒的矩形讓玩家知道他有按下按鍵,而 combo 數則是時時刻刻歸 0,每一次都重算 (用 showing_array 來做)

Code 實作

資源下載: https://github.com/onion0905/mayo_music_game
images 裡面是圖片,note_and_time 裡面是音符跟時間列表


前置作業

讓我們把整份 script 規劃成以下這樣子 (比較好看):
首先我們引入 pygametime模組,做出基本的視窗並循環顯示,最後讓使用者點擊關閉的時候可以關掉視窗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import pygame
import time

pygame.init()
wn = pygame.display.set_mode((800 ,600)) # 設 wn 為視窗物件,長 800 高 600

# Global Vars
running = True # 操控遊戲迴圈是否繼續的變數
mouse = "" # 存目前滑鼠的狀態

# Functions
def pygame_events():
global running
global mouse
for event in pygame.event.get(): # 取得目前的事件
if event.type == pygame.QUIT: # 事件為「點下退出鍵」
running = False # 結束遊戲迴圈
if event.type == pygame.MOUSEBUTTONDOWN: # 事件為「點下滑鼠」
mouse = "down"
if event.type != pygame.MOUSEBUTTONDOWN:
mouse = ""


# Game Loop
while running:
pygame_events()
pygame.display.update() # 更新視窗

第 15 行的 pygame.event.get() 函式可以取得各種事件 (如:滑鼠點擊),所以我們用第 12 行開始的自定義函數 pygame_events() 來統一管理這些事件。

比較需要注意的是在 pygame 裡面,視窗內的座標以左上角作為原點 (0, 0),下圖是我們這個遊戲的視窗座標。

如果沒有問題的話,這時候執行程式會跳出一個黑黑的視窗


時間控制

音遊需要盡量準確的時間,所以我們在每次迴圈的前跟後都加入一個負責處理迴圈執行時間的函式。

我們會希望每次迴圈執行的時間都一樣,但是執行的時間又不會長到讓人有卡頓感,經過測試最好的時間是 0.001 秒,如果一次迴圈沒有跑滿 0.001 秒,就用 sleep() 讓一定要過完這 0.001 秒才能執行下一次迴圈。

測試出大部分迴圈在不加時間處理的情況下都不會超過 0.0005 秒,但有時要顯示的物件太多,時間會大到 0.001 秒,因此取 0.001 秒

首先新增幾個變數

1
2
3
4
5
# Global Vars
...
loop_start_time = 0
start_time = 0
time_pass = 0

接著來 def 在迴圈前端處理時間的函式

1
2
3
4
5
6
7
8
9
def pre_time_handle():
global loop_start_time # 本次迴圈的開始時間
global start_time # 遊戲開始的時間 (按下 start 按鈕的時間)
global time_pass # 遊戲開始後經過幾秒
loop_start_time = time.time()
if not started:
start_time = loop_start_time
time_pass = float(loop_start_time - start_time) # 目前離遊戲開始有多久
time_pass = round(time_pass, 4) # 四捨五入到小數點後第四位

接著是在迴圈後端處理的函式

1
2
3
4
5
6
def post_time_handle(loop_start_time):
now_end_time = time.time()
now_end_time = round(now_end_time, 4)
loop_time = now_end_time - loop_start_time # 本次迴圈執行時間
if loop_time < 0.001: # 如果迴圈執行時間大於 0.001 秒就不 sleep 了 (會 error)
time.sleep(0.001 - loop_time)

把這兩個函式分別加在遊戲迴圈的最前面跟最後面,就可以讓每次迴圈執行都在 0.001 秒左右

1
2
3
4
5
6
7
8
# Game Loop
while running:
pre_time_handle()

pygame_events()
pygame.display.update()

post_time_handle(loop_start_time)

背景顯示

在遊戲之前我們要做一個準備畫面,並且有個 start 的按鈕,點下去後遊戲就會進到遊戲畫面。

首先在主要迴圈內新增兩個變數:mouse_poskeys

1
2
3
4
5
6
7
8
9
10
11
# Game Loop
while running:
pre_time_handle()

mouse_pos = pygame.mouse.get_pos() # 取得滑鼠座標
keys = pygame.key.get_pressed() # 取得有哪些鍵盤上的按鍵被按下
pygame_events()
pygame.display.update() # 更新視窗

post_time_handle(loop_start_time)

此時 keys 是一個串列 (list),透過 pygame.K_a 等這種變數可以存取該按鍵是否被按下,像是 keys[pygame.K_a] 就可以詢問 a 是否被按下。

接著我們把各個會用到的圖片與物件加到程式裡面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Objects
mayo = pygame.image.load("images\mayo.webp").convert_alpha() # 用來做落下音符的美乃滋圖片
start_menu = pygame.image.load("images\patrick_mayo.jpg").convert_alpha() # 開始畫面
start_button = pygame.image.load("images\start_button.png").convert_alpha() # 開始按鈕

mayo = pygame.transform.rotate(mayo, 90) # 將 mayo 旋轉 90 度
mayo = pygame.transform.scale(mayo, (100, 100)) # 把 mayo 的大小改為 100 * 100
start_menu = pygame.transform.scale(start_menu, (800, 600))
start_button = pygame.transform.scale(start_button, (200, 100))

white_back = pygame.Rect(0, 0, 800, 600) # (x位置, y位置, x長度, y長度)
border_left_line = pygame.Rect(140, 0, 10, 600)
border_right_line = pygame.Rect(650, 0, 10, 600)
display_pressed1 = pygame.Rect(150, 500, 125, 30)
display_pressed2 = pygame.Rect(275, 500, 125, 30)
display_pressed3 = pygame.Rect(400, 500, 125, 30)
display_pressed4 = pygame.Rect(525, 500, 125, 30)

music_location = "images\\ver.hard.mp3"
track = pygame.mixer.music.load(music_location) # 音樂載入
font = pygame.font.Font("freesansbold.ttf", 32) # 字型

畫出來會長得像這樣

接著我們來新增負責畫圖的函式們

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def draw_back(): # 負責畫遊戲畫面
pygame.draw.rect(wn, (107, 186, 241), white_back) # (視窗, 顏色, 矩形物件)
pygame.draw.rect(wn, (255, 255, 0), border_left_line)
pygame.draw.rect(wn, (255, 255, 0), border_right_line)
# 垂直線
pygame.draw.line(wn, (255, 255, 255), (275, 0),(275, 600)) # (視窗, 顏色, 起點, 終點)
pygame.draw.line(wn, (255, 255, 255), (400, 0),(400, 600))
pygame.draw.line(wn, (255, 255, 255), (525, 0),(525, 600))
# 水平線
pygame.draw.line(wn, (100, 100, 100), (150, 500),(650, 500))
pygame.draw.line(wn, (100, 100, 100), (150, 530),(650, 530))


def background_display(mouse_pos): # 負責處理背景繪製
global started
global start_time
if started:
draw_back() # 如果遊戲開始了就畫上藍色的那個遊戲畫面
else:
wn.blit(start_menu, (0, 0)) # 顯示開始 menu (開始畫面)
wn.blit(start_button, (370, 70)) # 顯示 start 按鈕
if mouse == "down":
if mouse_pos[0] > 300 and mouse_pos[0] < 500 and mouse_pos[1] > 100 and mouse_pos[1] < 400:
pygame.mixer.music.set_volume(0.1) # 設定音樂音量
pygame.mixer.music.play() # 開始撥放音樂
started = True # 開始了
start_time = time.time() # 設定開始的時間


def draw_press(): # 負責畫按鈕回饋 (就是玩家按下鍵盤後給顯示)
if keys[pygame.K_d]:
pygame.draw.rect(wn, (99, 170, 219), display_pressed1) # (視窗, 顏色, 矩形物件)
if keys[pygame.K_f]:
pygame.draw.rect(wn, (99, 170, 219), display_pressed2)
if keys[pygame.K_j]:
pygame.draw.rect(wn, (99, 170, 219), display_pressed3)
if keys[pygame.K_k]:
pygame.draw.rect(wn, (99, 170, 219), display_pressed4)

並且再增加幾個全域變數

1
2
3
4
# Global Vars
...
started = False # 遊戲還沒開始
start_time = 0 #遊戲開始時間 (開始後才會有真正的值)

來解說剛剛那坨 code 在幹什麼:D

draw_back() 負責把剛剛建立的矩形以及邊線畫上視窗

background_display 把負責處理背景繪製,若遊戲還沒開始就畫開始畫面,若偵測到滑鼠點下而且位置在按鈕附近就將 started 設為 True。之後就呼叫 draw_back() 來畫背景。

draw_press() 會看當前 keys 的狀況,如果對應按鍵被按下,就會顯示矩形。

background_displaydraw_press() 加到主迴圈內:

1
2
3
4
5
6
7
8
9
10
11
12
# Game Loop
while running:
pre_time_handle()

mouse_pos = pygame.mouse.get_pos() # 取得滑鼠座標
keys = pygame.key.get_pressed() # 取得有哪些鍵盤上的按鍵被按下
pygame_events()
background_display(mouse_pos)
draw_press()
pygame.display.update() # 更新視窗

post_time_handle(loop_start_time)

這時候程式會有開始畫面,點下後會進到遊戲畫面,而且按下 d、f、j、k 會有反應

開始畫面

f 被按下的遊戲畫面


檔案讀入

首先在 Global Vars 新增兩個變數

1
2
3
4
# Global Vars
...
drop_before_arrive = 0.8 # 音符到達判定線前多少秒要出現
pixel_per_second = 565 / drop_before_arrive # 音符每秒要跑幾單位距離

接著我們把這兩個東西裝進 times_arrivenotestimes_drop 三個不同的串列中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# files
times_arrive = []
times_drop = []
notes = []
note_dict = {64:0, 192:1, 320:2, 448:3}
with open(f"note_and_time\\times_normal.txt", "r") as time_f:
for i in time_f:
i = int(i)
i /= 1000
i = round(i, 4) # 小數點後第四位四捨五入
times_arrive.append(i)

with open(f"note_and_time\\notes_normal.txt", "r") as note_f:
for i in note_f:
i = int(i)
i = note_dict[i]
notes.append(i)

for i in times_arrive:
i -= drop_before_arrive # dropping rate
i = round(i, 4)
times_drop.append(i)

其中第一個 with 我們把時間列表中以「毫秒」為單位的數字轉成以「秒」為單位,並存進 times_arrive,代表音符應該在什麼時候到達判定線

第二個 with 我們音符列表裡面的數字轉成 0~3 的數字,代表他們應該出現在第幾格。

而第 19 行的那個 for 迴圈把剛剛的 times_arrive 中的數值,減去 drop_before_arrive ,也就是到達判定線前多少秒要出現。所以 times_drop 就是存每個音符什麼時候要出現在畫面上

ㄚ其實也可以直接在外面先預處理好這些資料然後存檔


class: Note

Music On!
:::danger
以下可能會有種「乾怎麼這麼毒瘤」的感覺wwww
:::
首先建立一個 class 做為一個音符,並帶有音符的資料與方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Note():
def __init__(self, drop_time, arrive_time, xcor, ycor, block):
self.drop_time = drop_time
self.arrive_time = arrive_time
self.xcor = xcor
self.ycor = ycor
self.block = block
self.hit = False
self.show = True

def ycor_update(self, time_pass):
p = time_pass - self.drop_time # 開始墜落後經過的時間。
# 上面的時間 * 像素每秒 - (目前位置 + 60) = 要增加的座標 (60 是測試出來的緩衝座標)
self.ycor += pixel_per_second * p - (self.ycor + 60)

def check_remove(self, time_pass):
block_check = keys[self.block]
time_check = abs(time_pass - self.arrive_time) <= 0.1 # 時間差小於 0.1 秒就可以移除
# 如果覺得判定太小可以把 0.1 改大
return block_check and time_check

__init__ 我們可以看到,這個類別帶有的資訊有:

  • 墜落時間 - 出現在棋盤上的時間
  • 到達時間 - 到達判定線的時間
  • x 座標 - cor 是 coordinate 的意思:D
  • y 座標
  • block - 該音符對應到鍵盤上哪個鍵(d、f、j、k)
  • hit - 是否被擊中 (初始值為 False)
  • show - 是否顯示 (被擊中後會被設為 False)

這個類別有兩個方法:ycor_update() 用來更新一個音符的 y 座標,check_remove() 會確認這個音符是否可以被移除。


將音符加入顯示串列

我們使用一個全域串列 showing_array。裡面存多個 Note 類別。在這個串列裡面的音符代表「已經」或「正在」被顯示。因此我們可以透過 drop_time 裡的資料以及遊戲開始後經過的時間 “time_pass“,判斷是否將這個音符加入 showing_array

來做將音符加入 showing_array 的函式。
首先在 Global Vars 的部分加入以下東西:

1
2
3
4
# Global Vars
...
showing_array = []
pointer = 0

其中 pointer 代表「下一個要顯示的音符的 index」
接著弄將音符加入 showing_array 的函式:

1
2
3
4
5
6
7
8
9
10
def showingArray_appending(time_pass):
global showing_array
global pointer
coresponding_location = [160, 285, 410, 535] # xcor 定位用
coresponding_key = {0: pygame.K_d, 1: pygame.K_f, 2: pygame.K_j, 3: pygame.K_k} # block 定位用
while pointer < len(times_drop) and abs(time_pass - times_drop[pointer]) <= 0.1:
# Note(drop_time, arrive_time, xcor, ycor, block)
one_note = Note(times_drop[pointer], times_arrive[pointer], coresponding_location[notes[pointer]], -100, coresponding_key[notes[pointer]])
showing_array.append(one_note)
pointer += 1

如果 pointer 的數字還在音符數量的大小之內,就判斷「現在時間」是否跟 「pointer 指向的音符所要出現的時間」 相差小於等於 0.1 秒 (因為計時沒辦法剛好準確而且又是浮點數所以用 0.1 秒作為判斷值)


讓顯示串列裡面的音符顯示出來

我們來定義一個 note_displaying

1
2
3
4
5
6
7
8
def note_displaying(time_pass):
global showing_array
for one_note in showing_array:
if one_note.show: # 初始值為 True
one_note.ycor_update(time_pass) # 更新自己的 y 座標
wn.blit(mayo, (one_note.xcor, one_note.ycor)) # blit 到螢幕上
if one_note.ycor >= 900:
one_note.show = False

如果自己的 y 座標大於等於 900 就讓這個音符的 show 屬性變成 False,之後就不會進到上面那個 if 結構了。

最後把上面兩個函式加到遊戲迴圈內

1
2
3
4
5
6
7
8
9
# Game Loop
while running:
pre_time_handle()
...
showingArray_appending(time_pass)
note_displaying(time_pass)

pygame.display.update()
post_time_handle(loop_start_time)

這時候應該可以看到美乃滋跑下來了:D

如果在意美乃滋的圖層太上面,可以把 draw_press() 移到 note_displaying() 的後面,讓他後退一層


打他!

最後要來實現打音符然後音符就會消失的函式:D

1
2
3
4
5
def note_remove(time_pass):
for one_note in showing_array:
if one_note.check_remove(time_pass):
one_note.hit = True # 被打到了
one_note.show = False # 不再顯示

直接呼叫我們剛剛寫好的 class method 就好了:D
:::success
OOP 是好東西:D
:::

然後加入遊戲迴圈裡面

1
2
3
4
5
6
7
8
# Game Loop
while running:
pre_time_handle()
...
note_remove(time_pass)

pygame.display.update()
post_time_handle(loop_start_time)

這時候如果再正確時間打音符就可以讓它消失了:DDDDD


Combo 數顯示

最後一步了:D

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def combo_showing():
# count combo
combo = 0
note_died_count = 0 # 已經有幾個不再顯示
for one_note in showing_array:
if one_note.arrive_time < time_pass: # 判斷不再顯示的條件
note_died_count += 1

for i in range(note_died_count): # 在這些不顯示的音符裡面,有被打到 combo 就加一,否則歸零
if showing_array[i].hit:
combo += 1
else:
combo = 0

# show combo
combo_show = font.render(f"COMBO: {combo}", True, (255, 255, 255))
# ^(顯示的字, 是否用滑順字體, 顏色)
wn.blit(combo_show, (10, 10)) # 顯示上面那個字形物件

一樣加到遊戲迴圈裡

1
2
3
4
5
6
7
8
# Game Loop
while running:
pre_time_handle()
...
combo_showing()

pygame.display.update()
post_time_handle(loop_start_time)

這時候應該可以完整體驗遊戲了:DDDDDD

完整 code

後記

因為這次選幹所以我才有機會又又又又把美乃滋拿出來看一次w
雖然之前靜態展已經有用過了不過那時候就是直接複製貼上wwww

然後在寫這篇的過程中發現:天啊我的 code 也太醜,所有東西塞在 while 迴圈然後還一堆沒改掉的註解跟 debug info

剛好在靜態展的時候為了維護幾千行的 code 練就了打 code 美學(?
所以就大改了美乃滋的結構www

還把音符重新用 class 實作,不然之前是用一個超大毒瘤二維陣列存所有資訊wwww
\我愛 OOP/

不過這種過一段時間就把之前寫的專案拿出來鞭還滿有趣的XDDD

三日不讀書,便覺面目可憎
三日不看 code,變數面目全非 // 看不懂啦ww

本文章同步發布於 HackMD