861 lines
30 KiB
HTML
861 lines
30 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>Goood Space - AI 石头剪刀布</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
min-height: 100vh;
|
||
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #2d1b69 100%);
|
||
color: #ffffff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 2rem;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
/* Background Animation */
|
||
body::before {
|
||
content: '';
|
||
position: fixed;
|
||
width: 200%;
|
||
height: 200%;
|
||
top: -50%;
|
||
left: -50%;
|
||
background: radial-gradient(circle, rgba(0, 212, 255, 0.1) 0%, transparent 70%);
|
||
animation: rotate 30s linear infinite;
|
||
pointer-events: none;
|
||
z-index: -1;
|
||
}
|
||
|
||
@keyframes rotate {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
/* Header */
|
||
.header {
|
||
text-align: center;
|
||
margin-bottom: 3rem;
|
||
animation: fadeInDown 0.8s ease-out;
|
||
}
|
||
|
||
.brand-title {
|
||
font-size: 3.5rem;
|
||
background: linear-gradient(90deg, #00d4ff, #ff00ff, #00ff88);
|
||
background-clip: text;
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
text-shadow: 0 0 60px rgba(0, 212, 255, 0.5);
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.subtitle {
|
||
font-size: 1.5rem;
|
||
color: #a8c0ff;
|
||
text-shadow: 0 0 20px rgba(168, 192, 255, 0.5);
|
||
}
|
||
|
||
@keyframes fadeInDown {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-30px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
/* Main Container */
|
||
.main-container {
|
||
display: flex;
|
||
gap: 3rem;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
max-width: 1400px;
|
||
width: 100%;
|
||
}
|
||
|
||
/* Video Section */
|
||
.video-section {
|
||
position: relative;
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
|
||
border: 3px solid #00d4ff;
|
||
border-radius: 20px;
|
||
padding: 2rem;
|
||
backdrop-filter: blur(10px);
|
||
box-shadow: 0 0 30px rgba(0, 212, 255, 0.5);
|
||
}
|
||
|
||
#video-container {
|
||
position: relative;
|
||
width: 640px;
|
||
height: 480px;
|
||
border-radius: 15px;
|
||
overflow: hidden;
|
||
background: #000;
|
||
}
|
||
|
||
#videoFeed, #poseCanvas {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 15px;
|
||
}
|
||
|
||
#videoFeed {
|
||
z-index: 1;
|
||
}
|
||
|
||
#poseCanvas {
|
||
z-index: 2;
|
||
}
|
||
|
||
.status-bar {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
padding: 1rem;
|
||
text-align: center;
|
||
color: #00ff88;
|
||
border-bottom-left-radius: 15px;
|
||
border-bottom-right-radius: 15px;
|
||
z-index: 3;
|
||
}
|
||
|
||
/* --- Start: Countdown specific styles --- */
|
||
.countdown-overlay {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.85); /* Slightly darker for better contrast */
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
font-size: 8rem; /* Large font for countdown */
|
||
font-weight: bold;
|
||
color: #00d4ff;
|
||
text-shadow: 0 0 30px rgba(0, 212, 255, 0.8);
|
||
z-index: 5; /* Above video/canvas but below status bar if needed, adjusted based on existing z-index structure */
|
||
border-radius: 15px;
|
||
opacity: 0;
|
||
transition: opacity 0.3s ease-in-out;
|
||
pointer-events: none; /* Allow interaction with elements behind it when not visible */
|
||
}
|
||
|
||
.countdown-overlay.show {
|
||
opacity: 1;
|
||
/* When visible, it takes pointer events to block interaction with video elements */
|
||
pointer-events: auto;
|
||
}
|
||
/* --- End: Countdown specific styles --- */
|
||
|
||
|
||
/* Control Panel */
|
||
.control-panel {
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
|
||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 20px;
|
||
padding: 2rem;
|
||
backdrop-filter: blur(10px);
|
||
min-width: 350px;
|
||
}
|
||
|
||
.score-board {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 2rem;
|
||
margin-bottom: 2rem;
|
||
padding: 1.5rem;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
border-radius: 15px;
|
||
}
|
||
|
||
.score-item {
|
||
text-align: center;
|
||
}
|
||
|
||
.score-label {
|
||
display: block;
|
||
color: #a8c0ff;
|
||
margin-bottom: 0.5rem;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.score-value {
|
||
font-size: 3rem;
|
||
font-weight: bold;
|
||
color: #00ff88;
|
||
text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
|
||
}
|
||
|
||
.game-status {
|
||
background: rgba(0, 0, 0, 0.3);
|
||
border-radius: 15px;
|
||
padding: 1.5rem;
|
||
margin-bottom: 2rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.current-round {
|
||
font-size: 1.2rem;
|
||
color: #ff00ff;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.choices {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.choice-item {
|
||
text-align: center;
|
||
}
|
||
|
||
.choice-label {
|
||
display: block;
|
||
color: #a8c0ff;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.choice-value {
|
||
font-size: 2rem;
|
||
}
|
||
|
||
.result-text {
|
||
font-size: 1.5rem;
|
||
font-weight: bold;
|
||
padding: 1rem;
|
||
border-radius: 10px;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.result-win {
|
||
color: #00ff88;
|
||
background: rgba(0, 255, 136, 0.1);
|
||
border: 2px solid #00ff88;
|
||
}
|
||
|
||
.result-lose {
|
||
color: #ff4444;
|
||
background: rgba(255, 68, 68, 0.1);
|
||
border: 2px solid #ff4444;
|
||
}
|
||
|
||
.result-tie {
|
||
color: #ffaa00;
|
||
background: rgba(255, 170, 0, 0.1);
|
||
border: 2px solid #ffaa00;
|
||
}
|
||
|
||
.control-buttons {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
}
|
||
|
||
button {
|
||
background: linear-gradient(90deg, #00d4ff, rgba(255, 255, 255, 0.2));
|
||
border: none;
|
||
color: #ffffff;
|
||
padding: 1rem 2rem;
|
||
border-radius: 50px;
|
||
font-size: 1.2rem;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
box-shadow: 0 4px 20px rgba(0, 212, 255, 0.3);
|
||
}
|
||
|
||
button:hover:not(:disabled) {
|
||
transform: scale(1.05);
|
||
box-shadow: 0 6px 30px rgba(0, 212, 255, 0.5);
|
||
}
|
||
|
||
button:disabled {
|
||
background: #444;
|
||
color: #888;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
#startBtn {
|
||
background: linear-gradient(90deg, #00ff88, #00d4ff);
|
||
}
|
||
|
||
#resetBtn {
|
||
background: linear-gradient(90deg, #ff00ff, #00d4ff);
|
||
}
|
||
|
||
.instructions {
|
||
background: rgba(0, 0, 0, 0.3);
|
||
border-radius: 15px;
|
||
padding: 1.5rem;
|
||
margin-top: 2rem;
|
||
}
|
||
|
||
.instructions h3 {
|
||
color: #00d4ff;
|
||
margin-bottom: 1rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.instructions ul {
|
||
list-style: none;
|
||
padding: 0;
|
||
}
|
||
|
||
.instructions li {
|
||
padding: 0.5rem;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||
color: #e0f2f7;
|
||
}
|
||
|
||
.instructions li:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
/* Responsive Design */
|
||
@media (max-width: 1200px) {
|
||
.main-container {
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
#video-container {
|
||
width: 100%;
|
||
max-width: 640px;
|
||
height: auto;
|
||
aspect-ratio: 4/3;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.brand-title {
|
||
font-size: 2.5rem;
|
||
}
|
||
|
||
.subtitle {
|
||
font-size: 1.2rem;
|
||
}
|
||
|
||
.control-panel {
|
||
width: 100%;
|
||
min-width: unset;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<!-- TensorFlow.js 核心库 -->
|
||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script>
|
||
<!-- KNN 分类器 -->
|
||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@1.2.2/dist/knn-classifier.min.js"></script>
|
||
<!-- MediaPipe Hands -->
|
||
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands"></script>
|
||
<!-- Hand Pose Detection -->
|
||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/hand-pose-detection"></script>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1 class="brand-title">Goood Space</h1>
|
||
<p class="subtitle">AI 石头剪刀布对战</p>
|
||
</div>
|
||
|
||
<div class="main-container">
|
||
<div class="video-section">
|
||
<div id="video-container">
|
||
<video id="videoFeed" autoplay muted playsinline></video>
|
||
<canvas id="poseCanvas"></canvas>
|
||
<!-- START: Countdown Overlay Element -->
|
||
<div id="countdownOverlay" class="countdown-overlay"></div>
|
||
<!-- END: Countdown Overlay Element -->
|
||
<div class="status-bar" id="statusDisplay">正在初始化系统...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="control-panel">
|
||
<div class="score-board">
|
||
<div class="score-item">
|
||
<span class="score-label">你的得分</span>
|
||
<span class="score-value" id="userScore">0</span>
|
||
</div>
|
||
<div class="score-item">
|
||
<span class="score-label">AI得分</span>
|
||
<span class="score-value" id="aiScore">0</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="game-status" id="gameStatus">
|
||
<div class="current-round" id="roundInfo">准备开始游戏</div>
|
||
<div class="choices" id="choicesDisplay" style="display: none;">
|
||
<div class="choice-item">
|
||
<span class="choice-label">你的选择</span>
|
||
<span class="choice-value" id="userChoice">-</span>
|
||
</div>
|
||
<div class="choice-item">
|
||
<span class="choice-label">AI的选择</span>
|
||
<span class="choice-value" id="aiChoice">-</span>
|
||
</div>
|
||
</div>
|
||
<div id="resultDisplay"></div>
|
||
</div>
|
||
|
||
<div class="control-buttons">
|
||
<button id="startBtn" disabled>开始游戏</button>
|
||
<button id="resetBtn">重置游戏</button>
|
||
<button id="importModelBtn">导入模型</button>
|
||
<input type="file" id="fileImporter" accept=".json" style="display: none;">
|
||
</div>
|
||
|
||
<div class="instructions">
|
||
<h3>游戏说明</h3>
|
||
<ul>
|
||
<li>✊ 石头 - 握拳手势</li>
|
||
<li>✋ 布 - 张开手掌</li>
|
||
<li>✌️ 剪刀 - 比V手势</li>
|
||
<li>确保手部在摄像头画面中清晰可见</li>
|
||
<li>系统将自动识别您的手势并与AI对战</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// 全局变量
|
||
const videoElement = document.getElementById('videoFeed');
|
||
const poseCanvas = document.getElementById('poseCanvas');
|
||
const poseCtx = poseCanvas.getContext('2d');
|
||
const statusDisplay = document.getElementById('statusDisplay');
|
||
const userScoreDisplay = document.getElementById('userScore');
|
||
const aiScoreDisplay = document.getElementById('aiScore');
|
||
const userChoiceDisplay = document.getElementById('userChoice');
|
||
const aiChoiceDisplay = document.getElementById('aiChoice');
|
||
const roundInfo = document.getElementById('roundInfo');
|
||
const choicesDisplay = document.getElementById('choicesDisplay');
|
||
const resultDisplay = document.getElementById('resultDisplay');
|
||
const startBtn = document.getElementById('startBtn');
|
||
const resetBtn = document.getElementById('resetBtn');
|
||
const importModelBtn = document.getElementById('importModelBtn');
|
||
const fileImporter = document.getElementById('fileImporter');
|
||
// START: Add Countdown Overlay Element
|
||
const countdownOverlay = document.getElementById('countdownOverlay');
|
||
// END: Add Countdown Overlay Element
|
||
|
||
let detector;
|
||
let classifier;
|
||
let isHandDetectionReady = false;
|
||
let isModelLoaded = false;
|
||
let isPlaying = false;
|
||
let animationFrameId;
|
||
let scores = { user: 0, ai: 0 };
|
||
let currentRound = 0;
|
||
let lastPrediction = null;
|
||
let predictionCooldown = false;
|
||
// START: Add countdown state variable
|
||
let isCountingDown = false;
|
||
// END: Add countdown state variable
|
||
|
||
// 手势映射
|
||
const gestureMap = {
|
||
'0': { name: '石头', emoji: '✊' },
|
||
'1': { name: '剪刀', emoji: '✌️' },
|
||
'2': { name: '布', emoji: '✋' }
|
||
|
||
};
|
||
|
||
// MediaPipe Hands 连接点
|
||
const HAND_CONNECTIONS = [
|
||
[0, 1], [1, 2], [2, 3], [3, 4], // 大拇指
|
||
[0, 5], [5, 6], [6, 7], [7, 8], // 食指
|
||
[0, 9], [9, 10], [10, 11], [11, 12], // 中指
|
||
[0, 13], [13, 14], [14, 15], [15, 16], // 无名指
|
||
[0, 17], [17, 18], [18, 19], [19, 20], // 小指
|
||
[0, 5], [5, 9], [9, 13], [13, 17], [17, 0] // 手掌
|
||
];
|
||
|
||
// 初始化应用
|
||
async function initApp() {
|
||
updateStatus('正在初始化手势识别系统...');
|
||
|
||
try {
|
||
// 初始化 KNN 分类器
|
||
classifier = knnClassifier.create();
|
||
|
||
// 初始化手部检测器
|
||
const model = handPoseDetection.SupportedModels.MediaPipeHands;
|
||
const detectorConfig = {
|
||
runtime: 'mediapipe',
|
||
solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/hands'
|
||
};
|
||
detector = await handPoseDetection.createDetector(model, detectorConfig);
|
||
|
||
// 设置摄像头
|
||
await setupCamera();
|
||
isHandDetectionReady = true;
|
||
|
||
updateStatus('手部检测器已就绪,正在尝试加载模型...');
|
||
|
||
// 启动检测循环
|
||
startDetectionLoop();
|
||
|
||
// 尝试从 CDN 加载模型
|
||
try {
|
||
const cdnModelUrl = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/models/hand-knn-model- 2.json';
|
||
await loadKNNModelData(null, cdnModelUrl);
|
||
updateStatus('模型加载成功!可以开始游戏了');
|
||
isModelLoaded = true;
|
||
startBtn.disabled = false;
|
||
importModelBtn.disabled = true;
|
||
} catch (cdnError) {
|
||
console.warn('CDN 模型加载失败:', cdnError);
|
||
updateStatus('CDN 模型加载失败,请手动导入模型');
|
||
importModelBtn.disabled = false;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('初始化失败:', error);
|
||
updateStatus('初始化失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 设置摄像头
|
||
async function setupCamera() {
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
video: { width: 640, height: 480, facingMode: 'user' }
|
||
});
|
||
|
||
videoElement.srcObject = stream;
|
||
|
||
return new Promise((resolve) => {
|
||
videoElement.onloadedmetadata = () => {
|
||
videoElement.play();
|
||
poseCanvas.width = videoElement.videoWidth;
|
||
poseCanvas.height = videoElement.videoHeight;
|
||
resolve();
|
||
};
|
||
});
|
||
}
|
||
|
||
// 加载 KNN 模型
|
||
async function loadKNNModelData(file = null, cdnUrl = null) {
|
||
try {
|
||
let loadedModelData;
|
||
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
loadedModelData = await new Promise((resolve, reject) => {
|
||
reader.onload = e => resolve(JSON.parse(e.target.result));
|
||
reader.onerror = () => reject(new Error('文件读取失败'));
|
||
reader.readAsText(file);
|
||
});
|
||
} else if (cdnUrl) {
|
||
const response = await fetch(cdnUrl);
|
||
if (!response.ok) {
|
||
throw new Error(`无法从 CDN 加载模型: ${response.statusText}`);
|
||
}
|
||
loadedModelData = await response.json();
|
||
} else {
|
||
throw new Error('未提供模型文件或 URL');
|
||
}
|
||
|
||
if (!loadedModelData || !loadedModelData.dataset) {
|
||
throw new Error('模型数据格式不正确');
|
||
}
|
||
|
||
classifier.clearAllClasses();
|
||
|
||
const dataset = {};
|
||
for (const classId in loadedModelData.dataset) {
|
||
const classData = loadedModelData.dataset[classId];
|
||
if (classData && classData.length > 0) {
|
||
const tensors = classData.map(data => tf.tensor1d(data));
|
||
dataset[classId] = tf.stack(tensors);
|
||
tensors.forEach(t => t.dispose());
|
||
}
|
||
}
|
||
|
||
classifier.setClassifierDataset(dataset);
|
||
console.log('模型加载成功');
|
||
|
||
} catch (error) {
|
||
console.error('模型加载失败:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 检测循环
|
||
async function startDetectionLoop() {
|
||
if (!isHandDetectionReady) return;
|
||
|
||
async function detect() {
|
||
try {
|
||
const hands = await detector.estimateHands(videoElement, { flipHorizontal: false });
|
||
|
||
poseCtx.clearRect(0, 0, poseCanvas.width, poseCanvas.height);
|
||
|
||
if (hands && hands.length > 0) {
|
||
drawHand(hands[0]);
|
||
|
||
// START: Modify prediction condition to include isCountingDown
|
||
// Only make predictions if game is playing AND not in cooldown AND NOT counting down
|
||
if (isModelLoaded && isPlaying && !predictionCooldown && !isCountingDown) {
|
||
// END: Modify prediction condition to include isCountingDown
|
||
const handTensor = flattenHand(hands[0]);
|
||
|
||
if (classifier.getNumClasses() > 0) {
|
||
const prediction = await classifier.predictClass(handTensor);
|
||
handTensor.dispose();
|
||
|
||
const confidence = prediction.confidences[prediction.label];
|
||
if (confidence > 0.7) {
|
||
const predictedGesture = gestureMap[prediction.label];
|
||
if (predictedGesture && prediction.label !== lastPrediction) {
|
||
lastPrediction = prediction.label;
|
||
// ORIGINAL playRound call
|
||
playRound(predictedGesture);
|
||
predictionCooldown = true;
|
||
// START: Initiate countdown AFTER a round is played
|
||
startCountdownForNextRound();
|
||
// END: Initiate countdown AFTER a round is played
|
||
}
|
||
}
|
||
} else {
|
||
handTensor.dispose();
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('检测错误:', error);
|
||
}
|
||
|
||
animationFrameId = requestAnimationFrame(detect);
|
||
}
|
||
|
||
animationFrameId = requestAnimationFrame(detect);
|
||
}
|
||
|
||
// 展平手部数据
|
||
function flattenHand(hand) {
|
||
const keypoints = hand.keypoints.map(p => [
|
||
p.x / videoElement.videoWidth,
|
||
p.y / videoElement.videoHeight
|
||
]).flat();
|
||
return tf.tensor(keypoints);
|
||
}
|
||
|
||
// 绘制手部
|
||
function drawHand(hand) {
|
||
// 绘制连接线
|
||
poseCtx.strokeStyle = '#00d4ff';
|
||
poseCtx.lineWidth = 3;
|
||
poseCtx.shadowColor = '#00d4ff';
|
||
poseCtx.shadowBlur = 10;
|
||
|
||
for (const connection of HAND_CONNECTIONS) {
|
||
const start = hand.keypoints[connection[0]];
|
||
const end = hand.keypoints[connection[1]];
|
||
|
||
poseCtx.beginPath();
|
||
poseCtx.moveTo(start.x, start.y);
|
||
poseCtx.lineTo(end.x, end.y);
|
||
poseCtx.stroke();
|
||
}
|
||
|
||
// 绘制关键点
|
||
poseCtx.fillStyle = '#ff00ff';
|
||
poseCtx.shadowColor = '#ff00ff';
|
||
poseCtx.shadowBlur = 15;
|
||
|
||
for (const keypoint of hand.keypoints) {
|
||
poseCtx.beginPath();
|
||
poseCtx.arc(keypoint.x, keypoint.y, 6, 0, 2 * Math.PI);
|
||
poseCtx.fill();
|
||
}
|
||
|
||
poseCtx.shadowBlur = 0;
|
||
}
|
||
|
||
// 游戏逻辑
|
||
function playRound(userGesture) {
|
||
currentRound++; // IMPORTANT: Restore this line
|
||
|
||
// AI 随机选择
|
||
const aiOptions = Object.values(gestureMap);
|
||
const aiGesture = aiOptions[Math.floor(Math.random() * aiOptions.length)];
|
||
|
||
// 显示选择
|
||
userChoiceDisplay.textContent = userGesture.emoji + ' ' + userGesture.name;
|
||
aiChoiceDisplay.textContent = aiGesture.emoji + ' ' + aiGesture.name;
|
||
choicesDisplay.style.display = 'flex';
|
||
|
||
// 判断胜负
|
||
let result = '';
|
||
let resultClass = '';
|
||
|
||
if (userGesture.name === aiGesture.name) {
|
||
result = '平局!';
|
||
resultClass = 'result-tie';
|
||
} else if (
|
||
(userGesture.name === '石头' && aiGesture.name === '剪刀') ||
|
||
(userGesture.name === '布' && aiGesture.name === '石头') ||
|
||
(userGesture.name === '剪刀' && aiGesture.name === '布')
|
||
) {
|
||
result = '你赢了!';
|
||
resultClass = 'result-win';
|
||
scores.user++;
|
||
userScoreDisplay.textContent = scores.user;
|
||
} else {
|
||
result = 'AI赢了!';
|
||
resultClass = 'result-lose';
|
||
scores.ai++;
|
||
aiScoreDisplay.textContent = scores.ai;
|
||
}
|
||
|
||
// 显示结果
|
||
roundInfo.textContent = `第 ${currentRound} 回合`;
|
||
resultDisplay.innerHTML = `<div class="result-text ${resultClass}">${result}</div>`;
|
||
}
|
||
|
||
// START: New function for countdown before next round
|
||
function startCountdownForNextRound() {
|
||
if (!isPlaying) return; // Only run if game is active
|
||
|
||
isCountingDown = true; // Disable hand detection during countdown
|
||
countdownOverlay.textContent = ''; // Clear previous text
|
||
countdownOverlay.classList.add('show');
|
||
updateStatus('下一回合准备中...');
|
||
|
||
let count = 3;
|
||
const countdownInterval = setInterval(() => {
|
||
if (count > 0) {
|
||
countdownOverlay.textContent = count;
|
||
count--;
|
||
} else {
|
||
clearInterval(countdownInterval);
|
||
countdownOverlay.classList.remove('show');
|
||
countdownOverlay.textContent = ''; // Clear "GO!" text immediately
|
||
isCountingDown = false; // Enable hand detection again
|
||
predictionCooldown = false; // Allow new predictions
|
||
lastPrediction = null; // Reset last prediction to allow new gesture
|
||
updateStatus('游戏进行中 - 请做出手势');
|
||
|
||
// Clear previous choices and results for the new round
|
||
userChoiceDisplay.textContent = '-';
|
||
aiChoiceDisplay.textContent = '-';
|
||
resultDisplay.innerHTML = '';
|
||
choicesDisplay.style.display = 'flex'; // Ensure choices are visible for new round
|
||
}
|
||
}, 1000);
|
||
}
|
||
// END: New function for countdown
|
||
|
||
// 开始游戏 (Modified to initiate first round countdown)
|
||
function startGame() {
|
||
isPlaying = true;
|
||
currentRound = 0; // Initialize to 0, playRound will increment it to 1 for the first round.
|
||
|
||
// Clear choices and results display immediately when game starts
|
||
userChoiceDisplay.textContent = '-';
|
||
aiChoiceDisplay.textContent = '-';
|
||
resultDisplay.innerHTML = '';
|
||
choicesDisplay.style.display = 'flex'; // Ensure choices are visible when game starts
|
||
|
||
startBtn.textContent = '游戏中...';
|
||
startBtn.disabled = true;
|
||
resetBtn.disabled = false; // Enable reset once game starts
|
||
importModelBtn.disabled = true; // Disable import once game starts
|
||
|
||
// Start the very first countdown for the first round
|
||
startCountdownForNextRound();
|
||
// Note: statusDisplay will be updated by startCountdownForNextRound
|
||
}
|
||
|
||
// 重置游戏 (Modified to handle countdown state cleanup)
|
||
function resetGame() {
|
||
isPlaying = false;
|
||
// START: Clean up countdown state on reset
|
||
isCountingDown = false;
|
||
countdownOverlay.classList.remove('show');
|
||
countdownOverlay.textContent = '';
|
||
// END: Clean up countdown state on reset
|
||
|
||
scores = { user: 0, ai: 0 };
|
||
currentRound = 0;
|
||
userScoreDisplay.textContent = '0';
|
||
aiScoreDisplay.textContent = '0';
|
||
roundInfo.textContent = '准备开始游戏';
|
||
choicesDisplay.style.display = 'none';
|
||
resultDisplay.innerHTML = '';
|
||
startBtn.textContent = '开始游戏';
|
||
startBtn.disabled = !isModelLoaded;
|
||
resetBtn.disabled = false; // Already enabled, just ensuring
|
||
importModelBtn.disabled = false; // Enable import button again
|
||
predictionCooldown = false;
|
||
lastPrediction = null;
|
||
updateStatus(isModelLoaded ? '准备就绪' : '请导入模型');
|
||
}
|
||
|
||
// 更新状态
|
||
function updateStatus(message) {
|
||
statusDisplay.textContent = message;
|
||
}
|
||
|
||
// 事件监听
|
||
startBtn.addEventListener('click', startGame);
|
||
resetBtn.addEventListener('click', resetGame);
|
||
importModelBtn.addEventListener('click', () => fileImporter.click());
|
||
fileImporter.addEventListener('change', async (event) => {
|
||
const file = event.target.files[0];
|
||
if (file) {
|
||
try {
|
||
updateStatus('正在导入模型...');
|
||
await loadKNNModelData(file);
|
||
updateStatus('模型导入成功!');
|
||
isModelLoaded = true;
|
||
startBtn.disabled = false;
|
||
importModelBtn.disabled = true;
|
||
} catch (error) {
|
||
updateStatus('模型导入失败: ' + error.message);
|
||
}
|
||
}
|
||
fileImporter.value = '';
|
||
});
|
||
|
||
// 页面加载完成后初始化
|
||
window.addEventListener('DOMContentLoaded', initApp);
|
||
|
||
// 页面卸载时清理资源
|
||
window.addEventListener('beforeunload', () => {
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId);
|
||
}
|
||
if (detector) {
|
||
detector.dispose();
|
||
}
|
||
if (classifier) {
|
||
classifier.clearAllClasses();
|
||
}
|
||
tf.disposeAll();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|