# 發射口罩!
## 一、發想與目的
  遊戲製作時正值疫情嚴峻時期,經常出現便利商店店員因規勸客人戴口罩而起衝突的社會事件。因此希望與組員們一起製作一款兼具娛樂性與教育性的小遊戲,將遊戲情境結合時事,宣導公共場合戴口罩的習慣。
## 二、實作架構
### 遊戲規則
* 玩家會操縱便利商店店員所在的飛船,以左右鍵控制飛船移動:

* 按任意鍵開始遊戲後會有不戴口罩的人物從上往下隨機掉落:

* 如果店員的飛船被砸中,視窗左上角的健康值條會減少,全部健康值扣完後,右上角的生命會減少一個飛船,一局共有三條生命,全部用完則遊戲結束:

* 按空白鍵可發射口罩,口罩砸中人物會產生爆炸動畫隨即人物消失,當擊中人物特定次數後,會隨機掉落兩種寶物,分別為護盾與口罩升級:
* 護盾會使店員的健康值上升
* 口罩升級則會讓一段時間內可發射的口罩數變為兩個
### 遊戲初始化與創建視窗
* 使用==pygame==函式庫
```python=
pygame.init()
pygame.mixer.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("便利商店生存戰")
clock = pygame.time.Clock()
```
### 載入遊戲資源
* 載入遊戲背景及物件所對應到的圖片
* set_colorkey(BLACK/WHITE)可讓圖片不會有邊緣底色,transform則可將圖片調整成適合的大小
* 因顧客為隨機掉落,因此先將所有顧客的圖片存在一個list當中,而對應的圖片檔名為customer0~customer4,故使用fstring,在字串中加入變數,即可使用for迴圈,方便呼叫使用
* 因爆炸時會需要爆炸動畫,因此載入爆炸的連續圖片,分別用大(lg)、小(sm)、玩家(player)三個list儲存,方便後續在不同的相撞情況呼叫使用
```python=+
# 載入圖片
background_img = pygame.image.load(os.path.join("image", "background.png")).convert()
player_img = pygame.image.load(os.path.join("image", "player.png")).convert()
player_mini_img = pygame.transform.scale(player_img, (25, 19))
player_mini_img.set_colorkey(BLACK)
pygame.display.set_icon(player_mini_img)
mask_img = pygame.image.load(os.path.join("image", "mask.png")).convert()
customer_imgs = []
for i in range(4):
customer_imgs.append(pygame.image.load(os.path.join("image", f"customer{i}.png")).convert())
expl_anim = {}
expl_anim['lg'] = []
expl_anim['sm'] = []
expl_anim['player'] = []
for i in range(9):
expl_img = pygame.image.load(os.path.join("image", f"expl{i}.png")).convert()
expl_img.set_colorkey(BLACK)
expl_anim['lg'].append(pygame.transform.scale(expl_img, (75, 75)))
expl_anim['sm'].append(pygame.transform.scale(expl_img, (30, 30)))
player_expl_img = pygame.image.load(os.path.join("image", f"player_expl{i}.png")).convert()
player_expl_img.set_colorkey(BLACK)
expl_anim['player'].append(player_expl_img)
power_imgs = {}
power_imgs['shield'] = pygame.image.load(os.path.join("image", "shield.png")).convert()
power_imgs['gun'] = pygame.image.load(os.path.join("image", "gun.png")).convert()
```
* 載入音樂、音效:
* 背景音效、射擊音效、生命減少音效
* 口罩擊中顧客的音效有兩種,隨機播放
```python=+
# 載入音樂、音效
shoot_sound = pygame.mixer.Sound(os.path.join("sound", "shoot.wav"))
gun_sound = pygame.mixer.Sound(os.path.join("sound", "level up.wav"))
shield_sound = pygame.mixer.Sound(os.path.join("sound", "heal.wav"))
die_sound = pygame.mixer.Sound(os.path.join("sound", "death.wav"))
expl_sounds = [
pygame.mixer.Sound(os.path.join("sound", "expl0.wav")),
pygame.mixer.Sound(os.path.join("sound", "expl1.mp3"))
]
pygame.mixer.music.load(os.path.join("sound", "background.mp3"))
pygame.mixer.music.set_volume(0.4)
```
* 載入遊戲畫面中使用到的字體
```python=+
font_name = os.path.join("font.ttf")
def draw_text(surf, text, size, x, y):
font = pygame.font.Font(font_name, size)
text_surface = font.render(text, True, BLACK)
text_rect = text_surface.get_rect()
text_rect.centerx = x
text_rect.top = y
surf.blit(text_surface, text_rect)
```
### 遊戲物件
* Player
* update
* 判斷並更新玩家狀態
* 讀入左右鍵操控,更新玩家座標
* 限定玩家座標不可超過螢幕介面
* 武力升級的時間限定為5秒、隱藏狀態限定為1秒,之後再將店員定位回畫面
* shoot
* 在未升級狀態下,呼叫一個口罩,使其從玩家所在座標發射
* 在升級狀態下,呼叫兩個口罩,使其從玩家所在座標之左端、右端發射
* 在玩家消失期間不能發射口罩
* hide
* 在玩家健康值歸零時,玩家會暫時消失(座標定位到視窗之外),再重新復活
* gunup
* 武力升級:可一次發射兩個口罩
```python=116
class Player(pygame.sprite.Sprite):
def __init__(self):
pygame.sprite.Sprite.__init__(self)
self.image = pygame.transform.scale(player_img, (50, 38))
self.image.set_colorkey(BLACK)
self.rect = self.image.get_rect()
self.radius = 20
# pygame.draw.circle(self.image, RED, self.rect.center, self.radius)
self.rect.centerx = WIDTH / 2
self.rect.bottom = HEIGHT - 10
self.speedx = 8
self.health = 100
self.lives = 3
self.hidden = False
self.hide_time = 0
self.gun = 1
self.gun_time = 0
def update(self):
now = pygame.time.get_ticks()
if self.gun > 1 and now - self.gun_time > 5000:
self.gun -= 1
self.gun_time = now
if self.hidden and now - self.hide_time > 1000:
self.hidden = False
self.rect.centerx = WIDTH / 2
self.rect.bottom = HEIGHT - 10
key_pressed = pygame.key.get_pressed()
if key_pressed[pygame.K_RIGHT]:
self.rect.x += self.speedx
if key_pressed[pygame.K_LEFT]:
self.rect.x -= self.speedx
if self.rect.right > WIDTH:
self.rect.right = WIDTH
if self.rect.left < 0:
self.rect.left = 0
def shoot(self):
if not(self.hidden):
if self.gun == 1:
bullet = Bullet(self.rect.centerx, self.rect.top)
all_sprites.add(bullet)
bullets.add(bullet)
shoot_sound.play()
elif self.gun >=2:
bullet1 = Bullet(self.rect.left, self.rect.centery)
bullet2 = Bullet(self.rect.right, self.rect.centery)
all_sprites.add(bullet1)
all_sprites.add(bullet2)
bullets.add(bullet1)
bullets.add(bullet2)
shoot_sound.play()
def hide(self):
self.hidden = True
self.hide_time = pygame.time.get_ticks()
self.rect.center = (WIDTH/2, HEIGHT+500)
def gunup(self):
self.gun += 1
self.gun_time = pygame.time.get_ticks()
```
* Customer
* rotate
* 利用pygame中的rotate旋轉顧客
* 由於轉動後的圖片會失真,因此複製一個原始圖片取代被旋轉的物件
* 因旋轉後圖片的中心點會改變,因此對旋轉後的圖片重新定位,設定回原始中心點,讓旋轉動畫比較符合直覺
* update
* 判斷並更新顧客狀態
* 當顧客所在位置超出視窗範圍時,讓顧客重新掉落:以random產生並指定新的座標及速度
```python=+
class Customer(pygame.sprite.Sprite):
def __init__(self):
pygame.sprite.Sprite.__init__(self)
self.image_ori = random.choice(customer_imgs)
self.image_ori.set_colorkey(WHITE)
self.image = self.image_ori.copy()
self.rect = self.image.get_rect()
self.radius = int(self.rect.width * 0.85 / 2)
self.speedy = random.randrange(2, 5)
self.speedx = random.randrange(-3, 3)
self.total_degree = 0
self.rot_degree = random.randrange(-3, 3)
def rotate(self):
self.total_degree += self.rot_degree
self.total_degree = self.total_degree % 360
self.image = pygame.transform.rotate(self.image_ori, self.total_degree)
center = self.rect.center
self.rect = self.image.get_rect()
self.rect.center = center
def update(self):
self.rotate()
self.rect.y += self.speedy
self.rect.x += self.speedx
if self.rect.top > HEIGHT or self.rect.left > WIDTH or self.rect.right < 0:
self.rect.x = random.randrange(0, WIDTH - self.rect.width)
self.rect.y = random.randrange(-100, -40)
self.speedy = random.randrange(2, 10)
self.speedx = random.randrange(-3, 3)
```
* Mask
* 傳入飛船當下座標作為口罩起點,判斷空白鍵來發射
* 「向上」發射,因此速度方向為負
* update
* 判斷並更新口罩狀態
* 當口罩的座標超出視窗範圍時,會將該口罩物件移除
```python=+
class Mask(pygame.sprite.Sprite):
def __init__(self, x, y):
pygame.sprite.Sprite.__init__(self)
self.image = bullet_img
self.image.set_colorkey(BLACK)
self.rect = self.image.get_rect()
self.rect.centerx = x
self.rect.bottom = y
self.speedy = -10
def update(self):
self.rect.y += self.speedy
if self.rect.bottom < 0:
self.kill()
```
* Explosion
* 在「口罩擊中顧客」及「顧客擊中店員」時,分別出現爆炸效果
* 傳入爆炸類型決定動畫種類、碰撞發生的中心位置座標作為爆炸位置
* update
* 會先確認現在的時間並且在一段時間後更新到下一張圖片來產生動畫的效果
* 當更新到最後一張時就刪除這個物件
```python=+
class Explosion(pygame.sprite.Sprite):
def __init__(self, center, size):
pygame.sprite.Sprite.__init__(self)
self.size = size
self.image = expl_anim[self.size][0]
self.rect = self.image.get_rect()
self.rect.center = center
self.frame = 0
self.last_update = pygame.time.get_ticks()
self.frame_rate = 50
def update(self):
now = pygame.time.get_ticks()
if now - self.last_update > self.frame_rate:
self.last_update = now
self.frame += 1
if self.frame == len(expl_anim[self.size]):
self.kill()
else:
self.image = expl_anim[self.size][self.frame]
center = self.rect.center
self.rect = self.image.get_rect()
self.rect.center = center
```
* Power
* 被呼叫的時候會隨機生成護盾或口罩升級
* update
* 設定y速度為正,使升級效果由擊中顧客處向下掉落
* 超過螢幕底端時就會消失
```python=+
class Power(pygame.sprite.Sprite):
def __init__(self, center):
pygame.sprite.Sprite.__init__(self)
self.type = random.choice(['shield', 'gun'])
self.image = power_imgs[self.type]
self.image.set_colorkey(WHITE)
self.rect = self.image.get_rect()
self.rect.center = center
self.speedy = 3
def update(self):
self.rect.y += self.speedy
if self.rect.top > HEIGHT:
self.kill()
```
### 遊戲迴圈
* 背景音樂循環播放
```python=+
pygame.mixer.music.play(-1)
```
* 初始設定與遊戲視窗
* show_init
* 決定是否顯示初始畫面
* draw_init函數
* 設定初始畫面的內容,包含遊戲名稱、遊玩方法介紹
* while迴圈會等待玩家按下任意鍵後使while迴圈結束,進入遊戲
* 使用Sprite Groups儲存遊戲中會使用到的物件(方便操控遊戲中的物件,例如判斷口罩和顧客的碰撞)
* FPS
* 遊戲每秒更新幀數
* 經嘗試後,設定為60畫面最流暢
```python=+
# 遊戲迴圈
show_init = True
running = True
while running:
if show_init:
close = draw_init()
if close:
break
show_init = False
all_sprites = pygame.sprite.Group()
customers = pygame.sprite.Group()
masks = pygame.sprite.Group()
powers = pygame.sprite.Group()
player = Player()
all_sprites.add(player)
for i in range(8):
new_customer()
score = 0
clock.tick(FPS)
```
* 判斷使用者輸入
* 在遊戲開始後,使用pygame.event.get持續讀取使用者輸入:
* 點擊退出介面,系統會跳出遊戲迴圈,結束遊戲
* 點擊空白鍵會呼叫shoot函數,發射口罩
```python=+
# 取得輸入
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
player.shoot()
# 更新遊戲
all_sprites.update()
```
* 判斷顧客與口罩相撞
* 使用pygame.sprite.groupcollide()內建函式:在相撞之後會使顧客消失
* 呼叫爆炸物件時,須傳入碰撞的位置與爆炸類型
* 每射中一個顧客,分數加20分
* 口罩和顧客相撞之後有機率會生成寶物,掉落寶物的機率設定為0.05
* 再生成一個顧客
```python=+
# 判斷顧客與口罩相撞
hits = pygame.sprite.groupcollide(customers, masks, True, True)
for hit in hits:
score += hit.radius
expl = Explosion(hit.rect.center, 'lg')
all_sprites.add(expl)
if random.random() > 0.9:
pow = Power(hit.rect.center)
all_sprites.add(pow)
powers.add(pow)
new_customer()
```
* 判斷店員與顧客相撞
* pygame.sprite.collide_circle
* 在顧客及口罩的部分都加上合適的圓形半徑,使碰撞判斷範圍由初始設定的矩形變成圓形,變得比較符合直覺
* 健康值
* 每撞到一次顧客健康減35%
* 當健康值低於0時,要呼叫玩家爆炸的物件並將生命值減1、健康值回復至100%,並執行player的hide函式
```python=+
# 判斷顧客與店員相撞
hits = pygame.sprite.spritecollide(player, customers, True, pygame.sprite.collide_circle)
for hit in hits:
new_customer()
player.health -= hit.radius * 2
expl = Explosion(hit.rect.center, 'sm')
all_sprites.add(expl)
if player.health <= 0:
death_expl = Explosion(player.rect.center, 'player')
all_sprites.add(death_expl)
player.lives -= 1
player.health = 100
player.hide()
```
* 判斷寶物與店員相撞
* 判斷有寶物與店員碰撞後,接著判斷碰到的寶物類型:
* 護盾:使健康值增加20%,但若血量已達到100%則維持在100%不會繼續增加
* 口罩升級:則執行player中定義的gunup函式增加發射的口罩數量
```python=+
hits = pygame.sprite.spritecollide(player, powers, True)
for hit in hits:
if hit.type == 'shield':
player.health += 20
if player.health > 100:
player.health = 100
elif hit.type == 'gun':
player.gunup()
```
* 分數、健康值與生命
* 分數
* 使用score儲存目前的分數
* 定義draw_text函數,再於遊戲迴圈中呼叫,更新目前的分數
* 健康值
* 初始值為100%
* 定義draw_health函數,用pygame內建的rect和draw來建立顯示健康值的長條於畫面左上角
* 在遊戲迴圈中會更新目前健康值,使長條的長度隨著健康值的比例調整
* 生命
* 生命共有3條
* 定義draw_lives函數,將目前的生命值顯示於畫面右上角
* 在遊戲迴圈中會更新目前生命數量:函數中迴圈執行的次數改變,會使小事的小飛船圖像數量(生命數量)減少
* 當生命數量歸0後,會在店員飛船爆炸的動畫結束後,將show_init變數設定為True,重新顯示初始畫面,按任意鍵可重複遊玩
```python=+
if player.lives == 0 and not(death_expl.alive()):
show_init = True
# 畫面顯示
screen.fill(BLACK)
screen.blit(background_img, (0,0))
all_sprites.draw(screen)
draw_text(screen, str(score), 18, WIDTH/2, 10)
draw_health(screen, player.health, 5, 15)
draw_lives(screen, player.lives, player_mini_img, WIDTH - 100, 15)
pygame.display.update()
```
* 結束遊戲
```python=+
pygame.quit()
```
## 三、結果呈現
## 四、優化方向
## 五、協作方式
* 協作軟體:Google Colab
* 團隊分工:
* ==職治二 楊淨雯:遊戲資源(圖片)、遊戲物件設計、遊戲迴圈==
* 動科二 蕭協錠:遊戲資源(音效)、遊戲迴圈Debug
* 動科二 陳品言:試玩遊戲,調整參數使遊玩體驗更順暢