iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0

在昨天介紹完 Text 後,到目前為們已經過了一個禮拜,我們把 PixiJS 中最基本的顯示物件都介紹過了一次,SpriteTextGraphicsContainer。而今天我們要來介紹怎麼讓這些物件可以互動。

▸ 開始動手

以下是一個最簡單的示範,當我們按下一個圖形的時候,它會改變色調,放開時變回來,就像一個按鈕一樣。

首先,將你的 app.ts 程式碼清空,並貼上以下程式碼:

import pixi = CG.Pixi.pixi;

async function start() {

	// 初始化 Pixi(請把 updateRendererOptions 當作 initialize)
	await pixi.updateRendererOptions({ stageWidth: 960, stageHeight: 540 });

	// 創建一個圓形按鈕
	const button = new PIXI.Graphics()
		.circle(0, 0, 100)
		.fill(0x66CCFF)
		.stroke({ width: 10, color: 0x2288DD });

	// 設定圓形位置
	button.position.set(pixi.stageWidth * 0.5, pixi.stageHeight * 0.5);

	// 讓物件可以被點擊
	button.eventMode = "dynamic";

	// 註冊一個 'pointerdown' 事件,當按鈕被按下時觸發
	button.on('pointerdown', () => {
		// 點擊後,改變圓形的顏色
		button.tint = 0xFF9900;
	});

	// 註冊一個 'pointerdown' 事件,當按鈕被放開時觸發
	button.on('pointerup', () => {
		// 放開後,恢復圓形的顏色
		button.tint = 0xFFFFFF;
	});

	// 將圓形加入舞台
	pixi.root.addChild(button);
}

start();

要讓顯示物件能夠互動,主要有兩點,一是要讓顯示物件可互動、二是讓顯示物件接收事件,接下來就讓我們分兩個段落來個別介紹它們吧!

tint 是顯示物件的其中一個屬性,用於快速改變物件的色調,可以設定為顏色的十六進位。預設為 0xFFFFFF,雖然是白色的意思,但代表的是原色,也就是沒有附加色調的意思。

Pixi 互動 預覽

