mudra-skills 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +19 -0
- package/CLAUDE.md +35 -0
- package/bin/install.js +25 -0
- package/package.json +28 -0
- package/skills/mudra-master/SKILL.md +202 -0
- package/skills/mudra-master/mudra-preview/SKILL.md +151 -0
- package/skills/mudra-master/mudra-preview/assets/ar-menu.html +382 -0
- package/skills/mudra-master/mudra-preview/assets/document-scroller.html +468 -0
- package/skills/mudra-master/mudra-preview/assets/drum-machine.html +788 -0
- package/skills/mudra-master/mudra-preview/assets/emg-visualizer.html +390 -0
- package/skills/mudra-master/mudra-preview/assets/generative-art.html +408 -0
- package/skills/mudra-master/mudra-preview/assets/gesture-assistant.html +342 -0
- package/skills/mudra-master/mudra-preview/assets/gesture-speech.html +444 -0
- package/skills/mudra-master/mudra-preview/assets/hands-free-desktop.html +376 -0
- package/skills/mudra-master/mudra-preview/assets/model-rotator.html +365 -0
- package/skills/mudra-master/mudra-preview/assets/mudra-duel.html +3200 -0
- package/skills/mudra-master/mudra-preview/assets/mudra-monitor.html +1451 -0
- package/skills/mudra-master/mudra-preview/assets/mudra-ultimate-template.html +891 -0
- package/skills/mudra-master/mudra-preview/assets/music-sequencer.html +455 -0
- package/skills/mudra-master/mudra-preview/assets/neural-pong.html +380 -0
- package/skills/mudra-master/mudra-preview/assets/neural-snake.html +364 -0
- package/skills/mudra-master/mudra-preview/assets/presentation-controller.html +345 -0
- package/skills/mudra-master/mudra-preview/assets/pressure-painter.html +285 -0
- package/skills/mudra-master/mudra-preview/assets/runner.html +1812 -0
- package/skills/mudra-master/mudra-preview/assets/smart-home.html +461 -0
- package/skills/mudra-master/mudra-preview/assets/space-invaders.html +1154 -0
- package/skills/mudra-master/mudra-preview/assets/waterful-ring-toss.html +1624 -0
- package/skills/mudra-master/mudra-preview/references/agent_protocol.json +646 -0
- package/skills/mudra-master/mudra-preview/references/promt.md +16829 -0
- package/skills/mudra-master/mudra-xr/SKILL.md +245 -0
- package/skills/mudra-master/mudra-xr/assets/demos/3dgs-walkthrough.html +242 -0
- package/skills/mudra-master/mudra-xr/assets/demos/aisimulator.html +792 -0
- package/skills/mudra-master/mudra-xr/assets/demos/balloonpop.html +422 -0
- package/skills/mudra-master/mudra-xr/assets/demos/ballpit.html +284 -0
- package/skills/mudra-master/mudra-xr/assets/demos/drone.html +41 -0
- package/skills/mudra-master/mudra-xr/assets/demos/gemini-icebreakers.html +338 -0
- package/skills/mudra-master/mudra-xr/assets/demos/gemini-xrobject.html +302 -0
- package/skills/mudra-master/mudra-xr/assets/demos/math3d.html +188 -0
- package/skills/mudra-master/mudra-xr/assets/demos/measure.html +247 -0
- package/skills/mudra-master/mudra-xr/assets/demos/occlusion.html +251 -0
- package/skills/mudra-master/mudra-xr/assets/demos/rain.html +498 -0
- package/skills/mudra-master/mudra-xr/assets/demos/screenwiper.html +432 -0
- package/skills/mudra-master/mudra-xr/assets/demos/splash.html +519 -0
- package/skills/mudra-master/mudra-xr/assets/demos/webcam_gestures.html +253 -0
- package/skills/mudra-master/mudra-xr/assets/demos/xremoji.html +833 -0
- package/skills/mudra-master/mudra-xr/assets/demos/xrpoet.html +151 -0
- package/skills/mudra-master/mudra-xr/assets/samples/depthmap.html +266 -0
- package/skills/mudra-master/mudra-xr/assets/samples/depthmesh.html +100 -0
- package/skills/mudra-master/mudra-xr/assets/samples/game_rps.html +1099 -0
- package/skills/mudra-master/mudra-xr/assets/samples/gestures_custom.html +467 -0
- package/skills/mudra-master/mudra-xr/assets/samples/gestures_heuristic.html +256 -0
- package/skills/mudra-master/mudra-xr/assets/samples/lighting.html +163 -0
- package/skills/mudra-master/mudra-xr/assets/samples/mesh_detection.html +46 -0
- package/skills/mudra-master/mudra-xr/assets/samples/modelviewer.html +183 -0
- package/skills/mudra-master/mudra-xr/assets/samples/paint.html +133 -0
- package/skills/mudra-master/mudra-xr/assets/samples/planar-vst.html +45 -0
- package/skills/mudra-master/mudra-xr/assets/samples/reticle.html +95 -0
- package/skills/mudra-master/mudra-xr/assets/samples/skybox_agent.html +384 -0
- package/skills/mudra-master/mudra-xr/assets/samples/sound.html +390 -0
- package/skills/mudra-master/mudra-xr/assets/samples/ui.html +496 -0
- package/skills/mudra-master/mudra-xr/assets/samples/virtual-screens.html +808 -0
- package/skills/mudra-master/mudra-xr/assets/templates/0_basic.html +64 -0
- package/skills/mudra-master/mudra-xr/assets/templates/1_ui.html +107 -0
- package/skills/mudra-master/mudra-xr/assets/templates/2_hands.html +183 -0
- package/skills/mudra-master/mudra-xr/assets/templates/3_depth.html +89 -0
- package/skills/mudra-master/mudra-xr/assets/templates/4_stereo.html +84 -0
- package/skills/mudra-master/mudra-xr/assets/templates/5_camera.html +117 -0
- package/skills/mudra-master/mudra-xr/assets/templates/6_ai.html +154 -0
- package/skills/mudra-master/mudra-xr/assets/templates/7_ai_live.html +253 -0
- package/skills/mudra-master/mudra-xr/assets/templates/8_objects.html +95 -0
- package/skills/mudra-master/mudra-xr/assets/templates/9_xr-toggle.html +90 -0
- package/skills/mudra-master/mudra-xr/assets/templates/heuristic_hand_gestures.html +102 -0
- package/skills/mudra-master/mudra-xr/assets/templates/meshes.html +47 -0
- package/skills/mudra-master/mudra-xr/assets/templates/planes.html +47 -0
- package/skills/mudra-master/mudra-xr/assets/templates/uikit.html +187 -0
- package/skills/mudra-master/mudra-xr/references/agent_protocol.json +878 -0
- package/skills/mudra-master/mudra-xr/references/promt.md +1516 -0
- package/skills/mudra-preview/SKILL.md +151 -0
- package/skills/mudra-preview/assets/ar-menu.html +382 -0
- package/skills/mudra-preview/assets/document-scroller.html +468 -0
- package/skills/mudra-preview/assets/drum-machine.html +788 -0
- package/skills/mudra-preview/assets/emg-visualizer.html +390 -0
- package/skills/mudra-preview/assets/generative-art.html +408 -0
- package/skills/mudra-preview/assets/gesture-assistant.html +342 -0
- package/skills/mudra-preview/assets/gesture-speech.html +444 -0
- package/skills/mudra-preview/assets/hands-free-desktop.html +376 -0
- package/skills/mudra-preview/assets/model-rotator.html +365 -0
- package/skills/mudra-preview/assets/mudra-duel.html +3200 -0
- package/skills/mudra-preview/assets/mudra-monitor.html +1451 -0
- package/skills/mudra-preview/assets/mudra-ultimate-template.html +891 -0
- package/skills/mudra-preview/assets/music-sequencer.html +455 -0
- package/skills/mudra-preview/assets/neural-pong.html +380 -0
- package/skills/mudra-preview/assets/neural-snake.html +364 -0
- package/skills/mudra-preview/assets/presentation-controller.html +345 -0
- package/skills/mudra-preview/assets/pressure-painter.html +285 -0
- package/skills/mudra-preview/assets/runner.html +1812 -0
- package/skills/mudra-preview/assets/smart-home.html +461 -0
- package/skills/mudra-preview/assets/space-invaders.html +1154 -0
- package/skills/mudra-preview/assets/waterful-ring-toss.html +1624 -0
- package/skills/mudra-preview/references/agent_protocol.json +646 -0
- package/skills/mudra-preview/references/promt.md +16829 -0
- package/skills/mudra-xr/SKILL.md +245 -0
- package/skills/mudra-xr/assets/demos/3dgs-walkthrough.html +242 -0
- package/skills/mudra-xr/assets/demos/aisimulator.html +792 -0
- package/skills/mudra-xr/assets/demos/balloonpop.html +422 -0
- package/skills/mudra-xr/assets/demos/ballpit.html +284 -0
- package/skills/mudra-xr/assets/demos/drone.html +41 -0
- package/skills/mudra-xr/assets/demos/gemini-icebreakers.html +338 -0
- package/skills/mudra-xr/assets/demos/gemini-xrobject.html +302 -0
- package/skills/mudra-xr/assets/demos/math3d.html +188 -0
- package/skills/mudra-xr/assets/demos/measure.html +247 -0
- package/skills/mudra-xr/assets/demos/occlusion.html +251 -0
- package/skills/mudra-xr/assets/demos/rain.html +498 -0
- package/skills/mudra-xr/assets/demos/screenwiper.html +432 -0
- package/skills/mudra-xr/assets/demos/splash.html +519 -0
- package/skills/mudra-xr/assets/demos/webcam_gestures.html +253 -0
- package/skills/mudra-xr/assets/demos/xremoji.html +833 -0
- package/skills/mudra-xr/assets/demos/xrpoet.html +151 -0
- package/skills/mudra-xr/assets/samples/depthmap.html +266 -0
- package/skills/mudra-xr/assets/samples/depthmesh.html +100 -0
- package/skills/mudra-xr/assets/samples/game_rps.html +1099 -0
- package/skills/mudra-xr/assets/samples/gestures_custom.html +467 -0
- package/skills/mudra-xr/assets/samples/gestures_heuristic.html +256 -0
- package/skills/mudra-xr/assets/samples/lighting.html +163 -0
- package/skills/mudra-xr/assets/samples/mesh_detection.html +46 -0
- package/skills/mudra-xr/assets/samples/modelviewer.html +183 -0
- package/skills/mudra-xr/assets/samples/paint.html +133 -0
- package/skills/mudra-xr/assets/samples/planar-vst.html +45 -0
- package/skills/mudra-xr/assets/samples/reticle.html +95 -0
- package/skills/mudra-xr/assets/samples/skybox_agent.html +384 -0
- package/skills/mudra-xr/assets/samples/sound.html +390 -0
- package/skills/mudra-xr/assets/samples/ui.html +496 -0
- package/skills/mudra-xr/assets/samples/virtual-screens.html +808 -0
- package/skills/mudra-xr/assets/templates/0_basic.html +64 -0
- package/skills/mudra-xr/assets/templates/1_ui.html +107 -0
- package/skills/mudra-xr/assets/templates/2_hands.html +183 -0
- package/skills/mudra-xr/assets/templates/3_depth.html +89 -0
- package/skills/mudra-xr/assets/templates/4_stereo.html +84 -0
- package/skills/mudra-xr/assets/templates/5_camera.html +117 -0
- package/skills/mudra-xr/assets/templates/6_ai.html +154 -0
- package/skills/mudra-xr/assets/templates/7_ai_live.html +253 -0
- package/skills/mudra-xr/assets/templates/8_objects.html +95 -0
- package/skills/mudra-xr/assets/templates/9_xr-toggle.html +90 -0
- package/skills/mudra-xr/assets/templates/heuristic_hand_gestures.html +102 -0
- package/skills/mudra-xr/assets/templates/meshes.html +47 -0
- package/skills/mudra-xr/assets/templates/planes.html +47 -0
- package/skills/mudra-xr/assets/templates/uikit.html +187 -0
- package/skills/mudra-xr/references/agent_protocol.json +878 -0
- package/skills/mudra-xr/references/promt.md +1516 -0
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!-- reference: samples/game_rps -->
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<title>Rock Paper Scissors | XR Blocks</title>
|
|
6
|
+
|
|
7
|
+
<meta
|
|
8
|
+
name="viewport"
|
|
9
|
+
content="width=device-width, initial-scale=1.0, user-scalable=no"
|
|
10
|
+
/>
|
|
11
|
+
|
|
12
|
+
<link type="text/css" rel="stylesheet" href="https://xrblocks.github.io/css/xr.css" />
|
|
13
|
+
<script>
|
|
14
|
+
window.litDisableBundleWarning = true;
|
|
15
|
+
</script>
|
|
16
|
+
<script type="importmap">
|
|
17
|
+
{
|
|
18
|
+
"imports": {
|
|
19
|
+
"lit": "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js",
|
|
20
|
+
"lit/": "https://esm.run/lit@3/",
|
|
21
|
+
"three": "https://cdn.jsdelivr.net/npm/three@0.182.0/build/three.module.js",
|
|
22
|
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/",
|
|
23
|
+
"troika-three-text": "https://cdn.jsdelivr.net/gh/protectwise/troika@028b81cf308f0f22e5aa8e78196be56ec1997af5/packages/troika-three-text/src/index.js",
|
|
24
|
+
"troika-three-utils": "https://cdn.jsdelivr.net/gh/protectwise/troika@v0.52.4/packages/troika-three-utils/src/index.js",
|
|
25
|
+
"troika-worker-utils": "https://cdn.jsdelivr.net/gh/protectwise/troika@v0.52.4/packages/troika-worker-utils/src/index.js",
|
|
26
|
+
"bidi-js": "https://esm.sh/bidi-js@%5E1.0.2?target=es2022",
|
|
27
|
+
"webgl-sdf-generator": "https://esm.sh/webgl-sdf-generator@1.1.1/es2022/webgl-sdf-generator.mjs",
|
|
28
|
+
"@tensorflow/tfjs-core": "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core/+esm",
|
|
29
|
+
"@tensorflow/tfjs": "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs/+esm",
|
|
30
|
+
"@tensorflow/tfjs-backend-webgpu": "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgpu/+esm",
|
|
31
|
+
"@litertjs/core": "https://unpkg.com/@litertjs/core@0.2.1",
|
|
32
|
+
"@litertjs/tfjs-interop": "https://unpkg.com/@litertjs/tfjs-interop@1.0.1",
|
|
33
|
+
"@litertjs/wasm-utils": "https://unpkg.com/@litertjs/wasm-utils@0.2.1",
|
|
34
|
+
"xrblocks": "https://cdn.jsdelivr.net/npm/xrblocks@0.11.0/build/xrblocks.js",
|
|
35
|
+
"xrblocks/addons/": "https://cdn.jsdelivr.net/npm/xrblocks@0.11.0/build/addons/"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
</head>
|
|
40
|
+
|
|
41
|
+
<body>
|
|
42
|
+
<script type="module">
|
|
43
|
+
import 'xrblocks/addons/simulator/SimulatorAddons.js';
|
|
44
|
+
|
|
45
|
+
// LiteRt
|
|
46
|
+
import {loadLiteRt, setWebGpuDevice} from '@litertjs/core';
|
|
47
|
+
import {runWithTfjsTensors} from '@litertjs/tfjs-interop';
|
|
48
|
+
// TensorflowJS + WebGPU backend
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
50
|
+
import {WebGPUBackend} from '@tensorflow/tfjs-backend-webgpu';
|
|
51
|
+
import * as tf from '@tensorflow/tfjs';
|
|
52
|
+
import * as THREE from 'three';
|
|
53
|
+
import * as xb from 'xrblocks';
|
|
54
|
+
|
|
55
|
+
// --- Inlined: GestureDetectionHandler.js ---
|
|
56
|
+
const GESTURE_RETURN_LABELS_MAX_ENUM = 5;
|
|
57
|
+
|
|
58
|
+
const UNKNOWN_GESTURE = 0;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Threshold for a dot product to indicate a straight or extended finger segment.
|
|
62
|
+
* 0.9 implies ~25.8 degrees tolerance.
|
|
63
|
+
* @const {number}
|
|
64
|
+
*/
|
|
65
|
+
const STRAIGHT_FINGER_THRESHOLD = 0.9;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Indices in the `relativeHandBoneAngles` tensor, derived directly from the
|
|
69
|
+
* order in the HAND_BONE_IDX_CONNECTION_MAP provided.
|
|
70
|
+
* @enum {number}
|
|
71
|
+
*/
|
|
72
|
+
const ANGLE_INDICES = {
|
|
73
|
+
// Thumb angles (2 angles for straightness)
|
|
74
|
+
THUMB_SEGMENT_ANGLE_1: 0,
|
|
75
|
+
THUMB_SEGMENT_ANGLE_2: 1,
|
|
76
|
+
|
|
77
|
+
// Index finger angles (3 angles for straightness)
|
|
78
|
+
INDEX_SEGMENT_ANGLE_1: 2,
|
|
79
|
+
INDEX_SEGMENT_ANGLE_2: 3,
|
|
80
|
+
INDEX_SEGMENT_ANGLE_3: 4,
|
|
81
|
+
|
|
82
|
+
// Middle finger angles (3 angles for straightness)
|
|
83
|
+
MIDDLE_SEGMENT_ANGLE_1: 5,
|
|
84
|
+
MIDDLE_SEGMENT_ANGLE_2: 6,
|
|
85
|
+
MIDDLE_SEGMENT_ANGLE_3: 7,
|
|
86
|
+
|
|
87
|
+
// Ring finger angles (3 angles for straightness)
|
|
88
|
+
RING_SEGMENT_ANGLE_1: 8,
|
|
89
|
+
RING_SEGMENT_ANGLE_2: 9,
|
|
90
|
+
RING_SEGMENT_ANGLE_3: 10,
|
|
91
|
+
|
|
92
|
+
// Pinky finger angles (3 angles for straightness)
|
|
93
|
+
PINKY_SEGMENT_ANGLE_1: 11,
|
|
94
|
+
PINKY_SEGMENT_ANGLE_2: 12,
|
|
95
|
+
PINKY_SEGMENT_ANGLE_3: 13,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A queue that only processes the most recent task, discarding outdated ones.
|
|
100
|
+
*/
|
|
101
|
+
class LatestTaskQueue {
|
|
102
|
+
constructor() {
|
|
103
|
+
this.latestTask = null;
|
|
104
|
+
this.isProcessing = false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
enqueue(task) {
|
|
108
|
+
if (typeof task !== 'function') {
|
|
109
|
+
console.error('Task must be a function.');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.latestTask = task;
|
|
113
|
+
if (!this.isProcessing) {
|
|
114
|
+
this.processLatestTask();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
processLatestTask() {
|
|
119
|
+
if (this.latestTask) {
|
|
120
|
+
this.isProcessing = true;
|
|
121
|
+
const taskToProcess = this.latestTask;
|
|
122
|
+
this.latestTask = null; // Clear the reference immediately
|
|
123
|
+
|
|
124
|
+
// Execute the task asynchronously using setTimeout (or queueMicrotask)
|
|
125
|
+
setTimeout(async () => {
|
|
126
|
+
try {
|
|
127
|
+
await taskToProcess(); // If the task is async
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error('Error processing latest task:', error);
|
|
130
|
+
} finally {
|
|
131
|
+
this.isProcessing = false;
|
|
132
|
+
if (this.latestTask) {
|
|
133
|
+
this.processLatestTask();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}, 0); // Delay of 0ms puts it in the event queue
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getSize() {
|
|
141
|
+
return this.latestTask === null ? 0 : 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
isEmpty() {
|
|
145
|
+
return this.latestTask === null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Handles gesture detection using TFJS and LiteRT.
|
|
151
|
+
*/
|
|
152
|
+
class GestureDetectionHandler {
|
|
153
|
+
constructor() {
|
|
154
|
+
// model
|
|
155
|
+
this.modelPath = './custom_gestures_model.tflite';
|
|
156
|
+
this.modelState = 'None';
|
|
157
|
+
|
|
158
|
+
// Enable/Disable paper gesture detection
|
|
159
|
+
this.enablePaperDetection = false;
|
|
160
|
+
|
|
161
|
+
//
|
|
162
|
+
// left and right hand queues
|
|
163
|
+
this.queue = [];
|
|
164
|
+
this.queue.push(new LatestTaskQueue());
|
|
165
|
+
this.queue.push(new LatestTaskQueue());
|
|
166
|
+
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
this.setBackendAndLoadModel();
|
|
169
|
+
}, 1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Enables or disables specific paper gesture detection logic.
|
|
174
|
+
* @param {boolean} value
|
|
175
|
+
*/
|
|
176
|
+
enablePaperGesture(value) {
|
|
177
|
+
this.enablePaperDetection = value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Sets up the WebGPU backend and loads the TFLite model.
|
|
182
|
+
*/
|
|
183
|
+
async setBackendAndLoadModel() {
|
|
184
|
+
this.modelState = 'Loading';
|
|
185
|
+
try {
|
|
186
|
+
await tf.setBackend('webgpu');
|
|
187
|
+
await tf.ready();
|
|
188
|
+
|
|
189
|
+
const wasmPath = 'https://unpkg.com/@litertjs/core@0.2.1/wasm/';
|
|
190
|
+
const liteRt = await loadLiteRt(wasmPath);
|
|
191
|
+
|
|
192
|
+
// Makes LiteRT use the same GPU device as TFJS (for tensor conversion).
|
|
193
|
+
const backend = tf.backend(); // as WebGPUBackend;
|
|
194
|
+
setWebGpuDevice(backend.device);
|
|
195
|
+
|
|
196
|
+
// Loads model via LiteRT.
|
|
197
|
+
await this.loadModel(liteRt);
|
|
198
|
+
|
|
199
|
+
if (this.model) {
|
|
200
|
+
// Prints model details to the log.
|
|
201
|
+
console.log('MODEL DETAILS: ', this.model.getInputDetails());
|
|
202
|
+
}
|
|
203
|
+
this.modelState = 'Ready';
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.error('Failed to load model or backend:', error);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Loads the TFLite model.
|
|
211
|
+
* @param {Object} liteRt The LiteRT instance.
|
|
212
|
+
*/
|
|
213
|
+
async loadModel(liteRt) {
|
|
214
|
+
try {
|
|
215
|
+
this.model = await liteRt.loadAndCompile(this.modelPath, {
|
|
216
|
+
// Currently, only 'webgpu' is supported.
|
|
217
|
+
accelerator: 'webgpu',
|
|
218
|
+
});
|
|
219
|
+
} catch (error) {
|
|
220
|
+
this.model = null;
|
|
221
|
+
console.error('Error loading model:', error);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
calculateRelativeHandBoneAngles(jointPositions) {
|
|
226
|
+
// Reshape jointPositions.
|
|
227
|
+
let jointPositionsReshaped = [];
|
|
228
|
+
|
|
229
|
+
jointPositionsReshaped = jointPositions.reshape([xb.HAND_JOINT_COUNT, 3]);
|
|
230
|
+
|
|
231
|
+
// Calculates bone vectors.
|
|
232
|
+
const boneVectors = [];
|
|
233
|
+
xb.HAND_JOINT_IDX_CONNECTION_MAP.forEach(([joint1, joint2]) => {
|
|
234
|
+
const boneVector = jointPositionsReshaped
|
|
235
|
+
.slice([joint2, 0], [1, 3])
|
|
236
|
+
.sub(jointPositionsReshaped.slice([joint1, 0], [1, 3]))
|
|
237
|
+
.squeeze();
|
|
238
|
+
const norm = boneVector.norm();
|
|
239
|
+
const normalizedBoneVector = boneVector.div(norm);
|
|
240
|
+
boneVectors.push(normalizedBoneVector);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Calculates relative hand bone angles.
|
|
244
|
+
const relativeHandBoneAngles = [];
|
|
245
|
+
xb.HAND_BONE_IDX_CONNECTION_MAP.forEach(([bone1, bone2]) => {
|
|
246
|
+
const angle = boneVectors[bone1].dot(boneVectors[bone2]);
|
|
247
|
+
relativeHandBoneAngles.push(angle);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Stacks the angles into a tensor.
|
|
251
|
+
return tf.stack(relativeHandBoneAngles);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Runs inference on hand joints.
|
|
256
|
+
* @param {Array<number>} handJoints Flat array of joint positions.
|
|
257
|
+
* @return {Promise<number>} The detected gesture ID.
|
|
258
|
+
*/
|
|
259
|
+
async detectGesture(handJoints) {
|
|
260
|
+
if (!this.model || !handJoints || handJoints.length !== 25 * 3) {
|
|
261
|
+
console.log('Invalid hand joints or model load error.');
|
|
262
|
+
return GESTURE_RETURN_LABELS_MAX_ENUM;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
// Use tidy to clean up intermediate tensors.
|
|
267
|
+
const tensor = tf.tidy(() =>
|
|
268
|
+
this.calculateRelativeHandBoneAngles(tf.tensor1d(handJoints))
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// Detect "Paper" gesture for 2 seconds.
|
|
272
|
+
if (this.enablePaperDetection && this.isPaperGesture(tensor)) {
|
|
273
|
+
// Paper gesture - GESTURE_RETURN_LABELS
|
|
274
|
+
return 2;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const tensorReshaped = tf.tidy(() =>
|
|
278
|
+
tensor.reshape([1, xb.HAND_BONE_IDX_CONNECTION_MAP.length, 1])
|
|
279
|
+
);
|
|
280
|
+
tensor.dispose(); // Disposes original tensor after reshape.
|
|
281
|
+
|
|
282
|
+
const result = runWithTfjsTensors(this.model, tensorReshaped);
|
|
283
|
+
tensorReshaped.dispose();
|
|
284
|
+
|
|
285
|
+
const integerLabel = result[0].as1D().arraySync();
|
|
286
|
+
if (integerLabel.length == 7) {
|
|
287
|
+
let maxScore = integerLabel[0];
|
|
288
|
+
|
|
289
|
+
let idx = 0;
|
|
290
|
+
for (let t = 0; t < 7; ++t) {
|
|
291
|
+
if (integerLabel[t] > maxScore) {
|
|
292
|
+
idx = t;
|
|
293
|
+
maxScore = integerLabel[t];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// No need to detect thumb up for a play mode
|
|
298
|
+
if (idx === 2) {
|
|
299
|
+
if (
|
|
300
|
+
this.enablePaperDetection ||
|
|
301
|
+
!this.isThumbUp(handJoints, 2, 3, 4)
|
|
302
|
+
) {
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
//
|
|
308
|
+
// Maps model result to the return index for the game.
|
|
309
|
+
//
|
|
310
|
+
|
|
311
|
+
// Thumb Up Logic check (Model Index 2)
|
|
312
|
+
if (idx == 2) {
|
|
313
|
+
return 4; // Thumb Up
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Fist (Model Index 4)
|
|
317
|
+
if (idx == 4) {
|
|
318
|
+
return 2; // Rock
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Victory / Scissors (Model Index 1)
|
|
322
|
+
else if (idx == 1) {
|
|
323
|
+
return 1; // Scissors
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// OTHER gesture
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error('Error:', error);
|
|
331
|
+
}
|
|
332
|
+
return GESTURE_RETURN_LABELS_MAX_ENUM;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Wrapper for thumb up detection.
|
|
337
|
+
*/
|
|
338
|
+
isThumbUp(d1, p1, p2, p3) {
|
|
339
|
+
return this.isThumbUpSimple(d1, p1, p3);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Advanced vector calculation for thumb up.
|
|
344
|
+
* @param {Array<number>} data Flat array of joints.
|
|
345
|
+
* @param {number} p1 Base index.
|
|
346
|
+
* @param {number} p2 Knuckle index.
|
|
347
|
+
* @param {number} p3 Tip index.
|
|
348
|
+
* @return {boolean}
|
|
349
|
+
*/
|
|
350
|
+
isThumbUpAdvanced(data, p1, p2, p3) {
|
|
351
|
+
const v1 = {
|
|
352
|
+
x: data[p2 * 3] - data[p1 * 3],
|
|
353
|
+
y: data[p2 * 3 + 1] - data[p1 * 3 + 1],
|
|
354
|
+
z: data[p2 * 3 + 2] - data[p1 * 3 + 2],
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const v2 = {
|
|
358
|
+
x: data[p3 * 3] - data[p2 * 3],
|
|
359
|
+
y: data[p3 * 3 + 1] - data[p2 * 3 + 1],
|
|
360
|
+
z: data[p3 * 3 + 2] - data[p2 * 3 + 2],
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const dotProduct = v1.x * v2.x + v1.y * v2.y + v1.z * v2.z;
|
|
364
|
+
|
|
365
|
+
const magnitudeV1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y + v1.z * v1.z);
|
|
366
|
+
const magnitudeV2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y + v2.z * v2.z);
|
|
367
|
+
|
|
368
|
+
if (magnitudeV1 === 0 || magnitudeV2 === 0) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const cosAngle = dotProduct / (magnitudeV1 * magnitudeV2);
|
|
373
|
+
const angleRadians = Math.acos(Math.max(-1, Math.min(1, cosAngle)));
|
|
374
|
+
const angleDegrees = angleRadians * (180 / Math.PI);
|
|
375
|
+
const thumbUpThreshold = 90;
|
|
376
|
+
|
|
377
|
+
return angleDegrees > thumbUpThreshold;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Detects if the hand is showing a "paper" (open palm) gesture.
|
|
382
|
+
* @param {tf.Tensor} relativeHandBoneAngles A TensorFlow.js tensor of bone angles.
|
|
383
|
+
* @returns {boolean} True if the gesture is "paper", false otherwise.
|
|
384
|
+
*/
|
|
385
|
+
isPaperGesture(relativeHandBoneAngles) {
|
|
386
|
+
if (!relativeHandBoneAngles || relativeHandBoneAngles.size === 0) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const angles = relativeHandBoneAngles.arraySync();
|
|
391
|
+
|
|
392
|
+
const areSegmentsStraight = (...angleIndices) => {
|
|
393
|
+
return angleIndices.every(
|
|
394
|
+
(idx) => angles[idx] > STRAIGHT_FINGER_THRESHOLD
|
|
395
|
+
);
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const isThumbStraight = areSegmentsStraight(
|
|
399
|
+
ANGLE_INDICES.THUMB_SEGMENT_ANGLE_1,
|
|
400
|
+
ANGLE_INDICES.THUMB_SEGMENT_ANGLE_2
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const isIndexStraight = areSegmentsStraight(
|
|
404
|
+
ANGLE_INDICES.INDEX_SEGMENT_ANGLE_1,
|
|
405
|
+
ANGLE_INDICES.INDEX_SEGMENT_ANGLE_2,
|
|
406
|
+
ANGLE_INDICES.INDEX_SEGMENT_ANGLE_3
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const isMiddleStraight = areSegmentsStraight(
|
|
410
|
+
ANGLE_INDICES.MIDDLE_SEGMENT_ANGLE_1,
|
|
411
|
+
ANGLE_INDICES.MIDDLE_SEGMENT_ANGLE_2,
|
|
412
|
+
ANGLE_INDICES.MIDDLE_SEGMENT_ANGLE_3
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const isRingStraight = areSegmentsStraight(
|
|
416
|
+
ANGLE_INDICES.RING_SEGMENT_ANGLE_1,
|
|
417
|
+
ANGLE_INDICES.RING_SEGMENT_ANGLE_2,
|
|
418
|
+
ANGLE_INDICES.RING_SEGMENT_ANGLE_3
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const isPinkyStraight = areSegmentsStraight(
|
|
422
|
+
ANGLE_INDICES.PINKY_SEGMENT_ANGLE_1,
|
|
423
|
+
ANGLE_INDICES.PINKY_SEGMENT_ANGLE_2,
|
|
424
|
+
ANGLE_INDICES.PINKY_SEGMENT_ANGLE_3
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const isPaper =
|
|
428
|
+
isThumbStraight &&
|
|
429
|
+
isIndexStraight &&
|
|
430
|
+
isMiddleStraight &&
|
|
431
|
+
isRingStraight &&
|
|
432
|
+
isPinkyStraight;
|
|
433
|
+
|
|
434
|
+
return isPaper;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
//
|
|
438
|
+
// Clone joints and post queue task
|
|
439
|
+
//
|
|
440
|
+
postTask(joints, handIndex) {
|
|
441
|
+
if (Object.keys(joints).length !== 25) {
|
|
442
|
+
return UNKNOWN_GESTURE;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let handJointPositions = [];
|
|
446
|
+
for (const i in joints) {
|
|
447
|
+
handJointPositions.push(joints[i].position.x);
|
|
448
|
+
handJointPositions.push(joints[i].position.y);
|
|
449
|
+
handJointPositions.push(joints[i].position.z);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (handJointPositions.length !== 25 * 3) {
|
|
453
|
+
return UNKNOWN_GESTURE;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (handIndex >= 0 && handIndex < this.queue.length) {
|
|
457
|
+
this.queue[handIndex].enqueue(async () => {
|
|
458
|
+
let result = await this.detectGesture(handJointPositions);
|
|
459
|
+
|
|
460
|
+
if (this.observer && this.observer.onGestureDetected) {
|
|
461
|
+
this.observer.onGestureDetected(handIndex, result);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
isThumbUpSimple(data, p1, p2) {
|
|
468
|
+
const vector = {
|
|
469
|
+
x: data[p2 * 3] - data[p1 * 3],
|
|
470
|
+
y: data[p2 * 3 + 1] - data[p1 * 3 + 1],
|
|
471
|
+
z: data[p2 * 3 + 2] - data[p1 * 3 + 2],
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const magnitude = Math.sqrt(
|
|
475
|
+
vector.x * vector.x + vector.y * vector.y + vector.z * vector.z
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
if (magnitude < 0.001) {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const normalizedVector = {
|
|
483
|
+
x: vector.x / magnitude,
|
|
484
|
+
y: vector.y / magnitude,
|
|
485
|
+
z: vector.z / magnitude,
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const upVector = {x: 0, y: 1, z: 0};
|
|
489
|
+
|
|
490
|
+
const dotProduct =
|
|
491
|
+
normalizedVector.x * upVector.x +
|
|
492
|
+
normalizedVector.y * upVector.y +
|
|
493
|
+
normalizedVector.z * upVector.z;
|
|
494
|
+
|
|
495
|
+
const cos45Degrees = Math.cos((45 * Math.PI) / 180); // Approximately 0.707
|
|
496
|
+
|
|
497
|
+
return dotProduct >= cos45Degrees;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
registerObserver(observer) {
|
|
501
|
+
this.observer = observer;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// --- Inlined: GameRps.js ---
|
|
506
|
+
const LEFT_HAND_INDEX = 0;
|
|
507
|
+
const RIGHT_HAND_INDEX = 1;
|
|
508
|
+
|
|
509
|
+
const countdownImages = [
|
|
510
|
+
'images/start1.webp',
|
|
511
|
+
'images/start2.webp',
|
|
512
|
+
'images/start3.webp',
|
|
513
|
+
'images/startGo.webp',
|
|
514
|
+
];
|
|
515
|
+
|
|
516
|
+
const rpsLeftImages = [
|
|
517
|
+
'images/gestureUnknown.webp',
|
|
518
|
+
'images/gestureFistLeft.webp',
|
|
519
|
+
'images/gestureScissorsLeft.webp',
|
|
520
|
+
'images/gesturePaperLeft.webp',
|
|
521
|
+
];
|
|
522
|
+
|
|
523
|
+
const rpsRightImages = [
|
|
524
|
+
'images/gestureUnknown.webp',
|
|
525
|
+
'images/gestureFistRight.webp',
|
|
526
|
+
'images/gestureScissorsRight.webp',
|
|
527
|
+
'images/gesturePaperRight.webp',
|
|
528
|
+
];
|
|
529
|
+
|
|
530
|
+
const resultImages = [
|
|
531
|
+
'images/resultTie.webp',
|
|
532
|
+
'images/resultWin.webp',
|
|
533
|
+
'images/resultLose.webp',
|
|
534
|
+
];
|
|
535
|
+
|
|
536
|
+
const gameOutcomePhrases = [
|
|
537
|
+
// Phrases for a draw
|
|
538
|
+
[
|
|
539
|
+
'A draw!',
|
|
540
|
+
"We've matched.",
|
|
541
|
+
'Great minds think alike.',
|
|
542
|
+
"It's a stalemate.",
|
|
543
|
+
"We're even.",
|
|
544
|
+
'Neither of us wins this time.',
|
|
545
|
+
"Looks like we're in sync.",
|
|
546
|
+
"We'll have to go again.",
|
|
547
|
+
'A perfect match.',
|
|
548
|
+
"It's a tie.",
|
|
549
|
+
],
|
|
550
|
+
// Phrases for a Gemeni lose
|
|
551
|
+
[
|
|
552
|
+
'You got me.',
|
|
553
|
+
'Nicely done, you win.',
|
|
554
|
+
'Ah, you were one step ahead.',
|
|
555
|
+
'The victory is yours.',
|
|
556
|
+
"I'll have to get you next time.",
|
|
557
|
+
"You've defeated me.",
|
|
558
|
+
"I couldn't beat that.",
|
|
559
|
+
'Well played, you earned it.',
|
|
560
|
+
'You read my mind.',
|
|
561
|
+
'The point goes to you.',
|
|
562
|
+
],
|
|
563
|
+
// Phrases for a Gemini victory
|
|
564
|
+
[
|
|
565
|
+
'Victory is mine!',
|
|
566
|
+
'I got you that time.',
|
|
567
|
+
'Looks like I came out on top.',
|
|
568
|
+
"I'll take that win.",
|
|
569
|
+
'Another one for my column.',
|
|
570
|
+
"You've been bested.",
|
|
571
|
+
'I predicted your move perfectly.',
|
|
572
|
+
"That's how it's done.",
|
|
573
|
+
'I have the winning strategy.',
|
|
574
|
+
'The round goes to me.',
|
|
575
|
+
],
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
class GameRps extends xb.Script {
|
|
579
|
+
constructor() {
|
|
580
|
+
super();
|
|
581
|
+
// List of detected gestures for the left and right hands.
|
|
582
|
+
this.handGesture = [[], []];
|
|
583
|
+
this.isDebug = false;
|
|
584
|
+
|
|
585
|
+
//
|
|
586
|
+
// Initializes UI.
|
|
587
|
+
//
|
|
588
|
+
{
|
|
589
|
+
// Makes a root panel > grid > row > controlPanel > grid.
|
|
590
|
+
const panel = new xb.SpatialPanel({
|
|
591
|
+
backgroundColor: '#00000000',
|
|
592
|
+
useDefaultPosition: false,
|
|
593
|
+
showEdge: false,
|
|
594
|
+
});
|
|
595
|
+
panel.scale.set(panel.width, panel.height, 1);
|
|
596
|
+
panel.isRoot = true;
|
|
597
|
+
this.add(panel);
|
|
598
|
+
|
|
599
|
+
const grid = panel.addGrid();
|
|
600
|
+
// Adds blank space on top of the ctrlPanel.
|
|
601
|
+
this.startImageRow = grid.addRow({weight: 0.4});
|
|
602
|
+
this.startImageRow.addCol({weight: 0.3});
|
|
603
|
+
this.startImage = this.startImageRow.addCol({weight: 0.4}).addImage({
|
|
604
|
+
src: 'images/startStart.webp',
|
|
605
|
+
});
|
|
606
|
+
this.startImageRow.addCol({weight: 0.3});
|
|
607
|
+
|
|
608
|
+
if (this.isDebug) {
|
|
609
|
+
//
|
|
610
|
+
// UI to debug user gestures.
|
|
611
|
+
//
|
|
612
|
+
this.playImageRow = grid.addRow({weight: 0.2});
|
|
613
|
+
this.playImageRow.addCol({weight: 0.2});
|
|
614
|
+
this.imageGesture1 = this.playImageRow.addCol({weight: 0.2}).addImage({
|
|
615
|
+
src: 'images/gestureEmpty.webp',
|
|
616
|
+
});
|
|
617
|
+
// VS text
|
|
618
|
+
this.playImageRow.addCol({weight: 0.2}).addImage({
|
|
619
|
+
src: 'images/resultVS.webp',
|
|
620
|
+
});
|
|
621
|
+
this.imageGesture2 = this.playImageRow.addCol({weight: 0.2}).addImage({
|
|
622
|
+
src: 'images/gestureEmpty.webp',
|
|
623
|
+
});
|
|
624
|
+
this.playImageRow.addCol({weight: 0.3});
|
|
625
|
+
this.playImageRow.hide();
|
|
626
|
+
|
|
627
|
+
this.resultImageRow = grid.addRow({weight: 0.2});
|
|
628
|
+
this.resultImageRow.addCol({weight: 0.4});
|
|
629
|
+
this.resultImage = this.resultImageRow.addCol({weight: 0.2}).addImage({
|
|
630
|
+
src: 'images/gestureEmpty.webp',
|
|
631
|
+
});
|
|
632
|
+
this.resultImageRow.hide();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Space for orbiter
|
|
636
|
+
grid.addRow({weight: 0.1});
|
|
637
|
+
// control row
|
|
638
|
+
const controlRow = grid.addRow({weight: 0.5});
|
|
639
|
+
const ctrlPanel = controlRow.addPanel({backgroundColor: '#000000bb'});
|
|
640
|
+
|
|
641
|
+
const ctrlGrid = ctrlPanel.addGrid();
|
|
642
|
+
{
|
|
643
|
+
// middle column
|
|
644
|
+
const midColumn = ctrlGrid.addCol({weight: 0.9});
|
|
645
|
+
|
|
646
|
+
// top indentation
|
|
647
|
+
midColumn.addRow({weight: 0.1});
|
|
648
|
+
|
|
649
|
+
const gesturesRow = midColumn.addRow({weight: 0.4});
|
|
650
|
+
|
|
651
|
+
// left indentation
|
|
652
|
+
gesturesRow.addCol({weight: 0.05});
|
|
653
|
+
|
|
654
|
+
const textCol = gesturesRow.addCol({weight: 1.0});
|
|
655
|
+
this.textField1 = textCol.addRow({weight: 1.0}).addText({
|
|
656
|
+
text: "Let's play Rock-Paper-Scissors!",
|
|
657
|
+
fontColor: '#ffffff',
|
|
658
|
+
fontSize: 0.045,
|
|
659
|
+
});
|
|
660
|
+
this.textField2 = textCol.addRow({weight: 1.0}).addText({
|
|
661
|
+
text: 'Do a thumbs-up gesture to get started!',
|
|
662
|
+
fontColor: '#ffffff',
|
|
663
|
+
fontSize: 0.045,
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// right indentation
|
|
667
|
+
gesturesRow.addCol({weight: 0.01});
|
|
668
|
+
|
|
669
|
+
// bottom indentation
|
|
670
|
+
midColumn.addRow({weight: 0.1});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const orbiter = ctrlGrid.addOrbiter();
|
|
674
|
+
orbiter.addExitButton();
|
|
675
|
+
|
|
676
|
+
panel.updateLayouts();
|
|
677
|
+
|
|
678
|
+
this.panel = panel;
|
|
679
|
+
|
|
680
|
+
// Gesture detector
|
|
681
|
+
this.gestureDetectionHandler = new GestureDetectionHandler();
|
|
682
|
+
this.gestureDetectionHandler.registerObserver(this);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// state for 1-2-3-GO
|
|
686
|
+
this.state = 0;
|
|
687
|
+
// delay for 1-2-3-GO
|
|
688
|
+
this.delayMs = this.isDebug ? 400 : 800;
|
|
689
|
+
// Wait for 2.5 sec to enable game restart
|
|
690
|
+
this.gameRestartTimeout = this.isDebug ? 1000 : 2500;
|
|
691
|
+
this.gameGestureDetectionTimeout = this.isDebug ? 900 : 2500;
|
|
692
|
+
|
|
693
|
+
// The gesture detection start time
|
|
694
|
+
this.gestureStartTime = 0;
|
|
695
|
+
|
|
696
|
+
// Frame counter
|
|
697
|
+
this.frameId = 0;
|
|
698
|
+
|
|
699
|
+
// Play states for both left and right hands
|
|
700
|
+
this.handRpsStates = [[], []];
|
|
701
|
+
|
|
702
|
+
// 1-2-3-GO
|
|
703
|
+
this.displayNextImage = this.displayNextImage.bind(this);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
displayNextImage() {
|
|
707
|
+
if (this.state < countdownImages.length) {
|
|
708
|
+
// Load and display the current image
|
|
709
|
+
this.startImage.load(countdownImages[this.state]);
|
|
710
|
+
this.state++; // Move to the next image for the next call
|
|
711
|
+
|
|
712
|
+
// Schedule the next image display after the delay
|
|
713
|
+
setTimeout(this.displayNextImage, this.delayMs);
|
|
714
|
+
} else {
|
|
715
|
+
this.gestureStartTime = Date.now();
|
|
716
|
+
// Enable the papter detection/disable thumb up
|
|
717
|
+
this.gestureDetectionHandler.enablePaperGesture(true);
|
|
718
|
+
|
|
719
|
+
setTimeout(() => {
|
|
720
|
+
this.displayRandomGesture();
|
|
721
|
+
}, this.delayMs);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
displayRandomGesture() {
|
|
726
|
+
this.randomGesture = Math.floor(Math.random() * 3) + 1;
|
|
727
|
+
if (this.isDebug) {
|
|
728
|
+
this.startImageRow.hide();
|
|
729
|
+
this.playImageRow.show();
|
|
730
|
+
this.resultImageRow.show();
|
|
731
|
+
// Load random gesture
|
|
732
|
+
this.imageGesture1.load(rpsLeftImages[this.randomGesture]);
|
|
733
|
+
} else {
|
|
734
|
+
this.startImage.load(rpsLeftImages[this.randomGesture]);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
displayGameSummary(idx) {
|
|
739
|
+
let result = 2;
|
|
740
|
+
if (idx == 0) {
|
|
741
|
+
this.textField1.setText("I didn't catch what you threw! Let's retry!");
|
|
742
|
+
this.textField2.setText('Do a thumbs-up gesture when you are ready!');
|
|
743
|
+
} else {
|
|
744
|
+
let result = this.getRPSOutcome(this.randomGesture, idx);
|
|
745
|
+
this.textField1.setText(this.getRandomPhrase(result));
|
|
746
|
+
this.textField2.setText('Do a thumbs-up gesture to play more!');
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
//
|
|
750
|
+
// Debug UI - show game summary and user gesture
|
|
751
|
+
//
|
|
752
|
+
if (this.isDebug) {
|
|
753
|
+
// 'OTHER', 'FIST', 'VICTORY', 'PAPER'
|
|
754
|
+
this.imageGesture2.load(rpsRightImages[idx < 4 ? idx : 0]);
|
|
755
|
+
// Show user gesture
|
|
756
|
+
this.resultImage.load(resultImages[result]);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Determines the outcome of a Rock-Paper-Scissors game.
|
|
762
|
+
* @param {number} playerChoice - The player's choice (1: Rock, 2: Paper, 3: Scissors).
|
|
763
|
+
* @param {number} opponentChoice - The opponent's choice (1: Rock, 2: Paper, 3: Scissors).
|
|
764
|
+
* @returns {number} The outcome (0: Match, 1: Win, 2: Lose).
|
|
765
|
+
*/
|
|
766
|
+
getRPSOutcome(playerChoice, opponentChoice) {
|
|
767
|
+
if (playerChoice === opponentChoice) {
|
|
768
|
+
return 0; // Match (Tie)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (
|
|
772
|
+
(playerChoice === 1 && opponentChoice === 3) || // Rock vs Scissors
|
|
773
|
+
(playerChoice === 2 && opponentChoice === 1) || // Paper vs Rock
|
|
774
|
+
(playerChoice === 3 && opponentChoice === 2) // Scissors vs Paper
|
|
775
|
+
) {
|
|
776
|
+
return 1; // Win
|
|
777
|
+
} else {
|
|
778
|
+
return 2; // Lose (all other cases are losses)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
startGame() {
|
|
783
|
+
if (this.isDebug) {
|
|
784
|
+
// Show 1-2-3-GO
|
|
785
|
+
this.startImageRow.show();
|
|
786
|
+
// Reset result images
|
|
787
|
+
this.imageGesture1.load('images/gestureEmpty.webp');
|
|
788
|
+
this.imageGesture2.load('images/gestureEmpty.webp');
|
|
789
|
+
this.resultImage.load('images/gestureEmpty.webp');
|
|
790
|
+
// hide result
|
|
791
|
+
this.playImageRow.hide();
|
|
792
|
+
this.resultImageRow.hide();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
this.textField1.setText('');
|
|
796
|
+
|
|
797
|
+
// start timer for the 1-2-3-go
|
|
798
|
+
this.displayNextImage();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
onGestureDetected(handIndex, result) {
|
|
802
|
+
// Thumb up
|
|
803
|
+
if (result == 4) {
|
|
804
|
+
// Ensure the game isn't currently active.
|
|
805
|
+
if (this.state === 0) {
|
|
806
|
+
this.startGame();
|
|
807
|
+
}
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (this.gestureStartTime === 0) {
|
|
812
|
+
// skip all late gesture detections
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Record all gesture changes for the
|
|
817
|
+
let delta = Date.now() - this.gestureStartTime;
|
|
818
|
+
//
|
|
819
|
+
// Gesture detection time interval exceeded
|
|
820
|
+
//
|
|
821
|
+
if (delta > this.gameGestureDetectionTimeout) {
|
|
822
|
+
this.gestureStartTime = 0;
|
|
823
|
+
this.detectFinalGestures();
|
|
824
|
+
} else {
|
|
825
|
+
let len = this.handRpsStates[handIndex].length;
|
|
826
|
+
if (
|
|
827
|
+
len == 0 ||
|
|
828
|
+
(this.handGesture[handIndex][len - 1] &&
|
|
829
|
+
this.handGesture[handIndex][len - 1].gesture !== result)
|
|
830
|
+
) {
|
|
831
|
+
// Save gesture-duration for each hand
|
|
832
|
+
this.handRpsStates[handIndex].push({gesture: result, delta: delta});
|
|
833
|
+
this.handGesture[handIndex] = result;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
detectFinalGestures() {
|
|
839
|
+
this.rightHandGesture = this.detectRPSGesture(
|
|
840
|
+
this.handRpsStates[RIGHT_HAND_INDEX]
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
this.displayGameSummary(this.rightHandGesture);
|
|
844
|
+
|
|
845
|
+
// Reset cached states
|
|
846
|
+
this.handRpsStates = [[], []];
|
|
847
|
+
|
|
848
|
+
// Disable the papter detection. Enable thumb up
|
|
849
|
+
this.gestureDetectionHandler.enablePaperGesture(false);
|
|
850
|
+
|
|
851
|
+
// add delay before user could re-start the game
|
|
852
|
+
setTimeout(() => {
|
|
853
|
+
// Ready to play again
|
|
854
|
+
this.state = 0;
|
|
855
|
+
}, this.gameRestartTimeout);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
detectRPSGesture(handRpsStates, options = {}) {
|
|
859
|
+
const defaultOptions = {
|
|
860
|
+
stabilityDurationMs: 300,
|
|
861
|
+
initialRockIgnoreDurationMs: 300,
|
|
862
|
+
maxDetectionWindowMs: this.gameGestureDetectionTimeout,
|
|
863
|
+
};
|
|
864
|
+
const {maxDetectionWindowMs} = {...defaultOptions, ...options};
|
|
865
|
+
|
|
866
|
+
if (!handRpsStates || handRpsStates.length === 0) {
|
|
867
|
+
return 0; // Other
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
handRpsStates.sort((a, b) => a.delta - b.delta);
|
|
871
|
+
|
|
872
|
+
const firstPhaseEndMs = maxDetectionWindowMs * (2 / 5);
|
|
873
|
+
const secondPhaseStartMs = maxDetectionWindowMs * (2 / 5);
|
|
874
|
+
|
|
875
|
+
let rockStartedInFirstPhase = false;
|
|
876
|
+
const gestureDurations = new Map();
|
|
877
|
+
|
|
878
|
+
let prevDelta = 0;
|
|
879
|
+
let prevGesture = 0;
|
|
880
|
+
|
|
881
|
+
for (let i = 0; i < handRpsStates.length; i++) {
|
|
882
|
+
const {gesture, delta} = handRpsStates[i];
|
|
883
|
+
|
|
884
|
+
if (delta > maxDetectionWindowMs) {
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const duration = delta - prevDelta;
|
|
889
|
+
|
|
890
|
+
if (prevGesture !== 0 && prevGesture > 0 && prevGesture < 4) {
|
|
891
|
+
gestureDurations.set(
|
|
892
|
+
prevGesture,
|
|
893
|
+
(gestureDurations.get(prevGesture) || 0) + duration
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (gesture === 1 && delta <= firstPhaseEndMs) {
|
|
898
|
+
rockStartedInFirstPhase = true;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
prevDelta = delta;
|
|
902
|
+
prevGesture = gesture;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (
|
|
906
|
+
prevDelta < maxDetectionWindowMs &&
|
|
907
|
+
prevGesture !== 0 &&
|
|
908
|
+
prevGesture > 0 &&
|
|
909
|
+
prevGesture < 4
|
|
910
|
+
) {
|
|
911
|
+
const remainingDuration = maxDetectionWindowMs - prevDelta;
|
|
912
|
+
gestureDurations.set(
|
|
913
|
+
prevGesture,
|
|
914
|
+
(gestureDurations.get(prevGesture) || 0) + remainingDuration
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
let longestGesture = 0;
|
|
919
|
+
let maxDuration = 0;
|
|
920
|
+
const minRequiredDuration = 300;
|
|
921
|
+
|
|
922
|
+
let effectiveStartTime = 0;
|
|
923
|
+
let effectiveEndTime = maxDetectionWindowMs;
|
|
924
|
+
|
|
925
|
+
if (rockStartedInFirstPhase) {
|
|
926
|
+
effectiveStartTime = secondPhaseStartMs;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const filteredGestureDurations = new Map();
|
|
930
|
+
let currentWindowPrevDelta = effectiveStartTime;
|
|
931
|
+
let currentWindowPrevGesture = 0;
|
|
932
|
+
|
|
933
|
+
for (let i = 0; i < handRpsStates.length; i++) {
|
|
934
|
+
const {gesture, delta} = handRpsStates[i];
|
|
935
|
+
if (delta >= effectiveStartTime) {
|
|
936
|
+
currentWindowPrevGesture = prevGesture;
|
|
937
|
+
if (i > 0) {
|
|
938
|
+
currentWindowPrevGesture = handRpsStates[i - 1].gesture;
|
|
939
|
+
} else {
|
|
940
|
+
currentWindowPrevGesture = gesture;
|
|
941
|
+
}
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
prevGesture = gesture;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
for (let i = 0; i < handRpsStates.length; i++) {
|
|
948
|
+
const {gesture, delta} = handRpsStates[i];
|
|
949
|
+
|
|
950
|
+
if (delta < effectiveStartTime) {
|
|
951
|
+
currentWindowPrevDelta = delta;
|
|
952
|
+
currentWindowPrevGesture = gesture;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (delta > effectiveEndTime) {
|
|
957
|
+
const duration = Math.max(
|
|
958
|
+
0,
|
|
959
|
+
effectiveEndTime -
|
|
960
|
+
Math.max(currentWindowPrevDelta, effectiveStartTime)
|
|
961
|
+
);
|
|
962
|
+
if (
|
|
963
|
+
currentWindowPrevGesture !== 0 &&
|
|
964
|
+
currentWindowPrevGesture > 0 &&
|
|
965
|
+
currentWindowPrevGesture < 4
|
|
966
|
+
) {
|
|
967
|
+
filteredGestureDurations.set(
|
|
968
|
+
currentWindowPrevGesture,
|
|
969
|
+
(filteredGestureDurations.get(currentWindowPrevGesture) || 0) +
|
|
970
|
+
duration
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const segmentStart = Math.max(currentWindowPrevDelta, effectiveStartTime);
|
|
977
|
+
const segmentEnd = delta;
|
|
978
|
+
const duration = segmentEnd - segmentStart;
|
|
979
|
+
|
|
980
|
+
if (
|
|
981
|
+
currentWindowPrevGesture !== 0 &&
|
|
982
|
+
currentWindowPrevGesture > 0 &&
|
|
983
|
+
currentWindowPrevGesture < 4
|
|
984
|
+
) {
|
|
985
|
+
filteredGestureDurations.set(
|
|
986
|
+
currentWindowPrevGesture,
|
|
987
|
+
(filteredGestureDurations.get(currentWindowPrevGesture) || 0) +
|
|
988
|
+
duration
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
currentWindowPrevDelta = delta;
|
|
993
|
+
currentWindowPrevGesture = gesture;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (
|
|
997
|
+
currentWindowPrevDelta < effectiveEndTime &&
|
|
998
|
+
currentWindowPrevGesture !== 0 &&
|
|
999
|
+
currentWindowPrevGesture > 0 &&
|
|
1000
|
+
currentWindowPrevGesture < 4
|
|
1001
|
+
) {
|
|
1002
|
+
const remainingDuration =
|
|
1003
|
+
effectiveEndTime - Math.max(currentWindowPrevDelta, effectiveStartTime);
|
|
1004
|
+
filteredGestureDurations.set(
|
|
1005
|
+
currentWindowPrevGesture,
|
|
1006
|
+
(filteredGestureDurations.get(currentWindowPrevGesture) || 0) +
|
|
1007
|
+
remainingDuration
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
for (const [gesture, duration] of filteredGestureDurations.entries()) {
|
|
1012
|
+
if (gesture !== 0 && duration >= minRequiredDuration) {
|
|
1013
|
+
if (duration > maxDuration) {
|
|
1014
|
+
maxDuration = duration;
|
|
1015
|
+
longestGesture = gesture;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return longestGesture;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Initializes the PaintScript.
|
|
1025
|
+
*/
|
|
1026
|
+
init() {
|
|
1027
|
+
xb.core.renderer.localClippingEnabled = true;
|
|
1028
|
+
|
|
1029
|
+
this.add(new THREE.HemisphereLight(0x888877, 0x777788, 3));
|
|
1030
|
+
const light = new THREE.DirectionalLight(0xffffff, 5.0);
|
|
1031
|
+
light.position.set(-0.5, 4, 1.0);
|
|
1032
|
+
this.add(light);
|
|
1033
|
+
|
|
1034
|
+
this.panel.position.set(0, xb.core.user.height, -1.0);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async update() {
|
|
1038
|
+
//
|
|
1039
|
+
// Run gesture detection 12 times per second for ~60fps
|
|
1040
|
+
// But an avg fps for webxr is 30-60
|
|
1041
|
+
//
|
|
1042
|
+
if (this.frameId % 5 === 0) {
|
|
1043
|
+
const hands = xb.core.user.hands;
|
|
1044
|
+
if (hands.isValid()) {
|
|
1045
|
+
this.gestureDetectionHandler.postTask(
|
|
1046
|
+
hands.hands[LEFT_HAND_INDEX].joints,
|
|
1047
|
+
LEFT_HAND_INDEX
|
|
1048
|
+
);
|
|
1049
|
+
this.gestureDetectionHandler.postTask(
|
|
1050
|
+
hands.hands[RIGHT_HAND_INDEX].joints,
|
|
1051
|
+
RIGHT_HAND_INDEX
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
this.frameId++;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Selects a random phrase from a specified category of game outcomes.
|
|
1061
|
+
* @param {number} categoryIndex - The index of the category.
|
|
1062
|
+
* @returns {string|null} A randomly selected phrase.
|
|
1063
|
+
*/
|
|
1064
|
+
getRandomPhrase(categoryIndex) {
|
|
1065
|
+
if (categoryIndex < 0 || categoryIndex >= gameOutcomePhrases.length) {
|
|
1066
|
+
console.error(
|
|
1067
|
+
'Invalid category index provided. Please use 0 for victory, 1 for loss, or 2 for draw.'
|
|
1068
|
+
);
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
const phrasesArray = gameOutcomePhrases[categoryIndex];
|
|
1072
|
+
const randomIndex = Math.floor(Math.random() * phrasesArray.length);
|
|
1073
|
+
return phrasesArray[randomIndex];
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// --- Main entry point ---
|
|
1078
|
+
const options = new xb.Options();
|
|
1079
|
+
options.simulator.instructions.enabled = false;
|
|
1080
|
+
options.enableReticles();
|
|
1081
|
+
options.enableHands();
|
|
1082
|
+
options.simulator.defaultMode = xb.SimulatorMode.POSE;
|
|
1083
|
+
options.simulator.defaultHand = xb.Handedness.RIGHT;
|
|
1084
|
+
options.setAppTitle('Rock Paper Scissors');
|
|
1085
|
+
|
|
1086
|
+
async function start() {
|
|
1087
|
+
xb.add(new GameRps());
|
|
1088
|
+
await xb.init(options);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
1092
|
+
setTimeout(function () {
|
|
1093
|
+
start();
|
|
1094
|
+
}, 200);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
</script>
|
|
1098
|
+
</body>
|
|
1099
|
+
</html>
|