861 lines
30 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>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>