iT邦幫忙

2025 iThome 鐵人賽

DAY 14
1
Modern Web

用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲系列 第 14

Day 14:第一階段總結 - 一個會動、會響的互動場景——幸運餅乾

  • 分享至 

  • xImage
  •  

今天是第二週的最後一天,前陣子我們已經把使用 PixiJS 製作遊戲時,最基本會使用到的各種物件都介紹過了,還有其他像是 CG 的更新循環系統、補間動畫(Tween)、Spritesheet 等概念,理論上只要將這些東西組合起來,就可以製作出一款基本的小遊戲了。

因此,今天我們要來總結一下到目前為止的所學,來製作一個會動、會響的互動場景——幸運餅乾

▸ 始前規劃

首先,在開始撰寫程式碼以前,我們通常要先想好要做什麼要怎麼做?這兩個問題非常重要,它決定了我們接下來該怎麼完成這個成品。如果沒有先規劃好要做什麼,很有可能就會在做到一半,甚至是剛開始著手就不知道該怎麼進行的窘境,因此我們先來規劃一下今天要做的內容。

你可以先用最簡單的白話文,稍微描述你心中所想的東西,只要包含開始、簡單的過程、結束三個部分即可,以我為例子:「一開始畫面上會出現三個幸運餅乾,選擇其中一個後,餅乾會消失,並從裡面拿出紙條顯示訊息,之後紙條會消失,餅乾會恢復原狀。」有了這個雛形之後,我們再來慢慢細化這個流程。

  1. 初始化遊戲資源(載入資源、設置背景、定義三個空位等)。
  2. 在沒有餅乾的空位上生成餅乾。
  3. 當玩家選擇任意餅乾後,餅乾會播放搖晃動畫。
  4. 搖晃動畫完成後移除餅乾,並顯示對應的紙條訊息。
  5. 點擊任一處,讓紙條訊息淡出,之後重複步驟 2。

將每個步驟拆分成一條條的項目之後,我們就可以來針對這些項目,一個個把他們的功能實作出來啦~

因為我其實已經做好了,所以就先讓各位看看最後成品的樣子,在揭開程式碼以前,你也可以自己思考該怎麼完成這個 demo 呢?

幸運餅乾 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 的封裝邏輯提取出來,做成 startTweenstartTweens 這兩個函數。
  • 這讓我們在主程式碼中,可以直接使用 await startTween(...) 這樣簡潔的語法,來等待一個或多個動畫完成。
  • fadeInfadeOut 則將常見的動畫效果封裝起來,方便我們重複使用。

▸ 創建餅乾:Spritesheet、Tween

setupCookies() 函數負責在畫面上創建三個幸運餅乾。我把餅乾和紙條的圖整合成一個 Spritesheet,因此利用 Spritesheet 來讀取 Texture 用來創建 SpriteTween 則用來讓餅乾出現,有淡入放大的動畫效果。

/**
 * 設置餅乾。
 * - 在沒有餅乾的空位上創建新的餅乾。
 * - 創建餅乾時會有縮放淡入動畫。
 */
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";
	}
}

程式碼重點

  • 我們透過 Spritesheettextures 屬性來取得餅乾圖片紋理。
  • 餅乾的 scale 屬性一開始被設為 0,這是為了配合接下來的 fadeIn 動畫。
  • 透過 cookie.once('pointertap', ...) 設定點擊事件,並確保每個餅乾只會被點擊一次。
  • Promise.all() 用來等待所有的餅乾都完成淡入動畫後,再一起將它們設為可互動(eventMode = "static")。

▸ 處理點擊事件:一個搖擺的 Tween 動畫

當玩家點擊幸運餅乾時,會觸發 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,這能防止玩家在動畫進行中重複點擊,造成非預期的錯誤。

▸ 幸運紙條:Container 的應用

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,並將 fortunePaperSpritefortunePaperText 放入其中,這樣就可以輕鬆地將整個紙條視為一個單元來控制其位置和動畫。
  • 圖層控制blackMask 會先被加入舞台,接著才是 fortunePaper。在 PixiJS 中,後被加入的物件會顯示在前面,因此紙條會蓋在半透明的黑色背景上方,形成一個完整的 UI 層級。
  • Graphics:我們使用 PIXI.Graphics 來繪製一個簡單的半透明黑色矩形,作為視窗的背景遮罩。
  • await startTweens(...):透自訂的 startTweens 函數,我們能將多個 Tween 組合起來,同時執行多個動畫,並等待所有動畫完成後再繼續執行後續程式碼。這是一個非常優雅的非同步程式碼寫法。
  • 資源釋放:當動畫結束後,我們呼叫 destroy() 來銷毀不再需要的物件,這是一個良好的習慣,可以幫助我們釋放記憶體資源。小提醒,已經 destroy() 的物件不該再被使用!

點我查看範例程式碼

Yes

最後,這是一個 Youtube 上的成品演示影片,畢竟只靠 GIF 還是聽不到聲音。

▸ 總結

恭喜!到今天為止,我們已經完整地介紹了遊戲開發中的核心基礎。從基本的圖形顯示,一路介紹到如何處理滑鼠點擊,並透過 TweenSpritesheetContainer 來創建複雜的互動場景。

到此為止,我們的旅程已經告一段落了,明天我們將進入第二階段,不再為了介紹功能而介紹,讓我們直接進入正題,為期 16 天的遊戲開發,讓我們邊做邊講,一起在最後一天打造出一個有趣的小遊戲吧~


上一篇
Day 13:遊戲的聽覺饗宴:播放音效與背景音樂
下一篇
Day 15:告別基礎教學!從今天開始,我們「直接做遊戲」
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言