魔法画板 · 灵蝶渐变光晕 | 隔空手势绘形
by GoldenTiger20702 lines28.0 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>魔法画板 · 灵蝶渐变光晕 | 隔空手势绘形</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
body {
background: #0a0a2a;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family: 'Segoe UI', 'Poppins', system-ui, sans-serif;
padding: 20px;
}
.magic-container {
background: #000000;
border-radius: 2rem;
padding: 1rem;
box-shadow: 0 25px 40px rgba(0,0,0,0.5), 0 0 0 2px rgba(255,255,255,0.1);
}
canvas {
display: block;
width: 100%;
height: auto;
border-radius: 1rem;
cursor: pointer;
box-shadow: 0 0 30px rgba(0,255,255,0.2);
}
.info-panel {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
border-radius: 2rem;
padding: 0.8rem 1.5rem;
border: 1px solid rgba(255,255,255,0.2);
}
.gesture-badge {
display: flex;
gap: 1rem;
background: #1a1a2e;
padding: 0.4rem 1rem;
border-radius: 2rem;
font-weight: bold;
}
.gesture-item {
display: flex;
align-items: center;
gap: 8px;
color: white;
font-size: 0.8rem;
}
.color-box {
width: 24px;
height: 24px;
border-radius: 8px;
border: 1px solid white;
}
.status {
font-family: monospace;
font-size: 0.9rem;
background: black;
padding: 0.3rem 1rem;
border-radius: 2rem;
color: cyan;
letter-spacing: 1px;
font-weight: bold;
}
.control-group {
display: flex;
align-items: center;
gap: 12px;
background: #1a1a2e;
padding: 0.3rem 1rem;
border-radius: 2rem;
}
.control-group label {
color: white;
font-size: 0.8rem;
}
input[type="range"] {
width: 140px;
cursor: pointer;
background: #333;
height: 3px;
border-radius: 3px;
}
.intensity-value {
color: #ffaa66;
font-weight: bold;
min-width: 35px;
}
button {
background: #2c2c3e;
border: none;
color: white;
padding: 6px 14px;
border-radius: 2rem;
font-weight: bold;
cursor: pointer;
transition: 0.2s;
font-family: inherit;
}
button:hover {
background: #ff4d6d;
box-shadow: 0 0 8px #ff4d6d;
transform: scale(0.96);
}
.footer {
font-size: 0.7rem;
color: #aaa;
text-align: center;
margin-top: 0.8rem;
}
.error-box {
color: #ffaaaa;
background: rgba(30,0,0,0.9);
padding: 10px 12px;
border-radius: 16px;
margin-top: 12px;
font-size: 0.75rem;
text-align: center;
border-left: 4px solid #ff5555;
backdrop-filter: blur(4px);
}
.retry-btn {
background: #ff884d;
margin-left: 12px;
padding: 4px 12px;
font-size: 0.75rem;
}
@media (max-width: 700px) {
.gesture-item span { display: none; }
.gesture-item { font-size: 0.7rem; }
.info-panel { padding: 0.6rem 1rem; }
.control-group label { font-size: 0.7rem; }
input[type="range"] { width: 100px; }
}
</style>
</head>
<body>
<div>
<div class="magic-container">
<canvas id="magicCanvas" width="800" height="600" style="width:100%; height:auto; max-width:1000px; aspect-ratio:800/600"></canvas>
<div class="info-panel">
<div class="gesture-badge">
<div class="gesture-item"><div class="color-box" style="background:#ff3333;"></div><span>1指 → 方 + 红蝶</span></div>
<div class="gesture-item"><div class="color-box" style="background:#33ff33;"></div><span>2指 → 圆 + 绿蝶</span></div>
<div class="gesture-item"><div class="color-box" style="background:#3399ff;"></div><span>3指 → 三角 + 蓝蝶</span></div>
</div>
<div class="control-group">
<label>✨ 光晕强度</label>
<input type="range" id="glowIntensitySlider" min="0" max="5" step="0.1" value="2.5">
<span id="intensityValue" class="intensity-value">2.5</span>
</div>
<div class="status" id="gestureStatus">🔍 正在请求摄像头...</div>
<button id="clearBtn">🧹 清空画板</button>
</div>
<div class="footer">
🦋 渐变光晕:中心亮色 → 边缘透明 | 滑块调节整体强度 | 点击画板固定图形
</div>
<div id="errorContainer" style="min-height: 65px;"></div>
</div>
</div>
<!-- MediaPipe 核心库 -->
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
<script>
(function(){
// ---------- DOM 元素 ----------
const canvas = document.getElementById('magicCanvas');
const ctx = canvas.getContext('2d');
const gestureStatusDiv = document.getElementById('gestureStatus');
const clearBtn = document.getElementById('clearBtn');
const errorContainer = document.getElementById('errorContainer');
const intensitySlider = document.getElementById('glowIntensitySlider');
const intensitySpan = document.getElementById('intensityValue');
canvas.width = 800;
canvas.height = 600;
// 全局强度 (0-5)
let globalGlowIntensity = 2.5;
intensitySlider.addEventListener('input', (e) => {
globalGlowIntensity = parseFloat(e.target.value);
intensitySpan.innerText = globalGlowIntensity.toFixed(1);
});
// ---------- 全局状态 ----------
let hands = null;
let cameraInstance = null;
let currentFingerCount = 0;
let handDetected = false;
let handCenter = { x: canvas.width/2, y: canvas.height/2 };
let shapesHistory = [];
let currentTempShape = null;
const COLOR_MAP = {
1: '#ff3333',
2: '#33ff33',
3: '#3399ff'
};
// ---------- 蝴蝶特效 (径向渐变光晕) ----------
let butterflies = [];
const MAX_BUTTERFLIES = 45;
let lastGenerateTime = 0;
const GENERATE_INTERVAL = 500;
const BUTTERFLIES_PER_GEN = 2;
class Butterfly {
constructor(x, y, colorType) {
this.x = x;
this.y = y;
this.vx = (Math.random() - 0.5) * 2.2;
this.vy = -1.5 - Math.random() * 1.5;
this.size = 12 + Math.random() * 6;
this.colorType = colorType;
this.wingAngle = 0;
this.flapSpeed = 0.15 + Math.random() * 0.1;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.vx *= 0.99;
this.vy += 0.05;
this.wingAngle += this.flapSpeed;
if (this.x + 80 < 0 || this.x - 80 > canvas.width || this.y + 80 < 0 || this.y - 80 > canvas.height) {
return false;
}
return true;
}
// 获取渐变所需的颜色 (中心饱和色,边缘淡色)
getGradientColors() {
if (this.colorType === 'red') {
return { center: '#ff4444', edge: '#ff8888' };
} else if (this.colorType === 'green') {
return { center: '#44ff44', edge: '#88ff88' };
} else {
return { center: '#4488ff', edge: '#88aaff' };
}
}
draw(ctx) {
const intensity = globalGlowIntensity;
const { center, edge } = this.getGradientColors();
const baseSize = this.size;
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(Math.sin(this.wingAngle) * 0.5);
// 1. 绘制径向渐变光晕 (从中心饱和色到边缘淡色,透明度也渐变)
// 最大半径设定为 2.5 倍翅膀尺寸,确保羽化充分
const maxRadius = baseSize * 2.5;
const gradient = ctx.createRadialGradient(0, 0, baseSize * 0.3, 0, 0, maxRadius);
// 根据强度调整颜色透明度:强度越高,颜色越不透明
const alphaCenter = Math.min(0.9, intensity * 0.2);
const alphaEdge = 0;
gradient.addColorStop(0, center);
gradient.addColorStop(0.4, edge);
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = Math.min(0.7, intensity * 0.15);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(0, 0, maxRadius, 0, Math.PI * 2);
ctx.fill();
// 2. 额外增加一层柔和的光晕阴影 (模拟发光,增强渐变感)
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.arc(0, 0, baseSize * 1.2, 0, Math.PI * 2);
ctx.fillStyle = center;
ctx.shadowBlur = intensity * 1.5;
ctx.shadowColor = center;
ctx.fill();
// 重置阴影和透明度
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
// 绘制实体翅膀 (颜色保持鲜艳)
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(-baseSize, -baseSize*0.7, -baseSize*0.8, -baseSize*0.2);
ctx.quadraticCurveTo(-baseSize*0.3, -baseSize*0.1, 0, 0);
ctx.fillStyle = this.colorType === 'red' ? '#e34234' : (this.colorType === 'green' ? '#3cba54' : '#4285f4');
ctx.fill();
ctx.fillStyle = this.colorType === 'red' ? '#ff7f6e' : (this.colorType === 'green' ? '#7ed37e' : '#7baaf7');
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(-baseSize*0.6, -baseSize*0.5, -baseSize*0.4, -baseSize*0.15);
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(baseSize, -baseSize*0.7, baseSize*0.8, -baseSize*0.2);
ctx.quadraticCurveTo(baseSize*0.3, -baseSize*0.1, 0, 0);
ctx.fillStyle = this.colorType === 'red' ? '#e34234' : (this.colorType === 'green' ? '#3cba54' : '#4285f4');
ctx.fill();
ctx.fillStyle = this.colorType === 'red' ? '#ff7f6e' : (this.colorType === 'green' ? '#7ed37e' : '#7baaf7');
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(baseSize*0.6, -baseSize*0.5, baseSize*0.4, -baseSize*0.15);
ctx.fill();
// 身体
ctx.fillStyle = '#2c2c2c';
ctx.beginPath();
ctx.ellipse(0, 2, 2, 4, 0, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#f0c0a0';
ctx.beginPath();
ctx.ellipse(0, 0, 1.5, 2.5, 0, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
}
function generateButterfliesFromHand(count, centerX, centerY) {
if (count !== 1 && count !== 2 && count !== 3) return;
let colorType = '';
if (count === 1) colorType = 'red';
else if (count === 2) colorType = 'green';
else colorType = 'blue';
for (let i = 0; i < BUTTERFLIES_PER_GEN; i++) {
if (butterflies.length >= MAX_BUTTERFLIES) butterflies.splice(0, 5);
const offsetX = (Math.random() - 0.5) * 55;
const offsetY = (Math.random() - 0.5) * 45 - 20;
let x = centerX + offsetX;
let y = centerY + offsetY;
x = Math.min(canvas.width - 15, Math.max(15, x));
y = Math.min(canvas.height - 15, Math.max(15, y));
butterflies.push(new Butterfly(x, y, colorType));
}
}
function updateButterflies() {
for (let i = butterflies.length-1; i >= 0; i--) {
if (!butterflies[i].update()) butterflies.splice(i,1);
}
}
function drawButterflies() {
for (let b of butterflies) b.draw(ctx);
}
function generateButterfliesByTimer(now) {
if (!handDetected) return;
if (currentFingerCount === 1 || currentFingerCount === 2 || currentFingerCount === 3) {
if (!lastGenerateTime) lastGenerateTime = now;
if (now - lastGenerateTime >= GENERATE_INTERVAL) {
generateButterfliesFromHand(currentFingerCount, handCenter.x, handCenter.y);
lastGenerateTime = now;
}
} else {
lastGenerateTime = now;
}
}
// ---------- 手势识别 ----------
const fingerTips = [8, 12, 16, 20];
const fingerMCPs = [6, 10, 14, 18];
function countRaisedFingers(landmarks) {
if (!landmarks) return 0;
let count = 0;
const threshold = 0.02;
for (let i = 0; i < fingerTips.length; i++) {
const tip = landmarks[fingerTips[i]];
const base = landmarks[fingerMCPs[i]];
if (tip && base && (tip.y < base.y - threshold)) count++;
}
return count;
}
function getHandCenterPos(landmarks, canvasW, canvasH) {
if (!landmarks || landmarks.length === 0) return null;
let sumX = 0, sumY = 0;
for (let lm of landmarks) {
sumX += lm.x;
sumY += lm.y;
}
return {
x: (sumX / landmarks.length) * canvasW,
y: (sumY / landmarks.length) * canvasH
};
}
function getDynamicSize(landmarks, canvasW, canvasH) {
if (!landmarks || landmarks.length === 0) return 70;
let minX = 1, maxX = 0, minY = 1, maxY = 0;
for (let lm of landmarks) {
minX = Math.min(minX, lm.x);
maxX = Math.max(maxX, lm.x);
minY = Math.min(minY, lm.y);
maxY = Math.max(maxY, lm.y);
}
const handSizeNorm = Math.max(maxX - minX, maxY - minY);
let size = Math.floor(handSizeNorm * 180);
return Math.min(115, Math.max(45, size));
}
// 图形绘制
function drawShape(ctx, type, x, y, size, colorHex) {
if (!ctx) return;
const half = size / 2;
ctx.save();
ctx.shadowBlur = 0;
ctx.beginPath();
if (type === 'square') {
ctx.fillStyle = colorHex;
ctx.fillRect(x - half, y - half, size, size);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2.5;
ctx.strokeRect(x - half, y - half, size, size);
}
else if (type === 'circle') {
ctx.fillStyle = colorHex;
ctx.arc(x, y, half, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2.5;
ctx.arc(x, y, half, 0, Math.PI * 2);
ctx.stroke();
}
else if (type === 'triangle') {
const r = half;
const angleOffset = -Math.PI / 2;
const points = [];
for (let i = 0; i < 3; i++) {
const angle = angleOffset + i * 2 * Math.PI / 3;
points.push({ x: x + r * Math.cos(angle), y: y + r * Math.sin(angle) });
}
ctx.fillStyle = colorHex;
ctx.moveTo(points[0].x, points[0].y);
ctx.lineTo(points[1].x, points[1].y);
ctx.lineTo(points[2].x, points[2].y);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2.5;
ctx.stroke();
}
ctx.restore();
}
function renderEverything() {
if (!ctx) return;
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let shape of shapesHistory) {
drawShape(ctx, shape.type, shape.x, shape.y, shape.size, shape.color);
}
if (currentTempShape && currentTempShape.type) {
ctx.save();
ctx.shadowBlur = 10;
ctx.shadowColor = currentTempShape.color;
drawShape(ctx, currentTempShape.type, currentTempShape.x, currentTempShape.y, currentTempShape.size, currentTempShape.color);
ctx.restore();
}
drawButterflies();
if (shapesHistory.length === 0 && !currentTempShape) {
ctx.font = "15px 'Segoe UI'";
ctx.fillStyle = "#6a6a6a";
ctx.shadowBlur = 0;
ctx.fillText("🤚 伸出手指 (1~3根) → 魔法图形 + 渐变光晕灵蝶", canvas.width/2-190, canvas.height-35);
ctx.fillText("✨ 点击画板固定图形 | 滑块调节整体强度 ✨", canvas.width/2-170, canvas.height-15);
}
if (handDetected) {
ctx.font = "bold 20px monospace";
ctx.fillStyle = "#aaffdd";
ctx.fillText(`✋ ${currentFingerCount} 指`, 20, 50);
} else {
ctx.font = "15px monospace";
ctx.fillStyle = "#555";
ctx.fillText("🖐️ 等待手部识别", 20, 50);
}
}
// MediaPipe 回调
function onHandResults(results) {
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
handDetected = true;
const landmarks = results.multiHandLandmarks[0];
currentFingerCount = countRaisedFingers(landmarks);
const center = getHandCenterPos(landmarks, canvas.width, canvas.height);
if (center) handCenter = center;
if (currentFingerCount === 1) gestureStatusDiv.innerText = '🔴 方形 + 红蝶(渐变光) · 1指';
else if (currentFingerCount === 2) gestureStatusDiv.innerText = '🟢 圆形 + 绿蝶(渐变光) · 2指';
else if (currentFingerCount === 3) gestureStatusDiv.innerText = '🔵 三角 + 蓝蝶(渐变光) · 3指';
else if (currentFingerCount === 0) gestureStatusDiv.innerText = '🖐️ 握拳 · 无图形';
else gestureStatusDiv.innerText = `✨ ${currentFingerCount}指 (仅1~3特效) ✨`;
const dynSize = getDynamicSize(landmarks, canvas.width, canvas.height);
if (center && (currentFingerCount === 1 || currentFingerCount === 2 || currentFingerCount === 3)) {
let shapeType = null, colorHex = null;
if (currentFingerCount === 1) { shapeType = 'square'; colorHex = COLOR_MAP[1]; }
else if (currentFingerCount === 2) { shapeType = 'circle'; colorHex = COLOR_MAP[2]; }
else if (currentFingerCount === 3) { shapeType = 'triangle'; colorHex = COLOR_MAP[3]; }
if (shapeType && colorHex) {
currentTempShape = { type: shapeType, x: center.x, y: center.y, size: dynSize, color: colorHex };
} else currentTempShape = null;
} else {
currentTempShape = null;
}
} else {
handDetected = false;
currentFingerCount = 0;
currentTempShape = null;
gestureStatusDiv.innerText = '🤚 未检测到手部 · 伸出手试试';
}
}
// 摄像头初始化
async function ensureCameraAndStart() {
errorContainer.innerHTML = '';
gestureStatusDiv.innerText = '📷 请求摄像头权限...';
let stream = null;
try {
stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach(track => track.stop());
} catch (err) {
let errorMsg = '';
if (err.name === 'NotAllowedError') errorMsg = '❌ 摄像头权限被拒绝。请允许摄像头访问后刷新页面。';
else if (err.name === 'NotFoundError') errorMsg = '❌ 未检测到摄像头设备。';
else if (err.name === 'NotReadableError') errorMsg = '❌ 摄像头被其他应用占用。';
else errorMsg = `❌ 无法访问摄像头: ${err.message}`;
showPermanentError(errorMsg);
gestureStatusDiv.innerText = '⚠️ 摄像头不可用';
return false;
}
gestureStatusDiv.innerText = '🔄 加载手势模型...';
try {
hands = new Hands({
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
});
hands.setOptions({
maxNumHands: 1,
modelComplexity: 1,
minDetectionConfidence: 0.6,
minTrackingConfidence: 0.5
});
hands.onResults(onHandResults);
} catch (err) {
showPermanentError('手势模型加载失败: ' + err.message);
return false;
}
const videoElement = document.createElement('video');
videoElement.style.display = 'none';
document.body.appendChild(videoElement);
try {
cameraInstance = new Camera(videoElement, {
onFrame: async () => {
if (hands && videoElement.readyState >= 2) {
try { await hands.send({ image: videoElement }); } catch(e) {}
}
},
width: 800,
height: 600
});
await cameraInstance.start();
gestureStatusDiv.innerText = '🎭 魔法画板激活 | 光晕径向渐变,中心亮边缘淡';
errorContainer.innerHTML = '';
return true;
} catch (err) {
showPermanentError('摄像头启动失败: ' + (err.message || '未知错误'));
gestureStatusDiv.innerText = '⚠️ 摄像头启动失败';
return false;
}
}
function showPermanentError(msg) {
errorContainer.innerHTML = `
<div class="error-box">
⚠️ ${msg}<br>
💡 解决建议:<br>
1️⃣ 点击地址栏锁形图标 → 允许摄像头 → 刷新页面<br>
2️⃣ 使用 HTTPS 或 localhost 访问<br>
3️⃣ 检查摄像头占用情况<br>
<button id="manualRetryBtn" class="retry-btn">🔄 手动重试</button>
</div>
`;
const retryBtn = document.getElementById('manualRetryBtn');
if (retryBtn) retryBtn.addEventListener('click', () => { location.reload(); });
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ff8888';
ctx.font = '14px monospace';
ctx.fillText('摄像头未就绪,请授权并刷新', canvas.width/2-150, canvas.height/2);
}
function commitCurrentShape() {
if (currentTempShape && currentTempShape.type) {
shapesHistory.push({ ...currentTempShape });
currentTempShape = null;
return true;
}
return false;
}
function clearCanvas() {
shapesHistory = [];
currentTempShape = null;
gestureStatusDiv.innerText = '✨ 画板已清空 ✨';
setTimeout(() => {
if (handDetected) {
let shape = '';
if (currentFingerCount === 1) shape = '方形';
else if (currentFingerCount === 2) shape = '圆形';
else if (currentFingerCount === 3) shape = '三角形';
else shape = '手势';
gestureStatusDiv.innerText = `🎨 ${currentFingerCount}指 → ${shape}`;
} else gestureStatusDiv.innerText = '✨ 魔法画板待机 ✨';
}, 800);
}
// 动画循环
function animationFrame(now) {
requestAnimationFrame(animationFrame);
updateButterflies();
generateButterfliesByTimer(now);
renderEverything();
}
function setupInteractions() {
canvas.addEventListener('click', () => {
if (commitCurrentShape()) {
gestureStatusDiv.innerText = '✨ 图形已固定 ✨';
setTimeout(() => {
if (handDetected) {
let shape = '';
if (currentFingerCount === 1) shape = '方形';
else if (currentFingerCount === 2) shape = '圆形';
else if (currentFingerCount === 3) shape = '三角形';
else shape = '手势';
gestureStatusDiv.innerText = `🎨 ${currentFingerCount}指 → ${shape}`;
} else gestureStatusDiv.innerText = '🪄 点击固定,继续创作';
}, 700);
} else {
gestureStatusDiv.innerText = '🤚 先伸出1~3根手指再点击固定';
setTimeout(() => {
if (handDetected) gestureStatusDiv.innerText = `🎨 ${currentFingerCount}指 → ${currentFingerCount===1?'方形':(currentFingerCount===2?'圆形':'三角')}`;
else gestureStatusDiv.innerText = '✨ 魔法画板 ✨';
}, 1000);
}
});
clearBtn.addEventListener('click', clearCanvas);
}
async function start() {
setupInteractions();
requestAnimationFrame(animationFrame);
await ensureCameraAndStart();
}
start();
})();
</script>
</body>
</html>Game Source: 魔法画板 · 灵蝶渐变光晕 | 隔空手势绘形
Creator: GoldenTiger20
Libraries: none
Complexity: complex (702 lines, 28.0 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: -goldentiger20" to link back to the original. Then publish at arcadelab.ai/publish.