代码: 全选
這是一個非常有趣且具備實體桌遊風格的 3D 遊戲設計!要用 Three.js 實作這個「炸彈超人」遊戲,我們需要結合 Three.js(負責 3D 渲染與鏡頭控制)、Cannon-es(負責擲骰子的物理碰撞),以及原生的 HTML/SVG/CSS 來處理 UI 互動與手機橫向提示。
以下為你提供一個完整的、可執行的單一 HTML 檔案基礎架構,包含了你所描述的場景佈置、材質生成、鏡頭限制、投擲用的 SVG 圓盤,以及物理引擎的整合。
### 遊戲核心程式碼
你可以將以下程式碼儲存為 `index.html`,並直接在現代瀏覽器中開啟(建議使用本地伺服器如 Live Server 執行以避免跨域載入限制):
```html
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>3D 炸彈超人 桌遊版</title>
<style>
body { margin: 0; overflow: hidden; background-color: #222; touch-action: none; }
canvas { display: block; }
/* 手機橫放提示 */
#orientation-prompt {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.9);
color: white;
font-size: 24px;
font-family: sans-serif;
align-items: center;
justify-content: center;
z-index: 9999;
text-align: center;
}
@media screen and (orientation: portrait) {
#orientation-prompt { display: flex; }
}
/* 投擲用 SVG 圓盤 */
#throw-ui {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 480px; height: 480px; /* 半徑 240 */
pointer-events: none; /* 預設不可點擊,輪到回合時啟用 */
display: none; /* 預設隱藏 */
z-index: 10;
}
#throw-ui.active {
pointer-events: auto;
display: block;
}
#throw-circle {
fill: rgba(173, 255, 47, 0.5); /* 綠色與黃色之間 (GreenYellow),半透明 */
}
#throw-line {
stroke: white;
stroke-width: 4;
display: none;
}
</style>
</head>
<body>
<div id="orientation-prompt">請將手機橫放以獲得最佳遊戲體驗</div>
<svg id="throw-ui" viewBox="0 0 480 480">
<circle id="throw-circle" cx="240" cy="240" r="240" />
<line id="throw-line" x1="240" y1="240" x2="240" y2="240" />
</svg>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/",
"cannon-es": "https://unpkg.com/cannon-es@0.20.0/dist/cannon-es.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import * as CANNON from 'cannon-es';
// ==========================================
// 1. 基本設定 (Three.js + Cannon.js)
// ==========================================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x333333);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 200, 150);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
// 鏡頭限制
const controls = new OrbitControls(camera, renderer.domElement);
controls.enablePan = false; // 禁止平移
controls.maxPolarAngle = Math.PI / 2; // 只能俯視到水平,不能到水平以下
controls.minAzimuthAngle = -Math.PI / 2; // 左右旋轉最多 90 度
controls.maxAzimuthAngle = Math.PI / 2;
controls.enableDamping = true;
// 物理引擎世界
const world = new CANNON.World({
gravity: new CANNON.Vec3(0, -982, 0), // 較大的重力讓木條感覺重一些
});
// 物理地面
const groundBody = new CANNON.Body({
type: CANNON.Body.STATIC,
shape: new CANNON.Plane(),
});
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
world.addBody(groundBody);
// 燈光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(50, 100, 50);
dirLight.castShadow = true;
scene.add(dirLight);
// ==========================================
// 2. 材質與貼圖生成 (Canvas Texture)
// ==========================================
const colorWood = 0xd2b48c; // 木色
const colorBrown = 0x8b4513; // 楬色
const colorDarkBlue = 0x00008b; // 深藍色
const colorPurple = 0x800080; // 紫色
const matWood = new THREE.MeshStandardMaterial({ color: colorWood });
const matBrown = new THREE.MeshStandardMaterial({ color: colorBrown });
const matDarkBlue = new THREE.MeshStandardMaterial({ color: colorDarkBlue });
const matPurple = new THREE.MeshStandardMaterial({ color: colorPurple });
// 地板木條材質陣列 (未塗色面朝上, 假設 Y 軸朝上)
// BoxGeometry 面的順序: +x, -x, +y, -y, +z, -z
const floorMaterials = [
matWood, matWood, // 左右端點
matWood, // 上面 (未塗色)
matBrown, // 下面
matDarkBlue, // 側面 1
matPurple // 側面 2
];
// 創建帶有數字的木條材質
function createDiceMaterial(color, numbers) {
const mats = [matWood, matWood]; // 端點
for(let i=0; i<4; i++){
const canvas = document.createElement('canvas');
canvas.width = 64; canvas.height = 256; // 比例 10x50
const ctx = canvas.getContext('2d');
ctx.fillStyle = color === 'brown' ? '#8b4513' : '#00008b';
ctx.fillRect(0, 0, 64, 256);
ctx.fillStyle = 'white';
ctx.font = '40px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 旋轉文字以符合長條方向
ctx.save();
ctx.translate(32, 128);
ctx.rotate(-Math.PI / 2);
ctx.fillText(numbers[i], 0, 0);
ctx.restore();
const tex = new THREE.CanvasTexture(canvas);
mats.push(new THREE.MeshStandardMaterial({ map: tex }));
}
return mats; // +y, -y, +z, -z
}
const diceMatBrown = createDiceMaterial('brown', ['0', '1', '2', '3']);
const diceMatBlue = createDiceMaterial('blue', ['1', '2', '3', '4']);
// 創建人物三角形貼圖
function createCharacterTexture(bgColorStr) {
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.fillStyle = bgColorStr;
ctx.fillRect(0, 0, 256, 256);
// 畫線 (中心到右角) - 對應 0度
ctx.strokeStyle = 'white';
ctx.lineWidth = 15;
ctx.beginPath();
ctx.moveTo(128, 128);
ctx.lineTo(256, 128);
ctx.stroke();
// 畫平行於左邊的線 (用數學近似,因為只是貼圖示意)
ctx.beginPath();
ctx.moveTo(64, 64);
ctx.lineTo(64, 192);
ctx.stroke();
return new THREE.CanvasTexture(canvas);
}
const charMatBrown = new THREE.MeshStandardMaterial({ map: createCharacterTexture('#8b4513') });
const charMatBlue = new THREE.MeshStandardMaterial({ map: createCharacterTexture('#00008b') });
// ==========================================
// 3. 創建遊戲物件
// ==========================================
// 14 根地板 (10x10x50)
const blockGeo = new THREE.BoxGeometry(50, 10, 10);
const startX = -3 * 52; // 7根木條,每根長50+間隔2 = 52。中心為0。
for (let row = 0; row < 2; row++) {
const zPos = row === 0 ? -25 : 25;
for (let col = 0; col < 7; col++) {
const block = new THREE.Mesh(blockGeo, floorMaterials);
block.position.set(startX + col * 52, 5, zPos); // 放在地上 Y=5 (高度10的一半)
block.receiveShadow = true;
block.castShadow = true;
scene.add(block);
}
}
// 人物 (邊長30的正三角形) - 使用 Cylinder 近似
const radiusTri = 30 / Math.sqrt(3);
const charGeo = new THREE.CylinderGeometry(radiusTri, radiusTri, 2, 3);
const charBrown = new THREE.Mesh(charGeo, charMatBrown);
charBrown.position.set(startX, 1, -25 - 15); // 上排最左上方
charBrown.rotation.y = -Math.PI / 6; // 朝右
scene.add(charBrown);
const charBlue = new THREE.Mesh(charGeo, charMatBlue);
charBlue.position.set(-startX, 1, 25 + 15); // 下排最右下方
charBlue.rotation.y = Math.PI - Math.PI / 6; // 朝左
scene.add(charBlue);
// 炸彈 (邊長10的正六邊形)
const bombGeo = new THREE.CylinderGeometry(10, 10, 2, 6);
const bombMat = new THREE.MeshStandardMaterial({ color: 0x555555 });
const bomb = new THREE.Mesh(bombGeo, bombMat);
bomb.position.set(0, 1, 0); // 視圖中心
// 確保上下兩邊水平
bomb.rotation.y = Math.PI / 6;
scene.add(bomb);
// 準備物理數字木條 (初始隱藏,投擲時生成)
const diceMeshes = [];
const diceBodies = [];
function createDice(x, y, z, materialIndex) {
const mesh = new THREE.Mesh(blockGeo, materialIndex === 0 ? diceMatBrown : diceMatBlue);
mesh.castShadow = true;
scene.add(mesh);
diceMeshes.push(mesh);
// 物理形狀 (Half extents)
const shape = new CANNON.Box(new CANNON.Vec3(25, 5, 5));
const body = new CANNON.Body({ mass: 5, shape: shape });
body.position.set(x, y, z);
world.addBody(body);
diceBodies.push(body);
}
// ==========================================
// 4. 遊戲流程與 SVG 投擲互動
// ==========================================
let currentTurn = 'BROWN';
const throwUI = document.getElementById('throw-ui');
const throwLine = document.getElementById('throw-line');
let isDragging = false;
let dragStart = { x: 240, y: 240 };
// 觸發回合
function startTurn() {
throwUI.classList.add('active');
// 鏡頭偏移邏輯 (簡化版:平滑移動 Target)
const targetZ = currentTurn === 'BROWN' ? -20 : 20;
controls.target.set(0, 0, targetZ);
controls.update();
}
// SVG 拖曳邏輯
throwUI.addEventListener('pointerdown', (e) => {
const rect = throwUI.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const dist = Math.hypot(x - 240, y - 240);
if (dist <= 240) {
isDragging = true;
throwLine.style.display = 'block';
throwLine.setAttribute('x2', x);
throwLine.setAttribute('y2', y);
}
});
window.addEventListener('pointermove', (e) => {
if (!isDragging) return;
const rect = throwUI.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
throwLine.setAttribute('x2', x);
throwLine.setAttribute('y2', y);
});
window.addEventListener('pointerup', (e) => {
if (!isDragging) return;
isDragging = false;
throwLine.style.display = 'none';
const rect = throwUI.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const dist = Math.hypot(x - 240, y - 240);
// 拖曳至圓外時拋出
if (dist > 240) {
throwUI.classList.remove('active');
// 計算方向向量
const dx = x - 240;
const dy = y - 240; // 螢幕 Y 向下,3D Z 向前
const dir = new THREE.Vector3(dx, 0, dy).normalize();
throwDice(dir);
}
});
function throwDice(dir) {
// 清理舊骰子
diceMeshes.forEach(m => scene.remove(m));
diceBodies.forEach(b => world.removeBody(b));
diceMeshes.length = 0;
diceBodies.length = 0;
// 生成兩顆數字木條並施加衝量
createDice(-10, 50, 0, 0); // 楬色
createDice(10, 50, 0, 1); // 藍色
const force = 2000;
diceBodies.forEach(body => {
// 隨機初始旋轉
body.quaternion.setFromEuler(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
// 施加方向力與隨機扭矩
body.applyImpulse(new CANNON.Vec3(dir.x * force, 500, dir.z * force), new CANNON.Vec3(0, 0, 0));
body.applyTorque(new CANNON.Vec3(Math.random()*force, Math.random()*force, Math.random()*force));
});
// 模擬等待骰子停下後切換鏡頭與移動 (簡化,實際需監聽 velocity)
setTimeout(() => {
controls.target.set(0, 0, 0); // 視角移回
controls.update();
// TODO: 讀取朝上的數字並移動人物
// 換下一回合
currentTurn = currentTurn === 'BROWN' ? 'BLUE' : 'BROWN';
setTimeout(startTurn, 2000);
}, 3000);
}
// ==========================================
// 5. 渲染迴圈
// ==========================================
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function animate() {
requestAnimationFrame(animate);
// 更新物理引擎
world.step(1 / 60);
// 同步物理數據到 3D 網格
for (let i = 0; i < diceMeshes.length; i++) {
diceMeshes[i].position.copy(diceBodies[i].position);
diceMeshes[i].quaternion.copy(diceBodies[i].quaternion);
}
controls.update();
renderer.render(scene, camera);
}
// 啟動遊戲
startTurn();
animate();
</script>
</body>
</html>
```
### 程式碼與設計說明:
1. **道具與佈置還原:**
* **木條地板:** 使用 `THREE.BoxGeometry(50, 10, 10)` 產生長方體。透過材質陣列 (Materials Array) 分別對三個特定側面塗上楬色、深藍色與紫色,頂面保持木色。共 14 根,分為兩排(間距 2),放置在距離 Z 軸中心 25 的位置。
* **人物:** 使用 `CylinderGeometry` 近似正三角形(三邊形柱體),透過 Canvas 動態生成帶有中心畫線(方向)及側邊輔助線的貼圖,分別貼在楬色與深藍色人物上,並精準擺放在木條的上方與下方 15 的距離。
* **炸彈與數字木條:** 正六邊形設定為半徑 10 的圓柱體六邊形。數字木條一樣是 `10*10*50`,使用了 `createDiceMaterial` 動態生成含有對應數字(0-3 與 1-4)的 Canvas 貼圖。
2. **SVG 投擲互動:**
* 遊戲不採用 3D 內的 Raycaster 投擲,而是照你的要求使用原生 `<svg>` 覆蓋在畫面中央。
* 半徑 240 的無邊框半透明綠黃色圓盤,支援 Pointer Events(相容手機觸控與滑鼠)。
* 當玩家點擊圓內並拖曳至圓外時,會觸發 `throwDice` 函數,計算滑鼠的向量。
3. **物理引擎 (Cannon-es):**
* 當拖出圓外時,會呼叫 `CANNON.Body.applyImpulse` 根據 SVG 拖出的方向,對兩根「數字木條」施加拋擲方向的力,同時加上隨機扭力使其在空中旋轉翻滾,直到落至平面。
4. **鏡頭與回合邏輯:**
* 使用 `OrbitControls`,並透過 `maxPolarAngle = Math.PI / 2` 限制只能由正上方俯視至水平,`minAzimuthAngle` 與 `maxAzimuthAngle` 限制左右平移,`enablePan = false` 禁止玩家自己平移鏡頭。
* 回合切換時,`controls.target` 會往上方或下方平移(修改 Target Z 值),以達到「將中心往上/下移」的視覺效果。
5. **手機橫向提示:**
* 使用了簡單強大的 CSS 媒體查詢 `@media screen and (orientation: portrait)`。當偵測到手機處於直向時,會跳出滿版的全黑遮罩提示玩家「請將手機橫放」。