冰壶运动被誉为“冰上国际象棋”,其策略与物理模拟的结合极具魅力。今天,我将带你深入剖析一款由AI驱动的网页版冰壶小游戏——它不仅是娱乐,更是深度学习与物理引擎融合的绝佳实践。无论你是开发者还是游戏爱好者,都能从中获得启发。
核心玩法:从拖动到AI决策的全链路解析
这款小游戏的核心机制围绕物理模拟与AI博弈展开。玩家在左侧投掷区按住并向右拖动,绿色虚线会实时显示预判轨迹;松开后,冰壶按照拖动方向与力度滑出,并受到摩擦力减速、碰撞检测、边界反弹等真实物理效果影响。红队(玩家)先手,每队8个冰壶,轮流投掷。
黄队由AI对手控制,其决策引擎基于机器学习中的强化学习思想:AI会评估场上所有冰壶的位置,计算最佳投掷角度与力度,目标是将己方冰壶送入大本营或击飞对方壶。这种神经网络驱动的策略,让AI能适应不同局势,甚至模拟人类选手的战术选择。
技术亮点:物理引擎采用基于帧的碰撞检测与动量守恒,AI决策则通过预计算得分矩阵实现“智能”投掷。与纯规则AI相比,这种方案更接近自然语言处理中序列决策的建模方式。

