- 更新标题为 FLUID ARCHIVE 并调整字体与背景色 - 简化 UI 布局,增强沉浸感并隐藏鼠标指针 - 优化加载动画为旋转圆环,改善过渡效果 - 粒子系统重写:减少数量、引入着色器材质与辉光效果 - 改进粒子物理行为,支持跟随、聚合与自由漂浮模式 - 更新主题配置逻辑,简化节气与节日判断规则 - 修复手势控制响应延迟与丢失问题,增强交互反馈 - 调整摄像机视角与后处理参数以增强空间氛围感
613 lines
23 KiB
HTML
613 lines
23 KiB
HTML
<!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>HONESTY | FLUID ARCHIVE</title>
|
||
<style>
|
||
:root {
|
||
--font-serif: 'Times New Roman', serif;
|
||
--font-sans: 'Arial', sans-serif;
|
||
--ui-color: 255, 255, 255;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
overflow: hidden;
|
||
background-color: #000000;
|
||
color: rgb(var(--ui-color));
|
||
font-family: var(--font-sans);
|
||
user-select: none;
|
||
cursor: none; /* 隐藏鼠标,增加沉浸感 */
|
||
}
|
||
|
||
#canvas-container {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1;
|
||
}
|
||
|
||
#input-video {
|
||
display: none; /* 隐藏原始视频流 */
|
||
}
|
||
|
||
/* UI HUD */
|
||
#ui-layer {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 10;
|
||
pointer-events: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
padding: 40px;
|
||
background: radial-gradient(circle at center, transparent 20%, rgba(0,0,0,0.8) 100%);
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 10px;
|
||
letter-spacing: 2px;
|
||
opacity: 0.6;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.center-text {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
text-align: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.n-title {
|
||
font-family: var(--font-serif);
|
||
font-size: clamp(30px, 4vw, 80px);
|
||
letter-spacing: 10px;
|
||
font-weight: 300;
|
||
text-shadow: 0 0 20px rgba(255,255,255,0.3);
|
||
margin-bottom: 10px;
|
||
transition: opacity 1s;
|
||
}
|
||
|
||
.n-sub {
|
||
font-size: 12px;
|
||
letter-spacing: 4px;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.footer {
|
||
text-align: center;
|
||
font-size: 12px;
|
||
letter-spacing: 2px;
|
||
opacity: 0.5;
|
||
animation: breathe 3s infinite ease-in-out;
|
||
}
|
||
|
||
/* 加载动画 */
|
||
#loader {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: #000;
|
||
z-index: 100;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
transition: opacity 0.8s;
|
||
}
|
||
|
||
.loader-ring {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 1px solid rgba(255,255,255,0.3);
|
||
border-top: 1px solid #fff;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes breathe { 0%,100% { opacity: 0.3; } 50% { opacity: 0.8; } }
|
||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||
|
||
</style>
|
||
|
||
<!-- 依赖库 -->
|
||
<script src="https://cdn.bootcdn.net/ajax/libs/three.js/r128/three.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="loader"><div class="loader-ring"></div></div>
|
||
|
||
<div id="canvas-container"></div>
|
||
<video id="input-video" playsinline></video>
|
||
|
||
<div id="ui-layer">
|
||
<div class="header">
|
||
<span id="theme-display">DETECTING TIME...</span>
|
||
<span id="hand-status">WAITING INPUT</span>
|
||
</div>
|
||
|
||
<div class="center-text">
|
||
<div class="n-title" id="main-title">HONESTY</div>
|
||
<div class="n-sub" id="sub-title">FLUID MEMORY</div>
|
||
</div>
|
||
|
||
<div class="footer" id="hint-text">
|
||
单手·流体跟随 | 合十·凝聚形态
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
/**
|
||
* ============================================================================
|
||
* 1. 节日与主题配置 (Theme Config)
|
||
* ============================================================================
|
||
*/
|
||
const Themes = {
|
||
DEFAULT: { name: 'Saturn / 深空', c1: [0.1, 0.5, 1.0], c2: [0.8, 0.9, 1.0], shape: 'SATURN' }, // 蓝白
|
||
SPRING: { name: 'Spring / 春笺', c1: [1.0, 0.0, 0.2], c2: [1.0, 0.8, 0.0], shape: 'LANTERN' }, // 红金
|
||
VALENTINE: { name: 'Love / 心语', c1: [1.0, 0.2, 0.6], c2: [0.8, 0.0, 0.8], shape: 'HEART' }, // 粉紫
|
||
QINGMING: { name: 'Rain / 雨巷', c1: [0.0, 0.3, 0.2], c2: [0.6, 0.7, 0.7], shape: 'WILLOW' }, // 青灰
|
||
MIDAUTUMN: { name: 'Moon / 月笺', c1: [0.0, 0.1, 0.3], c2: [1.0, 0.9, 0.6], shape: 'MOON' }, // 深蓝金
|
||
NATIONAL: { name: 'Glory / 山河', c1: [0.9, 0.1, 0.0], c2: [1.0, 1.0, 0.0], shape: 'STAR' }, // 红黄
|
||
HALLOWEEN: { name: 'Night / 夜宴', c1: [1.0, 0.5, 0.0], c2: [0.4, 0.0, 0.6], shape: 'PUMPKIN' }, // 橙紫
|
||
GHOST: { name: 'River / 幽思', c1: [0.0, 0.8, 0.5], c2: [0.1, 0.9, 0.8], shape: 'LOTUS' }, // 荧光绿
|
||
CHRISTMAS: { name: 'Snow / 雪颂', c1: [0.0, 0.4, 0.1], c2: [0.8, 0.1, 0.1], shape: 'TREE' } // 绿红
|
||
};
|
||
|
||
// 简单的日期检测算法
|
||
function getThemeByDate() {
|
||
const now = new Date();
|
||
const m = now.getMonth() + 1;
|
||
const d = now.getDate();
|
||
|
||
// 逻辑:如果在特定日期范围内,返回对应主题,否则返回 DEFAULT
|
||
if (m === 2 && d > 10 && d < 18) return Themes.VALENTINE;
|
||
if (m === 2 && d < 10) return Themes.SPRING; // 假设春节
|
||
if (m === 4 && d < 10) return Themes.QINGMING;
|
||
if (m === 9 && d > 10 && d < 20) return Themes.MIDAUTUMN;
|
||
if (m === 10 && d < 8) return Themes.NATIONAL;
|
||
if (m === 10 && d > 25) return Themes.HALLOWEEN;
|
||
if (m === 8 && d > 10 && d < 20) return Themes.GHOST;
|
||
if (m === 12 && d > 15) return Themes.CHRISTMAS;
|
||
|
||
return Themes.DEFAULT;
|
||
}
|
||
|
||
const currentTheme = getThemeByDate();
|
||
|
||
// 更新UI
|
||
document.getElementById('theme-display').innerText = `THEME: ${currentTheme.name}`;
|
||
|
||
/**
|
||
* ============================================================================
|
||
* 2. 粒子物理引擎 (Physics Engine)
|
||
* ============================================================================
|
||
*/
|
||
class ParticleSystem {
|
||
constructor(scene) {
|
||
this.scene = scene;
|
||
this.count = 6000; // 优化:减少数量,不再那么密集
|
||
|
||
this.geometry = new THREE.BufferGeometry();
|
||
this.positions = new Float32Array(this.count * 3);
|
||
this.velocities = new Float32Array(this.count * 3);
|
||
this.targets = new Float32Array(this.count * 3); // 聚合时的目标位置
|
||
this.colors = new Float32Array(this.count * 3);
|
||
this.sizes = new Float32Array(this.count);
|
||
this.randoms = new Float32Array(this.count * 3); // 用于噪点运动
|
||
|
||
// 状态管理
|
||
this.state = {
|
||
mode: 'SCATTER', // SCATTER(散开/漂浮), FOLLOW(跟随), AGGREGATE(聚合)
|
||
handPos: new THREE.Vector3(0, 0, 0),
|
||
scatterForce: 0.95, // 摩擦力
|
||
speed: 1.0
|
||
};
|
||
|
||
this.initParticles();
|
||
this.createShaderMaterial();
|
||
this.mesh = new THREE.Points(this.geometry, this.material);
|
||
this.scene.add(this.mesh);
|
||
|
||
// 预计算形状
|
||
this.calculateShape(currentTheme.shape);
|
||
}
|
||
|
||
initParticles() {
|
||
const color1 = new THREE.Color().setRGB(...currentTheme.c1);
|
||
const color2 = new THREE.Color().setRGB(...currentTheme.c2);
|
||
|
||
for(let i=0; i<this.count; i++) {
|
||
const i3 = i * 3;
|
||
// 初始随机分布 (宽屏分布)
|
||
this.positions[i3] = (Math.random() - 0.5) * 60;
|
||
this.positions[i3+1] = (Math.random() - 0.5) * 40;
|
||
this.positions[i3+2] = (Math.random() - 0.5) * 30;
|
||
|
||
// 随机颜色混合
|
||
const mixRatio = Math.random();
|
||
this.colors[i3] = color1.r * mixRatio + color2.r * (1-mixRatio);
|
||
this.colors[i3+1] = color1.g * mixRatio + color2.g * (1-mixRatio);
|
||
this.colors[i3+2] = color1.b * mixRatio + color2.b * (1-mixRatio);
|
||
|
||
// 随机大小 (让画面更有层次)
|
||
this.sizes[i] = Math.random() * 1.5 + 0.5;
|
||
|
||
// 随机参数
|
||
this.randoms[i3] = Math.random();
|
||
this.randoms[i3+1] = Math.random();
|
||
this.randoms[i3+2] = Math.random();
|
||
}
|
||
|
||
this.geometry.setAttribute('position', new THREE.BufferAttribute(this.positions, 3));
|
||
this.geometry.setAttribute('color', new THREE.BufferAttribute(this.colors, 3));
|
||
this.geometry.setAttribute('size', new THREE.BufferAttribute(this.sizes, 1));
|
||
}
|
||
|
||
// 解决“粒子是方块”的问题:使用 Shader 绘制圆形
|
||
createShaderMaterial() {
|
||
this.material = new THREE.ShaderMaterial({
|
||
uniforms: {
|
||
time: { value: 0 },
|
||
pixelRatio: { value: window.devicePixelRatio }
|
||
},
|
||
vertexShader: `
|
||
attribute float size;
|
||
attribute vec3 color;
|
||
varying vec3 vColor;
|
||
uniform float pixelRatio;
|
||
uniform float time;
|
||
|
||
void main() {
|
||
vColor = color;
|
||
vec3 pos = position;
|
||
|
||
// 简单的呼吸效果
|
||
float breath = 1.0 + sin(time * 2.0 + position.x) * 0.1;
|
||
|
||
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
||
gl_PointSize = size * 12.0 * pixelRatio * (20.0 / -mvPosition.z) * breath;
|
||
gl_Position = projectionMatrix * mvPosition;
|
||
}
|
||
`,
|
||
fragmentShader: `
|
||
varying vec3 vColor;
|
||
void main() {
|
||
// 绘制圆形
|
||
float r = distance(gl_PointCoord, vec2(0.5));
|
||
if (r > 0.5) discard;
|
||
|
||
// 径向渐变 (柔和边缘)
|
||
float glow = 1.0 - (r * 2.0);
|
||
glow = pow(glow, 2.0);
|
||
|
||
gl_FragColor = vec4(vColor, glow);
|
||
}
|
||
`,
|
||
transparent: true,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending // 叠加发光混合模式
|
||
});
|
||
}
|
||
|
||
// 计算特定主题的形状坐标
|
||
calculateShape(type) {
|
||
for(let i=0; i<this.count; i++) {
|
||
const i3 = i * 3;
|
||
let x=0, y=0, z=0;
|
||
|
||
// 简单的形状数学生成
|
||
const u = Math.random() * Math.PI * 2;
|
||
const v = Math.random() * Math.PI;
|
||
|
||
if (type === 'SATURN') {
|
||
// 土星:球体 + 环
|
||
if (Math.random() > 0.4) {
|
||
const r = 5;
|
||
x = r * Math.sin(v) * Math.cos(u);
|
||
y = r * Math.sin(v) * Math.sin(u);
|
||
z = r * Math.cos(v);
|
||
} else {
|
||
const r = 8 + Math.random() * 3;
|
||
x = r * Math.cos(u);
|
||
z = r * Math.sin(u);
|
||
y = (Math.random()-0.5) * 0.5;
|
||
}
|
||
} else if (type === 'HEART') {
|
||
// 爱心方程
|
||
const t = Math.PI * (Math.random() - 0.5) * 2; // -PI to PI
|
||
// 3D Heart approximation
|
||
x = 16 * Math.pow(Math.sin(u), 3);
|
||
y = 13 * Math.cos(u) - 5 * Math.cos(2*u) - 2 * Math.cos(3*u) - Math.cos(4*u);
|
||
z = t * 4;
|
||
x *= 0.4; y *= 0.4; z *= 0.4;
|
||
} else {
|
||
// 默认球体
|
||
const r = 7 * Math.sqrt(Math.random());
|
||
x = r * Math.sin(v) * Math.cos(u);
|
||
y = r * Math.sin(v) * Math.sin(u);
|
||
z = r * Math.cos(v);
|
||
}
|
||
|
||
this.targets[i3] = x;
|
||
this.targets[i3+1] = y;
|
||
this.targets[i3+2] = z;
|
||
}
|
||
}
|
||
|
||
update(time) {
|
||
this.material.uniforms.time.value = time;
|
||
const positions = this.geometry.attributes.position.array;
|
||
|
||
// 物理参数
|
||
const friction = this.state.scatterForce; // 阻力
|
||
const returnForce = 0.03; // 聚合时的吸力
|
||
const followForce = 0.08; // 跟随时的吸力
|
||
const noiseStrength = 0.05; // 漂浮时的扰动
|
||
|
||
for(let i=0; i<this.count; i++) {
|
||
const i3 = i * 3;
|
||
|
||
// 1. 计算目标力
|
||
let ax = 0, ay = 0, az = 0;
|
||
|
||
if (this.state.mode === 'AGGREGATE') {
|
||
// 聚合:被吸向预定形状
|
||
ax = (this.targets[i3] - positions[i3]) * returnForce;
|
||
ay = (this.targets[i3+1] - positions[i3+1]) * returnForce;
|
||
az = (this.targets[i3+2] - positions[i3+2]) * returnForce;
|
||
}
|
||
else if (this.state.mode === 'FOLLOW') {
|
||
// 跟随:被吸向手部位置 (解决“排斥”问题)
|
||
// 增加一点随机偏移,让它们围绕手旋转而不是坍缩成一个点
|
||
const dx = this.state.handPos.x - positions[i3];
|
||
const dy = this.state.handPos.y - positions[i3+1];
|
||
const dz = this.state.handPos.z - positions[i3+2];
|
||
|
||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
|
||
|
||
// 距离越近,吸力越弱,防止穿模
|
||
if (dist > 1.0) {
|
||
ax = dx * followForce * 0.5;
|
||
ay = dy * followForce * 0.5;
|
||
az = dz * followForce * 0.5;
|
||
}
|
||
|
||
// 添加螺旋运动 (Cross product模拟)
|
||
ax += (Math.random()-0.5) * 0.5;
|
||
ay += (Math.random()-0.5) * 0.5;
|
||
}
|
||
else {
|
||
// SCATTER / IDLE: 自由漂浮 + 噪点运动
|
||
// 回归到一个较大的随机区域,而不是无限飞走
|
||
const homeX = (this.randoms[i3]-0.5) * 80;
|
||
const homeY = (this.randoms[i3+1]-0.5) * 60;
|
||
|
||
// 缓慢的布朗运动
|
||
ax = (Math.sin(time + i) * noiseStrength) + (homeX - positions[i3]) * 0.002;
|
||
ay = (Math.cos(time + i) * noiseStrength) + (homeY - positions[i3+1]) * 0.002;
|
||
az = (Math.sin(time * 0.5 + i) * noiseStrength) - positions[i3+2] * 0.002;
|
||
}
|
||
|
||
// 2. 应用力到速度
|
||
this.velocities[i3] += ax;
|
||
this.velocities[i3+1] += ay;
|
||
this.velocities[i3+2] += az;
|
||
|
||
// 3. 应用阻力
|
||
this.velocities[i3] *= friction;
|
||
this.velocities[i3+1] *= friction;
|
||
this.velocities[i3+2] *= friction;
|
||
|
||
// 4. 更新位置
|
||
positions[i3] += this.velocities[i3] * this.state.speed;
|
||
positions[i3+1] += this.velocities[i3+1] * this.state.speed;
|
||
positions[i3+2] += this.velocities[i3+2] * this.state.speed;
|
||
}
|
||
|
||
this.geometry.attributes.position.needsUpdate = true;
|
||
}
|
||
|
||
// 状态切换方法
|
||
setState(mode, handPos = null) {
|
||
if (mode === 'SCATTER' && this.state.mode === 'AGGREGATE') {
|
||
// 解决问题4:如果从聚合状态丢失手势,给一个向外的“炸开”力
|
||
this.explode();
|
||
}
|
||
|
||
this.state.mode = mode;
|
||
if (handPos) {
|
||
this.state.handPos.copy(handPos);
|
||
}
|
||
}
|
||
|
||
explode() {
|
||
// 给所有粒子一个瞬间的随机向外速度
|
||
for(let i=0; i<this.count; i++) {
|
||
const i3 = i * 3;
|
||
this.velocities[i3] += (Math.random() - 0.5) * 2.0;
|
||
this.velocities[i3+1] += (Math.random() - 0.5) * 2.0;
|
||
this.velocities[i3+2] += (Math.random() - 0.5) * 2.0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ============================================================================
|
||
* 3. 程序主逻辑 (Main App)
|
||
* ============================================================================
|
||
*/
|
||
const scene = new THREE.Scene();
|
||
// 雾效增强空间感
|
||
scene.fog = new THREE.FogExp2(0x000000, 0.015);
|
||
|
||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||
camera.position.z = 30;
|
||
|
||
const renderer = new THREE.WebGLRenderer({
|
||
antialias: false, // 后处理时不需要原生抗锯齿
|
||
powerPreference: "high-performance"
|
||
});
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
document.getElementById('canvas-container').appendChild(renderer.domElement);
|
||
|
||
// 后处理:Bloom (辉光) 是浪漫感的关键
|
||
const composer = new THREE.EffectComposer(renderer);
|
||
composer.addPass(new THREE.RenderPass(scene, camera));
|
||
|
||
const bloomPass = new THREE.UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
|
||
bloomPass.strength = 1.8; // 强辉光
|
||
bloomPass.radius = 0.8;
|
||
bloomPass.threshold = 0.1;
|
||
composer.addPass(bloomPass);
|
||
|
||
// 初始化粒子系统
|
||
const particles = new ParticleSystem(scene);
|
||
|
||
// 手势识别
|
||
const videoElement = document.getElementById('input-video');
|
||
const hands = new Hands({locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`});
|
||
|
||
hands.setOptions({
|
||
maxNumHands: 2,
|
||
modelComplexity: 1,
|
||
minDetectionConfidence: 0.7,
|
||
minTrackingConfidence: 0.7
|
||
});
|
||
|
||
hands.onResults(onHandsResults);
|
||
|
||
const cameraUtils = new Camera(videoElement, {
|
||
onFrame: async () => {
|
||
await hands.send({image: videoElement});
|
||
},
|
||
width: 640,
|
||
height: 480
|
||
});
|
||
|
||
// 启动
|
||
setTimeout(() => {
|
||
cameraUtils.start();
|
||
document.getElementById('loader').style.opacity = '0';
|
||
setTimeout(()=>document.getElementById('loader').remove(), 800);
|
||
}, 1000);
|
||
|
||
// 手势逻辑处理
|
||
function onHandsResults(results) {
|
||
const landmarks = results.multiHandLandmarks;
|
||
const statusEl = document.getElementById('hand-status');
|
||
const hintEl = document.getElementById('hint-text');
|
||
|
||
if (!landmarks || landmarks.length === 0) {
|
||
statusEl.innerText = "NO SIGNAL";
|
||
// 解决问题4:手势丢失,自动散开
|
||
if (particles.state.mode !== 'SCATTER') {
|
||
particles.setState('SCATTER');
|
||
hintEl.innerText = "等待输入...";
|
||
}
|
||
return;
|
||
}
|
||
|
||
statusEl.innerText = "SIGNAL LINKED";
|
||
|
||
// 坐标映射:将归一化的 0-1 坐标映射到 3D 世界坐标 (-25 到 25)
|
||
// 注意:x 需要镜像翻转 (1-x)
|
||
const mapHand = (lm) => {
|
||
return new THREE.Vector3(
|
||
(1.0 - lm.x) * 50 - 25,
|
||
-(lm.y * 30 - 15),
|
||
0
|
||
);
|
||
};
|
||
|
||
// 取第一只手做跟随
|
||
const hand1 = landmarks[0][9]; // 中指根部
|
||
const pos1 = mapHand(hand1);
|
||
|
||
if (landmarks.length === 2) {
|
||
// 双手逻辑
|
||
const hand2 = landmarks[1][9];
|
||
const pos2 = mapHand(hand2);
|
||
|
||
// 计算双手距离
|
||
const dist = pos1.distanceTo(pos2);
|
||
|
||
if (dist < 6.0) {
|
||
// 合十 -> 聚合
|
||
particles.setState('AGGREGATE');
|
||
hintEl.innerText = "正在凝聚记忆...";
|
||
} else if (dist > 25.0) {
|
||
// 张开 -> 子弹时间 (减速)
|
||
particles.setState('SCATTER');
|
||
particles.state.speed = 0.1;
|
||
hintEl.innerText = "时间凝固";
|
||
} else {
|
||
// 双手移动 -> 强力跟随
|
||
particles.setState('FOLLOW', pos1.lerp(pos2, 0.5)); // 跟随双手中心
|
||
particles.state.speed = 1.0;
|
||
hintEl.innerText = "双倍引力";
|
||
}
|
||
} else {
|
||
// 单手逻辑 -> 跟随 (解决问题3)
|
||
// 捏合检测
|
||
const thumb = landmarks[0][4];
|
||
const index = landmarks[0][8];
|
||
const pinchDist = Math.hypot(thumb.x - index.x, thumb.y - index.y);
|
||
|
||
if (pinchDist < 0.05) {
|
||
// 捏合 -> 极强吸力
|
||
particles.setState('FOLLOW', pos1);
|
||
hintEl.innerText = "捕获中...";
|
||
} else {
|
||
// 普通移动 -> 柔和跟随
|
||
particles.setState('FOLLOW', pos1);
|
||
hintEl.innerText = "流体跟随";
|
||
}
|
||
particles.state.speed = 1.0;
|
||
}
|
||
}
|
||
|
||
// 渲染循环
|
||
const clock = new THREE.Clock();
|
||
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
|
||
const time = clock.getElapsedTime();
|
||
particles.update(time);
|
||
|
||
// 摄像机微动,增加沉浸感
|
||
camera.position.x = Math.sin(time * 0.1) * 2;
|
||
camera.position.y = Math.cos(time * 0.1) * 2;
|
||
camera.lookAt(0,0,0);
|
||
|
||
composer.render();
|
||
}
|
||
|
||
// 窗口大小适配
|
||
window.addEventListener('resize', () => {
|
||
camera.aspect = window.innerWidth / window.innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
composer.setSize(window.innerWidth, window.innerHeight);
|
||
particles.material.uniforms.pixelRatio.value = window.devicePixelRatio;
|
||
});
|
||
|
||
animate();
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|