代码: 全选
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ==========================================
// 1. 初始化場景、相機、渲染器
// ==========================================
const container = document.getElementById('game-container');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x34495e);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 500, 500);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
container.appendChild(renderer.domElement);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
dirLight.position.set(200, 400, 200);
scene.add(dirLight);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enablePan = false;
controls.enableZoom = true;
controls.minPolarAngle = 0;
controls.maxPolarAngle = Math.PI / 2;
controls.minAzimuthAngle = -Math.PI / 4;
controls.maxAzimuthAngle = Math.PI / 4;
// ==========================================
// 2. 建立底板與全局追蹤容器
// ==========================================
const boardSize = 480;
const planeGeo = new THREE.PlaneGeometry(boardSize, boardSize);
const planeMat = new THREE.MeshLambertMaterial({ color: 0xadd8e6 });
const plane = new THREE.Mesh(planeGeo, planeMat);
plane.rotation.x = -Math.PI / 2;
scene.add(plane);
const edgesGeo = new THREE.EdgesGeometry(planeGeo);
const edgesMat = new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 10, gapSize: 10 });
const planeEdges = new THREE.LineSegments(edgesGeo, edgesMat);
planeEdges.computeLineDistances();
planeEdges.rotation.x = -Math.PI / 2;
planeEdges.position.y = 0.5;
scene.add(planeEdges);
// 用於重新開局時清理的全局容器
let boardMeshes = [];
let cylinderMeshes = [];
let microphones = [];
// ==========================================
// [新增] 統一的進退場動畫控制陣列
// ==========================================
const introAnimData = [];
const outroAnimData = [];
function addIntroAnimation(mesh, startPos, targetPos, duration = 800) {
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
// 確保材質可以透明漸變
mats.forEach(m => {
m.transparent = true;
m.opacity = 0;
m.needsUpdate = true;
});
mesh.position.copy(startPos);
introAnimData.push({
mesh: mesh,
startTime: performance.now(),
duration: duration,
startPos: startPos.clone(),
targetPos: targetPos.clone(),
mats: mats
});
}
function removeMicrophoneAnim(mic) {
const mats = Array.isArray(mic.material) ? mic.material : [mic.material];
mats.forEach(m => {
m.transparent = true;
});
mic.userData.outroAnim = {
startTime: performance.now(),
duration: 600,
mats: mats
};
outroAnimData.push(mic);
}
// ==========================================
// 3. 共用幾何體與材質生成
// ==========================================
const gameBoardGeo = new THREE.BoxGeometry(118, 4, 118);
const boardEdgesGeo = new THREE.EdgesGeometry(gameBoardGeo);
const boardEdgesMat = new THREE.LineBasicMaterial({ color: 0x363636, linewidth: 4 });
const sideBoardMat = new THREE.MeshLambertMaterial({ color: 0xd3d3d3 });
function createBoardTopTexture(dirs) {
const canvas = document.createElement('canvas');
canvas.width = 120;
canvas.height = 120;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#DEB887';
ctx.fillRect(0, 0, 120, 120);
ctx.strokeStyle = '#000000';
ctx.lineWidth = 4;
ctx.beginPath();
if (dirs.includes('top')) {
ctx.moveTo(60, 60);
ctx.lineTo(60, 0);
}
if (dirs.includes('bottom')) {
ctx.moveTo(60, 60);
ctx.lineTo(60, 120);
}
if (dirs.includes('left')) {
ctx.moveTo(60, 60);
ctx.lineTo(0, 60);
}
if (dirs.includes('right')) {
ctx.moveTo(60, 60);
ctx.lineTo(120, 60);
}
ctx.stroke();
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
function createGameBoard(colCenter, rowCenter, dirs, startOffset = { x: 0, z: 0 }) {
const topMat = new THREE.MeshLambertMaterial({ map: createBoardTopTexture(dirs) });
// 克隆側面材質確保各自透明度獨立
const sideMatClone = sideBoardMat.clone();
const materials = [sideMatClone, sideMatClone, topMat, sideMatClone, sideMatClone, sideMatClone];
const board = new THREE.Mesh(gameBoardGeo, materials);
const pos = getCellWorldPos(colCenter, rowCenter);
const targetPos = new THREE.Vector3(pos.x, 2, pos.z);
const startPos = new THREE.Vector3(pos.x + startOffset.x, 2, pos.z + startOffset.z);
const boardEdges = new THREE.LineSegments(boardEdgesGeo, boardEdgesMat);
board.add(boardEdges);
scene.add(board);
boardMeshes.push(board);
addIntroAnimation(board, startPos, targetPos, 800);
return { mesh: board };
}
function getCellWorldPos(col, row) {
return { x: (col - 3.5) * 60, z: (row - 3.5) * 60 };
}
const faceMapping = [3, 4, 1, 6, 2, 5];
function createDiceMaterial(baseColor, dotColor, shape, number) {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.fillStyle = baseColor;
ctx.fillRect(0, 0, 128, 128);
ctx.fillStyle = dotColor;
const drawShape = (x, y) => {
ctx.beginPath();
if (shape === 'triangle') {
const r = 16;
ctx.moveTo(x, y - r);
ctx.lineTo(x + r * 0.866, y + r * 0.5);
ctx.lineTo(x - r * 0.866, y + r * 0.5);
} else {
const r = 14;
for (let i = 0; i < 6; i++) {
const angle = (i * Math.PI) / 3;
if (i === 0) ctx.moveTo(x + r * Math.cos(angle), y + r * Math.sin(angle));
else ctx.lineTo(x + r * Math.cos(angle), y + r * Math.sin(angle));
}
}
ctx.closePath();
ctx.fill();
};
const dots = [];
if (number === 1) dots.push([64, 64]);
if (number === 2) dots.push([32, 32], [96, 96]);
if (number === 3) dots.push([32, 32], [64, 64], [96, 96]);
if (number === 4) dots.push([32, 32], [96, 32], [32, 96], [96, 96]);
if (number === 5) dots.push([32, 32], [96, 32], [64, 64], [32, 96], [96, 96]);
if (number === 6) dots.push([32, 24], [32, 64], [32, 104], [96, 24], [96, 64], [96, 104]);
dots.forEach(p => drawShape(p[0], p[1]));
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return new THREE.MeshLambertMaterial({ map: texture });
}
function createDiceMesh(baseColor, dotColor, shape) {
const materials = faceMapping.map(num => createDiceMaterial(baseColor, dotColor, shape, num));
const cube = new THREE.Mesh(new THREE.BoxGeometry(48, 48, 48), materials);
cube.userData = {
currentNumber: 1,
score: 1,
animating: false,
startTime: 0,
startQuat: new THREE.Quaternion(),
targetQuat: new THREE.Quaternion(),
moving: false,
moveStartTime: 0,
moveDuration: 800,
startX: 0,
startZ: 0,
targetX: 0,
targetZ: 0,
col: 0,
row: 0,
color: ''
};
return cube;
}
// 依據分數翻轉立方體至指定朝上點數
function animateCubeToScore(cube, targetScore) {
cube.userData.animating = true;
cube.userData.startTime = performance.now();
cube.userData.startQuat.copy(cube.quaternion);
cube.userData.currentNumber = targetScore;
let rx = 0,
ry = 0,
rz = 0;
switch (targetScore) {
case 1:
break;
case 2:
rx = -Math.PI / 2;
break;
case 3:
rz = Math.PI / 2;
break;
case 4:
rz = -Math.PI / 2;
break;
case 5:
rx = Math.PI / 2;
break;
case 6:
rx = Math.PI;
break;
}
cube.userData.targetQuat.setFromEuler(new THREE.Euler(rx, ry, rz));
}
// ==========================================
// 4. 遊戲狀態與互動邏輯
// ==========================================
const itemY = 28;
let cubes = [];
let cylindersData = [];
let markers = [];
let raycaster = new THREE.Raycaster();
let mouse = new THREE.Vector2();
let blueCornerType = '';
let orangeCornerType = '';
let originalBlueCorner = '';
let originalOrangeCorner = '';
let placedEdges = { top: false, bottom: false, left: false, right: false };
let placedCorners = { LB: false, RB: false, LT: false, RT: false };
let centerBoardsAnimData = [];
let isAnimatingPhase1 = false;
let phase1Progress = 0;
let edgeTurn = 1;
let currentPhaseFn = null;
// ==========================================
// [新增] AI 狀態與設定變數
// ==========================================
let isBlueAI = false;
let isGreenAI = true; // 預設綠方為 AI
let blueAiTimeout = null; // 記錄藍隊 AI 延遲定時器
let greenAiTimeout = null; // 記錄綠隊 AI 延遲定時器
let aiStrength = 1000; // 預設困難
let currentTurnColor = ''; // 追蹤目前是誰的回合
let isAIThinking = false;
let aiPlannedActions = []; // 儲存預導的一整套行動 [{type: 'walk', c: X, r: Y}, ...]
// ==========================================
// [新增] 綁定 AI UI 事件 (請加在 animate() 之前)
// ==========================================
const btnAiBlue = document.getElementById('btn-ai-blue');
const btnAiGreen = document.getElementById('btn-ai-green');
const selectAiStrength = document.getElementById('ai-strength');
// 初始化按鈕狀態
if (isGreenAI) btnAiGreen.classList.add('active-green');
// 核心對戰局數與行走變數
let gameRound = 1;
let gameplayActive = false;
let currentPlayer = 'blue';
let walkCount = 1;
let lastDirection = null;
let turnStopPositions = [];
let blueTotalScore = 0;
let orangeTotalScore = 0;
const uiMsg = document.getElementById('message');
function showMessage(msg) {
uiMsg.style.display = 'block';
uiMsg.innerText = msg;
if (msg.includes('藍方')) currentTurnColor = 'blue';
else if (msg.includes('綠方')) currentTurnColor = 'green';
}
function createMarker(col, row, color, onClickCallback, customY = 5) {
const pos = getCellWorldPos(col, row);
const geo = new THREE.CylinderGeometry(12, 12, 2, 32);
const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.8 });
const marker = new THREE.Mesh(geo, mat);
marker.position.set(pos.x, customY, pos.z);
marker.userData.onClick = onClickCallback;
scene.add(marker);
markers.push(marker);
}
function clearMarkers() {
markers.forEach(m => scene.remove(m));
markers = [];
document.body.style.cursor = 'default';
}
function placeCylinder(col, row) {
const pos = getCellWorldPos(col, row);
const cylGeo = new THREE.CylinderGeometry(24, 24, 48, 32);
const cylMat = new THREE.MeshLambertMaterial({ color: 0xffa500 });
const cylinder = new THREE.Mesh(cylGeo, cylMat);
const targetPos = new THREE.Vector3(pos.x, itemY, pos.z);
const startPos = new THREE.Vector3(pos.x, itemY + 200, pos.z); // 從上方落下
addIntroAnimation(cylinder, startPos, targetPos, 800);
scene.add(cylinder);
cylinderMeshes.push(cylinder);
cylindersData.push({ c: col, r: row });
}
function placeCornerBoard(type) {
if (placedCorners[type]) return;
placedCorners[type] = true;
let offset = { x: 0, z: 0 };
// 斜向進入設定
if (type === 'LB') {
offset = { x: -300, z: 300 };
createGameBoard(0.5, 6.5, ['top', 'right'], offset);
}
if (type === 'RB') {
offset = { x: 300, z: 300 };
createGameBoard(6.5, 6.5, ['top', 'left'], offset);
}
if (type === 'LT') {
offset = { x: -300, z: -300 };
createGameBoard(0.5, 0.5, ['bottom', 'right'], offset);
}
if (type === 'RT') {
offset = { x: 300, z: -300 };
createGameBoard(6.5, 0.5, ['bottom', 'left'], offset);
}
}
function placeEdgeBoards(side) {
if (placedEdges[side]) return;
placedEdges[side] = true;
let offset = { x: 0, z: 0 };
// 邊緣外部進入設定
if (side === 'bottom') {
offset = { x: 0, z: 300 };
createGameBoard(2.5, 6.5, ['left', 'right', 'bottom'], offset);
createGameBoard(4.5, 6.5, ['left', 'right', 'bottom'], offset);
}
if (side === 'top') {
offset = { x: 0, z: -300 };
createGameBoard(2.5, 0.5, ['left', 'right', 'top'], offset);
createGameBoard(4.5, 0.5, ['left', 'right', 'top'], offset);
}
if (side === 'left') {
offset = { x: -300, z: 0 };
createGameBoard(0.5, 2.5, ['top', 'bottom', 'left'], offset);
createGameBoard(0.5, 4.5, ['top', 'bottom', 'left'], offset);
}
if (side === 'right') {
offset = { x: 300, z: 0 };
createGameBoard(6.5, 2.5, ['top', 'bottom', 'right'], offset);
createGameBoard(6.5, 4.5, ['top', 'bottom', 'right'], offset);
}
}
function placeCube(col, row, color) {
const pos = getCellWorldPos(col, row);
let cube;
if (color === 'blue') cube = createDiceMesh('#00008B', '#ff4444', 'hexagon');
else cube = createDiceMesh('#28a745', '#800080', 'triangle');
const targetPos = new THREE.Vector3(pos.x, itemY, pos.z);
const startPos = new THREE.Vector3(pos.x, itemY + 200, pos.z); // 從上方落下
addIntroAnimation(cube, startPos, targetPos, 800);
cube.userData.col = col;
cube.userData.row = row;
cube.userData.color = color;
cube.userData.score = 1;
cube.userData.currentNumber = 1;
scene.add(cube);
cubes.push(cube);
}
function createMicrophoneMesh(color, col, row) {
const pos = getCellWorldPos(col, row);
let geo;
const mat = new THREE.MeshLambertMaterial({ color: color === 'blue' ? 0xff4444 : 0x800080 });
if (color === 'blue') {
geo = new THREE.CylinderGeometry(20, 20, 4, 6);
} else {
geo = new THREE.CylinderGeometry(20, 20, 4, 3);
}
const mic = new THREE.Mesh(geo, mat);
const targetPos = new THREE.Vector3(pos.x, 6, pos.z);
const startPos = new THREE.Vector3(pos.x, 6 + 200, pos.z); // 從上方落下
addIntroAnimation(mic, startPos, targetPos, 800);
mic.userData = { color: color, col: col, row: row };
scene.add(mic);
microphones.push(mic);
}
function isOccupiedByCyl(c, r) {
return cylindersData.some(cyl => cyl.c === c && cyl.r === r);
}
// 新增:檢查橫線(Row)或縱線(Col)上是否已有圓柱體
function isRowOrColOccupied(c, r) {
return cylindersData.some(cyl => cyl.c === c || cyl.r === r);
}
function isDiagAdjacent(c1, r1, c2, r2) {
return Math.abs(c1 - c2) === 1 && Math.abs(r1 - r2) === 1;
}
function isValidOrangeCenterCyl(c, r) {
if (isRowOrColOccupied(c, r)) return false;
return !cylindersData.some(cyl => isDiagAdjacent(c, r, cyl.c, cyl.r));
}
function getEdgeCells(cornerType) {
let cells = [];
if (cornerType.includes('B')) {
for (let c = 2; c <= 5; c++) cells.push({ c, r: 7, side: 'bottom' });
}
if (cornerType.includes('T')) {
for (let c = 2; c <= 5; c++) cells.push({ c, r: 0, side: 'top' });
}
if (cornerType.includes('L')) {
for (let r = 2; r <= 5; r++) cells.push({ c: 0, r, side: 'left' });
}
if (cornerType.includes('R')) {
for (let r = 2; r <= 5; r++) cells.push({ c: 7, r, side: 'right' });
}
return cells;
}
function getOuterCornerCells(cornerType) {
if (cornerType === 'LB')
return [
{ c: 0, r: 7 },
{ c: 1, r: 7 },
{ c: 0, r: 6 }
];
if (cornerType === 'RB')
return [
{ c: 7, r: 7 },
{ c: 6, r: 7 },
{ c: 7, r: 6 }
];
if (cornerType === 'LT')
return [
{ c: 0, r: 0 },
{ c: 1, r: 0 },
{ c: 0, r: 1 }
];
if (cornerType === 'RT')
return [
{ c: 7, r: 0 },
{ c: 6, r: 0 },
{ c: 7, r: 1 }
];
}
// ==========================================
// [新增] 歷史紀錄與快照核心機制
// ==========================================
let historyUndoStack = [];
let historyRedoStack = [];
function saveSnapshot() {
return {
currentPlayer: currentPlayer,
walkCount: walkCount,
lastDirection: lastDirection ? { ...lastDirection } : null,
turnStopPositions: JSON.parse(JSON.stringify(turnStopPositions)),
gameplayActive: gameplayActive,
subPhase: markers.some(m => m.position.y === 6) ? 'mic' : 'walk',
currentPhaseFn: currentPhaseFn,
blueCornerType: blueCornerType,
orangeCornerType: orangeCornerType,
placedEdges: { ...placedEdges },
placedCorners: { ...placedCorners },
edgeTurn: edgeTurn,
cylindersData: JSON.parse(JSON.stringify(cylindersData)),
// 【修改】直接保存當前 3D 物件陣列的參照快照
boardMeshesRefs: [...boardMeshes],
cylinderMeshesRefs: [...cylinderMeshes],
openingCubesRefs: [...cubes],
cubes: cubes.map(c => ({
color: c.userData.color,
col: c.userData.col,
row: c.userData.row,
score: c.userData.score,
currentNumber: c.userData.currentNumber,
quat: c.quaternion.clone()
})),
microphones: microphones.map(m => ({
color: m.userData.color,
col: m.userData.col,
row: m.userData.row
}))
};
}
function restoreSnapshot(snap) {
currentPlayer = snap.currentPlayer;
walkCount = snap.walkCount;
lastDirection = snap.lastDirection;
turnStopPositions = snap.turnStopPositions;
gameplayActive = snap.gameplayActive;
clearMarkers();
// 【修改】開局階段:利用 3D 參照比對,完美支援開局的進退
if (!gameplayActive) {
currentPhaseFn = snap.currentPhaseFn;
blueCornerType = snap.blueCornerType;
orangeCornerType = snap.orangeCornerType;
placedEdges = { ...snap.placedEdges };
placedCorners = { ...snap.placedCorners };
edgeTurn = snap.edgeTurn;
cylindersData = JSON.parse(JSON.stringify(snap.cylindersData));
// ① 同步遊戲板 (boardMeshes)
boardMeshes.forEach(mesh => {
if (!snap.boardMeshesRefs.includes(mesh)) scene.remove(mesh);
});
snap.boardMeshesRefs.forEach(mesh => {
if (!boardMeshes.includes(mesh)) scene.add(mesh);
});
boardMeshes = [...snap.boardMeshesRefs];
// ② 同步圓柱 (cylinderMeshes)
cylinderMeshes.forEach(mesh => {
if (!snap.cylinderMeshesRefs.includes(mesh)) scene.remove(mesh);
});
snap.cylinderMeshesRefs.forEach(mesh => {
if (!cylinderMeshes.includes(mesh)) scene.add(mesh);
});
cylinderMeshes = [...snap.cylinderMeshesRefs];
// ③ 同步開局可能放置的立方體 (cubes)
cubes.forEach(mesh => {
if (!snap.openingCubesRefs.includes(mesh)) scene.remove(mesh);
});
snap.openingCubesRefs.forEach(mesh => {
if (!cubes.includes(mesh)) scene.add(mesh);
});
cubes = [...snap.openingCubesRefs];
// 重新觸發當前步驟的點擊提示圈
if (currentPhaseFn) currentPhaseFn();
return; // 開局階段處理完畢,直接返回
}
// 以下為原本【對戰階段】的還原邏輯(保持不變)...
cubes.forEach(c => scene.remove(c));
cubes = [];
microphones.forEach(m => scene.remove(m));
microphones = [];
snap.cubes.forEach(sCube => {
placeCube(sCube.col, sCube.row, sCube.color);
const newCube = cubes[cubes.length - 1];
newCube.userData.score = sCube.score;
newCube.userData.currentNumber = sCube.currentNumber;
newCube.userData.moving = false;
newCube.userData.animating = false;
newCube.quaternion.copy(sCube.quat);
const onOwnMic = snap.microphones.some(
m => m.color === sCube.color && m.col === sCube.col && m.row === sCube.row
);
const targetY = onOwnMic ? itemY + 5 : itemY;
newCube.position.y = targetY;
newCube.userData.targetY = targetY;
});
snap.microphones.forEach(sMic => {
createMicrophoneMesh(sMic.color, sMic.col, sMic.row);
});
if (snap.subPhase === 'walk') {
showWalkOptions();
} else {
showMicrophonePlacementOptions();
}
}
function pushAction() {
historyUndoStack.push(saveSnapshot());
historyRedoStack = []; // 有新操作時,清空重做棧
updateUndoRedoButtons();
}
function updateUndoRedoButtons() {
const wUndo = document.getElementById('btn-walk-undo');
const wRedo = document.getElementById('btn-walk-redo');
const tUndo = document.getElementById('btn-turn-undo');
const tRedo = document.getElementById('btn-turn-redo');
if (!wUndo) return;
wUndo.disabled = historyUndoStack.length === 0;
wRedo.disabled = historyRedoStack.length === 0;
if (gameplayActive) {
// 【對戰階段】回合後退判定
let hasTurnUndo = false;
for (let i = historyUndoStack.length - 1; i >= 0; i--) {
let snap = historyUndoStack[i];
if (snap.currentPlayer !== currentPlayer && snap.walkCount === 1 && snap.subPhase === 'walk') {
hasTurnUndo = true;
break;
}
}
tUndo.disabled = !hasTurnUndo;
// 【對戰階段】改進:回合前進判定(若沒有下一個回合則禁用)
let hasTurnRedo = false;
for (let i = historyRedoStack.length - 1; i >= 0; i--) {
let snap = historyRedoStack[i];
if (snap.currentPlayer !== currentPlayer && snap.walkCount === 1 && snap.subPhase === 'walk') {
hasTurnRedo = true;
break;
}
}
tRedo.disabled = !hasTurnRedo;
} else {
// 【開局階段】功能與行走撤銷/重做相同,狀態同步
tUndo.disabled = historyUndoStack.length === 0;
tRedo.disabled = historyRedoStack.length === 0;
}
}
function walkUndo() {
if (historyUndoStack.length === 0) return;
historyRedoStack.push(saveSnapshot());
restoreSnapshot(historyUndoStack.pop());
updateUndoRedoButtons();
}
function walkRedo() {
if (historyRedoStack.length === 0) return;
historyUndoStack.push(saveSnapshot());
restoreSnapshot(historyRedoStack.pop());
updateUndoRedoButtons();
}
function turnUndo() {
if (!gameplayActive) {
walkUndo(); // 【開局階段】與行走撤銷功能相同
return;
}
let targetIdx = -1;
for (let i = historyUndoStack.length - 1; i >= 0; i--) {
let snap = historyUndoStack[i];
if (snap.currentPlayer !== currentPlayer && snap.walkCount === 1 && snap.subPhase === 'walk') {
targetIdx = i;
break;
}
}
if (targetIdx === -1) return;
historyRedoStack.push(saveSnapshot());
while (historyUndoStack.length > targetIdx + 1) {
historyRedoStack.push(historyUndoStack.pop());
}
restoreSnapshot(historyUndoStack.pop());
updateUndoRedoButtons();
}
function turnRedo() {
if (!gameplayActive) {
walkRedo(); // 【開局階段】與行走重做功能相同
return;
}
let targetIdx = -1;
for (let i = historyRedoStack.length - 1; i >= 0; i--) {
let snap = historyRedoStack[i];
if (snap.currentPlayer !== currentPlayer && snap.walkCount === 1 && snap.subPhase === 'walk') {
targetIdx = i;
break;
}
}
if (targetIdx === -1) return;
historyUndoStack.push(saveSnapshot());
while (historyRedoStack.length > targetIdx + 1) {
historyUndoStack.push(historyRedoStack.pop());
}
restoreSnapshot(historyRedoStack.pop());
updateUndoRedoButtons();
}
// ==========================================
// 5. 核心開局流程控制器
// ==========================================
function startPhase1() {
showMessage(`第 ${gameRound} 局開局:中心遊戲板就位`);
createGameBoard(2.5, 2.5, ['top', 'bottom', 'left', 'right'], { x: 0, z: -600 });
createGameBoard(4.5, 2.5, ['top', 'bottom', 'left', 'right'], { x: 0, z: -600 });
createGameBoard(2.5, 4.5, ['top', 'bottom', 'left', 'right'], { x: 0, z: 600 });
createGameBoard(4.5, 4.5, ['top', 'bottom', 'left', 'right'], { x: 0, z: 600 });
// 動畫時間約800ms,結束後進入階段2
setTimeout(() => startPhase2(), 800);
}
function startPhase2() {
currentPhaseFn = startPhase2;
// 從選擇角遊戲板開始顯示控制按鈕
document.getElementById('history-controls').style.display = 'flex';
updateUndoRedoButtons();
if (gameRound === 1) {
showMessage('藍方行動:選擇並放置角遊戲板');
createMarker(0.5, 6.5, 0x00008b, () => {
pushAction();
clearMarkers();
blueCornerType = 'LB';
placeCornerBoard('LB');
startPhase3();
});
createMarker(6.5, 6.5, 0x00008b, () => {
pushAction();
clearMarkers();
blueCornerType = 'RB';
placeCornerBoard('RB');
startPhase3();
});
} else {
// 第二局由綠方先手,提供自由選擇左上角(LT)或右上角(RT)
showMessage('綠方行動:選擇並放置角遊戲板(左上角或右上角)');
createMarker(0.5, 0.5, 0xa5ff00, () => {
clearMarkers();
orangeCornerType = 'LT';
placeCornerBoard('LT');
startPhase3();
});
createMarker(6.5, 0.5, 0xa5ff00, () => {
clearMarkers();
orangeCornerType = 'RT';
placeCornerBoard('RT');
startPhase3();
});
}
triggerAIIfReady();
}
function startPhase3() {
currentPhaseFn = startPhase3;
if (gameRound === 1) {
showMessage('綠方行動:放置對角遊戲板');
orangeCornerType = blueCornerType === 'LB' ? 'RT' : 'LT';
const centerPos = orangeCornerType === 'RT' ? { c: 6.5, r: 0.5 } : { c: 0.5, r: 0.5 };
createMarker(centerPos.c, centerPos.r, 0xa5ff00, () => {
clearMarkers();
placeCornerBoard(orangeCornerType);
startPhase4();
});
} else {
showMessage('藍方行動:放置對角遊戲板');
blueCornerType = orangeCornerType === 'LT' ? 'RB' : 'LB';
const centerPos = blueCornerType === 'RB' ? { c: 6.5, r: 6.5 } : { c: 0.5, r: 6.5 };
createMarker(centerPos.c, centerPos.r, 0x00008b, () => {
clearMarkers();
placeCornerBoard(blueCornerType);
startPhase4();
});
}
triggerAIIfReady();
}
function startPhase4() {
currentPhaseFn = startPhase4;
const teamName = gameRound === 1 ? '藍方' : '綠方';
const colorHex = gameRound === 1 ? 0x00008b : 0xa5ff00;
showMessage(`${teamName}行動:在中心板上放置一個圓柱體`);
for (let r = 2; r <= 5; r++) {
for (let c = 2; c <= 5; c++) {
// 套用不可同行同列限制
if (!isRowOrColOccupied(c, r)) {
createMarker(c, r, colorHex, () => {
pushAction();
clearMarkers();
placeCylinder(c, r);
startPhase5();
});
}
}
}
triggerAIIfReady();
}
function startPhase5() {
currentPhaseFn = startPhase5;
const teamName = gameRound === 1 ? '綠方' : '藍方';
const colorHex = gameRound === 1 ? 0xa5ff00 : 0x00008b;
showMessage(`${teamName}行動:在中心板上放置一個圓柱體 (避開同行/同列/斜相鄰)`);
for (let r = 2; r <= 5; r++) {
for (let c = 2; c <= 5; c++) {
if (isValidOrangeCenterCyl(c, r)) {
createMarker(c, r, colorHex, () => {
pushAction();
clearMarkers();
placeCylinder(c, r);
startPhase6();
});
}
}
}
triggerAIIfReady();
}
function startPhase6() {
currentPhaseFn = startPhase6;
if (edgeTurn > 4) {
startPhase7();
return;
}
const isBlueTurn = gameRound === 1 ? edgeTurn % 2 !== 0 : edgeTurn % 2 === 0;
const colorHex = isBlueTurn ? 0x00008b : 0xa5ff00;
const cornerType = isBlueTurn ? blueCornerType : orangeCornerType;
const teamName = isBlueTurn ? '藍方' : '綠方';
showMessage(`${teamName}行動:放置邊遊戲板及圓柱體`);
// 修改:過濾邊緣棋位時同樣必須滿足「不與已有圓柱體同行同列」
const validCells = getEdgeCells(cornerType).filter(cell => !isRowOrColOccupied(cell.c, cell.r));
validCells.forEach(cell => {
createMarker(cell.c, cell.r, colorHex, () => {
pushAction();
clearMarkers();
placeEdgeBoards(cell.side);
placeCylinder(cell.c, cell.r);
edgeTurn++;
startPhase6();
});
});
triggerAIIfReady();
}
function startPhase7() {
currentPhaseFn = startPhase7;
const teamName = gameRound === 1 ? '藍方' : '綠方';
const colorHex = gameRound === 1 ? 0x00008b : 0xa5ff00;
const cornerType = gameRound === 1 ? blueCornerType : orangeCornerType;
showMessage(`${teamName}行動:放置本方移動立方體`);
const cells = getOuterCornerCells(cornerType);
cells.forEach(cell => {
createMarker(cell.c, cell.r, colorHex, () => {
pushAction();
clearMarkers();
placeCube(cell.c, cell.r, gameRound === 1 ? 'blue' : 'green');
startPhase8();
});
});
triggerAIIfReady();
}
function startPhase8() {
currentPhaseFn = startPhase8;
const teamName = gameRound === 1 ? '綠方' : '藍方';
const colorHex = gameRound === 1 ? 0xa5ff00 : 0x00008b;
const cornerType = gameRound === 1 ? orangeCornerType : blueCornerType;
showMessage(`${teamName}行動:放置本方移動立方體`);
const cells = getOuterCornerCells(cornerType);
cells.forEach(cell => {
createMarker(cell.c, cell.r, colorHex, () => {
pushAction();
clearMarkers();
placeCube(cell.c, cell.r, gameRound === 1 ? 'green' : 'blue');
// 補齊剩餘未覆蓋的角落
['LB', 'RB', 'LT', 'RT'].forEach(type => {
if (!placedCorners[type]) placeCornerBoard(type);
});
if (gameRound === 1) {
originalBlueCorner = blueCornerType;
originalOrangeCorner = orangeCornerType;
}
showMessage('開局階段結束!即將進入正式對戰模式...');
setTimeout(() => {
uiMsg.style.display = 'none';
startGameplayPhase();
}, 2500);
});
});
triggerAIIfReady();
}
// ==========================================
// 6. 正式對戰遊戲階段邏輯
// ==========================================
function startGameplayPhase() {
gameplayActive = true;
currentPlayer = gameRound === 1 ? 'blue' : 'green';
// 初始化清空棧並顯示控制面板
historyUndoStack = [];
historyRedoStack = [];
document.getElementById('history-controls').style.display = 'flex';
updateUndoRedoButtons();
startPlayerTurn();
}
function startPlayerTurn() {
walkCount = 1;
lastDirection = null;
turnStopPositions = [];
updateUndoRedoButtons();
showWalkOptions();
}
// 判斷格子是否為無法逾越的物理障礙物
function isObstacle(c, r) {
if (c < 0 || c > 7 || r < 0 || r > 7) return true; // 棋盤邊緣
if (isOccupiedByCyl(c, r)) return true; // 圓柱體
// 敵方立方體
const oppColor = currentPlayer === 'blue' ? 'green' : 'blue';
const oppCube = cubes.find(q => q.userData.color === oppColor);
if (oppCube && oppCube.userData.col === c && oppCube.userData.row === r) return true;
return false;
}
// 計算一直直線滑動直至撞擊障礙物停下的終點座標
function calculateStopPos(startC, startR, dc, dr) {
let c = startC;
let r = startR;
while (true) {
let nextC = c + dc;
let nextR = r + dr;
if (isObstacle(nextC, nextR)) {
break;
}
c = nextC;
r = nextR;
}
return { c, r };
}
function showWalkOptions() {
clearMarkers();
const activeCube = cubes.find(q => q.userData.color === currentPlayer);
const c = activeCube.userData.col;
const r = activeCube.userData.row;
let validMoves = [];
if (walkCount === 1) {
// 第一次行走:任意四個十字方向皆可
const dirs = [
{ dc: 0, dr: -1 },
{ dc: 0, dr: 1 },
{ dc: -1, dr: 0 },
{ dc: 1, dr: 0 }
];
dirs.forEach(d => {
const stop = calculateStopPos(c, r, d.dc, d.dr);
if (stop.c !== c || stop.r !== r) validMoves.push({ dir: d, stop: stop });
});
} else {
// 第二、三次行走:基於前一次方向進行90度偏轉(左或右)
const dLeft = { dc: lastDirection.dr, dr: -lastDirection.dc };
const dRight = { dc: -lastDirection.dr, dr: lastDirection.dc };
[dLeft, dRight].forEach(d => {
const stop = calculateStopPos(c, r, d.dc, d.dr);
if (stop.c !== c || stop.r !== r) validMoves.push({ dir: d, stop: stop });
});
// 僅當左右皆為死路時,才允許往後退 (180度反轉)
if (validMoves.length === 0) {
const dBack = { dc: -lastDirection.dc, dr: -lastDirection.dr };
const stop = calculateStopPos(c, r, dBack.dc, dBack.dr);
if (stop.c !== c || stop.r !== r) validMoves.push({ dir: dBack, stop: stop });
}
}
// 如果徹底無路可走,自動跳過本次行走
if (validMoves.length === 0) {
showMessage(`${currentPlayer === 'blue' ? '藍方' : '綠方'}第 ${walkCount} 次行走無路可走`);
setTimeout(() => {
handleWalkComplete(c, r, null, []);
}, 1200);
return;
}
showMessage(`輪到 ${currentPlayer === 'blue' ? '藍方' : '綠方'} 行動:第 ${walkCount} 次行走`);
validMoves.forEach(mv => {
// 如果終點格子上已經放了任何一方的話筒,則行動提示標示高度調高 5 單位
const hasMic = microphones.some(m => m.userData.col === mv.stop.c && m.userData.row === mv.stop.r);
const markerY = hasMic ? 10 : 5;
const colorHex = currentPlayer === 'blue' ? 0x0000ff : 0x28a745;
createMarker(
mv.stop.c,
mv.stop.r,
colorHex,
() => {
pushAction();
clearMarkers();
executeCubeMovement(activeCube, mv.dir, mv.stop);
},
markerY
);
});
triggerAIIfReady();
}
function executeCubeMovement(cube, dir, stop) {
let traversed = [];
let curC = cube.userData.col;
let curR = cube.userData.row;
// 紀錄直線平移中經過的所有路徑格子 (含終點)
while (curC !== stop.c || curR !== stop.r) {
curC += dir.dc;
curR += dir.dr;
traversed.push({ c: curC, r: curR });
}
const worldPos = getCellWorldPos(stop.c, stop.r);
cube.userData.moving = true;
cube.userData.startX = cube.position.x;
cube.userData.startZ = cube.position.z;
cube.userData.startY = cube.position.y; // 新增起始高度紀錄
cube.userData.targetX = worldPos.x;
cube.userData.targetZ = worldPos.z;
// 判斷終點是否有己方話筒,並設定目標高度
const onOwnMic = microphones.some(
m => m.userData.color === currentPlayer && m.userData.col === stop.c && m.userData.row === stop.r
);
cube.userData.targetY = onOwnMic ? itemY + 5 : itemY;
cube.userData.moveStartTime = performance.now();
cube.userData.onMoveComplete = () => {
cube.userData.col = stop.c;
cube.userData.row = stop.r;
handleWalkComplete(stop.c, stop.r, dir, traversed);
};
}
function handleWalkComplete(stopC, stopR, dir, traversed) {
lastDirection = dir;
const oppColor = currentPlayer === 'blue' ? 'green' : 'blue';
if (walkCount === 1 || walkCount === 2) {
turnStopPositions.push({ c: stopC, r: stopR });
// 規則:第1, 2次行走若經過敵方話筒,將其清除,但不給分
if (traversed.length > 0) {
microphones = microphones.filter(m => {
const passThrough =
m.userData.color === oppColor && traversed.some(t => t.c === m.userData.col && t.r === m.userData.row);
if (passThrough) removeMicrophoneAnim(m);
return !passThrough;
});
}
walkCount++;
showWalkOptions();
} else if (walkCount === 3) {
// 第三次行走結束:結算得分與話筒移除
let earnedPoints = 0;
let penaltyPoint = false;
if (traversed.length > 0) {
// 1. 經過敵方話筒:每經過一個得一分,並清空該話筒
const enemyMics = microphones.filter(
m =>
m.userData.color === oppColor && traversed.some(t => t.c === m.userData.col && t.r === m.userData.row)
);
earnedPoints = enemyMics.length;
enemyMics.forEach(m => removeMicrophoneAnim(m));
microphones = microphones.filter(m => !enemyMics.includes(m));
// 2. 本方話筒判定:若最終停留在本方話筒上,拿走話筒,且使對方得一分
const ownMicAtEnd = microphones.find(
m => m.userData.color === currentPlayer && m.userData.col === stopC && m.userData.row === stopR
);
if (ownMicAtEnd) {
penaltyPoint = true;
removeMicrophoneAnim(ownMicAtEnd);
microphones = microphones.filter(m => m !== ownMicAtEnd);
}
}
const pCube = cubes.find(q => q.userData.color === currentPlayer);
const oCube = cubes.find(q => q.userData.color === oppColor);
if (earnedPoints > 0) {
pCube.userData.score += earnedPoints;
animateCubeToScore(pCube, Math.min(6, pCube.userData.score));
}
if (penaltyPoint) {
oCube.userData.score += 1;
animateCubeToScore(oCube, Math.min(6, oCube.userData.score));
}
// 檢查是否有一方達到 6 分而終止遊戲
if (pCube.userData.score >= 6 || oCube.userData.score >= 6) {
// 等翻轉動畫完成 (1000 毫秒) 後再結束本局
setTimeout(() => {
triggerRoundEnd();
}, 1000);
return;
}
// 進入放置話筒階段
showMicrophonePlacementOptions();
}
}
function showMicrophonePlacementOptions() {
clearMarkers();
const currentMicsCount = microphones.filter(m => m.userData.color === currentPlayer).length;
// 觸發不需放置話筒的特殊條件:場上已有4個本方話筒
if (currentMicsCount >= 4) {
switchTurn();
return;
}
// 提取第1、2次停留點(去重)
let targetSpots = [];
turnStopPositions.forEach(p => {
if (!targetSpots.some(ts => ts.c === p.c && ts.r === p.r)) targetSpots.push(p);
});
// 過濾掉已經有本方話筒的棋位
let availableSpots = targetSpots.filter(p => {
return !microphones.some(
m => m.userData.color === currentPlayer && m.userData.col === p.c && m.userData.row === p.r
);
});
// 如果兩個停留點皆已存在本方話筒,則本次行動不需放置
if (availableSpots.length === 0) {
switchTurn();
return;
}
showMessage(`${currentPlayer === 'blue' ? '藍方' : '綠方'}行動:選擇在第一次或第二次停靠點放置話筒`);
const micMarkerColor = currentPlayer === 'blue' ? 0xff4444 : 0x800080;
availableSpots.forEach(spot => {
createMarker(
spot.c,
spot.r,
micMarkerColor,
() => {
pushAction();
clearMarkers();
createMicrophoneMesh(currentPlayer, spot.c, spot.r);
switchTurn();
},
6
);
});
triggerAIIfReady();
}
function switchTurn() {
currentPlayer = currentPlayer === 'blue' ? 'green' : 'blue';
startPlayerTurn();
}
// ==========================================
// 7. 局末結算與重開 UI 控制
// ==========================================
function triggerRoundEnd() {
gameplayActive = false;
clearMarkers();
document.getElementById('history-controls').style.display = 'none';
const blueFinal = cubes.find(q => q.userData.color === 'blue').userData.score;
const orangeFinal = cubes.find(q => q.userData.color === 'green').userData.score;
blueTotalScore += blueFinal;
orangeTotalScore += orangeFinal;
let roundWinner = blueFinal > orangeFinal ? '藍方' : orangeFinal > blueFinal ? '綠方' : '平手';
// 構建 HTML 結算互動介面
const overlay = document.createElement('div');
overlay.style.cssText =
'position:absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.85); display:flex; flex-direction:column; justify-content:center; align-items:center; color:#fff; font-family:sans-serif; z-index:100;';
const title = document.createElement('h2');
title.style.fontSize = '36px';
title.innerText = `第 ${gameRound} 局結束!本局勝者:${roundWinner}`;
overlay.appendChild(title);
const scoreInfo = document.createElement('p');
scoreInfo.style.fontSize = '22px';
scoreInfo.innerText = `藍方單局得分:${blueFinal} | 綠方單局得分:${orangeFinal}`;
overlay.appendChild(scoreInfo);
if (gameRound === 1) {
// 第一局結束,提供三個選項
const btnNo = createOverlayButton('否 (顯示總結算)', () => {
document.body.removeChild(overlay);
showFinalGameSummary();
});
const btnSame = createOverlayButton('用當前場地再戰一局', () => {
document.body.removeChild(overlay);
gameRound = 2;
reinitNextRound(true);
});
const btnNew = createOverlayButton('重新開局', () => {
document.body.removeChild(overlay);
gameRound = 2;
reinitNextRound(false);
});
overlay.appendChild(btnNo);
overlay.appendChild(btnSame);
overlay.appendChild(btnNew);
} else {
// 兩局都打完了
const btnEnd = createOverlayButton('查看最終贏家及總分', () => {
document.body.removeChild(overlay);
showFinalGameSummary();
});
overlay.appendChild(btnEnd);
}
document.body.appendChild(overlay);
}
// ==========================================
// [新增] 蒙地卡洛樹搜尋 (MCTS) 與 AI 核心邏輯
// ==========================================
function triggerAIIfReady() {
// 只有當場上真正有可選的 markers,且 AI 沒有在思考中,才啟動 AI
if (markers.length > 0 && !isAIThinking) {
const shouldRun = (currentTurnColor === 'blue' && isBlueAI) || (currentTurnColor === 'green' && isGreenAI);
if (shouldRun) {
checkAndRunAI();
}
}
}
function checkAndRunAI() {
console.log('markers.length', markers.length);
if (markers.length === 0 || isAIThinking) return;
console.log('currentTurnColor', currentTurnColor);
const shouldRun = (currentTurnColor === 'blue' && isBlueAI) || (currentTurnColor === 'green' && isGreenAI);
if (!shouldRun) return;
// 【新增】如果對戰階段已經有計算好的整套計畫,直接依序執行,不再重算
console.log('aiPlannedActions', aiPlannedActions);
if (gameplayActive && aiPlannedActions.length > 0) {
const nextAction = aiPlannedActions.shift();
console.log('nextAction', nextAction);
const targetMarker = markers.find(m => {
const mc = Math.round(m.position.x / 60 + 3.5);
const mr = Math.round(m.position.z / 60 + 3.5);
return mc === nextAction.c && mr === nextAction.r;
});
console.log('targetMarker', targetMarker);
if (targetMarker) {
setTimeout(() => {
targetMarker.userData.onClick();
}, 400); // 微調延遲讓動畫更自然
} else {
aiPlannedActions = []; // 防呆:若狀態不一致則清空重算
}
return;
}
isAIThinking = true;
const statusDiv = document.getElementById('ai-status');
statusDiv.style.display = 'block';
statusDiv.innerHTML = 'AI 思考中...';
document.body.style.pointerEvents = 'none';
if (!gameplayActive) {
doOpeningAI();
} else {
doMCTSAI(); // 將在此處觸發一整套的計算
}
}
function finishAIAction(selectedMarker, timeTaken, winRateText) {
const statusDiv = document.getElementById('ai-status');
statusDiv.innerHTML = `思考結束<br>用時: ${timeTaken}s<br>勝率: ${winRateText}`;
setTimeout(() => {
statusDiv.style.display = 'none';
document.body.style.pointerEvents = 'auto';
isAIThinking = false;
if (selectedMarker) {
// 如果原本的 marker 參照不在了,透過座標找出畫面上對應的新 marker
let activeMarker = markers.find(
m =>
Math.abs(m.position.x - selectedMarker.position.x) < 1 &&
Math.abs(m.position.z - selectedMarker.position.z) < 1
);
if (activeMarker) {
activeMarker.userData.onClick();
}
}
}, 800); // 展示結果 0.8 秒後執行點擊
}
function doOpeningAI() {
let startTime = performance.now();
let selectedMarker = null;
// 特殊規則:如果場上只有三個要放圓柱的棋位可選,只在同一橫線或縱線的兩個點之間選
if (
(currentPhaseFn === startPhase4 || currentPhaseFn === startPhase5 || currentPhaseFn === startPhase6) &&
markers.length === 3
) {
let m1, m2;
for (let i = 0; i < 3; i++) {
for (let j = i + 1; j < 3; j++) {
let pos1 = {
c: Math.round(markers[i].position.x / 60 + 3.5),
r: Math.round(markers[i].position.z / 60 + 3.5)
};
let pos2 = {
c: Math.round(markers[j].position.x / 60 + 3.5),
r: Math.round(markers[j].position.z / 60 + 3.5)
};
if (pos1.c === pos2.c || pos1.r === pos2.r) {
m1 = markers[i];
m2 = markers[j];
break;
}
}
if (m1) break;
}
if (m1 && m2) {
selectedMarker = Math.random() < 0.5 ? m1 : m2;
}
}
// 否則完全隨機
if (!selectedMarker) {
selectedMarker = markers[Math.floor(Math.random() * markers.length)];
}
let timeTaken = ((performance.now() - startTime) / 1000).toFixed(1);
finishAIAction(selectedMarker, timeTaken, 'N/A (開局)');
}
function doMCTSAI() {
const startTime = performance.now();
// 只有在回合開始(第1次行走)時,才計算未來的一整套動作
if (walkCount === 1) {
aiPlannedActions = planFullTurnActions();
}
// 取出計畫中的第一步來執行
const firstAction = aiPlannedActions.shift();
const selectedMarker = markers.find(m => {
const mc = Math.round(m.position.x / 60 + 3.5);
const mr = Math.round(m.position.z / 60 + 3.5);
return mc === firstAction.c && mr === firstAction.r;
});
const timeTaken = ((performance.now() - startTime) / 1000).toFixed(2);
finishAIAction(selectedMarker, timeTaken, '100% (全局最優解)');
}
function planFullTurnActions() {
const activeCube = cubes.find(q => q.userData.color === currentPlayer);
const oppColor = currentPlayer === 'blue' ? 'green' : 'blue';
const oppCube = cubes.find(q => q.userData.color === oppColor);
const startC = activeCube.userData.col;
const startR = activeCube.userData.row;
const oppC = oppCube ? oppCube.userData.col : -1;
const oppR = oppCube ? oppCube.userData.row : -1;
// 複製當前話筒快照
const currentMics = microphones.map(m => ({ color: m.userData.color, c: m.userData.col, r: m.userData.row }));
// 模擬撞擊物理障礙物
function simIsObstacle(c, r) {
if (c < 0 || c > 7 || r < 0 || r > 7) return true;
if (cylindersData.some(cyl => cyl.c === c && cyl.r === r)) return true;
if (oppC === c && oppR === r) return true;
return false;
}
function simCalculateStopPos(sc, sr, dc, dr) {
let c = sc,
r = sr;
while (true) {
let nc = c + dc,
nr = r + dr;
if (simIsObstacle(nc, nr)) break;
c = nc;
r = nr;
}
return { c, r };
}
let bestScore = -Infinity;
let bestSequence = [];
// 1. 模擬第一次行走 (4個方向)
const dirs1 = [
{ dc: 0, dr: -1 },
{ dc: 0, dr: 1 },
{ dc: -1, dr: 0 },
{ dc: 1, dr: 0 }
];
dirs1.forEach(d1 => {
const stop1 = simCalculateStopPos(startC, startR, d1.dc, d1.dr);
if (stop1.c === startC && stop1.r === startR) return;
// 規則:第1次行走經過敵方話筒只移除,不計分
const micsAfter1 = currentMics.filter(m => {
let tc = startC,
tr = startR;
while (tc !== stop1.c || tr !== stop1.r) {
tc += d1.dc;
tr += d1.dr;
if (m.color === oppColor && m.c === tc && m.r === tr) return false;
}
return true;
});
// 2. 模擬第二次行走 (優先90度偏轉,全死路才允許180度)
const dirs2Normal = [
{ dc: d1.dr, dr: -d1.dc },
{ dc: -d1.dr, dr: d1.dc }
];
// 先測試哪些 90 度方向是真的可以移動的
let validDirs2 = dirs2Normal.filter(d2 => {
const nextPos = simCalculateStopPos(stop1.c, stop1.r, d2.dc, d2.dr);
return nextPos.c !== stop1.c || nextPos.r !== stop1.r;
});
// 只有當兩個 90 度方向都是死路時,才允許 180 度倒退
if (validDirs2.length === 0) {
validDirs2 = [{ dc: -d1.dc, dr: -d1.dr }];
}
validDirs2.forEach(d2Act => {
const stop2 = simCalculateStopPos(stop1.c, stop1.r, d2Act.dc, d2Act.dr);
if (stop2.c === stop1.c && stop2.r === stop1.r) return; // 180度也是死路就跳過
// 規則:第2次行走經過敵方話筒只移除,不計分
const micsAfter2 = micsAfter1.filter(m => {
let tc = stop1.c,
tr = stop1.r;
while (tc !== stop2.c || tr !== stop2.r) {
tc += d2Act.dc;
tr += d2Act.dr;
if (m.color === oppColor && m.c === tc && m.r === tr) return false;
}
return true;
});
// 3. 模擬第三次行走 (優先90度偏轉,全死路才允許180度)
const dirs3Normal = [
{ dc: d2Act.dr, dr: -d2Act.dc },
{ dc: -d2Act.dr, dr: d2Act.dc }
];
// 先測試哪些 90 度方向是真的可以移動的
let validDirs3 = dirs3Normal.filter(d3 => {
const nextPos = simCalculateStopPos(stop2.c, stop2.r, d3.dc, d3.dr);
return nextPos.c !== stop2.c || nextPos.r !== stop2.r;
});
// 只有當兩個 90 度方向都是死路時,才允許 180 度倒退
if (validDirs3.length === 0) {
validDirs3 = [{ dc: -d2Act.dc, dr: -d2Act.dr }];
}
validDirs3.forEach(d3Act => {
const stop3 = simCalculateStopPos(stop2.c, stop2.r, d3Act.dc, d3Act.dr);
if (stop3.c === stop2.c && stop3.r === stop2.r) return; // 180度也是死路就跳過
// --- 核心得分結算 ---
let scoreEval = 0;
let tc = stop2.c,
tr = stop2.r;
const traversed3 = [];
while (tc !== stop3.c || tr !== stop3.r) {
tc += d3Act.dc;
tr += d3Act.dr;
traversed3.push({ c: tc, r: tr });
}
// 正確規則:只有第3次行走經過敵方話筒才真正加分
micsAfter2.forEach(m => {
if (m.color === oppColor && traversed3.some(t => t.c === m.c && t.r === m.r)) {
scoreEval += 100; // 吃一個敵方話筒大幅加分
}
});
// 正確規則:若最後停在己方話筒上,對手得1分 (嚴重扣分 penalty)
const landOnOwnMic = micsAfter2.some(
m => m.color === currentPlayer && m.c === stop3.c && m.r === stop3.r
);
if (landOnOwnMic) {
scoreEval -= 150;
}
// 更新剩餘話筒狀態以評估話筒放置
const micsAfter3 = micsAfter2.filter(m => {
if (m.color === oppColor && traversed3.some(t => t.c === m.c && t.r === m.r)) return false;
if (m.color === currentPlayer && m.c === stop3.c && m.r === stop3.r) return false;
return true;
});
// 4. 模擬話筒放置評估
const ownMicsCount = micsAfter3.filter(m => m.color === currentPlayer).length;
let possibleMicSpots = [];
if (ownMicsCount < 4) {
const uniqueSpots = [];
if (!uniqueSpots.some(s => s.c === stop1.c && s.r === stop1.r)) uniqueSpots.push(stop1);
if (!uniqueSpots.some(s => s.c === stop2.c && s.r === stop2.r)) uniqueSpots.push(stop2);
possibleMicSpots = uniqueSpots.filter(
s => !micsAfter3.some(m => m.color === currentPlayer && m.c === s.c && m.r === s.r)
);
}
// 基礎地形啟發分:鼓勵棋子停在中央區域
const centerBonus = (10 - (Math.abs(stop3.c - 3.5) + Math.abs(stop3.r - 3.5))) * 2;
const totalPathScore = scoreEval + centerBonus;
if (possibleMicSpots.length > 0) {
possibleMicSpots.forEach(s => {
// 話筒也優先放在靠近中央的位置
const finalEval = totalPathScore + (10 - (Math.abs(s.c - 3.5) + Math.abs(s.r - 3.5)));
if (finalEval > bestScore) {
bestScore = finalEval;
bestSequence = [
{ type: 'walk', c: stop1.c, r: stop1.r },
{ type: 'walk', c: stop2.c, r: stop2.r },
{ type: 'walk', c: stop3.c, r: stop3.r },
{ type: 'mic', c: s.c, r: s.r }
];
}
});
} else {
if (totalPathScore > bestScore) {
bestScore = totalPathScore;
bestSequence = [
{ type: 'walk', c: stop1.c, r: stop1.r },
{ type: 'walk', c: stop2.c, r: stop2.r },
{ type: 'walk', c: stop3.c, r: stop3.r }
];
}
}
});
});
});
return bestSequence;
}
function createOverlayButton(text, clickFn) {
const btn = document.createElement('button');
btn.innerText = text;
btn.style.cssText =
'padding:14px 35px; margin:10px; font-size:18px; font-weight:bold; color:#fff; background-color:#3498db; border:none; border-radius:5px; cursor:pointer; min-width:260px; box-shadow:0 3px 6px rgba(0,0,0,0.3);';
btn.addEventListener('click', clickFn);
return btn;
}
function cleanupEntities() {
microphones.forEach(m => scene.remove(m));
microphones = [];
cubes.forEach(c => scene.remove(c));
cubes = [];
clearMarkers();
}
function reinitNextRound(keepBoard) {
// 修改:移除原本在此處寫死的 gameRound = 2 賦值,使其相容於全重開機制
cleanupEntities();
if (keepBoard) {
// 沿用場地:不清除棋盤與圓柱,直接重新進入立方體角位放置階段
orangeCornerType = originalBlueCorner === 'LB' ? 'LT' : 'RT';
blueCornerType = orangeCornerType === 'LT' ? 'RB' : 'LB';
startPhase7();
} else {
// 重新開局:將所有動態棋盤、圓柱從場景與陣列中徹底清空
boardMeshes.forEach(b => scene.remove(b));
boardMeshes = [];
centerBoardsAnimData = [];
cylinderMeshes.forEach(c => scene.remove(c));
cylinderMeshes = [];
cylindersData = [];
placedEdges = { top: false, bottom: false, left: false, right: false };
placedCorners = { LB: false, RB: false, LT: false, RT: false };
edgeTurn = 1;
phase1Progress = 0;
startPhase1();
}
}
function showFinalGameSummary() {
const overlay = document.createElement('div');
overlay.style.cssText =
'position:absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.92); display:flex; flex-direction:column; justify-content:center; align-items:center; color:#fff; font-family:sans-serif; z-index:100;';
let ultimateWinner =
blueTotalScore > orangeTotalScore ? '藍方' : orangeTotalScore > blueTotalScore ? '綠方' : '平手';
const h1 = document.createElement('h1');
h1.style.fontSize = '42px';
h1.innerText = `對抗賽終局!最終贏家:${ultimateWinner}`;
overlay.appendChild(h1);
const p = document.createElement('p');
p.style.fontSize = '26px';
p.style.margin = '20px 0 40px 0';
p.innerText = `總比分 -> 藍方總得分:${blueTotalScore} 點 || 綠方總得分:${orangeTotalScore} 點`;
overlay.appendChild(p);
const btnRestartAll = createOverlayButton('重新開始整個遊戲', () => {
document.body.removeChild(overlay);
// 完全重置所有統計數據歸零,重開必定為藍方先行 (gameRound = 1)
gameRound = 1;
blueTotalScore = 0;
orangeTotalScore = 0;
reinitNextRound(false);
});
overlay.appendChild(btnRestartAll);
document.body.appendChild(overlay);
}
// ==========================================
// 8. 事件綁定與主迴圈
// ==========================================
document.getElementById('btn-start').addEventListener('click', e => {
e.target.parentElement.style.display = 'none';
startPhase1();
});
container.addEventListener('pointerdown', event => {
const rect = container.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
if (markers.length > 0) {
const intersects = raycaster.intersectObjects(markers);
if (intersects.length > 0) {
intersects[0].object.userData.onClick();
}
}
});
container.addEventListener('pointermove', event => {
if (markers.length === 0) return;
const rect = container.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(markers);
if (intersects.length > 0) document.body.style.cursor = 'pointer';
else document.body.style.cursor = 'default';
});
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
// 處理中心遊戲板入場動畫
if (isAnimatingPhase1) {
phase1Progress += 0.015;
if (phase1Progress >= 1) {
phase1Progress = 1;
isAnimatingPhase1 = false;
startPhase2();
}
const easeOut = phase1Progress * (2 - phase1Progress);
centerBoardsAnimData.forEach(b => {
b.mesh.position.z = b.startZ + (b.targetZ - b.startZ) * easeOut;
});
}
// 處理統一的入場動畫 (透明度過渡與位移)
for (let i = introAnimData.length - 1; i >= 0; i--) {
const anim = introAnimData[i];
const elapsed = now - anim.startTime;
let t = elapsed / anim.duration;
if (t >= 1) t = 1;
const easeOut = t * (2 - t); // 緩動效果
anim.mesh.position.lerpVectors(anim.startPos, anim.targetPos, easeOut);
anim.mats.forEach(m => {
m.opacity = t;
});
if (t === 1) introAnimData.splice(i, 1);
}
// 處理話筒退場動畫 (放大與透明度消失)
for (let i = outroAnimData.length - 1; i >= 0; i--) {
const mic = outroAnimData[i];
const anim = mic.userData.outroAnim;
const elapsed = now - anim.startTime;
let t = elapsed / anim.duration;
if (t >= 1) t = 1;
const scale = 1 + 0.2 * t; // 從 1 放大至 1.2
mic.scale.set(scale, scale, scale);
anim.mats.forEach(m => {
m.opacity = 1 - t;
});
if (t === 1) {
scene.remove(mic);
outroAnimData.splice(i, 1);
}
}
// 處理正式對戰階段:立方體平移與高拋平滑曲線控制效果
cubes.forEach(cube => {
if (cube.userData.moving) {
const elapsed = now - cube.userData.moveStartTime;
let t = elapsed / cube.userData.moveDuration;
if (t >= 1) {
t = 1;
cube.userData.moving = false;
if (cube.userData.onMoveComplete) {
const cb = cube.userData.onMoveComplete;
cube.userData.onMoveComplete = null;
cb();
}
}
// 直線平移 X 與 Z
cube.position.x = THREE.MathUtils.lerp(cube.userData.startX, cube.userData.targetX, t);
cube.position.z = THREE.MathUtils.lerp(cube.userData.startZ, cube.userData.targetZ, t);
// 加入起點與終點的 Y 軸高度平滑過渡 (取代原本直接寫死的 itemY)
const currentStartY = cube.userData.startY !== undefined ? cube.userData.startY : itemY;
const currentTargetY = cube.userData.targetY !== undefined ? cube.userData.targetY : itemY;
cube.position.y = THREE.MathUtils.lerp(currentStartY, currentTargetY, t) + Math.sin(t * Math.PI) * 20;
}
});
// 處理立方體得分時的 3D 旋轉翻轉點數動畫
cubes.forEach(cube => {
if (cube.userData.animating) {
const elapsed = now - cube.userData.startTime;
const duration = 1000;
let t = elapsed / duration;
if (t >= 1) {
t = 1;
cube.userData.animating = false;
}
// 翻轉時的離地跳躍微幅晃動,需基於其所在的目標高度
if (!cube.userData.moving) {
const baseRestY = cube.userData.targetY !== undefined ? cube.userData.targetY : itemY;
cube.position.y = baseRestY + Math.sin(t * Math.PI) * 48;
}
cube.quaternion.slerpQuaternions(cube.userData.startQuat, cube.userData.targetQuat, t);
}
});
controls.update();
renderer.render(scene, camera);
}
document.getElementById('btn-walk-undo').addEventListener('click', walkUndo);
document.getElementById('btn-walk-redo').addEventListener('click', walkRedo);
document.getElementById('btn-turn-undo').addEventListener('click', turnUndo);
document.getElementById('btn-turn-redo').addEventListener('click', turnRedo);
btnAiBlue.addEventListener('click', () => {
isBlueAI = !isBlueAI;
btnAiBlue.classList.toggle('active-blue', isBlueAI);
triggerAIIfReady();
});
btnAiGreen.addEventListener('click', () => {
isGreenAI = !isGreenAI;
btnAiGreen.classList.toggle('active-green', isGreenAI);
triggerAIIfReady();
});
selectAiStrength.addEventListener('change', e => {
aiStrength = parseInt(e.target.value);
});
animate();
</script>
當一局結束時,應清空aiPlannedActions。