大家都喜歡打音遊,那有要不要自己來做一個音遊呢:D
會用到的東西:
python3
pygame - python 的一個套件
數學 - 算座標
耐心 - 有時候一些數字是反覆測試出來的
因為這不是 unity,所以做這遊戲滿土炮的www 除了使用一些沒辦法自己寫的東西外 (如: 載入圖片、畫面顯示) 剩下就真的用手算w (如: 物件顯示座標),沒有 GUI QQQQ
先來看看一個基本的 音遊要有什麼東西
音樂
音符 (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 規劃成以下這樣子 (比較好看): 首先我們引入 pygame
、time
模組,做出基本的視窗並循環顯示,最後讓使用者點擊關閉的時候可以關掉視窗。
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 pygameimport timepygame.init() wn = pygame.display.set_mode((800 ,600 )) running = True mouse = "" 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 = "" 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 ... 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 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 : time.sleep(0.001 - loop_time)
把這兩個函式分別加在遊戲迴圈的最前面跟最後面,就可以讓每次迴圈執行都在 0.001 秒左右
1 2 3 4 5 6 7 8 while running: pre_time_handle() pygame_events() pygame.display.update() post_time_handle(loop_start_time)
背景顯示 在遊戲之前我們要做一個準備畫面,並且有個 start
的按鈕,點下去後遊戲就會進到遊戲畫面。
首先在主要迴圈內新增兩個變數:mouse_pos
跟 keys
1 2 3 4 5 6 7 8 9 10 11 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 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 = pygame.transform.scale(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 ) 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 )) wn.blit(start_button, (370 , 70 )) 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 ... started = False start_time = 0
來解說剛剛那坨 code 在幹什麼:D
draw_back()
負責把剛剛建立的矩形以及邊線畫上視窗
background_display
把負責處理背景繪製,若遊戲還沒開始就畫開始畫面,若偵測到滑鼠點下而且位置在按鈕附近就將 started
設為 True。之後就呼叫 draw_back()
來畫背景。
draw_press()
會看當前 keys 的狀況,如果對應按鍵被按下,就會顯示矩形。
把 background_display
和 draw_press()
加到主迴圈內:
1 2 3 4 5 6 7 8 9 10 11 12 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 ... drop_before_arrive = 0.8 pixel_per_second = 565 / drop_before_arrive
接著我們把這兩個東西裝進 times_arrive
、notes
、times_drop
三個不同的串列中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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 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 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 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 ... 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 ] coresponding_key = {0 : pygame.K_d, 1 : pygame.K_f, 2 : pygame.K_j, 3 : pygame.K_k} while pointer < len (times_drop) and abs (time_pass - times_drop[pointer]) <= 0.1 : 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: one_note.ycor_update(time_pass) wn.blit(mayo, (one_note.xcor, one_note.ycor)) if one_note.ycor >= 900 : one_note.show = False
如果自己的 y 座標大於等於 900 就讓這個音符的 show
屬性變成 False,之後就不會進到上面那個 if 結構了。
最後把上面兩個函式加到遊戲迴圈內
1 2 3 4 5 6 7 8 9 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 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 (): 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): if showing_array[i].hit: combo += 1 else : combo = 0 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 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