2025-08-26 09:47:04 +08:00

768 lines
26 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;
}
/* 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>
<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');
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;
// 手势映射
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]);
if (isModelLoaded && isPlaying && !predictionCooldown) {
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;
playRound(predictedGesture);
predictionCooldown = true;
setTimeout(() => {
predictionCooldown = false;
lastPrediction = null;
}, 2000);
}
}
} 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++;
// 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>`;
}
// 开始游戏
function startGame() {
isPlaying = true;
currentRound = 0;
roundInfo.textContent = '游戏进行中...';
choicesDisplay.style.display = 'none';
resultDisplay.innerHTML = '';
startBtn.textContent = '游戏中...';
startBtn.disabled = true;
updateStatus('游戏进行中 - 请做出手势');
}
// 重置游戏
function resetGame() {
isPlaying = false;
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;
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>