第四~六天的目標是把遊戲的主要相關物件給刻出來,在這裡,我們將採用物件導向設計來開發這個遊戲。關於物件導向的詳細內容,建議參考這篇 Medium 文章:物件導向 vs 程序式導向-平庸與高級工程師開發速度差異的秘密。一言以蔽之物件導向開發模式,可以這麼說:想像一下樂高積木,樂高的每一小塊積木形狀、顏色,以及所要組建的部位都不同。這些小積木透過層層相疊,最後組成了成品。而這就是物件導向設計所追求的,透過將程式碼依照功能、屬性不停分割,得出一個又一個模組,就像樂高積木。再透過這些小小的模組,組成系統。這麼做的好處是每一個模組的功能明確,避免了程式碼雜亂,對未來的維護與擴展很有幫助。至於物件導向設計中的繼承、介面實作,與多型等面試最愛考的概念則暫且擱置,留待用別的技術文章細談,在本系列的後續實作中,我們只會簡單帶過。
暫時把整個遊戲的開發進程切分成基礎功能階段,與進階功能的階段。在基礎功能階段中(也就是這幾天的內容),需要先確保遊戲中包含:一個可以控制左右移動的實體、一個持續向下捲動的遊戲畫面、隨機分佈的階梯、人物血條,以及遊戲終止的方式。進階階段中,則會在原本的基礎上,再加入不穩定的階梯,以及有尖刺的階梯,這部分則等到我們將前後端接起來,完成了 MVP(minimum viable product)後,再回頭補上。
Day 4 ,我們將完成下列的模組:
frontend/down-game/src/components/down-stairs-game/config.js
export const GAME_CONFIG = {
canvasWidth: 400,
canvasHeight: 600,
stairWidth: 100,
stairHeight: 20,
playerWidth: 30,
playerHeight: 40,
initialSpeed: 1,
maxSpeed: 8,
speedIncrement: 0.1,
speedIncrementScore: 500,
playerMoveStep: 10,
gravity: 0.2,
stairSpacing: 100, // 階梯間距
initialHealth: 3, // 初始血量
invincibleTime: 60, // 無敵時間(幀數)
healthBarWidth: 100,
healthBarHeight: 15,
healthBarPadding: 5,
minStairCount: 8, // 畫面上的最小階梯數量
stairDensity: 1.5, // 階梯密度係數(值越大,階梯越密集)
};
export const ScoreLevel = {
LOW: 1000,
MEDIUM: 2000,
HIGH: 4000,
};
首先,讓我們在 down-stairs-game
資料夾底下創立一個 config.js 檔案,並且使用一個 GAME_CONFIG 物件來集中管理所需的遊戲參數。
frontend/down-game/src/components/down-stairs-game/entities/Entity.js
export default class Entity {
x;
y;
width;
height;
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
setPosition(x, y) {
this.x = x;
this.y = y;
}
// 畫面刷新處理
update(ctx) {}
// 渲染處理
render(...args) {}
}
這個 Entity 類別將是所有實體的父類別,接下來的 Player 與 Stair 都會繼承 Entity。繼承的子類別會擁有父類別中的所有公開屬性與方法,藉由類別繼承的設計,我們確立了物件與物件之間的垂直關係,同時也集中管理擁有特定功能的程式碼,讓它得以有效地被重複使用。在 Entity 中,我們定義了 update 與 render 兩個方法,這兩個方法幾乎在各種遊戲引擎的實作中都可以看見。update 負責更新每一幀要處理的內容,而 render 則負責將要視覺化的內容渲染出來。在整個遊戲模組中,每一個實體都會包含這兩個方法。
frontend/down-game/src/components/down-stairs-game/entities/Player.js
import { GAME_CONFIG } from "../config";
import Timer from "../utils/timer";
import Entity from "./Entity";
let FLIPPING_PARAM = 5;
export default class Player extends Entity {
velocity = 0;
health;
maxHealth;
invincible = false;
isSad = false;
invincibleTimer;
blinkTimer;
sadTimer;
isVisible = true;
constructor(x, y) {
super(x, y, GAME_CONFIG.playerWidth, GAME_CONFIG.playerHeight);
this.health = GAME_CONFIG.initialHealth;
this.maxHealth = GAME_CONFIG.initialHealth;
this.invincibleTimer = new Timer(GAME_CONFIG.invincibleTime, () => {
this.invincible = false;
});
this.blinkTimer = new Timer(FLIPPING_PARAM, () => {
this.invincible = !this.invincible;
this.blinkTimer.reset();
});
this.sadTimer = new Timer(GAME_CONFIG.invincibleTime, () => {
this.isSad = false;
});
}
updateTimers() {
// 更新無敵計時器
if (this.invincible) {
this.invincibleTimer.update();
this.blinkTimer.update();
}
if (this.isSad) {
this.sadTimer.update();
}
}
handleMovement(keysPressed) {
if (keysPressed.ArrowLeft) {
this.x = Math.max(0, this.x - GAME_CONFIG.playerMoveStep);
}
if (keysPressed.ArrowRight) {
this.x = Math.min(
GAME_CONFIG.canvasWidth - this.width,
this.x + GAME_CONFIG.playerMoveStep
);
}
}
checkIfOnStair(stairs) {
for (const stair of stairs) {
if (!stair.isSolid) {
continue;
}
if (this.isStandingOnStair(stair)) {
this.velocity = 0;
this.y = stair.y - this.height;
return true;
}
}
return false;
}
isStandingOnStair(stair) {
return (
this.y + this.height >= stair.y - 2 &&
this.y + this.height <= stair.y + stair.height + 2 &&
this.x + this.width > stair.x &&
this.x < stair.x + stair.width
);
}
applyGravity(stairs) {
const prevY = this.y;
this.velocity -= GAME_CONFIG.gravity;
this.y -= this.velocity;
if (this.velocity < 0) {
this.checkForMidairCollisions(stairs, prevY);
}
}
checkForMidairCollisions(stairs, prevY) {
for (const stair of stairs) {
if (
prevY + this.height <= stair.y && // 之前在階梯上方
this.y + this.height >= stair.y && // 現在穿過階梯
this.isStandingOnStair(stair)
) {
this.y = stair.y - this.height;
this.velocity = 0;
break;
}
}
}
isStandingOnStair(stair) {
return (
this.y + this.height >= stair.y - 2 &&
this.y + this.height <= stair.y + stair.height + 2 &&
this.x + this.width > stair.x &&
this.x < stair.x + stair.width
);
}
takeDamage() {
if (this.health <= 0 || this.invincible) return;
this.health--;
this.invincible = true;
this.invincibleTimer.reset();
this.blinkTimer.reset();
this.isSad = true;
this.sadTimer.reset();
}
// 設置初始無敵狀態
setInitialInvincibility() {
this.invincible = true;
this.invincibleTimer.reset();
this.blinkTimer.reset();
}
renderHealthBar(ctx) {
const barWidth = GAME_CONFIG.healthBarWidth;
const barHeight = GAME_CONFIG.healthBarHeight;
const padding = GAME_CONFIG.healthBarPadding;
// 外框
ctx.strokeStyle = "#000000";
ctx.lineWidth = 2;
ctx.strokeRect(10, 40, barWidth, barHeight);
// 背景
ctx.fillStyle = "#DDDDDD";
ctx.fillRect(10, 40, barWidth, barHeight);
// 血量
const healthWidth =
(this.health / this.maxHealth) * (barWidth - padding * 2);
ctx.fillStyle = "#FF0000";
ctx.fillRect(
10 + padding,
40 + padding,
healthWidth,
barHeight - padding * 2
);
// 心形圖示
ctx.fillStyle = "#FF0000";
ctx.font = "16px Arial";
ctx.fillText("♥ " + this.health, barWidth + 20, 40 + barHeight - 2);
}
renderHealthBar(ctx) {
const barWidth = GAME_CONFIG.healthBarWidth;
const barHeight = GAME_CONFIG.healthBarHeight;
const padding = GAME_CONFIG.healthBarPadding;
// 外框
ctx.strokeStyle = "#000000";
ctx.lineWidth = 2;
ctx.strokeRect(10, 40, barWidth, barHeight);
// 背景
ctx.fillStyle = "#DDDDDD";
ctx.fillRect(10, 40, barWidth, barHeight);
// 血量
const healthWidth =
(this.health / this.maxHealth) * (barWidth - padding * 2);
ctx.fillStyle = "#FF0000";
ctx.fillRect(
10 + padding,
40 + padding,
healthWidth,
barHeight - padding * 2
);
// 心形圖示
ctx.fillStyle = "#FF0000";
ctx.font = "16px Arial";
ctx.fillText("♥ " + this.health, barWidth + 20, 40 + barHeight - 2);
}
renderFace(ctx) {
// 眼睛
ctx.fillStyle = "#000000";
ctx.fillRect(
this.x + this.width * 0.2,
this.y + this.height * 0.2,
this.width * 0.15,
this.height * 0.15
);
ctx.fillRect(
this.x + this.width * 0.65,
this.y + this.height * 0.2,
this.width * 0.15,
this.height * 0.15
);
// 嘴巴
ctx.beginPath();
if (this.isSad) {
// 哭臉
ctx.arc(
this.x + this.width / 2,
this.y + this.height * 0.7,
this.width * 0.2,
0,
Math.PI,
true // 嘴角向下
);
// 眼淚
ctx.moveTo(this.x + this.width * 0.25, this.y + this.height * 0.35);
ctx.lineTo(this.x + this.width * 0.25, this.y + this.height * 0.5);
ctx.moveTo(this.x + this.width * 0.75, this.y + this.height * 0.35);
ctx.lineTo(this.x + this.width * 0.75, this.y + this.height * 0.5);
} else {
// 笑臉
ctx.arc(
this.x + this.width / 2,
this.y + this.height * 0.6,
this.width * 0.2,
0,
Math.PI,
false // 嘴角向上
);
}
ctx.stroke();
}
update(keysPressed, stairs) {
this.updateTimers();
this.handleMovement(keysPressed);
const onStair = this.checkIfOnStair(stairs);
if (!onStair) {
this.applyGravity(stairs);
}
}
render(ctx) {
if (this.invincible && !this.isVisible) {
return;
}
ctx.fillStyle = "#FF6347"; // 紅色
ctx.fillRect(this.x, this.y, this.width, this.height);
this.renderFace(ctx);
}
}
Player 模組比較複雜,因為我們要在這個地方考量到整個遊戲會涵蓋到的各種行為。我們處理了人物移動、重力下墜、受傷,以及是否站在階梯上等問題。最後,一樣透過 update 和 render,在每一幀當中做動態更新。
frontend/down-game/src/components/down-stairs-game/entities/Stair.js
import { GAME_CONFIG } from "../config";
import Entity from "./Entity";
export default class Stair extends Entity {
id;
constructor(id, x, y, width) {
super(x, y, width || GAME_CONFIG.stairWidth, GAME_CONFIG.stairHeight);
this.id = id;
}
update(gameSpeed) {
this.y -= gameSpeed;
}
render(ctx) {
ctx.fillStyle = "#8B4513"; // 棕色
ctx.fillRect(this.x, this.y, this.width, this.height);
}
}
Stair 模組單純很多,因為我們只需要定義階梯怎麼被初始化和渲染,並且留意在這個遊戲當中,為了要呈現畫面往上捲動的效果,階梯在每一幀 update 中都會往上升一點。
frontend/down-game/src/components/down-stairs-game/utils/Timer.js
export default class Timer {
active = false;
counter = 0;
maxCount;
callback = undefined;
constructor(maxCount, callback = undefined) {
this.maxCount = maxCount;
if (callback) this.callback = callback;
}
reset() {
this.counter = 0;
this.active = true;
}
stop() {
this.active = false;
}
update() {
if (!this.active) {
return false;
}
this.counter++;
if (this.counter >= this.maxCount) {
this.active = false;
if (this.callback) {
this.callback;
}
return true;
}
return false;
}
}
再來,我們新增一個 utils
資料夾在 down-stairs-game
底下,並且創立一個 Timer.js 類別。這個物件應該早於 Player 之前被創建,因為我是先寫完程式再寫文章的,才會有這樣問題。Timer 被設計用來集中管理 Player 當中很多的狀態改變,如果我們讓計時器散落在 Player 當中的各個方法,很容易導致程式碼變成一坨義大利麵,有違物件導向設計的精神。將 Timer 獨立出來的好處顯而易見,我們可以很簡單的在 Player 當中使用 new Timer()
方法,並且定義是否需要利用 callback 做後續處理,就可以輕鬆設計出各種有時間限制的狀態改變功能了。
Day 4 內容就到這邊吧!Day 5 我們將再接再厲,把更上層的應用模組一一建立起來!
Thank you for reading. If you enjoyed this post, consider sharing it with others.