今天是第二週的最後一天,前陣子我們已經把使用 PixiJS 製作遊戲時,最基本會使用到的各種物件都介紹過了,還有其他像是 CG 的更新循環系統、補間動畫(Tween)、Spritesheet 等概念,理論上只要將這些東西組合起來,就可以製作出一款基本的小遊戲了。
因此,今天我們要來總結一下到目前為止的所學,來製作一個會動、會響的互動場景——幸運餅乾。
首先,在開始撰寫程式碼以前,我們通常要先想好要做什麼,要怎麼做?這兩個問題非常重要,它決定了我們接下來該怎麼完成這個成品。如果沒有先規劃好要做什麼,很有可能就會在做到一半,甚至是剛開始著手就不知道該怎麼進行的窘境,因此我們先來規劃一下今天要做的內容。
你可以先用最簡單的白話文,稍微描述你心中所想的東西,只要包含開始、簡單的過程、結束三個部分即可,以我為例子:「一開始畫面上會出現三個幸運餅乾,選擇其中一個後,餅乾會消失,並從裡面拿出紙條顯示訊息,之後紙條會消失,餅乾會恢復原狀。」有了這個雛形之後,我們再來慢慢細化這個流程。
將每個步驟拆分成一條條的項目之後,我們就可以來針對這些項目,一個個把他們的功能實作出來啦~
因為我其實已經做好了,所以就先讓各位看看最後成品的樣子,在揭開程式碼以前,你也可以自己思考該怎麼完成這個 demo 呢?
這部分是整個專案的起點。在這裡,我們定義資源別名,並負責載入所有的圖片和圖集資源,以及初始化遊戲舞台。
import pixi = CG.Pixi.pixi;
// 定義資源別名,因為會一直使用到。(記得將資源別名修改成你自己專案的資源別名喔!)
const backgroundAlias = "ironman2025_cook.圖片.幸運餅乾背景";
const spritesheetAlias = "ironman2025_cook.圖集動畫.幸運餅乾";
const sfxAlias = "ironman2025_cook.音效.捏碎餅乾";
const bgmAlias = "ironman2025_cook.音樂.bgm";
const fontAlias = "ironman2025_cook.字型.SourceHanSerifTW-Heavy";
// 定義幸運餅乾陣列,用於儲存所有創建出來的餅乾。
const cookies: PIXI.Sprite[] = [];
// 定義幸運餅乾內的紙條訊息
const messages: string[] = [
"再開一個餅乾",
"再...再一個...",
":D",
"你看起來很餓\n要不要再多吃一個?",
"你剛剛浪費了兩秒鐘\n來閱讀這條訊息。",
"你的運氣取決於\n你今天吃的餅乾數量。"
];
async function start(): Promise<void> {
// 載入資源
await pixi.assets
.add(backgroundAlias)
.add(spritesheetAlias)
.add(sfxAlias)
.add(bgmAlias)
.add(fontAlias)
.load();
// 初始化 Pixi。
await pixi.initialize({ stageWidth: 960, stageHeight: 540 });
// 於舞台畫面上添加背景
const background = pixi.assets.createSprite(backgroundAlias);
pixi.root.addChild(background);
// 於舞台畫面上添加標題
const title = new PIXI.Text({
text: "選擇你的幸運餅乾!",
style: {
fontFamily: "Source Han Serif TW Heavy",
fontSize: 60,
fill: 0xFFECA1,
stroke: {
color: 0x000000,
width: 6,
join: "round"
} as PIXI.StrokeStyle
},
anchor: 0.5,
position: { x: pixi.stageWidth * 0.5, y: pixi.stageHeight * 0.2 },
resolution: 2
} as PIXI.TextOptions);
pixi.root.addChild(title);
// 初次設置所有餅乾
setupCookies();
// 播放背景音樂,因為音效有點小聲,所以也把音樂調小
pixi.assets.playSound(bgmAlias, { volume: 0.4, loop: true });
}
// 開始執行初始化
start();
程式碼重點:
const
來定義資源別名,讓程式碼更易於閱讀和維護。start()
函數是程式的入口,負責載入資源和初始化舞台。setupCookies()
函數來建立初始場景。在我們深入 setupCookies()
和 onCookieClick()
這些主邏輯之前,讓我們先來了解幾個可以讓程式碼更簡潔的工具函數。我們將 Tween
的啟動和 Promise
的封裝邏輯提取出來,這樣可以大幅提升程式碼的可讀性。
// ========== 各種工具 function ==========
/**
* 開始執行 Tween 並回傳 Promise。(用於等待 Tween 完成)
* @param tween - 要執行的 Tween
*/
function startTween(tween: TWEEN.Tween<any>): Promise<void> {
return new Promise(resolve => tween.onComplete(resolve).start());
}
/**
* 開始執行多個 Tween 並回傳 Promise。(用於等待多個 Tween 全部完成)
* @param tweens - 要執行的 Tween 們
*/
function startTweens(...tweens: TWEEN.Tween<any>[]): Promise<void[]> {
return Promise.all(tweens.map((tween) => startTween(tween)));
}
/**
* 縮放淡入顯示物件。
* @param container - 要淡入的顯示物件。
* @param duration - 動畫時間(毫秒)
*/
function fadeIn(container: PIXI.Container, duration: number = 300): Promise<void> {
container.scale.set(0); // 先將顯示物件縮放設為 0
return startTween(
// 讓顯示物件的縮放值放大至 1,使用回彈淡出動畫效果
new TWEEN.Tween(container.scale).to({ x: 1, y: 1 }, duration).easing(TWEEN.Easing.Back.Out)
);
}
/**
* 縮放淡出顯示物件。
* @param container - 要淡出的顯示物件。
* @param duration - 動畫時間(毫秒)
*/
function fadeOut(container: PIXI.Container, duration: number = 300): Promise<void[]> {
return startTweens(
// 讓顯示物件的不透明度淡出到 0,使用三次方淡出動畫效果
new TWEEN.Tween(container).to({ alpha: 0 }, duration).easing(TWEEN.Easing.Cubic.Out),
// 讓顯示物件的縮放值縮小至 0.5,使用三次方淡出動畫效果
new TWEEN.Tween(container.scale).to({ x: 0.5, y: 0.5 }, duration).easing(TWEEN.Easing.Cubic.Out)
);
}
程式碼重點:
Tween
的啟動和 Promise
的封裝邏輯提取出來,做成 startTween
和 startTweens
這兩個函數。await startTween(...)
這樣簡潔的語法,來等待一個或多個動畫完成。fadeIn
和 fadeOut
則將常見的動畫效果封裝起來,方便我們重複使用。setupCookies()
函數負責在畫面上創建三個幸運餅乾。我把餅乾和紙條的圖整合成一個 Spritesheet,因此利用 Spritesheet 來讀取 Texture
用來創建 Sprite
,Tween
則用來讓餅乾出現,有淡入放大的動畫效果。
/**
* 設置餅乾。
* - 在沒有餅乾的空位上創建新的餅乾。
* - 創建餅乾時會有縮放淡入動畫。
*/
async function setupCookies(): Promise<any> {
// 取得幸運餅乾的 Spritesheet 資源
const asset = pixi.assets.getSpritesheet(spritesheetAlias);
// 定義一個 Promise 陣列,用於等待所有動畫完成
const promises: Promise<void>[] = [];
for (let i = 0; i < 3; ++i) {
if (cookies[i]) continue; // 如果餅乾已經存在,則略過
// 創建幸運餅乾 Sprite 物件
const cookie = new PIXI.Sprite({
texture: asset.textures["幸運餅乾"],
anchor: 0.5, // 錨點設為 0.5,表示中心點在正中間
scale: 0, // 設定一開始縮放大小為 0,等等用於放大淡入
y: pixi.stageHeight * 0.6
});
// 隨機設定幸運餅乾的文字訊息,用於之後顯示文字
cookie["message"] = messages[Math.floor(Math.random() * messages.length)];
// 根據索引值來設定 x 座標
switch (i) {
case 0:
cookie.x = pixi.stageWidth * 0.16;
break;
case 1:
cookie.x = pixi.stageWidth * 0.5;
break;
case 2:
cookie.x = pixi.stageWidth * 0.84;
break;
}
// 設定滑鼠移入餅乾後的樣式,以及點擊後的監聽事件。
cookie.cursor = 'pointer';
cookie.once('pointertap', onCookieClick);
// 將餅乾儲存在陣列中,並加入到 Pixi 舞台顯示
cookies[i] = cookie;
pixi.root.addChild(cookie);
// 讓餅乾縮放淡入,並將 Promise 儲存於陣列中
promises.push(fadeIn(cookie, 500));
}
// 如果 Promise 陣列有東西,則等待所有 Promise 完成。
if (promises.length) await Promise.all(promises);
// 設定所有餅乾的 eventMode 使其可接收點擊事件
for (const cookie of cookies) {
cookie.eventMode = "static";
}
}
程式碼重點:
Spritesheet
的 textures
屬性來取得餅乾圖片紋理。scale
屬性一開始被設為 0
,這是為了配合接下來的 fadeIn
動畫。cookie.once('pointertap', ...)
設定點擊事件,並確保每個餅乾只會被點擊一次。Promise.all()
用來等待所有的餅乾都完成淡入動畫後,再一起將它們設為可互動(eventMode = "static"
)。當玩家點擊幸運餅乾時,會觸發 onCookieClick
函數。這個函數會先讓餅乾搖晃,然後再執行後續的邏輯。
/**
* 當餅乾被點擊時。
* @param e - 滑鼠點擊事件
*/
async function onCookieClick(e: PIXI.FederatedPointerEvent): Promise<void> {
// e.target 可以取出被點擊的對象,也就是餅乾。
const cookie = e.target as PIXI.Sprite; // as PIXI.Sprite 告訴編輯器該物件型別為 PIXI.Sprite
// 將所有餅乾設定為不可互動
for (const _cookie of cookies) {
_cookie.eventMode = "none";
}
// 讓餅乾搖晃的動畫
await startTween(
new TWEEN.Tween(cookie)
.to({ rotation: 0.2 }, 100) // 讓餅乾稍微順時針旋轉
.repeat(3) // 重複 3 次
.yoyo(true) // 讓動畫向溜溜球(Yoyo 球)一樣反覆來回執行
);
// 顯示該餅乾的紙條
showFortunePaper(cookie);
// 餅乾縮小消失
removeCookie(cookie);
// 播放音效
pixi.assets.playSound(sfxAlias);
}
程式碼重點:
e.target
:這是一個非常實用的技巧,讓我們可以直接取得被點擊的顯示物件,而不需要預先知道它是誰。因為這裡我們可以從前後文判斷這個 e.target
一定是餅乾,因此可以直接拿來用。await startTween(...)
:這裡我們使用 startTween
函數帶入克制的 Tween
,讓程式碼更簡潔,並確保在執行後續邏輯前,餅乾的搖晃動畫已經完成。eventMode
設定為 none
,這能防止玩家在動畫進行中重複點擊,造成非預期的錯誤。showFortunePaper()
是整個專案最複雜的部分,它結合了 Container
和動畫來實現一個彈出式視窗。
/**
* 顯示對應餅乾的紙條。
* @param cookie - 要顯示紙條的餅乾。
*/
async function showFortunePaper(cookie: PIXI.Sprite): Promise<void> {
// 創建一個半透明的黑色背景
const blackMask = new PIXI.Graphics()
.rect(0, 0, pixi.stageWidth, pixi.stageHeight)
.fill({ color: 0x000000, alpha: 0.75 });
blackMask.alpha = 0; // 先將不透明度設為 0 隱藏
// 將黑色背景加入到舞台上顯示
pixi.root.addChild(blackMask);
// 創建紙條容器,用於裝紙條背景和訊息文字。
const fortunePaper = new PIXI.Container();
fortunePaper.scale.set(0); // 一開始縮放為 0,用於待會放大
fortunePaper.position.copyFrom(cookie.position); // 使用餅乾的位置來設定紙條的位置
pixi.root.addChild(fortunePaper); // 將紙條加入 Pixi 舞台顯示
// 利用 Spritesheet 創建紙條 Sprite 物件
const asset = pixi.assets.getSpritesheet(spritesheetAlias);
const fortunePaperSprite = new PIXI.Sprite({
texture: asset.textures["紙條"],
anchor: 0.5, // 錨點設為 0.5,表示中心點在正中間
});
fortunePaper.addChild(fortunePaperSprite); // 將紙條加入紙條容器中
// 創建紙條文字物件
const fortunePaperText = new PIXI.Text({
text: cookie["message"], // 使用餅乾的訊息設定文字
anchor: 0.5, // 錨點設為 0.5,表示中心點在正中間
resolution: 2 // 設定文字解析度為 2
} as PIXI.TextOptions);
fortunePaper.addChild(fortunePaperText);
// 定義動畫時間(毫秒),方便下方重複使用
const duration = 500;
// 同時執行三個 Tween 動畫並等待完成
await Promise.all([
startTweens(
// 淡入黑色背景,使用三次方淡出動畫效果
new TWEEN.Tween(blackMask).to({ alpha: 1 }, duration).easing(TWEEN.Easing.Cubic.Out),
// 讓紙條容器滑動到舞台畫面正中心,使用五次方淡出動畫效果
new TWEEN.Tween(fortunePaper).easing(TWEEN.Easing.Quintic.Out)
.to({ x: pixi.stageWidth * 0.5, y: pixi.stageHeight * 0.5 }, duration)
),
// 淡入紙條容器並等待淡入完成
fadeIn(fortunePaper, duration)
]);
// 讓黑色背景可以被點擊
blackMask.eventMode = 'static';
blackMask.once("pointertap", async () => {
await startTweens(
// 讓紙條縮小,使用回彈淡入動畫效果
new TWEEN.Tween(fortunePaper.scale).to({ x: 0, y: 0 }, 300).easing(TWEEN.Easing.Back.In),
// 讓黑色背景淡出,使用三次方淡入動畫效果
new TWEEN.Tween(blackMask).to({ alpha: 0 }, 300).easing(TWEEN.Easing.Cubic.In)
);
// 銷毀紙條容器、黑色背景,釋放記憶體(包含子物件)
fortunePaper.destroy({ children: true });
blackMask.destroy();
// 重新設置餅乾,開始新的循環
setupCookies();
});
}
程式碼重點:
Container
:我們創建了 fortunePaper
這個 Container
,並將 fortunePaperSprite
和 fortunePaperText
放入其中,這樣就可以輕鬆地將整個紙條視為一個單元來控制其位置和動畫。blackMask
會先被加入舞台,接著才是 fortunePaper
。在 PixiJS 中,後被加入的物件會顯示在前面,因此紙條會蓋在半透明的黑色背景上方,形成一個完整的 UI 層級。Graphics
:我們使用 PIXI.Graphics
來繪製一個簡單的半透明黑色矩形,作為視窗的背景遮罩。await startTweens(...)
:透自訂的 startTweens
函數,我們能將多個 Tween
組合起來,同時執行多個動畫,並等待所有動畫完成後再繼續執行後續程式碼。這是一個非常優雅的非同步程式碼寫法。destroy()
來銷毀不再需要的物件,這是一個良好的習慣,可以幫助我們釋放記憶體資源。小提醒,已經 destroy()
的物件不該再被使用!
最後,這是一個 Youtube 上的成品演示影片,畢竟只靠 GIF 還是聽不到聲音。
恭喜!到今天為止,我們已經完整地介紹了遊戲開發中的核心基礎。從基本的圖形顯示,一路介紹到如何處理滑鼠點擊,並透過 Tween
、Spritesheet
和 Container
來創建複雜的互動場景。
到此為止,我們的旅程已經告一段落了,明天我們將進入第二階段,不再為了介紹功能而介紹,讓我們直接進入正題,為期 16 天的遊戲開發,讓我們邊做邊講,一起在最後一天打造出一個有趣的小遊戲吧~