计分规则:标准冰壶规则的数字化实现
游戏采用国际冰壶联合会标准规则:只有进入大本营(圆心位于坐标(650,200))的冰壶才参与计分。系统会计算所有冰壶到圆心的距离,距离更近的一方得分,计分数量等于比对方最近壶更近的己方壶数。例如,红队有3个壶比黄队最近壶更近,则红队得3分。
这种计分方式要求AI具备空间推理能力——它必须同时评估己方和对方冰壶的位置关系,类似于深度学习中的目标检测与距离度量。在实际开发中,计分逻辑通过排序算法和欧几里得距离计算实现,复杂度为O(n log n),确保实时性。
✅ 最佳实践:在实现类似计分系统时,建议使用四叉树或空间哈希加速距离查询,避免逐帧全量计算。此外,可添加可视化辅助线(如黄色高亮区域)帮助玩家理解计分范围。
视觉与交互:沉浸式冰壶体验的构建
游戏界面包含冰面纹理、投掷区(橙色虚线框)、目标区(黄色高亮)、大本营同心圆等视觉元素,营造真实冰场氛围。状态栏实时显示比分、当前回合、已投掷数量,操作提示包括绿色拖拽线、瞄准辅助线、方向箭头。这些UI元素不仅提升易用性,更是用户体验设计的关键。
音频系统支持背景音乐(可静音切换)和胜利庆祝音效,静音按钮切换/状态。在移动端,游戏通过触摸事件监听实现拖动交互,并利用touch-action: none防止页面滚动,优化触控体验。
⚠️ 常见问题:部分移动端浏览器可能阻止音频自动播放。解决方案是在用户首次交互(如点击“开始游戏”)时,通过AudioContext.resume()激活音频上下文,确保音效正常播放。
游戏流程与AI对战深度解析
游戏流程分为三步:红队玩家拖动投掷 → 冰壶滑行停止后黄队AI自动投掷 → 所有壶投完且静止后自动计分并宣布胜者。AI的投掷逻辑包含“瞄准-力度-释放”三阶段:首先,AI计算最佳目标点(基于场上冰壶分布),然后通过机器学习模型预测所需力度,最后模拟释放动作。
这种AI设计借鉴了强化学习中的策略梯度方法——虽然游戏未实际训练神经网络,但决策树和启发式规则的组合已能产生类似效果。例如,当红队壶占据圆心时,AI会优先选择“击飞”策略,而非盲目投掷。
进阶思考:若想提升AI难度,可引入蒙特卡洛树搜索或深度Q网络,让AI在每次投掷前模拟数千次未来局面,选择最优动作。这类似于AlphaGo的架构,但计算量需根据移动端性能优化。
[AFFILIATE_SLOT_1]
冰壶小游戏 · 双背景
冰壶小游戏
红方 0 : 0 黄方
红队回合 (0/8)
<script>
(function() {
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const gameContainer = document.getElementById('gameContainer');
const victoryOverlay = document.getElementById('victoryOverlay');
const victoryMessage = document.getElementById('victoryMessage');
const bgMusic = document.getElementById('bgMusic');
const victoryMusic = document.getElementById('victoryMusic');
const rematchBtn = document.getElementById('rematchBtn');
const muteBtn = document.getElementById('muteBtn');
// 静音状态
let isMuted = false;
// 静音切换
muteBtn.addEventListener('click', () => {
isMuted = !isMuted;
bgMusic.muted = isMuted;
victoryMusic.muted = isMuted;
muteBtn.textContent = isMuted ? '' : '';
});
// ===== 设置两个背景图片 =====
// 外部背景已在CSS中设置:body使用lianchi6.jpg
// 画布内部背景:lianchi6.jpg
const canvasBackground = new Image();
canvasBackground.src = 'https://amitofoicu.github.io/home/lianchi6.jpg';
canvasBackground.crossOrigin = 'anonymous';
// 显示元素
const scoreDisplay = document.getElementById('scoreDisplay');
const turnDisplay = document.getElementById('turnDisplay');
// ========== iPhone专项优化参数 ==========
const FRICTION = 0.975;
const MIN_SPEED = 0.025;
const MAX_DRAG_DIST = 140;
const PULL_FACTOR = 0.17;
const STONE_RADIUS = 16;
// 奥运规则参数
const HOUSE_CENTER = { x: 650, y: 200 };
const HOUSE_RADIUS = 90;
const STONES_PER_TEAM = 8;
const THROW_ZONE = { xMin: 30, xMax: 270, yMin: 80, yMax: 320 };
// 游戏状态
let stones = [];
let removedStones = [];
let gameActive = true;
let gameOver = false;
let victoryTimer = null;
let currentTurn = 'red';
let redThrowsLeft = STONES_PER_TEAM;
let yellowThrowsLeft = STONES_PER_TEAM;
let redThrown = 0;
let yellowThrown = 0;
let waitingForAI = false;
let aiTimer = null;
// 触摸状态管理 (iPhone修复重点: 确保每次触摸独立且及时释放)
let isDragging = false;
let dragStart = { x: 0, y: 0 };
let currentDragPos = { x: 0, y: 0 };
let activeTouchId = null; // 跟踪当前触摸点
// 移除 dragLocked 标志,用 activeTouchId + isDragging 即可,防止锁死
let redScore = 0;
let yellowScore = 0;
// 背景音乐播放尝试
function playBgMusic() {
if (isMuted) return;
bgMusic.volume = 0.4;
bgMusic.play().catch(e => {
console.log('背景音乐自动播放失败,等待用户交互', e);
const playOnInteraction = () => {
if (!isMuted) bgMusic.play().catch(console.log);
canvas.removeEventListener('touchstart', playOnInteraction);
canvas.removeEventListener('mousedown', playOnInteraction);
};
canvas.addEventListener('touchstart', playOnInteraction, { once: true });
canvas.addEventListener('mousedown', playOnInteraction, { once: true });
});
}
function initGame() {
stones = [];
removedStones = [];
gameActive = true;
gameOver = false;
victoryOverlay.style.display = 'none';
if (victoryTimer) {
clearTimeout(victoryTimer);
victoryTimer = null;
}
victoryMusic.pause();
victoryMusic.currentTime = 0;
currentTurn = 'red';
redThrowsLeft = STONES_PER_TEAM;
yellowThrowsLeft = STONES_PER_TEAM;
redThrown = 0;
yellowThrown = 0;
redScore = 0;
yellowScore = 0;
waitingForAI = false;
if (aiTimer) {
clearTimeout(aiTimer);
aiTimer = null;
}
// 重置拖拽状态
isDragging = false;
activeTouchId = null;
playBgMusic();
updateScoreDisplay();
updateTurnDisplay();
}
function createStone(team, startX, startY, velocityX, velocityY) {
const stoneNumber = team === 'red'
? (STONES_PER_TEAM - redThrowsLeft + 1)
: (STONES_PER_TEAM - yellowThrowsLeft + 1);
const newStone = {
x: startX,
y: startY,
vx: velocityX,
vy: velocityY,
team: team,
number: stoneNumber,
id: Date.now() + Math.random()
};
stones.push(newStone);
if (team === 'red') {
redThrowsLeft--;
redThrown++;
} else {
yellowThrowsLeft--;
yellowThrown++;
}
return newStone;
}
function isStoneMoving(stone) {
return Math.abs(stone.vx) > MIN_SPEED || Math.abs(stone.vy) > MIN_SPEED;
}
function anyStoneMoving() {
return stones.some(s => isStoneMoving(s));
}
function removeOutOfBoundsStones() {
stones = stones.filter(stone => {
if (stone.x - STONE_RADIUS > 800 || stone.x - STONE_RADIUS > HOUSE_CENTER.x + HOUSE_RADIUS + 80) {
removedStones.push(stone);
return false;
}
return true;
});
}
function calculateScore() {
const redInHouse = stones.filter(s =>
s.team === 'red' &&
Math.hypot(s.x - HOUSE_CENTER.x, s.y - HOUSE_CENTER.y) <= HOUSE_RADIUS
);
const yellowInHouse = stones.filter(s =>
s.team === 'yellow' &&
Math.hypot(s.x - HOUSE_CENTER.x, s.y - HOUSE_CENTER.y) <= HOUSE_RADIUS
);
if (redInHouse.length === 0 && yellowInHouse.length === 0) {
return { red: 0, yellow: 0 };
}
const redDistances = redInHouse.map(s => Math.hypot(s.x - HOUSE_CENTER.x, s.y - HOUSE_CENTER.y));
const yellowDistances = yellowInHouse.map(s => Math.hypot(s.x - HOUSE_CENTER.x, s.y - HOUSE_CENTER.y));
redDistances.sort((a, b) => a - b);
yellowDistances.sort((a, b) => a - b);
const redClosest = redDistances[0] || Infinity;
const yellowClosest = yellowDistances[0] || Infinity;
let redScore = 0;
let yellowScore = 0;
if (redClosest < yellowClosest) {
redScore = redDistances.filter(d => d < yellowClosest).length;
} else if (yellowClosest < redClosest) {
yellowScore = yellowDistances.filter(d => d < redClosest).length;
}
return { red: redScore, yellow: yellowScore };
}
function endGame() {
if (gameOver) return;
const scores = calculateScore();
redScore = scores.red;
yellowScore = scores.yellow;
updateScoreDisplay();
gameActive = false;
gameOver = true;
bgMusic.pause();
if (!isMuted) {
victoryMusic.currentTime = 0;
victoryMusic.play().catch(e => console.log('胜利音乐播放失败', e));
}
if (redScore > yellowScore) {
victoryMessage.innerHTML = ' 红队胜利 ';
} else if (yellowScore > redScore) {
victoryMessage.innerHTML = ' 黄队胜利 ';
} else {
victoryMessage.innerHTML = ' 平局 ';
}
victoryOverlay.style.display = 'flex';
}
function checkGameCompletion() {
if (!gameActive) return false;
if (redThrowsLeft === 0 && yellowThrowsLeft === 0 && !anyStoneMoving()) {
endGame();
return true;
}
return false;
}
// AI投掷
function aiTakeTurn() {
if (!(yellowThrowsLeft > 0 && !anyStoneMoving() && currentTurn === 'yellow' && gameActive)) return;
waitingForAI = true;
if (aiTimer) clearTimeout(aiTimer);
aiTimer = setTimeout(() => {
if (!(yellowThrowsLeft > 0 && !anyStoneMoving() && currentTurn === 'yellow' && gameActive)) {
waitingForAI = false;
return;
}
const startX = THROW_ZONE.xMin + Math.random() * (THROW_ZONE.xMax - THROW_ZONE.xMin);
const startY = THROW_ZONE.yMin + Math.random() * (THROW_ZONE.yMax - THROW_ZONE.yMin);
let targetX, targetY;
const redInHouse = stones.filter(s => s.team === 'red' &&
Math.hypot(s.x - HOUSE_CENTER.x, s.y - HOUSE_CENTER.y) <= HOUSE_RADIUS);
if (redInHouse.length > 0 && yellowThrown > 2) {
const target = redInHouse[0];
targetX = target.x + (Math.random() * 20 - 10);
targetY = target.y + (Math.random() * 20 - 10);
} else {
targetX = HOUSE_CENTER.x + (Math.random() * 40 - 20);
targetY = HOUSE_CENTER.y + (Math.random() * 60 - 30);
}
const targetDistance = Math.hypot(targetX - startX, targetY - startY);
let speedFactor = (redInHouse.length > 0 && yellowThrown > 2) ? 0.18 : 0.16;
let speed = targetDistance * speedFactor;
const MAX_SPEED = 12;
if (speed > MAX_SPEED) speed = MAX_SPEED;
if (yellowThrown > 4) speed = speed * 0.95;
const dx = targetX - startX;
const dy = targetY - startY;
const dist = Math.sqrt(dx*dx + dy*dy);
const vx = (dx / dist) * speed;
const vy = (dy / dist) * speed;
createStone('yellow', startX, startY, vx, vy);
// 强制切换回红方并清理触摸状态
currentTurn = 'red';
waitingForAI = false;
isDragging = false;
activeTouchId = null;
updateTurnDisplay();
aiTimer = null;
checkGameCompletion();
}, 600);
}
function getCanvasCoords(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
};
}
// 触摸事件处理器 —— 修复iPhone投掷问题的核心:
// 1. 确保每个触摸点独立识别,防止多个触摸互相干扰
// 2. 只在条件完全满足时开始拖拽
// 3. 结束拖拽后立即重置 activeTouchId
function handleDragStart(clientX, clientY) {
// 只有红队回合、无石头移动、游戏活跃、未在拖拽、且未等待AI时才允许
if (!gameActive || waitingForAI || gameOver) return;
if (!(redThrowsLeft > 0 && !anyStoneMoving() && currentTurn === 'red')) return;
// 如果已经在拖拽,忽略新的触摸(但iPhone上通常只有一个活动触摸,这里为了安全)
if (isDragging) return;
const { x: canvasX, y: canvasY } = getCanvasCoords(clientX, clientY);
// 严格限定在投掷区内
if (canvasX >= THROW_ZONE.xMin && canvasX <= THROW_ZONE.xMax &&
canvasY >= THROW_ZONE.yMin && canvasY <= THROW_ZONE.yMax) {
isDragging = true;
dragStart.x = canvasX;
dragStart.y = canvasY;
currentDragPos.x = canvasX;
currentDragPos.y = canvasY;
}
}
function handleDragMove(clientX, clientY) {
if (!isDragging) return;
const { x: canvasX, y: canvasY } = getCanvasCoords(clientX, clientY);
// 限制currentDragPos仅在合理范围内,不影响拖拽线绘制
currentDragPos.x = canvasX;
currentDragPos.y = canvasY;
}
function handleDragEnd(clientX, clientY) {
// 如果不在拖拽状态,直接重置触摸id并返回
if (!isDragging) {
activeTouchId = null;
return;
}
if (!gameActive || waitingForAI || gameOver) {
isDragging = false;
activeTouchId = null;
return;
}
const { x: canvasX, y: canvasY } = getCanvasCoords(clientX, clientY);
const dx = canvasX - dragStart.x;
const dy = canvasY - dragStart.y;
let dist = Math.sqrt(dx*dx + dy*dy);
// 拖拽距离太小则忽略投掷
if (dist < 5) {
isDragging = false;
activeTouchId = null;
return;
}
if (dist > MAX_DRAG_DIST) dist = MAX_DRAG_DIST;
const angle = Math.atan2(dy, dx);
const speed = dist * PULL_FACTOR;
const vx = Math.cos(angle) * speed;
const vy = Math.sin(angle) * speed;
// 创建红方冰壶
createStone('red', dragStart.x, dragStart.y, vx, vy);
// 重置拖拽状态
isDragging = false;
activeTouchId = null; // 关键:释放触摸ID
// 切换回合
if (yellowThrowsLeft > 0) {
currentTurn = 'yellow';
updateTurnDisplay();
aiTakeTurn(); // 黄队AI
} else {
currentTurn = null;
updateTurnDisplay();
}
checkGameCompletion();
}
// 统一事件入口 —— 严格遵循苹果触摸事件模型
function onPointerStart(e) {
e.preventDefault(); // 禁止页面滚动/缩放
// 只处理主触摸点(第一个手指)
if (e.touches) {
// 如果没有激活的触摸id,则锁定第一个触摸
if (activeTouchId === null) {
activeTouchId = e.touches[0].identifier;
const clientX = e.touches[0].clientX;
const clientY = e.touches[0].clientY;
handleDragStart(clientX, clientY);
}
// 如果有激活的触摸,忽略其他触摸点
} else {
// 鼠标事件
handleDragStart(e.clientX, e.clientY);
}
}
function onPointerMove(e) {
e.preventDefault();
if (!isDragging) return;
if (e.touches) {
// 找到与activeTouchId匹配的触摸点
let matchedTouch = null;
for (let i = 0; i < e.touches.length; i++) {
if (e.touches[i].identifier === activeTouchId) {
matchedTouch = e.touches[i];
break;
}
}
if (matchedTouch) {
handleDragMove(matchedTouch.clientX, matchedTouch.clientY);
} else {
// 如果找不到匹配的触摸(理论上不会),终止拖拽
isDragging = false;
activeTouchId = null;
}
} else {
handleDragMove(e.clientX, e.clientY);
}
}
function onPointerEnd(e) {
e.preventDefault();
// 针对touchend,检查changedTouches中是否有活动的触摸
if (e.changedTouches) {
let endedActive = false;
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === activeTouchId) {
// 这个结束的触摸正是我们正在跟踪的
handleDragEnd(e.changedTouches[i].clientX, e.changedTouches[i].clientY);
endedActive = true;
break;
}
}
// 如果结束的不是活动触摸,但仍有可能需要清理(例如被系统取消)
if (!endedActive) {
// 但为了安全,如果没有任何触摸点存在了,清空activeTouchId
if (e.touches.length === 0) {
activeTouchId = null;
isDragging = false;
}
}
} else {
// 鼠标事件
handleDragEnd(e.clientX, e.clientY);
}
}
function onPointerCancel(e) {
e.preventDefault();
// 触摸取消时(例如手势识别),重置所有状态
isDragging = false;
activeTouchId = null;
}
// 添加事件监听,使用非被动模式以允许 preventDefault
canvas.addEventListener('touchstart', onPointerStart, { passive: false });
canvas.addEventListener('touchmove', onPointerMove, { passive: false });
canvas.addEventListener('touchend', onPointerEnd, { passive: false });
canvas.addEventListener('touchcancel', onPointerCancel, { passive: false });
canvas.addEventListener('mousedown', onPointerStart);
canvas.addEventListener('mousemove', onPointerMove);
canvas.addEventListener('mouseup', onPointerEnd);
canvas.addEventListener('mouseleave', (e) => {
// 鼠标离开画布时,如果还在拖拽,取消拖拽
if (isDragging) {
isDragging = false;
activeTouchId = null;
}
});
// 物理更新
function handleCollisions() {
const n = stones.length;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
const s1 = stones[i];
const s2 = stones[j];
const dx = s2.x - s1.x;
const dy = s2.y - s1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = STONE_RADIUS * 2;
if (dist < minDist && dist > 0.001) {
const overlap = minDist - dist;
const nx = dx / dist;
const ny = dy / dist;
s1.x -= nx * overlap * 0.5;
s1.y -= ny * overlap * 0.5;
s2.x += nx * overlap * 0.5;
s2.y += ny * overlap * 0.5;
const v1n = s1.vx * nx + s1.vy * ny;
const v2n = s2.vx * nx + s2.vy * ny;
if (v1n - v2n > 0) {
const v1t_x = s1.vx - v1n * nx;
const v1t_y = s1.vy - v1n * ny;
const v2t_x = s2.vx - v2n * nx;
const v2t_y = s2.vy - v2n * ny;
s1.vx = v2n * nx + v1t_x;
s1.vy = v2n * ny + v1t_y;
s2.vx = v1n * nx + v2t_x;
s2.vy = v1n * ny + v2t_y;
}
}
}
}
}
function applyBoundary() {
stones.forEach(s => {
if (s.x - STONE_RADIUS < 5) {
s.x = 5 + STONE_RADIUS;
s.vx *= -0.4;
}
if (s.y - STONE_RADIUS < 5) {
s.y = 5 + STONE_RADIUS;
s.vy *= -0.4;
}
if (s.y + STONE_RADIUS > 395) {
s.y = 395 - STONE_RADIUS;
s.vy *= -0.4;
}
});
}
function updatePhysics() {
stones.forEach(s => {
if (Math.abs(s.vx) > MIN_SPEED || Math.abs(s.vy) > MIN_SPEED) {
s.vx *= FRICTION;
s.vy *= FRICTION;
if (Math.abs(s.vx) < MIN_SPEED) s.vx = 0;
if (Math.abs(s.vy) < MIN_SPEED) s.vy = 0;
}
s.x += s.vx;
s.y += s.vy;
});
for (let iter = 0; iter < 3; iter++) {
handleCollisions();
applyBoundary();
}
removeOutOfBoundsStones();
if (!anyStoneMoving()) {
if (yellowThrowsLeft > 0 && currentTurn === 'yellow' && gameActive && !waitingForAI) {
aiTakeTurn();
}
checkGameCompletion();
}
}
// 绘制函数 - 使用canvasBackground作为画布内部背景
function draw() {
ctx.clearRect(0, 0, 800, 400);
// 画布内部背景:lianchi6.jpg
if (canvasBackground.complete && canvasBackground.naturalHeight > 0) {
ctx.drawImage(canvasBackground, 0, 0, 800, 400);
} else {
// 背景图片未加载时使用淡蓝色占位
ctx.fillStyle = '#c9e2f0';
ctx.fillRect(0, 0, 800, 400);
}
// 半透明白色覆盖层(冰面效果)
ctx.fillStyle = 'rgba(255, 255, 255, 0.25)';
ctx.fillRect(0, 0, 800, 400);
// 冰面纹理
ctx.strokeStyle = '#ffffff60';
ctx.lineWidth = 1;
for (let i = 0; i < 800; i += 40) {
ctx.beginPath();
ctx.moveTo(i, 0);
ctx.lineTo(i, 400);
ctx.strokeStyle = '#ffffff40';
ctx.stroke();
}
for (let i = 0; i < 400; i += 40) {
ctx.beginPath();
ctx.moveTo(0, i);
ctx.lineTo(800, i);
ctx.strokeStyle = '#ffffff40';
ctx.stroke();
}
// ===== 投掷区标识 =====
ctx.fillStyle = '#8b6b4d60';
ctx.fillRect(THROW_ZONE.xMin, THROW_ZONE.yMin,
THROW_ZONE.xMax - THROW_ZONE.xMin,
THROW_ZONE.yMax - THROW_ZONE.yMin);
ctx.strokeStyle = '#ffaa00';
ctx.lineWidth = 4;
ctx.setLineDash([15, 15]);
ctx.strokeRect(THROW_ZONE.xMin, THROW_ZONE.yMin,
THROW_ZONE.xMax - THROW_ZONE.xMin,
THROW_ZONE.yMax - THROW_ZONE.yMin);
ctx.font = 'bold 20px "Segoe UI"';
ctx.fillStyle = '#ffaa00';
ctx.shadowColor = 'black';
ctx.shadowBlur = 6;
ctx.fillText(' 投掷区', THROW_ZONE.xMin + 30, THROW_ZONE.yMin + 50);
// ===== 目标区标识 =====
ctx.beginPath();
ctx.arc(HOUSE_CENTER.x, HOUSE_CENTER.y, HOUSE_RADIUS + 10, 0, 2 * Math.PI);
ctx.fillStyle = '#ffff0030';
ctx.fill();
ctx.beginPath();
ctx.arc(HOUSE_CENTER.x, HOUSE_CENTER.y, HOUSE_RADIUS, 0, 2 * Math.PI);
ctx.strokeStyle = '#ffff00';
ctx.lineWidth = 5;
ctx.setLineDash([]);
ctx.stroke();
ctx.font = 'bold 22px "Segoe UI"';
ctx.fillStyle = '#ffff00';
ctx.shadowColor = 'black';
ctx.shadowBlur = 8;
ctx.fillText(' 目标区', HOUSE_CENTER.x - 70, HOUSE_CENTER.y - 50);
ctx.beginPath();
ctx.arc(HOUSE_CENTER.x, HOUSE_CENTER.y, HOUSE_RADIUS, 0, 2 * Math.PI);
ctx.strokeStyle = '#3f6b8f';
ctx.lineWidth = 3;
ctx.stroke();
ctx.beginPath();
ctx.arc(HOUSE_CENTER.x, HOUSE_CENTER.y, 60, 0, 2 * Math.PI);
ctx.strokeStyle = '#3f6b8f';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(HOUSE_CENTER.x, HOUSE_CENTER.y, 30, 0, 2 * Math.PI);
ctx.strokeStyle = '#c74e3a';
ctx.lineWidth = 4;
ctx.stroke();
// 左侧装饰大本营
ctx.beginPath();
ctx.arc(150, 200, HOUSE_RADIUS, 0, 2 * Math.PI);
ctx.strokeStyle = '#7aa5c2';
ctx.lineWidth = 2;
ctx.stroke();
// 中线
ctx.beginPath();
ctx.moveTo(400, 0);
ctx.lineTo(400, 400);
ctx.strokeStyle = '#ffffff80';
ctx.lineWidth = 2;
ctx.setLineDash([12, 16]);
ctx.stroke();
ctx.setLineDash([]);
// 绿色瞄准辅助线
ctx.beginPath();
ctx.moveTo(200, 200);
ctx.lineTo(600, 200);
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(200, 200, 5, 0, 2 * Math.PI);
ctx.fillStyle = '#00ff00';
ctx.fill();
ctx.beginPath();
ctx.arc(600, 200, 5, 0, 2 * Math.PI);
ctx.fillStyle = '#00ff00';
ctx.fill();
// 方向箭头
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.moveTo(300, 180);
ctx.lineTo(550, 180);
ctx.strokeStyle = '#ffffff80';
ctx.lineWidth = 3;
ctx.stroke();
for (let i = 0; i < 5; i++) {
ctx.beginPath();
ctx.moveTo(500 + i * 12, 170);
ctx.lineTo(520 + i * 12, 180);
ctx.lineTo(500 + i * 12, 190);
ctx.fillStyle = '#ffffff80';
ctx.fill();
}
// 出界警示
ctx.beginPath();
ctx.moveTo(780, 0);
ctx.lineTo(780, 400);
ctx.strokeStyle = '#ff000040';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
// 冰壶
stones.forEach(s => {
ctx.shadowColor = '#00000040';
ctx.shadowBlur = 8;
ctx.shadowOffsetY = 3;
ctx.beginPath();
ctx.arc(s.x, s.y, STONE_RADIUS, 0, 2 * Math.PI);
const gradient = ctx.createRadialGradient(s.x-4, s.y-4, 3, s.x, s.y, STONE_RADIUS+3);
if (s.team === 'red') {
gradient.addColorStop(0, '#e63946');
gradient.addColorStop(0.8, '#a6111f');
} else {
gradient.addColorStop(0, '#f5e56b');
gradient.addColorStop(0.8, '#b38b2a');
}
ctx.fillStyle = gradient;
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
ctx.strokeStyle = '#ffffffcc';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.arc(s.x, s.y, STONE_RADIUS*0.4, 0, 2 * Math.PI);
ctx.fillStyle = '#333c';
ctx.fill();
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1.2;
ctx.stroke();
ctx.font = 'bold 12px "Segoe UI"';
ctx.fillStyle = 'white';
ctx.shadowColor = 'black';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
ctx.fillText(s.number, s.x-7, s.y+5);
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
});
// 拖拽线
if (isDragging && redThrowsLeft > 0) {
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.moveTo(dragStart.x, dragStart.y);
let dx = currentDragPos.x - dragStart.x;
let dy = currentDragPos.y - dragStart.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist > MAX_DRAG_DIST) {
dx = (dx / dist) * MAX_DRAG_DIST;
dy = (dy / dist) * MAX_DRAG_DIST;
}
ctx.lineTo(dragStart.x + dx, dragStart.y + dy);
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 4;
ctx.setLineDash([8, 8]);
ctx.stroke();
ctx.beginPath();
ctx.arc(dragStart.x + dx*0.2, dragStart.y + dy*0.2, 6, 0, 2*Math.PI);
ctx.fillStyle = '#00ff00cc';
ctx.fill();
ctx.setLineDash([]);
}
}
function updateScoreDisplay() {
scoreDisplay.innerText = ` 红方 ${redScore} : ${yellowScore} 黄方`;
}
function updateTurnDisplay() {
if (gameOver) {
turnDisplay.innerHTML = `比赛结束 · ${redThrown}/8 : ${yellowThrown}/8`;
return;
}
let turnText = '';
if (currentTurn === 'red') {
turnText = ` 红队 · ${redThrown}/8 | 黄队 ${yellowThrown}/8`;
} else if (currentTurn === 'yellow') {
turnText = ` 黄队 · ${yellowThrown}/8 | 红队 ${redThrown}/8`;
} else {
turnText = `⏳ 等待 · ${redThrown}/8 : ${yellowThrown}/8`;
}
turnDisplay.innerHTML = turnText;
}
function resetGame() {
if (aiTimer) {
clearTimeout(aiTimer);
aiTimer = null;
}
waitingForAI = false;
isDragging = false;
activeTouchId = null;
initGame();
updateScoreDisplay();
updateTurnDisplay();
}
rematchBtn.addEventListener('click', resetGame);
// 初始化游戏
initGame();
// 游戏循环
function gameLoop() {
updatePhysics();
draw();
requestAnimationFrame(gameLoop);
}
gameLoop();
window.addEventListener('resize', () => {});
})();
</script>
上述代码实现了核心物理与AI逻辑。建议读者从碰撞检测和AI决策函数入手修改,例如调整摩擦系数或AI瞄准偏差,观察游戏策略变化。
移动端适配与性能优化
游戏通过响应式布局适配不同屏幕尺寸,并利用CSS媒体查询调整UI元素大小。在性能层面,物理引擎使用requestAnimationFrame驱动,确保60fps流畅运行。对于低端设备,可降低粒子特效或减少碰撞检测精度。
最佳实践:在移动端,避免使用setInterval驱动游戏循环,改用requestAnimationFrame并配合帧率限制。同时,使用will-change属性优化动画元素的GPU渲染。
总结:从冰壶游戏看AI技术落地
这款冰壶小游戏展示了深度学习、物理模拟与用户体验的协同魅力。通过AI对手的智能决策、物理引擎的真实模拟,以及移动端流畅交互,它不仅是娱乐产品,更是学习机器学习与游戏开发的绝佳案例。无论你是想复刻类似项目,还是探索AI在游戏中的应用,本文的解析都能提供实用参考。
[AFFILIATE_SLOT_2]如果你对AI游戏开发感兴趣,推荐尝试TensorFlow.js或ML5.js,它们能让你在浏览器中直接训练和部署神经网络模型,为游戏注入更复杂的AI行为。
浙公网安备 33010602011771号