874 lines
35 KiB
HTML
874 lines
35 KiB
HTML
<!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 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>
|
||
<!-- 引入 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>双手举过头顶</span></li>
|
||
<li><strong>向下:</strong> <span>双手放在身体两侧或下垂</span></li>
|
||
<li><strong>向左:</strong> <span>左手平举</span></li>
|
||
<li><strong>向右:</strong> <span>右手平举</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 = {};
|
||
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('loading');
|
||
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; // 启用导入按钮
|
||
|
||
// 启动姿态检测循环(只进行检测和绘制,不预测,直到模型导入)
|
||
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') {
|
||
startBtn.click(); // 模拟点击开始按钮
|
||
} else if (gameStatus === 'playing') {
|
||
// 可选:添加暂停功能
|
||
}
|
||
}
|
||
});
|
||
|
||
updateGameUI();
|
||
|
||
} catch (error) {
|
||
console.error("应用初始化失败:", error);
|
||
statusDisplay.textContent = `初始化失败: ${error.message}`;
|
||
alert(`应用初始化失败: ${error.message}\n请检查摄像头权限或网络连接。`);
|
||
}
|
||
}
|
||
|
||
// ==========================================================
|
||
// 摄像头和姿态检测相关
|
||
// ==========================================================
|
||
|
||
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() {
|
||
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];
|
||
currentConfidence = (confidence * 100).toFixed(1);
|
||
|
||
// 设定一个置信度阈值,例如 70%
|
||
if (confidence > 0.70) {
|
||
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 if (!isModelLoaded) {
|
||
currentDetectedClassId = null;
|
||
currentConfidence = 0;
|
||
controlCommandDisplay.textContent = '静止 (等待模型)';
|
||
} else if (gameStatus !== 'playing') {
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// ==========================================================
|
||
// 模型导入导出
|
||
// ==========================================================
|
||
|
||
async function handleModelImport(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
|
||
updateGameStatus('loading');
|
||
statusDisplay.textContent = '正在导入模型...';
|
||
startBtn.disabled = true;
|
||
importModelBtn.disabled = true;
|
||
|
||
try {
|
||
await loadModelFromFile(file);
|
||
statusDisplay.textContent = '姿态模型导入成功!';
|
||
isModelLoaded = true;
|
||
startBtn.disabled = false; // 启用开始按钮
|
||
importModelBtn.disabled = true; // 禁用导入按钮
|
||
updateGameStatus('ready');
|
||
} catch (error) {
|
||
console.error('模型导入失败:', error);
|
||
statusDisplay.textContent = `模型导入失败: ${error.message}`;
|
||
alert(`模型导入失败: ${error.message}\n请确保文件是正确的模型JSON文件。`);
|
||
startBtn.disabled = true; // 导入失败则不能开始
|
||
importModelBtn.disabled = false; // 可以再试一次导入
|
||
isModelLoaded = false;
|
||
updateGameStatus('initial');
|
||
} finally {
|
||
fileImporter.value = ''; // 清空文件输入
|
||
}
|
||
}
|
||
|
||
async function loadModelFromFile(file) {
|
||
// 这段逻辑与您之前在 script.js 中 `importModel` 的核心逻辑一致
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = async (e) => {
|
||
try {
|
||
const loadedModelData = JSON.parse(e.target.result);
|
||
|
||
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);
|
||
resolve();
|
||
|
||
} catch (error) {
|
||
reject(error);
|
||
}
|
||
};
|
||
reader.onerror = (error) => {
|
||
reject(new Error('文件读取失败。'));
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
}
|
||
|
||
// ===================================
|
||
// 贪吃蛇游戏逻辑
|
||
// ===================================
|
||
|
||
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;
|
||
|
||
// 循环直到找到一个不在蛇身体上的食物位置
|
||
let newFoodX, newFoodY;
|
||
do {
|
||
newFoodX = Math.floor(Math.random() * numCols);
|
||
newFoodY = Math.floor(Math.random() * numRows);
|
||
} while (isFoodOnSnake(newFoodX, newFoodY)); // 检查是否与蛇重叠
|
||
|
||
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';
|
||
}
|
||
|
||
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} (${currentConfidence}%)`;
|
||
gestureConfidenceDisplay.textContent = `置信度: ${currentConfidence}%`;
|
||
gestureConfidenceDisplay.style.color = currentConfidence > 70 ? '#00ff00' : '#ffaa00';
|
||
} else {
|
||
currentGestureDisplay.textContent = '未识别';
|
||
gestureConfidenceDisplay.textContent = '';
|
||
}
|
||
|
||
// 更新状态信息
|
||
if (gameStatus === 'initial') {
|
||
statusDisplay.textContent = '等待模型导入...';
|
||
} else if (gameStatus === 'loading') {
|
||
// 状态文本已经在 initApp 或 handleModelImport 中设置
|
||
} else if (gameStatus === 'ready') {
|
||
statusDisplay.textContent = '模型已加载,点击“开始游戏”或按“Enter”键。';
|
||
} else if (gameStatus === 'playing') {
|
||
statusDisplay.textContent = '游戏进行中...';
|
||
} else if (gameStatus === 'gameOver') {
|
||
statusDisplay.textContent = '游戏结束!点击“重新开始”或按“Enter”键。';
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|