mobileNet/game/贪吃蛇/snake_game.html

1196 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 姿态控制贪吃蛇</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #a8c0ff, #3f2b96);
color: #fff;
margin: 0;
padding: 20px;
}
h1 {
margin-bottom: 20px;
color: #fff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
#game-area {
display: flex;
gap: 20px;
flex-wrap: wrap;
justify-content: center;
}
#snake-canvas-container {
/* === 修改点 1显式设置宽度和高度使其与 canvas 元素尺寸一致 === */
width: 600px; /* 与 JavaScript 中 CANVAS_WIDTH 匹配 */
height: 400px; /* 与 JavaScript 中 CANVAS_HEIGHT 匹配 */
position: relative;
border: 5px solid #00f2fe;
box-shadow: 0 0 20px #00f2fe, 0 0 40px #00f2fe;
background-color: #333;
border-radius: 10px;
overflow: hidden;
}
#snakeCanvas {
background-color: #333;
display: block;
/* === 修改点 1确保 canvas 实际宽度和高度与容器一致 === */
width: 100%;
height: 100%;
}
#game-info-panel {
background: rgba(0, 0, 0, 0.6);
padding: 25px;
border-radius: 10px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);
min-width: 300px;
display: flex;
flex-direction: column;
gap: 15px;
}
.info-item {
font-size: 1.1em;
display: flex;
justify-content: space-between;
}
.info-item span:first-child {
font-weight: bold;
color: #a8c0ff;
}
#status-display {
background: #222;
padding: 10px;
border-radius: 5px;
font-size: 0.9em;
min-height: 30px;
color: #00f2fe;
text-align: center;
}
.gesture-indicator {
font-size: 1.2em;
color: #00ff00;
margin-top: 10px;
padding: 5px;
background: #2c2c2c;
border-radius: 5px;
text-align: center;
min-height: 25px;
}
.control-buttons {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 20px;
}
.control-buttons button {
padding: 12px 25px;
font-size: 1.1em;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
color: white;
}
#startBtn {
background: linear-gradient(45deg, #2196F3, #21CBF3);
}
#startBtn:hover {
background: linear-gradient(45deg, #1976D2, #1AC8EF);
transform: translateY(-2px);
}
#startBtn:disabled {
background: #607d8b;
cursor: not-allowed;
opacity: 0.7;
}
#importModelBtn {
background: linear-gradient(45deg, #FFC107, #FFEB3B);
}
#importModelBtn:hover {
background: linear-gradient(45deg, #FFA000, #FFD700);
transform: translateY(-2px);
}
#importModelBtn:disabled {
background: #9E9E9E;
cursor: not-allowed;
opacity: 0.7;
}
#video-feed-container {
position: absolute;
top: 20px;
left: 20px;
width: 320px;
height: 240px;
border: 3px solid #6a1b9a;
border-radius: 10px;
background: #000;
overflow: hidden;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.6);
z-index: 100; /* 确保在游戏画布之上 */
}
#video-feed-container video {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 7px;
}
#video-feed-container canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 7px;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
color: white;
font-size: 2em;
text-align: center;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.overlay.active {
opacity: 1;
visibility: visible;
}
.overlay h2 {
font-size: 3em;
margin-bottom: 20px;
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.5);
}
.overlay p {
font-size: 0.8em; /* 调整字体大小 */
}
.overlay button {
margin-top: 30px;
font-size: 1.5em;
padding: 15px 30px;
background: linear-gradient(45deg, #28a745, #20c997);
color: white;
border: none;
border-radius: 50px;
cursor: pointer;
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.3);
transition: transform 0.2s, box-shadow 0.2s;
}
.overlay button:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
}
.overlay button:active {
transform: translateY(0);
}
.instructions {
background: rgba(0, 0, 0, 0.6);
padding: 15px;
border-radius: 8px;
margin-top: 20px;
font-size: 0.9em;
line-height: 1.6;
width: 80%;
max-width: 500px;
text-align: left;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
animation: fadeIn 1s ease-out;
}
.instructions h3 {
color: #00f2fe;
margin-bottom: 10px;
text-align: center;
}
.instructions ul {
list-style: none;
padding-left: 0;
}
.instructions li {
margin-bottom: 5px;
display: flex;
align-items: center;
}
.instructions li strong {
color: #a8c0ff;
min-width: 80px;
}
.instructions li span {
flex-grow: 1;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<!-- !!!!!! 核心劫持代码:确保在任何 TF.js 库之前加载 !!!!!! -->
<script>
(function() {
// 定义你的镜像服务器的公共前缀,用于存放 MoveNet 模型文件
// 例如:'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/movenet-mirror/'
// 重要:确保你的镜像服务器的目录结构与原始模型文件的路径部分匹配。
// 举例:
// 如果原始是 https://tfhub.dev/deepmind/movenet/singlepose-lightning/4/model.json
// 那么在你的CDN上你需要部署为
// https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/movenet-mirror/tfhub.dev/deepmind/movenet/singlepose-lightning/4/model.json
//
// 如果你的CDN就是直接放了 model.json, group1-shard*of*.bin那么 MIRROR_BASE_URL 将不含后续路径。
//
// **** 根据你前一个回复的镜像路径,我们假设你的镜像结构是: ****
// https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/snake_game/model.json
// https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/snake_game/group1-shard1of2.bin
// https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/snake_game/group1-shard2of2.bin
//
// 那么我们需要将匹配的原始URL路径重写为 MIRROR_SPECIFIC_FILENAME_PREFIX
// 也就是将类似 "https://tfhub.dev/.../model.json..."
// 替换为 "https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/snake_game/model.json"
const MIRROR_SPECIFIC_FILENAME_PREFIX = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/snake_game/';
// 定义需要被劫持的原始 URL 的域名模式
const INTERCEPT_DOMAINS = [
'https://tfhub.dev/',
// 如果实际的最终模型文件仍然解析到 storage.googleapis.com也需要包含
// 例如:'https://storage.googleapis.com/tfjs-models/'
// 或者你观察到的实际的最终 Google Storage 域名
];
// 备份原始的 fetch 函数
const originalFetch = window.fetch;
window.fetch = function(input, init) {
let url = input;
if (input instanceof Request) {
url = input.url;
}
let newUrl = url;
let isIntercepted = false;
// 检查 URL 是否以我们关注的域名开头
for (const domain of INTERCEPT_DOMAINS) {
if (url.startsWith(domain)) {
// 尝试从 URL 中提取文件名 (不包含查询参数)
// 匹配 model.json 或 group1-shardXofY.bin
const fileNameMatch = url.match(/(model\.json|group1-shard\dof\d\.bin)/);
if (fileNameMatch) {
const fileName = fileNameMatch[0]; // 获取匹配到的文件名
newUrl = MIRROR_SPECIFIC_FILENAME_PREFIX + fileName; // 拼接新的镜像 URL
isIntercepted = true;
break; // 找到匹配的域名和文件,停止循环
}
}
}
if (isIntercepted) {
console.warn(`[TFJS Fetch Intercepted] Original: ${url}`);
console.warn(`[TFJS Fetch Intercepted] Redirecting to: ${newUrl}`);
if (input instanceof Request) {
input = new Request(newUrl, {
method: input.method,
headers: input.headers,
body: input.body,
referrer: input.referrer,
referrerPolicy: input.referrerPolicy,
mode: 'cors',
credentials: input.credentials,
cache: 'default',
redirect: 'follow',
integrity: undefined, // 移除 integrity 属性以避免校验失败
signal: input.signal,
});
} else {
input = newUrl;
}
}
return originalFetch(input, init).catch(error => {
console.error(`[TFJS Fetch Intercepted Error] Failed to load ${url} (redirected to ${newUrl || url || input}):`, error);
throw error;
});
};
// -------------------- 劫持 XMLHttpRequest API (备用安全网) --------------------
// 尽管 TF.js 主要用 fetch但安全起见保留 XHR 劫持
const originalXHR = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
const xhr = new originalXHR();
const originalOpen = xhr.open;
xhr.open = function(method, url, async = true, user = null, password = null) {
let newUrl = url;
let isIntercepted = false;
for (const domain of INTERCEPT_DOMAINS) {
if (url.startsWith(domain)) {
const fileNameMatch = url.match(/(model\.json|group1-shard\dof\d\.bin)/);
if (fileNameMatch) {
const fileName = fileNameMatch[0];
newUrl = MIRROR_SPECIFIC_FILENAME_PREFIX + fileName;
isIntercepted = true;
break;
}
}
}
if (isIntercepted) {
console.warn(`[TFJS XHR Intercepted] Original: ${url}`);
console.warn(`[TFJS XHR Intercepted] Redirecting to: ${newUrl}`);
url = newUrl; // 修改传入 open 的 URL
}
return originalOpen.apply(this, arguments);
};
for (const key in originalXHR) {
if (originalXHR.hasOwnProperty(key)) {
window.XMLHttpRequest[key] = originalXHR[key];
}
}
return xhr;
};
})();
</script>
<!-- 引入 TensorFlow.js 核心库 -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script>
<!-- 引入 pose-detection 库 (包含 MoveNet) -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/pose-detection@2.1.3/dist/pose-detection.min.js"></script>
<!-- 引入 KNN 分类器 -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@1.2.2/dist/knn-classifier.min.js"></script>
</head>
<body>
<h1>AI 姿态控制贪吃蛇</h1>
<div id="game-area">
<div id="video-feed-container">
<video id="videoFeed" autoplay muted playsinline></video>
<canvas id="poseCanvas"></canvas>
</div>
<div id="snake-canvas-container">
<canvas id="snakeCanvas"></canvas>
</div>
<div id="game-info-panel">
<div class="info-item"><span>得分:</span> <span id="score">0</span></div>
<div class="info-item"><span>姿态识别:</span> <span id="currentGesture">未识别</span></div>
<div class="info-item"><span>控制指令:</span> <span id="controlCommand">静止</span></div>
<div id="status-display">等待模型导入...</div>
<div class="gesture-indicator" id="gestureConfidence"></div>
<div class="control-buttons">
<button id="startBtn" disabled>开始游戏 (Enter)</button>
<input type="file" id="fileImporter" accept=".json" style="display: none;">
<button id="importModelBtn">导入模型</button>
</div>
<div class="instructions">
<h3>姿态控制说明</h3>
<ul>
<li><strong>向上:</strong> <span>双手举过头顶 (分类0)</span></li>
<li><strong>向下:</strong> <span>双手放在身体两侧或下垂 (分类1)</span></li>
<li><strong>向左:</strong> <span>左手平举 (分类2)</span></li>
<li><strong>向右:</strong> <span>右手平举 (分类3)</span></li>
<li><strong>静止:</strong> <span>无特定动作 (游戏开始前/结束后姿态)</span></li>
</ul>
<p style="font-size:0.8em;text-align:center;margin-top:10px;color:#ccc;">(确保在摄像头画面中能清晰识别全身)</p>
</div>
</div>
</div>
<!-- 游戏结束/开始 Overlay -->
<div id="gameOverOverlay" class="overlay">
<h2 id="overlayText">游戏结束!</h2>
<p>最终得分: <span id="finalScore">0</span></p>
<button id="restartBtn">重新开始 (Enter)</button>
</div>
<script>
// ==========================================================
// 全局变量和 DOM 引用
// ==========================================================
const videoElement = document.getElementById('videoFeed');
const poseCanvas = document.getElementById('poseCanvas');
const poseCtx = poseCanvas.getContext('2d');
const snakeCanvas = document.getElementById('snakeCanvas');
const snakeCtx = snakeCanvas.getContext('2d');
const scoreDisplay = document.getElementById('score');
const currentGestureDisplay = document.getElementById('currentGesture');
const controlCommandDisplay = document.getElementById('controlCommand');
const statusDisplay = document.getElementById('status-display');
const gestureConfidenceDisplay = document.getElementById('gestureConfidence');
const startBtn = document.getElementById('startBtn');
const importModelBtn = document.getElementById('importModelBtn');
const fileImporter = document.getElementById('fileImporter');
const gameOverOverlay = document.getElementById('gameOverOverlay');
const overlayText = document.getElementById('overlayText');
const finalScoreDisplay = document.getElementById('finalScore');
const restartBtn = document.getElementById('restartBtn');
let detector; // MoveNet 姿态检测器
let classifier; // KNN 分类器
let isPoseDetectionReady = false; // 姿态检测系统是否就绪
let isModelLoaded = false; // KNN 模型是否已加载
let animationFrameId; // 用于姿态检测的 requestAnimationFrame ID
let gameLoopId; // 用于贪吃蛇游戏循环的 setInterval ID
let gameStatus = 'initial'; // 'initial', 'loading', 'ready', 'playing', 'paused', 'gameOver'
let currentDetectedClassId = null; // 当前检测到的姿态分类ID
let currentConfidence = 0; // 当前检测到的置信度
// 用于映射姿态分类ID到游戏方向
// 请确保这些ID与您训练模型时使用的类别ID一致
const gestureClassToGameDirection = {
'0': 'UP', // 类别0 -> 向上
'1': 'DOWN', // 类别1 -> 向下
'2': 'LEFT', // 类别2 -> 向左
'3': 'RIGHT' // 类别3 -> 向右
};
// 用于显示给用户的类别名称加载模型时从JSON中读取
// 确保你的训练模型有这四个类别
let importedClassNames = {
'0': '举手',
'1': '下蹲',
'2': '左抬手',
'3': '右抬手'
};
let snakeDirection = 'RIGHT'; // 贪吃蛇当前方向
// ==========================================================
// 贪吃蛇游戏设置
// ==========================================================
const GRID_SIZE = 20; // 网格大小(像素)
const CANVAS_WIDTH = 600;
const CANVAS_HEIGHT = 400;
snakeCanvas.width = CANVAS_WIDTH;
snakeCanvas.height = CANVAS_HEIGHT;
let snake = [{ x: 10, y: 10 }]; // 蛇的身体 (以网格坐标表示)
let food = {}; // 食物位置
let score = 0;
let gameSpeed = 300; // 游戏速度(毫秒)
// ==========================================================
// 初始化函数
// ==========================================================
document.addEventListener('DOMContentLoaded', initApp);
async function initApp() {
updateGameStatus('initial'); // 初始状态设为 'initial'
statusDisplay.textContent = '正在加载 MoveNet 模型和摄像头...';
startBtn.disabled = true;
importModelBtn.disabled = true;
try {
// 初始化 KNN 分类器
classifier = knnClassifier.create();
// 初始化 MoveNet 检测器
const detectorConfig = { modelType: poseDetection.movenet.modelType.SINGLEPOSE_LIGHTNING };
detector = await poseDetection.createDetector(poseDetection.SupportedModels.MoveNet, detectorConfig);
// 设置摄像头
await setupCamera();
isPoseDetectionReady = true;
statusDisplay.textContent = 'MoveNet 和摄像头已就绪。'; // 状态更新
importModelBtn.disabled = false; // 启用导入按钮 (因为CDN加载可能失败需要手动导入)
// 启动姿态检测循环(只进行检测和绘制,不预测,直到模型导入)
startPoseDetectionLoop();
// 绑定按钮事件
startBtn.addEventListener('click', startGame);
importModelBtn.addEventListener('click', () => fileImporter.click());
fileImporter.addEventListener('change', handleModelImport);
restartBtn.addEventListener('click', resetGame);
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
// 确保只有当游戏处于“准备好”或“游戏结束”状态才能启动或重新开始
if (gameStatus === 'ready' || gameStatus === 'gameOver') {
// 如果是游戏结束状态且按钮可用,点击 restartBtn
if (gameStatus === 'gameOver' && !restartBtn.disabled) {
restartBtn.click();
} else if (gameStatus === 'ready' && !startBtn.disabled) {
// 如果是准备就绪状态且按钮可用,点击 startBtn
startBtn.click();
}
}
}
});
// --- 新增:尝试自动从 CDN 加载 KNN 模型数据 ---
// !!! 请替换为你的实际 CDN 模型 URL !!!
const cdnModelJsonUrl = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/models/pose-knn-model.json';
console.log(`尝试从 CDN 自动加载 KNN 模型数据: ${cdnModelJsonUrl}`);
statusDisplay.textContent = '正在尝试从 CDN 加载姿态识别模型...';
try {
await loadKNNModelData(null, cdnModelJsonUrl); // 传入 CDN URL, file 为 null
statusDisplay.textContent = 'CDN 姿态识别模型加载成功!可以开始游戏了。';
isModelLoaded = true; // 标记模型已加载
startBtn.disabled = false; // 启用开始游戏按钮
importModelBtn.disabled = true; // 自动加载成功后,禁用手动导入按钮
updateGameStatus('ready'); // 更新游戏状态
} catch (cdnError) {
console.warn('CDN KNN 模型数据自动加载失败:', cdnError);
statusDisplay.textContent = `CDN 模型加载失败: ${cdnError.message}。请手动导入模型。`;
isModelLoaded = false; // 标记模型未加载
startBtn.disabled = true; // 模型未加载,开始按钮仍禁用
importModelBtn.disabled = false; // 允许手动导入
updateGameStatus('initial'); // 保持初始状态
}
// --- 结束 CDN 自动加载 ---
updateGameUI(); // 确保 UI 在初始化结束时更新一次
} catch (error) {
console.error("应用初始化失败:", error);
statusDisplay.textContent = `初始化失败: ${error.message}`;
alert(`应用初始化失败: ${error.message}\n请检查摄像头权限、网络连接或刷新页面。`);
updateGameStatus('initial');
}
}
// ==========================================================
// 摄像头和姿态检测相关
// ==========================================================
async function setupCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: videoElement.width }, // 使用videoFeed的width/height
height: { ideal: videoElement.height },
facingMode: 'user'
}
});
videoElement.srcObject = stream;
return new Promise((resolve, reject) => {
videoElement.onloadedmetadata = () => {
videoElement.play().then(() => {
// 确保 poseCanvas 和 videoFeed 尺寸匹配
poseCanvas.width = videoElement.videoWidth;
poseCanvas.height = videoElement.videoHeight;
resolve();
}).catch(reject);
};
setTimeout(() => reject(new Error('摄像头元数据加载或播放超时')), 10000);
});
} catch (error) {
if (error.name === 'NotAllowedError') {
throw new Error('用户拒绝了摄像头权限。');
} else if (error.name === 'NotFoundError') {
throw new Error('未找到摄像头设备。');
} else {
throw error;
}
}
}
async function startPoseDetectionLoop() {
// 手动调整方向
window.addEventListener('keydown', (event) => {
let direction = null;
// 检查按下的键,并映射到对应的方向
switch (event.key.toUpperCase()) { // 转换为大写以确保大小写不敏感
case 'W':
direction = 'UP';
break;
case 'S':
direction = 'DOWN';
break;
case 'A':
direction = 'LEFT';
break;
case 'D':
direction = 'RIGHT';
break;
default:
// 如果按下的不是W S A D则不执行任何操作
return;
}
// 如果方向被设置即按下了W S A D之一
if (direction) {
// 阻止某些键如A和D可能导致的浏览器默认行为
// 这在游戏场景中非常重要,以确保键只用于游戏控制
event.preventDefault();
// 调用外部函数来改变蛇的方向
changeSnakeDirection(direction);
}
});
if (!isPoseDetectionReady) return;
async function detectAndPredict() {
try {
const poses = await detector.estimatePoses(videoElement, { flipHorizontal: true }); // 镜像摄像头
poseCtx.clearRect(0, 0, poseCanvas.width, poseCanvas.height);
if (poses && poses.length > 0) {
drawPose(poses[0]); // 绘制检测到的姿态
// 仅在游戏进行中且模型已加载时才预测并控制贪吃蛇
if (isModelLoaded && gameStatus === 'playing') {
const poseTensor = flattenPose(poses[0]);
if (classifier.getNumClasses() > 0) {
const prediction = await classifier.predictClass(poseTensor);
poseTensor.dispose(); // 释放张量内存
const predictedClassId = prediction.label;
const confidence = prediction.confidences[predictedClassId] || 0; // 确保有默认值
currentConfidence = (confidence * 100).toFixed(1);
// 设定一个置信度阈值,例如 70%
const MIN_PREDICT_CONFIDENCE = 70;
if (confidence * 100 > MIN_PREDICT_CONFIDENCE) {
currentDetectedClassId = predictedClassId;
const gameDirection = gestureClassToGameDirection[predictedClassId];
if (gameDirection) {
changeSnakeDirection(gameDirection); // 控制贪吃蛇移动
controlCommandDisplay.textContent = gameDirection;
} else {
controlCommandDisplay.textContent = '未知方向';
}
} else {
currentDetectedClassId = null; // 置信度不足,视为未识别
currentConfidence = 0;
controlCommandDisplay.textContent = '静止 (置信度不足)';
}
} else {
poseTensor.dispose(); // 确保张量被释放
currentDetectedClassId = null;
currentConfidence = 0;
controlCommandDisplay.textContent = '静止 (模型无数据)';
}
} else { // 如果游戏未进行或模型未加载,则不进行预测控制
currentDetectedClassId = null;
currentConfidence = 0;
controlCommandDisplay.textContent = '静止';
}
} else {
currentDetectedClassId = null;
currentConfidence = 0;
currentGestureDisplay.textContent = '未检测到姿态';
controlCommandDisplay.textContent = '静止';
}
updateGameUI(); // 更新显示
} catch (error) {
console.error('姿态检测或预测出错:', error);
statusDisplay.textContent = `检测错误: ${error.message}`;
// 如果错误持续发生,考虑停止循环或提供更多反馈
} finally {
animationFrameId = requestAnimationFrame(detectAndPredict);
}
}
animationFrameId = requestAnimationFrame(detectAndPredict); // 启动循环
}
// 展平姿态关键点并归一化
function flattenPose(pose) {
// 将所有关键点的 (x, y) 坐标展平为一个一维数组
// 并进行归一化 (除以视频的宽度和高度),使得特征独立于图像大小
const keypoints = pose.keypoints.map(p => [p.x / videoElement.videoWidth, p.y / videoElement.videoHeight]).flat();
return tf.tensor(keypoints); // 转换为 TensorFlow 张量
}
// 绘制 MoveNet 姿态骨骼图
function drawPose(pose) {
if (pose.keypoints) {
// 绘制关键点
for (const keypoint of pose.keypoints) {
if (keypoint.score > 0.3) { // 仅绘制置信度高的关键点
poseCtx.beginPath();
poseCtx.arc(keypoint.x, keypoint.y, 5, 0, 2 * Math.PI);
poseCtx.fillStyle = '#00f2fe'; // 浅蓝色
poseCtx.fill();
}
}
// 绘制骨骼连接线
const adjacentPairs = poseDetection.util.getAdjacentPairs(poseDetection.SupportedModels.MoveNet);
adjacentPairs.forEach(([i, j]) => {
const kp1 = pose.keypoints[i];
const kp2 = pose.keypoints[j];
if (kp1.score > 0.3 && kp2.score > 0.3) { // 仅连接置信度高的关键点
poseCtx.beginPath();
poseCtx.moveTo(kp1.x, kp1.y);
poseCtx.lineTo(kp2.x, kp2.y);
poseCtx.strokeStyle = '#a8c0ff'; // 紫蓝色
poseCtx.lineWidth = 2;
poseCtx.stroke();
}
});
}
}
// ==========================================================
// 模型导入导出 (改造为支持文件和 CDN URL)
// ==========================================================
/**
* 加载 KNN 模型数据,支持从文件或 CDN URL 加载。
* @param {File} [file] - 可选,用户选择的 KNN 模型 JSON 文件。
* @param {string} [cdnUrl] - 可选KNN 模型 JSON 文件的 CDN URL。
* @returns {Promise<void>}
*/
async function loadKNNModelData(file = null, cdnUrl = null) {
statusDisplay.textContent = '正在加载模型数据...';
startBtn.disabled = true; // 加载中禁用开始按钮
importModelBtn.disabled = true; // 加载中禁用导入按钮
try {
let loadedModelData;
if (file) {
const reader = new FileReader();
const fileReadPromise = new Promise((resolve, reject) => {
reader.onload = e => resolve(JSON.parse(e.target.result));
reader.onerror = error => reject(new Error('文件读取失败。'));
reader.readAsText(file);
});
loadedModelData = await fileReadPromise;
} else if (cdnUrl) {
const response = await fetch(cdnUrl);
if (!response.ok) {
throw new Error(`无法从 ${cdnUrl} 加载模型数据: ${response.statusText}`);
}
loadedModelData = await response.json();
} else {
throw new Error('未提供模型文件或 CDN URL。');
}
if (!loadedModelData || !loadedModelData.dataset || !loadedModelData.classMap) {
throw new Error('模型数据结构不正确 (缺少 classMap 或 dataset)。');
}
classifier.clearAllClasses();
const dataset = {};
let totalExamples = 0;
for (const classId in loadedModelData.dataset) {
const classData = loadedModelData.dataset[classId];
if (classData && classData.length > 0) {
if (classData.some(sample => !Array.isArray(sample) || sample.some(val => typeof val !== 'number'))) {
throw new Error(`类别 ${classId} 包含无效的样本数据 (不是数字数组)。`);
}
const tensors = classData.map(data => tf.tensor1d(data));
const stacked = tf.stack(tensors);
dataset[classId] = stacked;
totalExamples += classData.length;
tensors.forEach(t => t.dispose());
} else {
console.warn(`类别 ${classId} 没有样本数据。`);
}
}
classifier.setClassifierDataset(dataset);
importedClassNames = loadedModelData.classMap; // 更新类别名称映射
console.log(`模型加载成功!共加载 ${totalExamples} 个样本。`);
console.log('类别映射 (导入):', importedClassNames);
// 更新页面上的映射显示,如果需要的话。
// 确保 instructions 中的分类名称与 importedClassNames 保持一致
const instructionListItems = document.querySelectorAll('.instructions li');
instructionListItems.forEach((item, index) => {
const classId = String(index); // 假定指令顺序与分类ID一致
const classNameFromModel = importedClassNames[classId] || '未定义';
let controlDesc = '';
switch(classId) {
case '0': controlDesc = '双手举过头顶'; break;
case '1': controlDesc = '双手放在身体两侧或下垂'; break;
case '2': controlDesc = '左手平举'; break;
case '3': controlDesc = '右手平举'; break;
default: controlDesc = '未知动作';
}
item.querySelector('span').innerHTML = `${controlDesc} (<strong>分类${classId}</strong>: ${classNameFromModel})`;
// 如果你的 instructions 区域已经有预设的文字,并且你只希望更新 Classification ID
// 可以根据 HTML 结构微调这里的更新逻辑。
});
statusDisplay.textContent = '姿态模型导入成功!可以开始游戏了。';
isModelLoaded = true; // 设置模型已加载状态
startBtn.disabled = false; // 启用开始游戏按钮
importModelBtn.disabled = true; // 导入按钮禁用
updateGameStatus('ready');
} catch (error) {
console.error('模型加载失败:', error);
statusDisplay.textContent = `模型加载失败: ${error.message}`;
isModelLoaded = false;
startBtn.disabled = true; // 失败后保持禁用
importModelBtn.disabled = false; // 失败后可再次导入
updateGameStatus('initial');
throw error; // 重新抛出错误,以便调用者能捕获
} finally {
fileImporter.value = ''; // 清空文件输入
}
}
// 文件选择事件处理器 (现在它调用通用加载函数)
async function handleModelImport(event) {
const file = event.target.files[0];
if (!file) {
statusDisplay.textContent = '未选择文件。';
return;
}
statusDisplay.textContent = '正在从本地文件导入模型...';
startBtn.disabled = true; // 导入中禁用开始按钮
importModelBtn.disabled = true; // 导入中禁用导入按钮
try {
await loadKNNModelData(file, null); // 传入文件对象cdnUrl 为 null
} catch (error) {
// 错误信息已在 loadKNNModelData 内部处理并设置状态
alert(error.message); // 弹出错误提示
}
}
// ===================================
// 贪吃蛇游戏逻辑
// ===================================
function startGame() {
if (!isPoseDetectionReady) {
alert('姿态检测系统未准备就绪,请稍候。');
return;
}
if (!isModelLoaded) {
alert('请先导入姿态模型!');
return;
}
if (!classifier || classifier.getNumClasses() === 0) {
alert('已导入的模型中没有训练数据,请导入一个有效的模型文件。');
return;
}
gameOverOverlay.classList.remove('active');
updateGameStatus('playing');
resetGameState();
generateFood();
gameLoopId = setInterval(drawGame, gameSpeed); // 开始游戏循环
startBtn.disabled = true;
importModelBtn.disabled = true;
}
function resetGame() {
gameOverOverlay.classList.remove('active');
updateGameStatus('ready');
resetGameState(); // 重置游戏状态
snakeDirection = 'RIGHT'; // 重新开始时默认为向右
startBtn.disabled = false;
importModelBtn.disabled = true; // 模型加载后禁用导入按钮
updateGameUI();
}
function resetGameState() {
clearInterval(gameLoopId);
snake = [{ x: 10, y: 10 }]; // 蛇回到起始位置长度为1
food = {};
score = 0;
updateScoreDisplay();
}
function generateFood() {
const numCols = CANVAS_WIDTH / GRID_SIZE;
const numRows = CANVAS_HEIGHT / GRID_SIZE;
const boundaryPadding = 5; // 边界内5格不允许生成食物
// 计算食物生成的可允许范围
const minX = boundaryPadding;
const maxX = numCols - 1 - boundaryPadding; // -1 是因为索引从0开始
const minY = boundaryPadding;
const maxY = numRows - 1 - boundaryPadding;
// 确保有足够的空间生成食物
if (maxX < minX || maxY < minY) {
console.warn("游戏区域太小,无法满足边界限制!食物可能仍会在边界附近生成。");
// 在这种极端情况下,可以考虑放宽限制或结束游戏
// 为了当前问题,我们简单地继续,但开发者应知晓此情况
}
let newFoodX, newFoodY;
let validPositionFound = false;
let attempts = 0;
const MAX_ATTEMPTS = 100; // 防止无限循环
do {
// 在允许的范围内生成随机坐标
newFoodX = Math.floor(Math.random() * (maxX - minX + 1)) + minX;
newFoodY = Math.floor(Math.random() * (maxY - minY + 1)) + minY;
// 检查是否在蛇的身体上
validPositionFound = !isFoodOnSnake(newFoodX, newFoodY);
attempts++;
if (attempts > MAX_ATTEMPTS) {
console.warn("未能找到有效的食物生成位置,可能由于蛇太长或区域限制。尝试放宽限制。");
// 这里可以添加逻辑来放宽边界限制,或者简单地跳出循环(可能生成在蛇身上)
break;
}
} while (!validPositionFound);
food = {
x: newFoodX,
y: newFoodY
};
}
// 辅助函数:检查给定坐标是否在蛇的身体上
function isFoodOnSnake(x, y) {
for (let i = 0; i < snake.length; i++) {
if (x === snake[i].x && y === snake[i].y) {
return true;
}
}
return false;
}
function drawGame() {
// 清空 snakeCanvas
snakeCtx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// === 修改点 2绘制网格线 (增强视觉效果) ===
snakeCtx.strokeStyle = '#444'; // 深灰色网格线
snakeCtx.lineWidth = 0.5; // 网格线细一点
for (let x = 0; x < CANVAS_WIDTH; x += GRID_SIZE) {
snakeCtx.beginPath();
snakeCtx.moveTo(x, 0);
snakeCtx.lineTo(x, CANVAS_HEIGHT);
snakeCtx.stroke();
}
for (let y = 0; y < CANVAS_HEIGHT; y += GRID_SIZE) {
snakeCtx.beginPath();
snakeCtx.moveTo(0, y);
snakeCtx.lineTo(CANVAS_WIDTH, y);
snakeCtx.stroke();
}
// 绘制食物
snakeCtx.fillStyle = '#ff4444'; // 更鲜艳的红色
snakeCtx.fillRect(food.x * GRID_SIZE, food.y * GRID_SIZE, GRID_SIZE, GRID_SIZE);
snakeCtx.strokeStyle = '#ff0000'; // 食物边框
snakeCtx.lineWidth = 2; // 微粗边框
snakeCtx.strokeRect(food.x * GRID_SIZE, food.y * GRID_SIZE, GRID_SIZE, GRID_SIZE);
// 绘制蛇
for (let i = 0; i < snake.length; i++) {
snakeCtx.fillStyle = (i === 0) ? '#33ff33' : '#00cc00'; // 蛇头亮绿色,身体普通绿色
snakeCtx.fillRect(snake[i].x * GRID_SIZE, snake[i].y * GRID_SIZE, GRID_SIZE, GRID_SIZE);
// === 修改点 2增强蛇身的方格显示效果添加描边 ===
snakeCtx.strokeStyle = '#006600'; // 深绿色描边
snakeCtx.lineWidth = 1.5; // 描边宽度
snakeCtx.strokeRect(snake[i].x * GRID_SIZE, snake[i].y * GRID_SIZE, GRID_SIZE, GRID_SIZE);
}
// 移动蛇头
let headX = snake[0].x;
let headY = snake[0].y;
switch (snakeDirection) {
case 'LEFT': headX--; break;
case 'UP': headY--; break;
case 'RIGHT': headX++; break;
case 'DOWN': headY++; break;
}
// 碰撞检测
// 撞墙
if (headX < 0 || headX >= CANVAS_WIDTH / GRID_SIZE ||
headY < 0 || headY >= CANVAS_HEIGHT / GRID_SIZE) {
endGame();
return;
}
// 撞自己
for (let i = 1; i < snake.length; i++) {
if (headX === snake[i].x && headY === snake[i].y) {
endGame();
return;
}
}
// 吃到食物
if (headX === food.x && headY === food.y) {
score++;
updateScoreDisplay();
generateFood();
// 暂时不增加速度,保持稳定
} else {
// 如果没吃到食物,移除蛇尾,实现移动效果
snake.pop();
}
// 添加新蛇头
snake.unshift({ x: headX, y: headY });
}
function changeSnakeDirection(newDirection) {
// 防止蛇立即反向掉头
if (newDirection === 'LEFT' && snakeDirection !== 'RIGHT') snakeDirection = 'LEFT';
else if (newDirection === 'UP' && snakeDirection !== 'DOWN') snakeDirection = 'UP';
else if (newDirection === 'RIGHT' && snakeDirection !== 'LEFT') snakeDirection = 'RIGHT';
else if (newDirection === 'DOWN' && snakeDirection !== 'UP') snakeDirection = 'DOWN';
// console.log("Snake direction changed to:", snakeDirection); // 调试用
}
function endGame() {
updateGameStatus('gameOver');
clearInterval(gameLoopId);
overlayText.textContent = '游戏结束!';
finalScoreDisplay.textContent = score;
gameOverOverlay.classList.add('active');
startBtn.disabled = true; // 游戏结束后开始按钮不可用,直到点击重玩
importModelBtn.disabled = true; // 保持禁用
}
// ==========================================================
// UI 更新
// ==========================================================
function updateGameStatus(status) {
gameStatus = status;
// console.log("Game status updated to:", gameStatus); // 调试用
}
function updateScoreDisplay() {
scoreDisplay.textContent = score;
}
function updateGameUI() {
// 更新姿态识别显示
if (currentDetectedClassId !== null) {
const className = importedClassNames[currentDetectedClassId] || `未知类别 ${currentDetectedClassId}`;
currentGestureDisplay.textContent = `${className}`;
gestureConfidenceDisplay.textContent = `置信度: ${currentConfidence}%`;
gestureConfidenceDisplay.style.color = currentConfidence > 70 ? '#00ff00' : '#ffaa00';
} else {
currentGestureDisplay.textContent = '未识别';
gestureConfidenceDisplay.textContent = '';
}
// 根据游戏状态更新按钮和文本
if (gameStatus === 'initial') {
statusDisplay.textContent = '等待模型导入...';
startBtn.disabled = true;
importModelBtn.disabled = false;
restartBtn.disabled = true;
} else if (gameStatus === 'loading') {
// 状态文本已经在加载函数中设置
startBtn.disabled = true;
importModelBtn.disabled = true;
restartBtn.disabled = true;
} else if (gameStatus === 'ready') {
statusDisplay.textContent = '模型已加载点击“开始游戏”或按“Enter”键。';
startBtn.disabled = false;
importModelBtn.disabled = true;
restartBtn.disabled = true; // 只有在gameOver后才启用重新开始
} else if (gameStatus === 'playing') {
statusDisplay.textContent = '游戏进行中...';
startBtn.disabled = true;
importModelBtn.disabled = true;
restartBtn.disabled = true;
} else if (gameStatus === 'gameOver') {
statusDisplay.textContent = '游戏结束点击“重新开始”或按“Enter”键。';
startBtn.disabled = true;
importModelBtn.disabled = true;
restartBtn.disabled = false; // 游戏结束时启用重新开始
}
}
// --- 应用启动 ---
window.addEventListener('DOMContentLoaded', () => {
console.log('DOM content loaded. Initializing hand detection...');
initApp().catch(error => {
console.error('App initialization failed:', error);
// 错误信息已在 initApp 内部处理并alert
});
// 绑定导入模型事件
fileImporter.addEventListener('change', handleModelImport);
importModelBtn.addEventListener('click', () => {
fileImporter.click(); // 点击按钮触发文件输入
});
});
// 页面关闭时清理资源 (可选,但推荐)
window.onbeforeunload = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
if (gameLoopId) {
clearInterval(gameLoopId);
}
if (detector) {
// MediaPipe runtime 通常会管理其自己的 WebGL 资源,
// 明确调用 dispose() 可能会导致后续异常,如果 MediaPipe 内部未预期再次使用。
// 如果遇到问题,可以注释掉这行。
// detector.dispose();
}
if (classifier) {
classifier.clearAllClasses();
}
tf.disposeAll(); // 释放所有TensorFlow.js张量防止内存泄露
console.log('Resources cleaned up.');
};
</script>
</body>
</html>