▸ 事件模式(PIXI.EventMode

每個顯示物件都會有一個屬性叫做 eventMode,型別為 PIXI.EventMode,它是一個有五種固定值的字串,預設值為 "passive",而它總共有五種可選的值:

  • "none":忽略所有互動事件,包括其子物件。專為非互動元素進行了效能最佳化。
  • "passive":忽略自身的命中測試,不發出事件,但可互動的子物件仍然會接收到事件。
  • "auto":只有在父物件可互動時才會參與命中測試,本身不發出事件。
  • "static":會發出事件並進行命中測試。適用於靜止不動的互動元素,例如按鈕。
  • "dynamic":與 static 相同,但當指標閒置時也會接收到合成事件。適用於動畫或移動中的目標。

合成事件:這不是由使用者直接操作(例如點擊)產生的事件,而是由程式模擬出來的,就像是電腦自動幫你發出的滑鼠移動訊號。

以上是從 PixiJS 官方的原文介紹翻譯而來,而我個人把它總結成這樣:

  • 如果想要讓顯示物件完全不能互動,包括它的子物件,那就設定為 none。例如某個設定頁面,底下有各式各樣的按鈕可以點擊,想要讓設定頁不能被點到的時候。或是某個按鈕點了之後,要暫時禁用。
  • 如果想要讓顯示物件恢復原樣,不可被點擊,但不影響子物件,就設定為 passive。例如要讓設定頁恢復可以點擊等。
  • 如果想要讓顯示物件可以被點擊,就設定為 static。例如按鈕,或是其他可點擊的遊戲物件,像是翻牌遊戲中的卡片等。

至於 autodynamic,對於剛接觸 PixiJS 的人來說,幾乎不會有使用到的時候,等對 PixiJS 有一定的了解程度,或是需要優化效能的時候,再來深入了解即可。

eventMode 是 PixiJS v7 才被加入的機制,更早期我們會使用 button.interactive = true; 這種方式來啟用可互動,設為 false 來禁用互動。事實上現在的 v8 也還可以用,設為 true 的同時 eventMode 會被設為 "static"false 則是 "passive"

▸ 事件發射器(EventEmitter

所有的顯示物件都是基於(繼承) Container 衍生的,但 Container 其實也是基於某個物件的產物,那就是 EventEmitter

EventEmitter另一個函示庫的工具,提供了高性能、輕量化的事件管理系統,而 PixiJS 的互動系統便是基於這個系統所製作的。

簡單的說,繼承 EventEmitter 的物件,可以使用 emit() 來發送事件以及參數,on() 來接收事件並取的參數,off() 停止接收事件,。

但我們今天的重點不是在 EventEmitter,還是讓我們拉回主題,看看怎麼樣讓顯示物件接收點擊事件吧。

// 註冊一個 pointerdown 事件,當按鈕被按下時觸發
button.on("pointerdown", () => {
    // 點擊後,改變圓形的色調
    button.tint = 0xFF9900;
});

以上方的程式碼為例,on() 接收了兩個參數,分別為事件名稱執行的函數,當接收到對應的事件名稱,便會執行後方的函數,而 pointerdown 就是指按下的瞬間,另外 pointerup 則是放開的瞬間。

當我們將按鈕設為可互動後,根據使用者的操作,顯示物件就會發送對應的事件,此時只要使用 on() 將事件接起來,就可以執行對應的動作。

其他常用的還有像是 pointermove 滑鼠/觸碰移動時, pointertap 點擊(按下並放開)時等,但詳細的事件太多了,有需要的話可以到 PixiJS 的官方文件找找看自己需要的事件名稱為何。

▸ 實際效果演示

為了更直觀的看到各種狀態的效果,我稍微寫了個小程式來演示不同父子物件,狀態不同時的效果如何。

有興趣的話可以將下方的程式碼貼近 start() 裡面看看。

// 初始化 Pixi
await pixi.initialize({ stageWidth: 960, stageHeight: 540 });

// 建立一個用來顯示訊息的文字框
const infoText = new PIXI.Text({
	text: "移入並點擊方塊!",
	style: {
		fontFamily: "Arial",
		fontSize: 24,
		fill: 0xffffff,
		fontWeight: "bold",
		dropShadow: {
			color: 0x000000,
			blur: 4,
			angle: Math.PI / 6,
			distance: 6
		}
	},
	anchor: 0.5,
	position: { x: pixi.stageWidth / 2, y: 40 },
	resolution: 2
} as PIXI.TextOptions);
pixi.root.addChild(infoText);

// 建立一個容器來放置所有的示範方塊
const container = new PIXI.Container();
pixi.root.addChild(container);

// 設定文字樣式
const textStyle = new PIXI.TextStyle({
	fontFamily: "Arial",
	fontSize: 14, // 調整字體大小以適應小格子
	fill: "white",
	align: "center",
});

// 定義按鈕大小和間距
const boxSize = { width: 150, height: 80 };
const padding = 5;
const modes: PIXI.EventMode[] = ["none", "passive", "auto", "static", "dynamic"];
const colors = [0xC0392B, 0x27AE60, 0x2980B9, 0xF39C12, 0x8E44AD];

// 建立一個 5x5 的二維陣列,每個方塊代表一種父子 eventMode 組合
for (let i = 0; i < modes.length; i++) {
	for (let j = 0; j < modes.length; j++) {
		const parentMode = modes[i];
		const childMode = modes[j];
		const color = colors[i];

		// 父方塊
		const parentBox = new PIXI.Container();
		parentBox.x = (j - 2) * (boxSize.width + padding);
		parentBox.y = (i - 2) * (boxSize.height + padding);
		parentBox.eventMode = parentMode;
		container.addChild(parentBox);

		// 父方塊背景
		const parentBg = new PIXI.Graphics()
			.rect(-boxSize.width * 0.5, -boxSize.height * 0.5, boxSize.width, boxSize.height)
			.fill(color);
		parentBox.addChild(parentBg);

		// 子方塊
		const childBoxSize = { width: boxSize.width * 0.5, height: boxSize.height * 0.5 };
		const childBox = new PIXI.Graphics()
			.rect(-childBoxSize.width * 0.5, -childBoxSize.height * 0.5, childBoxSize.width, childBoxSize.height)
			.fill(colors[j]); // 子方塊顏色依據自己的 eventMode
		childBox.eventMode = childMode;
		parentBox.addChild(childBox);

		// 標籤文字
		const parentLabel = new PIXI.Text({
			text: parentMode.toUpperCase(),
			style: textStyle,
			anchor: 0.5,
			position: { x: 0, y: -boxSize.height * 0.375 },
			resolution: 2
		} as PIXI.TextOptions);
		parentBox.addChild(parentLabel);

		const childLabel = new PIXI.Text({
			text: childMode.toUpperCase(),
			style: textStyle,
			anchor: 0.5,
			resolution: 2
		} as PIXI.TextOptions);
		childBox.addChild(childLabel);

		// 設置父方塊的事件監聽器
		parentBox.on("pointerover", (e) => {
			infoText.text = `移入父節點(${parentMode})`;
			parentBg.alpha = 0.8;
		});
		parentBox.on("pointerout", () => {
			infoText.text = `移出父節點(${parentMode})`;
			parentBg.alpha = 1;
		});
		parentBox.on("pointerdown", () => {
			infoText.text = `按下父節點(${parentMode})`;
			parentBg.alpha = 0.5;
		});
		const parentBoxPointerUp = () => {
			infoText.text = `放開父節點(${parentMode})`;
			parentBg.alpha = 1;
		};
		parentBox.on("pointerup", parentBoxPointerUp);
		parentBox.on("pointerupoutside", parentBoxPointerUp);

		// 設置子方塊的事件監聽器
		childBox.on("pointerover", () => {
			infoText.text = `移入子物件(${childMode})父節點(${parentMode})`;
			childBox.alpha = 0.8;
			childLabel.scale.set(1.1);
		});
		childBox.on("pointerout", () => {
			infoText.text = `移出子物件(${childMode})父節點(${parentMode})`;
			childBox.alpha = 1;
			childLabel.scale.set(1);
		});
		childBox.on("pointerdown", () => {
			infoText.text = `按下子物件(${childMode})父節點(${parentMode})`;
			childBox.alpha = 0.5;
			childLabel.scale.set(0.9);
		});
		const childBoxPointerUp = () => {
			infoText.text = `放開子物件(${childMode})父節點(${parentMode})`;
			childBox.alpha = 1;
			childLabel.scale.set(1);
		};
		childBox.on("pointerup", childBoxPointerUp);
		childBox.on("pointerupoutside", childBoxPointerUp);
	}
}

// 將整個容器置中於畫面上
container.position.set(pixi.stageWidth * 0.5, pixi.stageHeight * 0.5);

這段程式碼會在畫面中央生成 5x5 大小的格子,分別是 5 種狀態的父物件,以及 5 種狀態的子物件的組合。詳細的運作各位可以自行參考註解來了解,所有的程式碼都是我們已經介紹過的,文字、容器、圖形,以及今天的互動事件,可以順便檢視一下自己是不是都已經了解了基礎的運作。

Pixi 各種事件組合預覽

▸ 聯合指標事件(PIXI.FederatedPointerEvent

所有的互動事件,其實都有一個參數,類別是 PIXI.FederatedPointerEvent,裡面包刮了非常詳細的資料,從最簡單的點擊的座標,到複雜的事件是從哪邊一層傳遞過來的。

// 註冊一個 pointermove 事件,當鼠標在容器上移動時觸發
container.on("pointermove", (event: PIXI.FederatedPointerEvent) => {
    // ... 暫時省略 ...
});

這邊我稍微介紹幾個常見、常用的屬性/函數:

global: PIXI.Point;

這是一個 PIXI.Point,代表的是基於視窗大小的全域座標,不管 PixiJS 上的舞台怎麼縮放,位置在哪裡,點擊的位置永遠是基於視窗最左上角的地方作為 (0,0)。

getLocalPosition(container: PIXI.Container): PIXI.Point;

這是一個函數,可以傳入一個顯示物件作為參考對象,同樣回傳一個 PIXI.Point,但裡面的值是相對於顯示物件的位置。

stopPropagation(): void;

這是一個函數,用於阻止事件傳遞到父物件上,因為點擊事件是會一直向上傳遞到根節點(pixi.root)上的。舉個例子,有一個全畫面的半透明背景,上面加了一個視窗物件,背景添加了點擊監聽,用於在玩家點擊視窗外時可以關閉視窗,那要怎麼讓點擊視窗內不會關閉視窗呢?那就是讓視窗也添加點擊監聽,並且在視窗點擊後執行 event.stopPropagation(),如此一來視窗的點擊事件就會在這裡被截斷,不會繼續往上傳遞到父節點,自然就不會關閉視窗了。

▸ 全域坐標與區域坐標

以下這個範例,將會更清楚地演示 globalgetLocalPosition 的差異與用法。當你移動滑鼠時,分別會顯示滑鼠的全域座標和區域座標。

// 創建一個黑色背景(這樣監聽舞台鼠標移動)
const background = new PIXI.Graphics()
    .rect(0, 0, pixi.stageWidth, pixi.stageHeight)
    .fill({ color: 0x000000 });
pixi.root.addChild(background);

// 創建一個矩形圖形
const rect = new PIXI.Graphics()
    .rect(0, 0, pixi.stageWidth * 0.8, pixi.stageHeight * 0.8)
    .fill({ color: 0xFF0000, alpha: 0.5 });
rect.position.set(pixi.stageWidth * 0.1, pixi.stageHeight * 0.1);
pixi.root.addChild(rect);

// 創建一個顯示資訊的文字物件
const infoText = new PIXI.Text({
    text: "全域座標:(0,0)\n區域座標:(0,0)",
    style: {
        fill: 0xFFFFFF,
        fontSize: 60,
    },
    anchor: 0.5,
    position: { x: pixi.stageWidth * 0.5, y: pixi.stageHeight * 0.5 }
} as PIXI.TextOptions);
pixi.root.addChild(infoText);

// 讓舞台可以被點擊
pixi.root.eventMode = "static";

// 註冊一個 pointermove 事件,當鼠標在舞台上移動時觸發
pixi.root.on("pointermove", (event: PIXI.FederatedPointerEvent) => {
    // 將點擊事件的全域座標,轉換成矩形內的座標
    const local = event.getLocalPosition(rect);
    infoText.text = `全域座標:(${event.global.x},${event.global.y})\n`;
    infoText.text += `區域座標:(${local.x},${local.y})`;
});

從畫面中你可以看到,全域座標會隨著滑鼠在螢幕上的位置而改變,而區域座標則是以紅色矩形的左上角為 (0,0),是相對於矩形內部的座標。這個功能在製作遊戲中需要將滑鼠座標轉換為角色移動方向時非常有用。

Pixi 座標轉換演示

點我查看範例程式碼

▸ 總結

今天的內容比較文謅謅一點,但是相當重要,畢竟組成遊戲最重要的核心元素之一就是互動

  • 要讓物件可以被滑鼠或觸控偵測,可以將它的 eventMode 屬性設為 "static"
  • 所有顯示物件都繼承了 EventEmitter 的功能,讓我們可以使用 on()off() 等方法來註冊和移除事件監聽器。
  • 事件監聽器會傳入一個 PIXI.FederatedPointerEvent 物件,其中包含了 globalgetLocalPosition() 等屬性/函數。

有了關於滑鼠、觸控的互動偵測以後,是不是還少了點什麼呢?沒錯,鍵盤!PixiJS 其實並沒有特別提供針對鍵盤的偵測功能,但沒關係,CG 有!因此明天我們就要來介紹怎麼利用 CG 提供的工具,快速的進行鍵盤的輸入、狀態檢查等。


上一篇
Day 07:顯示遊戲文字與訊息 - Text
系列文
用 PixiJS 寫遊戲!告別繁瑣設定,在 Code.Gamelet 打造你的第一個遊戲8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言