在 Day 5,我們的目標是讓 Day 4 設計的人物、階梯等可以在畫面上呈現,並且做到操控人物左右移動,讓整個遊戲動起來。Day 6,則讓我們一起修復 Bug(這可是軟體開發的日常喔!),設法使遊戲照著我們預期的樣子運行。那就開始吧,Let’s get our hands dirty!
回顧 Day 4,我們已經完成了階梯、人物的實體,接下來,就要進到更上層的 Business logic 的設計階段了。我們需要一套縝密的邏輯,讓這些實體照著我們想要的方式來互動。今天的內容,我們將會用到一點設計模式(Design Pattern)——工廠模式,有興趣了解的朋友可以參考這篇文章 深入淺出設計模式。
打造一個遊戲程式往往涉及複雜的邏輯設計,即使是一個規模很小的遊戲,都萬萬不可小覷。因為遊戲中牽涉到各種操作、數據運算,以及畫面更新等等作業。為了讓每一部分彼此協調運作,遊戲開發本身就是一個不小的挑戰。如果考量後續維護與擴展,那麼我們就更必須留意每一個程式模組負責的分工內容,想辦法讓後續接手的人(或者幾個月後的自己)不必太痛苦的就看懂程式。畢竟,就算是天縱英才,也難以看懂一坨義大利麵般的混亂程式碼的。
在這個小朋友下樓梯遊戲中,我們暫且先這樣切分每一個模組功能(這也許不是最好的,未來仍有調整空間,但是至少我們已經跨出了第一步):
update
與 render
是很常見的作法。在這裡,我們則再使用一個 GameRenderer 負責畫面背景渲染、分數渲染、UI 渲染等工作,同時 GameRenderer 也會調用實體當中的 render 方法,這樣我們就可以讓所有的渲染工作,都交由 GameRenderer 來負責了!說完程式分工,就讓我們把程式碼給放上來吧!如果要細講每一個細節的話,可能得用上三天三夜,這裡就得請讀者自己搭配上述的分工介紹來參透了 =D。程式碼的部分有點繁瑣,不妨在 Github 上面直接 fork 下來。
StairGame.jsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { GAME_CONFIG } from "./config";
import { GameState } from "./GameStateManager";
import Game from "./Game";
const StairGame = () => {
const canvasRef = useRef(null);
const gameRef = useRef(null);
const [gameStatus, setGameStatus] = useState({
score: 0,
highestScore: localStorage.getItem("highScore"),
health: GAME_CONFIG.initialHealth,
maxHealth: GAME_CONFIG.initialHealth,
gameState: GameState.NOT_STARTED,
isInvincible: false,
timeElapsed: 0,
});
const animationFrameIdRef = useRef(null);
const keysPressed = useRef({});
const handleKeyDown = (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
keysPressed.current[event.key] = true;
} else if (event.key === " " || event.key === "Enter") {
const game = gameRef.current;
if (game && game.stateManager === GameState.NOT_STARTED) {
startGame();
} else if (game && game.stateManager.state === GameState.GAME_OVER) {
startGame();
}
}
};
const handleKeyUp = (event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
keysPressed.current[event.key] = false;
}
};
useLayoutEffect(() => {
if (window !== "undefined") {
console.log("初始化畫布和事件處理器");
}
initGame();
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
const game = gameRef.current;
if (game) {
game.stateManager.onStateChange(() => {
updateGameStatus();
});
}
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
if (animationFrameIdRef.current) {
cancelAnimationFrame(animationFrameIdRef.current);
animationFrameIdRef.current = null;
}
};
}, []);
useEffect(() => {
console.log("遊戲狀態更新:", gameStatus);
}, [gameStatus]);
const getGameStateText = () => {
switch (gameStatus.gameState) {
case GameState.NOT_STARTED:
return "準備開始";
case GameState.PLAYING:
return "遊戲中";
case GameState.GAME_OVER:
return "遊戲結束";
default:
return "";
}
};
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
const initGame = () => {
console.log("遊戲初始化");
if (canvasRef.current && !gameRef.current) {
gameRef.current = new Game(canvasRef.current);
}
const game = gameRef.current;
if (!game) return;
game.init();
if (animationFrameIdRef.current) {
cancelAnimationFrame(animationFrameIdRef.current);
}
animationFrameIdRef.current = requestAnimationFrame(gameLoop);
};
const startGame = () => {
const game = gameRef.current;
if (!game) return;
game.stateManager.startNewGame();
updateGameStatus();
};
const updateGameStatus = () => {
const game = gameRef.current;
if (!game) return;
setGameStatus({
score: game.stateManager.stats.score,
highestScore: game.stateManager.stats.highScore,
health: game.player.health,
maxHealth: game.player.maxHealth,
gameState: game.stateManager.state,
isInvincible: game.player.invincible,
timeElapsed: game.stateManager.stats.timeElapsed,
});
};
const gameLoop = () => {
const game = gameRef.current;
if (!game) return;
game.update(keysPressed.current);
game.render();
if (game.stateManager.state === GameState.PLAYING) {
if (game.stateManager.score % 50 === 0) {
updateGameStatus();
}
}
animationFrameIdRef.current = requestAnimationFrame(gameLoop);
};
return (
<div className="flex flex-col items-center justify-center min-h-screen p-5 bg-gray-100">
<h1 className="text-2xl font-bold mb-4 text-black">小朋友下樓梯</h1>
<div className="relative">
<canvas
ref={canvasRef}
width={GAME_CONFIG.canvasWidth}
height={GAME_CONFIG.canvasHeight}
className="border-2 border-gray-800 rounded-lg shadow-lg"
/>
</div>
<div className="mt-4 space-y-2 w-full max-w-md">
<div className="flex justify-between text-black items-center">
<span className="font-bold">遊戲狀態:</span>
<span className="text-lg">{getGameStateText()}</span>
</div>
<div className="flex justify-between text-black items-center">
<span className="font-bold">分數:</span>
<span className="text-lg">{gameStatus.score}</span>
</div>
<div className="flex justify-between text-black items-center">
<span className="font-bold">最高分:</span>
<span className="text-lg">{gameStatus.highestScore}</span>
</div>
<div className="flex justify-between text-black items-center">
<span className="font-bold">時間:</span>
<span className="text-lg">{formatTime(gameStatus.timeElapsed)}</span>
</div>
<div className="flex justify-between text-black items-center">
<span className="font-bold">生命值:</span>
<div className="flex">
{[...Array(gameStatus.maxHealth)].map((_, i) => (
<span
key={i}
className={`text-2xl ${
i < gameStatus.health ? "text-red-500" : "text-gray-300"
} ${gameStatus.isInvincible ? "animate-pulse" : ""}`}
>
♥
</span>
))}
</div>
</div>
<div className="flex space-x-2">
{gameStatus.gameState === GameState.NOT_STARTED && (
<button
className="flex-1 px-6 py-2 bg-green-500 text-white rounded-md font-medium hover:bg-green-600 transition duration-300 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
onClick={startGame}
>
開始遊戲
</button>
)}
{gameStatus.gameState === GameState.GAME_OVER && (
<button
className="flex-1 px-6 py-2 bg-blue-500 text-white rounded-md font-medium hover:bg-blue-600 transition duration-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
onClick={startGame}
>
重新開始
</button>
)}
</div>
</div>
<div className="mt-4 bg-white text-black p-4 rounded-lg shadow w-full max-w-md">
<h3 className="font-bold text-lg mb-2">遊戲說明</h3>
<ul className="list-disc pl-5 space-y-1">
<li>使用鍵盤左右箭頭鍵控制角色移動</li>
<li>避免碰到頂部邊界,否則會扣血並被彈回</li>
<li>避免掉落到底部,否則遊戲結束</li>
<li>小心尖刺樓梯(紅色)會扣血</li>
<li>不穩定樓梯(黃色)會在站立一段時間後消失</li>
<li>每個角色有 {GAME_CONFIG.initialHealth} 條命</li>
</ul>
</div>
</div>
);
};
export default StairGame;
Game.js
import { GAME_CONFIG } from "./config";
import Player from "./entities/Player";
import EntityFactory from "./EntityFactory";
import GameRenderer from "./GameRender";
import GameStateManager, { GameState } from "./GameStateManager";
export default class Game {
player;
stairs;
stateManager;
entityFactory;
renderer;
canvas;
context;
constructor(canvas) {
this.canvas = canvas;
this.context = canvas.getContext("2d");
// Init the services
this.stateManager = new GameStateManager();
this.entityFactory = new EntityFactory();
this.renderer = new GameRenderer();
// Init the entities
this.player = new Player(0, 0);
this.stairs = [];
this.bindEvents();
this.init();
}
init() {
this.stateManager.setState(GameState.NOT_STARTED);
this.entityFactory.resetIdCounter();
this.stairs = this.entityFactory.createInitialStairs();
this.player = this.entityFactory.createPlayerOnStair(this.stairs);
this.ensureEnoughStairs();
}
bindEvents() {
this.canvas.addEventListener("click", () => {
this.handleCanvasClick();
});
}
handleCanvasClick() {
switch (this.stateManager.state) {
case GameState.NOT_STARTED:
this.init();
this.stateManager.startNewGame();
break;
case GameState.GAME_OVER:
this.init();
this.stateManager.startNewGame();
break;
default:
break;
}
}
resetGame() {
this.stateManager.resetScore();
this.entityFactory.resetIdCounter();
this.stairs = this.entityFactory.createInitialStairs();
this.player = this.entityFactory.createPlayerOnStair(this.stairs);
this.ensureEnoughStairs();
}
update(keysPressed) {
this.stateManager.updateScore();
this.stateManager.updateTime();
this.updateEntities(keysPressed);
this.checkGameConditions();
}
updateEntities(keysPressed) {
const gameSpeed = this.stateManager.stats.gameSpeed;
this.updateStairs(gameSpeed);
this.player.update(keysPressed, this.stairs);
}
ensureEnoughStairs() {
if (this.stairs.length === 0) return;
const lowestStair = [...this.stairs].sort((a, b) => b.y - a.y)[0];
if (lowestStair.y < GAME_CONFIG.canvasHeight + 5) {
const stairsNeeded =
Math.ceil(
(GAME_CONFIG.canvasHeight + 5 - lowestStair.y) /
GAME_CONFIG.stairSpacing
) + 2;
for (let i = 0; i < stairsNeeded; i++) {
this.addNewStair();
}
}
}
addNewStair() {
if (this.stairs.length === 0) {
this.stairs.push(
this.entityFactory.createStair(Math.random() * GAME_CONFIG.canvasWidth)
);
return;
}
const lastStair = this.stairs[this.stairs.length - 1];
const stairY = lastStair.y + GAME_CONFIG.stairSpacing;
const stairX =
Math.random() * (GAME_CONFIG.canvasHeight - GAME_CONFIG.stairWidth);
const newStair = this.entityFactory.createStair(stairX, stairY);
this.stairs.push(newStair);
}
updateStairs(gameSpeed) {
this.stairs.forEach((stair) => stair.update(gameSpeed));
}
manageStairs() {
this.stairs = this.stairs.filter((stair) => stair.y + stair.height > 0);
this.ensureEnoughStairs();
}
checkGameConditions() {
if (this.player.y <= 0) {
this.handleTopCollision();
}
if (this.player.y >= GAME_CONFIG.canvasHeight + this.player.height) {
this.stateManager.endGame();
return;
}
if (this.player.health <= 0) {
this.stateManager.endGame();
}
}
handleTopCollision() {
if (this.stateManager.stats.score > 50 && this.player.takeDamage()) {
this.player.y = 5;
this.player.velocity = -5;
} else {
this.player.y = 5;
this.player.velocity = -3;
}
}
render() {
this.renderer.render(
this.context,
this.stateManager.state,
this.player,
this.stairs,
this.stateManager.stats
);
}
}
EntityFactory.js
import { GAME_CONFIG } from "./config";
import Player from "./entities/Player";
import Stair from "./entities/Stair";
export default class EntityFactory {
// To manage the entities
nextEntityId = 0;
constructor() {
this.reset;
}
resetIdCounter() {
this.nextEntityId = 0;
}
createPlayerOnStair(stairs) {
const topStairs = stairs.filter((stair) => stair.y < 200 && stair.y > 50);
if (topStairs.length > 0) {
const randomStair =
topStairs[Math.floor(Math.random() * topStairs.length)];
const player = new Player(
randomStair.x + randomStair.width / 2 - GAME_CONFIG.playerWidth / 2,
randomStair.y - GAME_CONFIG.playerHeight
);
player.y = Math.max(player.y, 20);
player.setInitialInvincibility();
return player;
}
const player = new Player(
GAME_CONFIG.canvasWidth / 2 - GAME_CONFIG.playerWidth / 2,
100
);
player.setInitialInvincibility();
return player;
}
createPlayer() {}
createStair(x, y, width = undefined) {
return new Stair(this.nextEntityId++, x, y, width);
}
createInitialStairs() {
let stairs = [];
let stairsNeeded =
Math.ceil(GAME_CONFIG.canvasHeight / GAME_CONFIG.stairSpacing) + 3;
for (let i = 1; i < stairsNeeded; i++) {
stairs.push(
this.createStair(
Math.random() * (GAME_CONFIG.canvasWidth - GAME_CONFIG.stairWidth),
GAME_CONFIG.canvasHeight - i * GAME_CONFIG.stairSpacing - 50
)
);
}
return stairs;
}
}
GameStateManager.js
import { GAME_CONFIG, ScoreLevel } from "./config";
export const GameState = {
NOT_STARTED: "NOT_STARTED",
PLAYING: "PLAYING",
GAME_OVER: "GAME_OVER",
};
export default class GameStateManager {
state = GameState.NOT_STARTED;
stats = {
score: 0,
highScore: 0,
gameSpeed: GAME_CONFIG.initialSpeed,
timeElapsed: 0,
};
lastUpdateTime = 0;
onStateChangeCallbacks = [];
constructor() {
let savedHighScore = localStorage.getItem("highScore");
if (savedHighScore) {
this.stats.highScore = parseInt(savedHighScore);
}
this.lastUpdateTime = Date.now();
}
setState(newState) {
if (this.state !== newState) {
this.state = newState;
if (newState === GameState.GAME_OVER) {
this.checkAndUpdateHighScore();
}
this.notifyStateChange(newState);
}
}
onStateChange(callback) {
this.onStateChangeCallbacks.push(callback);
}
notifyStateChange(newState) {
for (const callback of this.onStateChangeCallbacks) {
callback(newState);
}
}
updateScore(incrementAmount = 1) {
if (this.state !== GameState.PLAYING) {
return;
}
this.stats.score += incrementAmount;
this.updateGameSpeed();
}
resetScore() {
this.stats.score = 0;
this.stats.gameSpeed = GAME_CONFIG.initialSpeed;
this.stats.timeElapsed = 0;
this.lastUpdateTime = Date.now();
}
checkAndUpdateHighScore() {
if (this.stats.score > this.stats.highScore) {
this.stats.highScore = this.stats.score;
localStorage.setItem("highScore", this.stats.highScore.toString());
}
}
updateGameSpeed() {
if (
this.stats.score > 0 &&
this.stats.score % GAME_CONFIG.speedIncrementScore === 0
) {
this.stats.gameSpeed = Math.min(
this.stats.gameSpeed + GAME_CONFIG.speedIncrement,
GAME_CONFIG.maxSpeed
);
}
}
updateTime() {
if (this.state !== GameState.PLAYING) return;
const currentTime = Date.now();
const deltaTime = currentTime - this.lastUpdateTime;
this.lastUpdateTime = currentTime;
this.stats.timeElapsed += deltaTime;
}
getTimeElapsed() {
return Math.floor(this.stats.timeElapsed / 1000);
}
startNewGame() {
this.resetScore();
this.setState(GameState.PLAYING);
}
endGame() {
if (this.state === GameState.PLAYING) {
this.setState(GameState.GAME_OVER);
}
}
}
GameRenderer.js
import { GAME_CONFIG, ScoreLevel } from "./config";
import { GameState } from "./GameStateManager";
export default class GameRenderer {
config;
constructor(config = undefined) {
this.config = {
width: GAME_CONFIG.canvasWidth,
height: GAME_CONFIG.canvasHeight,
backgroundColor: "#87CEEB",
textColor: "#FFFFFF",
overlayColor: "rgba(0,0,0,0.7)",
...config,
};
}
render(ctx, gameState, player, stairs, stats) {
this.clearCanvas(ctx);
// this.renderBackground(ctx);
switch (gameState) {
case GameState.NOT_STARTED:
this.renderEntities(ctx, player, stairs);
this.renderStartScreen(ctx);
break;
case GameState.PLAYING:
this.renderEntities(ctx, player, stairs);
this.renderUI(ctx, player, stats);
break;
case GameState.GAME_OVER:
this.renderEntities(ctx, player, stairs);
this.renderGameOverScreen(ctx, stats);
break;
default:
break;
}
}
clearCanvas(ctx) {
ctx.clearRect(0, 0, this.config.width, this.config.height);
}
renderBackground(ctx) {
gradient = ctx.createLinearGradient(0, 0, 0, this.config.height);
gradient.addColorStop(0, "#87CEEB"); // 天空藍
gradient.addColorStop(1, "#4682B4"); // 鋼藍
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, this.config.width, this.config.height);
ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
this.drawCloud(ctx, 50, 80, 70, 30);
this.drawCloud(ctx, 250, 40, 60, 25);
this.drawCloud(ctx, 150, 120, 80, 35);
}
renderEntities(ctx, player, stairs) {
for (const stair of stairs) {
stair.render(ctx);
}
player.render(ctx);
}
renderUI(ctx, player, stats) {
this.renderScore(ctx, stats.score);
player.renderHealthBar(ctx);
this.renderHighestScore(ctx, stats.highestScore);
this.renderGameTime(ctx, stats.time);
}
renderScore(ctx, score) {
ctx.fillStyle = "#000000";
ctx.font = "20px Arial";
ctx.textAlign = "left";
ctx.fillText(`分數: ${score}`, 10, 30);
}
renderHighestScore(ctx, highestScore) {
ctx.fillStyle = "#000000";
ctx.font = "20px Arial";
ctx.textAlign = "right";
ctx.fillText(`最高分數: ${highestScore}`, this.config.width - 10, 30);
}
renderGameTime(ctx, seconds) {
let minutes = Math.floor(seconds / 60);
let remaingSeconds = seconds % 60;
ctx.fillStyle = "#000000";
ctx.font = "16px Arial";
ctx.textAlign = "right";
ctx.fillText(
`時間: ${minutes}:${remaingSeconds.toString().padStart(2, "0")}`,
this.config.width - 10,
60
);
}
drawCloud(ctx, x, y, width, height) {
ctx.beginPath();
ctx.moveTo(x, y + height / 2);
ctx.bezierCurveTo(
x,
y,
x + width / 2,
y - height / 2,
x + width,
y + height / 2
);
ctx.bezierCurveTo(
x + width * 1.2,
y + height,
x + width * 0.8,
y + height * 1.5,
x + width / 2,
y + height
);
ctx.bezierCurveTo(
x + width / 5,
y + height * 1.2,
x,
y + height,
x,
y + height / 2
);
ctx.closePath();
ctx.fill();
}
renderStartScreen(ctx) {
ctx.fillStyle = this.config.overlayColor;
ctx.fillRect(0, 0, this.config.width, this.config.height);
ctx.fillStyle = this.config.textColor;
ctx.font = "30px Arial";
ctx.textAligh = "center";
ctx.fillText(
"小朋友下樓梯",
this.config.width / 2,
this.config.height / 2 - 50
);
ctx.font = "20px Arial";
ctx.fillText(
"← → 鍵控制移動",
this.config.width / 2,
this.config.height / 2
);
ctx.fillText(
"避免撞到頂部或掉下去",
this.config.width / 2,
this.config.height / 2 + 40
);
ctx.fillText(
"點擊開始遊戲",
this.config.width / 2,
this.config.height / 2 + 80
);
}
renderGameOverScreen(ctx, stats) {
ctx.fillStyle = this.config.overlayColor;
ctx.fillRect(0, 0, this.config.width, this.config.height);
ctx.fillStyle = this.config.textColor;
ctx.font = "30px Arial";
ctx.textAligh = "center";
ctx.fillText(
"遊戲結束",
this.config.width / 2,
this.config.height / 2 - 60
);
ctx.fillText(
`最終分數: ${stats.score}`,
this.config.width / 2,
this.config.height / 2 - 10
);
ctx.font = "20px Arial";
let comment = this.getScoreComment(stats.score);
ctx.fillText(comment, this.config.width / 2, this.config.height / 2 + 30);
ctx.fillText(
"點擊重新開始",
this.config.width / 2,
this.config.height / 2 + 70
);
}
getScoreComment(score) {
if (score < ScoreLevel.LOW) {
return "再多練習一下吧!";
} else if (score < ScoreLevel.MEDIUM) {
return "還不錯的表現!";
} else if (score < ScoreLevel.HIGH) {
return "真是太棒了!";
} else {
return "你是下樓梯大師!";
}
}
}
完成後,讓我們進入到前端的專案之中(30-days-build-up-api-driven-architecture/frontend/down-game),輸入 npm run dev
試試看!
看起來畫面的確順利出現了,人物也可以移動,但是比例還怪怪的,並且新的階梯不會自動生成,不過這些就留待 Day 6 解決吧!
Thank you for reading. If you enjoyed this post, consider sharing it with others.