Trae Assistant commited on
Commit
c868c0f
·
0 Parent(s):
Files changed (8) hide show
  1. .gitattributes +2 -0
  2. .gitignore +9 -0
  3. Dockerfile +10 -0
  4. README.md +36 -0
  5. app.py +15 -0
  6. requirements.txt +2 -0
  7. static/js/main.js +506 -0
  8. templates/index.html +66 -0
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
2
+ *.onnx filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+ env/
7
+ venv/
8
+ .env
9
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 剑来-手势交互 (Sword Coming)
3
+ emoji: 🗡️
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ short_description: 移动端手势识别:剑指召唤,五指开天 (Gesture Controlled Sword Effect)
9
+ ---
10
+
11
+ # 剑来 (Sword Coming) - 手势识别系统
12
+
13
+ ## 项目介绍
14
+ 本项目复刻了国漫《剑来》中“剑来”的经典场景,并升级了视觉特效。通过计算机视觉(MediaPipe)识别用户手势,实现“召唤飞剑”及“一剑开天”的震撼视觉效果。
15
+ 支持移动端和PC端,推荐使用移动端体验更佳。
16
+
17
+ ## 功能特点
18
+ - **仙气飞剑**: 全白光效,流光溢彩,粒子拖尾。
19
+ - **双重手势**:
20
+ - **剑指(两指)**: 召唤飞剑,御剑跟随。
21
+ - **五指(开天)**: 触发“一剑开天”必杀技,全屏光刃特效。
22
+ - **震撼特效**: 屏幕震动、粒子爆发、巨大光束。
23
+ - **移动端适配**: 响应式设计,全屏沉浸体验。
24
+ - **纯前端推理**: 基于 MediaPipe JS,低延迟,保护隐私。
25
+
26
+ ## 使用说明
27
+ 1. 允许浏览器访问摄像头。
28
+ 2. **御剑术(跟随)**: 举起手,做出“剑指”(食指中指伸直,其余弯曲),飞剑将飞来并跟随指尖。
29
+ 3. **必杀技(开天)**: 突然五指张开(手掌伸直),触发“一剑开天”特效,斩破屏幕!
30
+ 4. 喊出(心里默念)“剑来”!
31
+
32
+ ## 技术栈
33
+ - Python Flask
34
+ - Vue.js 3
35
+ - Tailwind CSS
36
+ - Google MediaPipe Hands
app.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from flask import Flask, render_template, send_from_directory
3
+
4
+ app = Flask(__name__)
5
+
6
+ @app.route('/')
7
+ def index():
8
+ return render_template('index.html')
9
+
10
+ @app.route('/static/<path:path>')
11
+ def send_static(path):
12
+ return send_from_directory('static', path)
13
+
14
+ if __name__ == '__main__':
15
+ app.run(host='0.0.0.0', port=7860, debug=True)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ Flask==3.0.0
2
+ gunicorn==21.2.0
static/js/main.js ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { createApp, ref, onMounted } = Vue;
2
+
3
+ createApp({
4
+ setup() {
5
+ const videoElement = ref(null);
6
+ const canvasElement = ref(null);
7
+ const loading = ref(true);
8
+ const loadingProgress = ref(0);
9
+ const cameraActive = ref(false);
10
+ const debug = ref(false);
11
+ const fps = ref(0);
12
+
13
+ let canvasCtx = null;
14
+ let hands = null;
15
+ let camera = null;
16
+
17
+ // Game Objects
18
+ let sword = null;
19
+ let particles = [];
20
+ let stars = [];
21
+ let riftEffect = null; // The "Sky Split" effect
22
+ let shockwave = 0;
23
+
24
+ // Interaction State
25
+ let gestureTimer = 0;
26
+ let lastGesture = 'NONE';
27
+ let chargeLevel = 0; // 0.0 to 1.0
28
+
29
+ // Audio
30
+ let audioCtx = null;
31
+ let oscillators = {};
32
+
33
+ // --- Audio Controller ---
34
+ const initAudio = () => {
35
+ if (!audioCtx) {
36
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
37
+ }
38
+ if (audioCtx.state === 'suspended') {
39
+ audioCtx.resume();
40
+ }
41
+ };
42
+
43
+ const playSound = (type) => {
44
+ if (!audioCtx) return;
45
+
46
+ const t = audioCtx.currentTime;
47
+
48
+ if (type === 'CHARGE') {
49
+ // Rising pitch hum
50
+ const osc = audioCtx.createOscillator();
51
+ const gain = audioCtx.createGain();
52
+ osc.type = 'sawtooth';
53
+ osc.frequency.setValueAtTime(100, t);
54
+ osc.frequency.exponentialRampToValueAtTime(400, t + 0.5);
55
+ gain.gain.setValueAtTime(0.1, t);
56
+ gain.gain.exponentialRampToValueAtTime(0.01, t + 0.5);
57
+
58
+ osc.connect(gain);
59
+ gain.connect(audioCtx.destination);
60
+ osc.start();
61
+ osc.stop(t + 0.5);
62
+ } else if (type === 'SLASH') {
63
+ // Sharp noise/crack
64
+ const osc = audioCtx.createOscillator();
65
+ const gain = audioCtx.createGain();
66
+ osc.type = 'square';
67
+ osc.frequency.setValueAtTime(800, t);
68
+ osc.frequency.exponentialRampToValueAtTime(100, t + 0.3);
69
+
70
+ gain.gain.setValueAtTime(0.3, t);
71
+ gain.gain.exponentialRampToValueAtTime(0.01, t + 0.3);
72
+
73
+ osc.connect(gain);
74
+ gain.connect(audioCtx.destination);
75
+ osc.start();
76
+ osc.stop(t + 0.3);
77
+ }
78
+ };
79
+
80
+ // --- Helpers ---
81
+ const dist = (p1, p2) => Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
82
+ const randomRange = (min, max) => Math.random() * (max - min) + min;
83
+
84
+ // --- Classes ---
85
+
86
+ class Particle {
87
+ constructor(x, y, type = 'TRAIL') {
88
+ this.x = x;
89
+ this.y = y;
90
+ this.type = type;
91
+ this.life = 1.0;
92
+
93
+ if (type === 'TRAIL') {
94
+ this.vx = (Math.random() - 0.5) * 2;
95
+ this.vy = (Math.random() - 0.5) * 2;
96
+ this.size = Math.random() * 3 + 1;
97
+ this.decay = 0.05;
98
+ this.color = [200, 255, 255]; // Cyan-ish
99
+ } else if (type === 'SPIRIT') {
100
+ // Orbiting/Gathering particles
101
+ this.angle = Math.random() * Math.PI * 2;
102
+ this.radius = Math.random() * 40 + 20;
103
+ this.speed = Math.random() * 0.1 + 0.05;
104
+ this.size = Math.random() * 2 + 1;
105
+ this.decay = 0.0; // Managed by sword
106
+ this.color = [255, 255, 200]; // Golden/White
107
+ } else if (type === 'SPARK') {
108
+ const angle = Math.random() * Math.PI * 2;
109
+ const speed = Math.random() * 20 + 5;
110
+ this.vx = Math.cos(angle) * speed;
111
+ this.vy = Math.sin(angle) * speed;
112
+ this.size = Math.random() * 4 + 1;
113
+ this.decay = 0.02;
114
+ this.color = [255, 200, 100]; // Gold sparks
115
+ }
116
+ }
117
+
118
+ update(targetX, targetY) {
119
+ if (this.type === 'SPIRIT') {
120
+ // Orbit logic
121
+ this.angle += this.speed + (chargeLevel * 0.2); // Spin faster when charging
122
+ let currentRadius = this.radius;
123
+
124
+ if (chargeLevel > 0) {
125
+ currentRadius = this.radius * (1.0 - chargeLevel * 0.8); // Pull in
126
+ }
127
+
128
+ this.x = targetX + Math.cos(this.angle) * currentRadius;
129
+ this.y = targetY + Math.sin(this.angle) * currentRadius;
130
+
131
+ // Trail
132
+ if (Math.random() < 0.3) {
133
+ particles.push(new Particle(this.x, this.y, 'TRAIL'));
134
+ }
135
+ } else {
136
+ this.x += this.vx;
137
+ this.y += this.vy;
138
+ this.life -= this.decay;
139
+ }
140
+ }
141
+
142
+ draw(ctx) {
143
+ ctx.save();
144
+ ctx.globalAlpha = this.life;
145
+ ctx.fillStyle = `rgba(${this.color[0]}, ${this.color[1]}, ${this.color[2]}, ${this.life})`;
146
+ ctx.shadowBlur = 10 * this.life;
147
+ ctx.shadowColor = `rgba(${this.color[0]}, ${this.color[1]}, ${this.color[2]}, 1)`;
148
+ ctx.beginPath();
149
+ ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
150
+ ctx.fill();
151
+ ctx.restore();
152
+ }
153
+ }
154
+
155
+ class Sword {
156
+ constructor() {
157
+ this.x = window.innerWidth / 2;
158
+ this.y = window.innerHeight + 200;
159
+ this.angle = -Math.PI / 2;
160
+ this.scale = 1.5; // Smaller, more refined
161
+ this.state = 'IDLE'; // IDLE, FOLLOW, CHARGING, SLASHING
162
+
163
+ // Spirit Particles
164
+ this.spirits = [];
165
+ for(let i=0; i<8; i++) {
166
+ this.spirits.push(new Particle(this.x, this.y, 'SPIRIT'));
167
+ }
168
+ }
169
+
170
+ update(targetX, targetY) {
171
+ // Smooth Movement
172
+ const dx = targetX - this.x;
173
+ const dy = targetY - this.y;
174
+ const d = Math.sqrt(dx * dx + dy * dy);
175
+
176
+ // Rotation
177
+ if (d > 20) {
178
+ const targetAngle = Math.atan2(dy, dx) + Math.PI / 2;
179
+ let da = targetAngle - this.angle;
180
+ while (da > Math.PI) da -= Math.PI * 2;
181
+ while (da < -Math.PI) da += Math.PI * 2;
182
+ this.angle += da * 0.15;
183
+ }
184
+
185
+ // Position
186
+ const ease = 0.15;
187
+ this.x += dx * ease;
188
+ this.y += dy * ease;
189
+
190
+ // Vibration when charging
191
+ if (this.state === 'CHARGING') {
192
+ this.x += (Math.random() - 0.5) * 5 * chargeLevel;
193
+ this.y += (Math.random() - 0.5) * 5 * chargeLevel;
194
+ }
195
+
196
+ // Update Spirits
197
+ this.spirits.forEach(s => s.update(this.x, this.y));
198
+ }
199
+
200
+ draw(ctx) {
201
+ ctx.save();
202
+ ctx.translate(this.x, this.y);
203
+ ctx.rotate(this.angle);
204
+ ctx.scale(this.scale, this.scale);
205
+
206
+ // --- 灵气特效 (Aura) ---
207
+ const time = Date.now() / 200;
208
+ const pulse = Math.sin(time) * 0.2 + 1.0 + (chargeLevel * 0.5); // Pulse gets stronger
209
+
210
+ // Outer Glow
211
+ ctx.shadowBlur = 30 * pulse;
212
+ ctx.shadowColor = chargeLevel > 0.5 ? "rgba(255, 200, 100, 0.9)" : "rgba(200, 240, 255, 0.8)";
213
+
214
+ // Blade
215
+ let grad = ctx.createLinearGradient(0, -100, 0, 0);
216
+ grad.addColorStop(0, "#FFF");
217
+ grad.addColorStop(0.5, chargeLevel > 0.5 ? "#FFFFA0" : "#E0F0FF");
218
+ grad.addColorStop(1, "#FFF");
219
+
220
+ ctx.fillStyle = grad;
221
+
222
+ // Refined Blade Shape
223
+ ctx.beginPath();
224
+ ctx.moveTo(0, -100);
225
+ ctx.lineTo(5, -80);
226
+ ctx.lineTo(4, 0);
227
+ ctx.lineTo(-4, 0);
228
+ ctx.lineTo(-5, -80);
229
+ ctx.closePath();
230
+ ctx.fill();
231
+
232
+ // Center Line
233
+ ctx.beginPath();
234
+ ctx.moveTo(0, -95);
235
+ ctx.lineTo(0, 0);
236
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.9)";
237
+ ctx.lineWidth = 1;
238
+ ctx.stroke();
239
+
240
+ // Guard (Golden accents)
241
+ ctx.fillStyle = chargeLevel > 0.5 ? "#FFD700" : "#C0C0C0";
242
+ ctx.shadowBlur = 10;
243
+ ctx.beginPath();
244
+ ctx.moveTo(-12, 0);
245
+ ctx.quadraticCurveTo(0, -5, 12, 0);
246
+ ctx.lineTo(10, 4);
247
+ ctx.lineTo(-10, 4);
248
+ ctx.fill();
249
+
250
+ // Handle
251
+ ctx.fillStyle = "#333";
252
+ ctx.fillRect(-2, 4, 4, 20);
253
+
254
+ // Pommel
255
+ ctx.fillStyle = chargeLevel > 0.5 ? "#FFD700" : "#C0C0C0";
256
+ ctx.beginPath();
257
+ ctx.arc(0, 26, 4, 0, Math.PI*2);
258
+ ctx.fill();
259
+
260
+ ctx.restore();
261
+
262
+ // Draw Spirits (World Space)
263
+ this.spirits.forEach(s => s.draw(ctx));
264
+ }
265
+ }
266
+
267
+ class RiftEffect {
268
+ constructor() {
269
+ this.life = 1.0;
270
+ this.points = [];
271
+ // Generate jagged line through center
272
+ const segments = 20;
273
+ const startY = -100;
274
+ const endY = window.innerHeight + 100;
275
+ const stepY = (endY - startY) / segments;
276
+
277
+ for(let i=0; i<=segments; i++) {
278
+ this.points.push({
279
+ x: window.innerWidth / 2 + (Math.random() - 0.5) * 100, // Jaggedness
280
+ y: startY + i * stepY
281
+ });
282
+ }
283
+ }
284
+
285
+ update() {
286
+ this.life -= 0.02;
287
+ }
288
+
289
+ draw(ctx) {
290
+ if (this.life <= 0) return;
291
+
292
+ ctx.save();
293
+
294
+ const width = Math.sin(this.life * Math.PI) * 150; // Open and close
295
+ const alpha = Math.min(1, this.life * 2);
296
+
297
+ // 1. Darken Background (World darkening)
298
+ ctx.fillStyle = `rgba(0, 0, 0, ${alpha * 0.8})`;
299
+ ctx.fillRect(0, 0, canvasElement.value.width, canvasElement.value.height);
300
+
301
+ // 2. The Rift (Opening)
302
+ ctx.beginPath();
303
+ // Left side
304
+ this.points.forEach((p, i) => {
305
+ const offset = (Math.sin(i * 0.5) + 1) * width * 0.5;
306
+ if (i===0) ctx.moveTo(p.x - offset, p.y);
307
+ else ctx.lineTo(p.x - offset, p.y);
308
+ });
309
+ // Right side (reverse)
310
+ for(let i=this.points.length-1; i>=0; i--) {
311
+ const p = this.points[i];
312
+ const offset = (Math.sin(i * 0.5) + 1) * width * 0.5;
313
+ ctx.lineTo(p.x + offset, p.y);
314
+ }
315
+ ctx.closePath();
316
+
317
+ // Void/Space inside Rift
318
+ ctx.fillStyle = "#FFF";
319
+ ctx.shadowBlur = 50;
320
+ ctx.shadowColor = "#FFF";
321
+ ctx.fill();
322
+
323
+ // 3. Blinding Light Rays
324
+ if (this.life > 0.5) {
325
+ ctx.globalCompositeOperation = 'lighter';
326
+ const rayWidth = window.innerWidth * (this.life - 0.5);
327
+ let grad = ctx.createLinearGradient(window.innerWidth/2 - rayWidth, 0, window.innerWidth/2 + rayWidth, 0);
328
+ grad.addColorStop(0, "rgba(255, 255, 255, 0)");
329
+ grad.addColorStop(0.5, `rgba(255, 255, 255, ${this.life})`);
330
+ grad.addColorStop(1, "rgba(255, 255, 255, 0)");
331
+ ctx.fillStyle = grad;
332
+ ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
333
+ ctx.globalCompositeOperation = 'source-over';
334
+ }
335
+
336
+ ctx.restore();
337
+ }
338
+ }
339
+
340
+ // --- Logic ---
341
+
342
+ const onResults = (results) => {
343
+ loading.value = false;
344
+ if (!canvasElement.value) return;
345
+
346
+ canvasElement.value.width = window.innerWidth;
347
+ canvasElement.value.height = window.innerHeight;
348
+
349
+ // Screen Shake
350
+ let shakeX = 0;
351
+ let shakeY = 0;
352
+ if (shockwave > 0) {
353
+ shakeX = (Math.random() - 0.5) * shockwave * 40;
354
+ shakeY = (Math.random() - 0.5) * shockwave * 40;
355
+ shockwave -= 0.04;
356
+ }
357
+
358
+ canvasCtx.save();
359
+ canvasCtx.translate(shakeX, shakeY);
360
+ canvasCtx.clearRect(-shakeX, -shakeY, canvasElement.value.width, canvasElement.value.height);
361
+
362
+ // Draw Background
363
+ canvasCtx.fillStyle = '#050510';
364
+ canvasCtx.fillRect(0, 0, canvasElement.value.width, canvasElement.value.height);
365
+
366
+ // Draw Stars
367
+ canvasCtx.fillStyle = '#FFFFFF';
368
+ stars.forEach(star => {
369
+ canvasCtx.globalAlpha = Math.max(0.1, star.opacity + Math.sin(Date.now() / 300 + star.x) * 0.2);
370
+ canvasCtx.beginPath();
371
+ canvasCtx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
372
+ canvasCtx.fill();
373
+ });
374
+ canvasCtx.globalAlpha = 1.0;
375
+
376
+ // Hand & Gesture Logic
377
+ let targetX = sword ? sword.x : window.innerWidth / 2;
378
+ let targetY = sword ? sword.y : window.innerHeight + 300;
379
+ let detectedGesture = 'NONE';
380
+
381
+ if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
382
+ const lm = results.multiHandLandmarks[0];
383
+ const isFingerExtended = (tipIdx, pipIdx) => lm[tipIdx].y < lm[pipIdx].y;
384
+
385
+ const indexExt = isFingerExtended(8, 6);
386
+ const middleExt = isFingerExtended(12, 10);
387
+ const ringExt = isFingerExtended(16, 14);
388
+ const pinkyExt = isFingerExtended(20, 18);
389
+
390
+ // 1. Sword Finger (Index+Middle) -> Summon/Follow
391
+ if (indexExt && middleExt && !ringExt && !pinkyExt) {
392
+ detectedGesture = 'SWORD_FINGER';
393
+ }
394
+ // 2. Open Palm -> Charge/Split
395
+ else if (indexExt && middleExt && ringExt && pinkyExt) {
396
+ detectedGesture = 'OPEN_PALM';
397
+ }
398
+
399
+ targetX = lm[8].x * canvasElement.value.width;
400
+ targetY = lm[8].y * canvasElement.value.height;
401
+
402
+ // Initialize Audio on first interaction
403
+ initAudio();
404
+ }
405
+
406
+ // Debounce
407
+ if (detectedGesture === lastGesture) {
408
+ gestureTimer++;
409
+ } else {
410
+ gestureTimer = 0;
411
+ lastGesture = detectedGesture;
412
+ }
413
+
414
+ // Init Sword
415
+ if (!sword) {
416
+ sword = new Sword();
417
+ for(let i=0; i<80; i++) {
418
+ stars.push({
419
+ x: Math.random() * window.innerWidth,
420
+ y: Math.random() * window.innerHeight,
421
+ size: Math.random() * 2,
422
+ opacity: Math.random() * 0.5
423
+ });
424
+ }
425
+ }
426
+
427
+ // State Machine
428
+ const STABLE = 5;
429
+
430
+ if (gestureTimer > STABLE) {
431
+ if (detectedGesture === 'SWORD_FINGER') {
432
+ // Charging State
433
+ sword.state = 'CHARGING';
434
+ if (chargeLevel < 1.0) {
435
+ chargeLevel += 0.02;
436
+ if (Math.random() < 0.2) playSound('CHARGE');
437
+ }
438
+ } else if (detectedGesture === 'OPEN_PALM') {
439
+ // If Charged, Trigger SPLIT
440
+ if (chargeLevel > 0.8) {
441
+ if (!riftEffect) {
442
+ riftEffect = new RiftEffect();
443
+ shockwave = 1.0;
444
+ playSound('SLASH');
445
+ // Sparks
446
+ for(let i=0; i<100; i++) {
447
+ particles.push(new Particle(targetX, targetY, 'SPARK'));
448
+ }
449
+ chargeLevel = 0; // Reset
450
+ }
451
+ } else {
452
+ // Just Follow
453
+ sword.state = 'FOLLOW';
454
+ chargeLevel = Math.max(0, chargeLevel - 0.05);
455
+ }
456
+ } else {
457
+ sword.state = 'IDLE';
458
+ chargeLevel = Math.max(0, chargeLevel - 0.05);
459
+ }
460
+ }
461
+
462
+ // Update Sword
463
+ sword.update(targetX, targetY);
464
+
465
+ // Draw Rift (Behind Sword or Front? Front is better for "Split Sky")
466
+ if (riftEffect) {
467
+ riftEffect.update();
468
+ riftEffect.draw(canvasCtx);
469
+ if (riftEffect.life <= 0) riftEffect = null;
470
+ }
471
+
472
+ // Particles
473
+ particles.forEach((p, i) => {
474
+ p.update(sword.x, sword.y);
475
+ p.draw(canvasCtx);
476
+ if (p.life <= 0 && p.type !== 'SPIRIT') particles.splice(i, 1);
477
+ });
478
+
479
+ // Draw Sword
480
+ sword.draw(canvasCtx);
481
+
482
+ canvasCtx.restore();
483
+ };
484
+
485
+ const initCamera = async () => {
486
+ if (!videoElement.value || !canvasElement.value) return;
487
+ canvasCtx = canvasElement.value.getContext('2d');
488
+ hands = new Hands({locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`});
489
+ hands.setOptions({ maxNumHands: 1, modelComplexity: 1, minDetectionConfidence: 0.6, minTrackingConfidence: 0.6 });
490
+ hands.onResults(onResults);
491
+
492
+ camera = new Camera(videoElement.value, {
493
+ onFrame: async () => await hands.send({image: videoElement.value}),
494
+ width: 1280, height: 720
495
+ });
496
+ await camera.start();
497
+ cameraActive.value = true;
498
+ };
499
+
500
+ onMounted(() => {
501
+ initCamera();
502
+ });
503
+
504
+ return { videoElement, canvasElement, loading, loadingProgress, cameraActive, debug, fps };
505
+ }
506
+ }).mount('#app');
templates/index.html ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>剑来 - Sword Coming</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script>
11
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
12
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
13
+ <style>
14
+ body { margin: 0; overflow: hidden; background-color: #000; }
15
+ canvas { display: block; width: 100vw; height: 100vh; transform: scaleX(-1); } /* Mirror effect */
16
+ .ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; transform: none;}
17
+ .loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; text-align: center; }
18
+
19
+ /* Custom Font Effect */
20
+ .title-text {
21
+ font-family: 'Courier New', Courier, monospace; /* Fallback */
22
+ text-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
23
+ }
24
+
25
+ /* Fade transition */
26
+ .fade-enter-active, .fade-leave-active { transition: opacity 0.5s; }
27
+ .fade-enter-from, .fade-leave-to { opacity: 0; }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <div id="app" class="relative w-full h-full">
32
+ <!-- Video Input (Hidden) -->
33
+ <video ref="videoElement" class="hidden" playsinline></video>
34
+
35
+ <!-- Main Canvas -->
36
+ <canvas ref="canvasElement"></canvas>
37
+
38
+ <!-- UI Layer -->
39
+ <div class="ui-layer flex flex-col justify-between p-4">
40
+ <div class="text-center mt-4">
41
+ <h1 class="text-3xl md:text-5xl font-bold text-white tracking-widest title-text opacity-80">剑 来 · 开 天</h1>
42
+ <p class="text-gray-300 text-xs md:text-sm mt-2 opacity-60">剑指聚气,挥掌开天</p>
43
+ </div>
44
+
45
+ <div class="mb-8 text-center">
46
+ <div v-if="loading" class="text-white animate-pulse">
47
+ 正在以此方天地(模型)... {{ loadingProgress }}%
48
+ </div>
49
+ <div v-else-if="!cameraActive" class="text-red-400">
50
+ 请允许访问摄像头以召唤飞剑
51
+ </div>
52
+ <!-- <div v-else class="text-green-400 text-xs opacity-50">
53
+ 灵气已复苏 (System Ready)
54
+ </div> -->
55
+ </div>
56
+ </div>
57
+
58
+ <!-- Debug/Stats (Optional) -->
59
+ <div v-if="debug" class="absolute bottom-2 right-2 text-xs text-gray-500 transform scale-x-[-1]">
60
+ FPS: {{ fps }}
61
+ </div>
62
+ </div>
63
+
64
+ <script src="/static/js/main.js"></script>
65
+ </body>
66
+ </html>