在 Day 15 中,我們完成了古堡地宮的場景建設、動態壁燈光影以及 2.5D 相機鎖定迴圈。然而,這座精緻的世界目前還缺乏一位探索者。
在 HD-2D 的設計美學中,最核心的視覺衝擊來自於「3D 精緻世界」與「2D 復古像素紙片人」的結合。今天我們將正式實裝玩家角色,利用 THREE.Sprite 解決 2D 平面在 3D 視角中躺平的痛點,為主角掛載一盞照亮周遭的隨身火把,並結合地圖二維陣列實作精準的網格碰撞與移動控制。
【核心開發目標】
如果將 2D 圖片貼在標準平面網格(PlaneGeometry)上,當相機以 28 度低仰角俯視時,角色看起來會像一張躺在地上的貼紙;手動計算旋轉矩陣讓平面永遠面對相機又極耗效能。Three.js 的 THREE.Sprite 在底層天生具備 Billboard 特性,無論視角如何移動,都會自動維持與鏡頭視線垂直。 在真實專案中,角色動畫通常是由橫向動畫條(Strip)構成,而非對稱的方形宮格。我們的實際素材為待機 5 幀(IDLE_COLS = 5)與跑動 6 幀(RUN_COLS = 6)。我們透過設定 repeat 與 offset 來實現精準的單幀 UV 裁切:
//將角色的精靈圖與地圖的素材分開存放以方便維護
import idleTextureUrl from '../2D Pixel Art Character Template - Platformer & Metroidvania/idle/idle.png';[cite: 4]
import runTextureUrl from '../2D Pixel Art Character Template - Platformer & Metroidvania/running/running.png';[cite: 4]
// 載入貼圖並強制保持 NearestFilter 像素銳利
const idleTex = loadPixelTexture(idleTextureUrl);[cite: 4]
const runTex = loadPixelTexture(runTextureUrl);[cite: 4]
// 依據素材規格定義橫向欄數 (單列動畫條)
const IDLE_COLS = 5;[cite: 4]
const RUN_COLS = 6;[cite: 4]
// 設定橫向裁切寬度 (1 / COLS),縱向高度保持 1
idleTex.repeat.set(1 / IDLE_COLS, 1);[cite: 4]
idleTex.offset.set(0, 0);[cite: 4]
runTex.repeat.set(1 / RUN_COLS, 1);[cite: 4]
runTex.offset.set(0, 0);[cite: 4]
// 建立帶有透明度測試的 Sprite 材質
const playerMat = new THREE.SpriteMaterial({
map: idleTex,
transparent: true,
alphaTest: 0.1 // 剔除邊緣半透明雜質,保持邊界刀鋒銳利
});
const playerSprite = new THREE.Sprite(playerMat);[cite: 4]
【Step 2:解決「下半身陷地」與身形比例調整】
THREE.Sprite 預設的幾何中心錨點為 (0.5, 0.5)(正中心)。如果將 Sprite 的座標直接設在地表高度,角色的下半身會硬生生插入地面石磚內部。 此外,如果角色比例僅設定為 1x1,在 2~3 格高的城牆對比下會顯得過於矮小。我們必須校正錨點至腳底,並放大身形比例:
// 關鍵修正:將錨點重設為腳底底部正中心 (0.5, 0.0)
playerSprite.center.set(0.5, 0.0);
// 放大角色比例至 2.8 倍,與 2~3 層高的古堡石牆呈現等高挺拔的黃金比例
const playerBaseScale = 2.8;
playerSprite.scale.set(playerBaseScale, playerBaseScale, 1);
// 建立主角層級群組
const playerGroup = new THREE.Group();
playerGroup.add(playerSprite);
【Step 3:手持隨身火把與雙向座標轉換矩陣】
為了強化探險氛圍,我們為主角配置一盞隨身暖光點光源,並建立「二維網格行列(Row, Col)」與「3D 世界座標(X, Y, Z)」的雙向換算邏輯:
// 配合身形調高隨身火把至胸口高度 (y=1.3),並設定 7.0 照亮半徑
const playerLight = new THREE.PointLight('#ffb066', 2.0, 7.0, 1.3);
playerLight.position.set(0, 1.3, 0.2);
playerGroup.add(playerLight);
scene.add(playerGroup);
// 1. 網格座標 (r, c) 轉換為 3D 世界座標 (X, Y, Z)
function gridToWorld(r, c) {
return {
x: c - (mapSize / 2) + 0.5,
y: 1.0, // 地表頂面基準高度
z: r - (mapSize / 2) + 0.5
};
}
// 2. 3D 世界座標 (X, Z) 反向映射為網格座標 (r, c)
function worldToGrid(x, z) {
return {
row: Math.round(z + (mapSize / 2) - 0.5),
col: Math.round(x + (mapSize / 2) - 0.5)
};
}
// 設定主角安全出生點:中央大道平坦石磚 (row 25, col 24)
const spawnGrid = { r: 25, c: 24 };
const spawnPos = gridToWorld(spawnGrid.r, spawnGrid.c);
playerGroup.position.set(spawnPos.x, spawnPos.y, spawnPos.z);
// 初始化相機目標對齊主角胸口 (y = 1.6)
cameraTarget.set(spawnPos.x, 1.6, spawnPos.z);
【Step 4:四點碰撞箱與滑牆移動演算法(Wall Sliding)】
單純檢測單一中心點的碰撞容易產生邊緣穿模。我們為角色定義一個半徑為 0.32 的四角取樣點邊界框(Bounding Box)。 更重要的是,若玩家朝斜前方(例如同時按 W 與 D)撞上牆壁,傳統碰撞檢測會直接把位移歸零,導致角色強烈卡頓。我們實作了軸向分離滑牆演算法:當斜向受阻時,分別測試單獨走 X 軸或單獨走 Z 軸,讓角色能順暢地貼著牆壁滑行:
// WASD 與方向鍵狀態監聽
const keys = {
w: false, a: false, s: false, d: false,
ArrowUp: false, ArrowDown: false, ArrowLeft: false, ArrowRight: false
};
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (k === 'w' || e.key === 'ArrowUp') keys.w = true;
if (k === 's' || e.key === 'ArrowDown') keys.s = true;
if (k === 'a' || e.key === 'ArrowLeft') keys.a = true;
if (k === 'd' || e.key === 'ArrowRight') keys.d = true;
});
window.addEventListener('keyup', (e) => {
const k = e.key.toLowerCase();
if (k === 'w' || e.key === 'ArrowUp') keys.w = false;
if (k === 's' || e.key === 'ArrowDown') keys.s = false;
if (k === 'a' || e.key === 'ArrowLeft') keys.a = false;
if (k === 'd' || e.key === 'ArrowRight') keys.d = false;
});
// 碰撞半徑設定為 0.32,兼顧身形並保留穿過 1 格窄道的餘裕
const playerCollisionRadius = 0.32;
function checkCollision(x, z) {
const r = playerCollisionRadius;
// 建立四角取樣點
const testPoints = [
{ x: x - r, z: z - r },
{ x: x + r, z: z - r },
{ x: x - r, z: z + r },
{ x: x + r, z: z + r }
];
for (const pt of testPoints) {
const grid = worldToGrid(pt.x, pt.z);
// 檢查邊界越界
if (grid.row < 0 || grid.row >= gridRows || grid.col < 0 || grid.col >= gridCols) {
return true;
}
const tileType = mapGrid[grid.row][grid.col];
// 阻擋代碼 5 (牆壁) 與代碼 2 (碎石)
if (tileType === 5 || tileType === 2) {
return true;
}
}
return false;
}
【Step 5:水平鏡像翻轉、動畫計時切換與相機平滑跟隨】
THREE.Sprite 由於 GPU Billboard 鎖定機制的緣故,無法使用傳統的 rotation.y 來完成角色轉身;正確的做法是對 X 軸縮放值進行正負切換(scale.x = -playerScale)。 我們封裝了 updatePlayer(dt) 函式,以真實時間差(dt)驅動位移與動畫計時,並在主迴圈中以 MathUtils.lerp 實現相機的動態追焦:
const playerSpeed = 4.5;
let currentAnim = 'idle';
let currentFrame = 0;
let animTimer = 0;
let facingRight = true;
function updatePlayer(dt) {
let moveX = 0;
let moveZ = 0;
if (keys.w) moveZ -= 1;
if (keys.s) moveZ += 1;
if (keys.a) moveX -= 1;
if (keys.d) moveX += 1;
const isMoving = (moveX !== 0 || moveZ !== 0);
if (isMoving) {
// 斜向向量正規化,防止對角線移動超速
const len = Math.hypot(moveX, moveZ);
moveX /= len;
moveZ /= len;
// 水平朝向判斷
if (moveX < -0.01) facingRight = false;
else if (moveX > 0.01) facingRight = true;
const dx = moveX * playerSpeed * dt;
const dz = moveZ * playerSpeed * dt;
const currX = playerGroup.position.x;
const currZ = playerGroup.position.z;
// 滑牆處理 (Wall Sliding)
if (!checkCollision(currX + dx, currZ + dz)) {
playerGroup.position.x += dx;
playerGroup.position.z += dz;
} else {
// 斜向受阻時,嘗試單軸滑動
if (!checkCollision(currX + dx, currZ)) playerGroup.position.x += dx;
if (!checkCollision(currX, currZ + dz)) playerGroup.position.z += dz;
}
// 動畫切換:跑步
if (currentAnim !== 'run') {
currentAnim = 'run';
currentFrame = 0;
animTimer = 0;
playerMat.map = runTex;
playerMat.needsUpdate = true;
}
} else {
// 動畫切換:待機
if (currentAnim !== 'idle') {
currentAnim = 'idle';
currentFrame = 0;
animTimer = 0;
playerMat.map = idleTex;
playerMat.needsUpdate = true;
}
}
// 鏡像翻轉:透過 scale.x 正負值實作水平轉向
playerSprite.scale.x = facingRight ? playerBaseScale : -playerBaseScale;
// 動畫幀輪播更新
const frameDuration = currentAnim === 'run' ? 0.09 : 0.16;
const totalFrames = currentAnim === 'run' ? RUN_COLS : IDLE_COLS;
animTimer += dt;
if (animTimer >= frameDuration) {
animTimer = 0;
currentFrame = (currentFrame + 1) % totalFrames;
if (playerMat.map) {
playerMat.map.offset.x = currentFrame / totalFrames;
}
}
}
最後,將渲染迴圈中的相機更新改為帶有阻尼感的線性插值跟隨:
function animate() {
requestAnimationFrame(animate);
// 取得真實幀間時間差 (防止切換視窗產生過大時間差)
const dt = Math.min(clock.getDelta(), 0.1);
const elapsedTime = clock.getElapsedTime();
// 壁燈火光微動
animatedLights.forEach(item => {
const flicker = Math.sin(elapsedTime * item.speed + item.phase) * 0.35 +
Math.cos(elapsedTime * item.speed * 2.1 + item.phase) * 0.2;
item.light.intensity = item.baseIntensity + flicker;
// 每幀更新主角物理、滑牆碰撞與動畫
updatePlayer(dt);
// 相機以 0.08 線性插值平滑跟隨主角,鎖定胸口核心高度 y = 1.6
cameraTarget.x = THREE.MathUtils.lerp(cameraTarget.x, playerGroup.position.x, 0.08);
cameraTarget.z = THREE.MathUtils.lerp(cameraTarget.z, playerGroup.position.z, 0.08);
cameraTarget.y = 1.6;
camera.position.copy(cameraTarget).add(cameraOffset);
camera.lookAt(cameraTarget);
renderer.render(scene, camera);
}
animate();
至此,你的 2D 像素主角已經在 3D 古堡地宮中奔跑起來。
雖然目前主角還沒有拿火把,因為素材是別人開放免費下載的,大家可以去這個網站下載自己喜歡的素材,後續會想辦法把火把的素材補上的。
https://n3cloud.itch.io/2d-pixel-art-character-template-platformer-metroidvania
【本日結語】
在 WASD 的操控下,角色具備流暢的待機與跑動動作切換,遇到石牆或碎石時能自然滑動而不卡死,且鏡頭以 2.5D 平視低仰角平滑伴隨主角移動,正式達成了 HD-2D 的完整動態體驗,但是現在角色只能朝著右邊奔跑,沒有其他方向的動作,Day17將繼續完善這些畫面上的小細節,後續的日子裡會增加更多光影和畫面的細節讓他變得更有HD-2D的感覺。