Spaces:
Running
Running
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { | |
try { | |
var info = gen[key](arg); | |
var value = info.value; | |
} catch (error) { | |
reject(error); | |
return; | |
} | |
if (info.done) { | |
resolve(value); | |
} else { | |
Promise.resolve(value).then(_next, _throw); | |
} | |
} | |
function _async_to_generator(fn) { | |
return function() { | |
var self = this, args = arguments; | |
return new Promise(function(resolve, reject) { | |
var gen = fn.apply(self, args); | |
function _next(value) { | |
asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); | |
} | |
function _throw(err) { | |
asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); | |
} | |
_next(undefined); | |
}); | |
}; | |
} | |
function _class_call_check(instance, Constructor) { | |
if (!(instance instanceof Constructor)) { | |
throw new TypeError("Cannot call a class as a function"); | |
} | |
} | |
function _defineProperties(target, props) { | |
for(var i = 0; i < props.length; i++){ | |
var descriptor = props[i]; | |
descriptor.enumerable = descriptor.enumerable || false; | |
descriptor.configurable = true; | |
if ("value" in descriptor) descriptor.writable = true; | |
Object.defineProperty(target, descriptor.key, descriptor); | |
} | |
} | |
function _create_class(Constructor, protoProps, staticProps) { | |
if (protoProps) _defineProperties(Constructor.prototype, protoProps); | |
if (staticProps) _defineProperties(Constructor, staticProps); | |
return Constructor; | |
} | |
function _define_property(obj, key, value) { | |
if (key in obj) { | |
Object.defineProperty(obj, key, { | |
value: value, | |
enumerable: true, | |
configurable: true, | |
writable: true | |
}); | |
} else { | |
obj[key] = value; | |
} | |
return obj; | |
} | |
function _object_spread(target) { | |
for(var i = 1; i < arguments.length; i++){ | |
var source = arguments[i] != null ? arguments[i] : {}; | |
var ownKeys = Object.keys(source); | |
if (typeof Object.getOwnPropertySymbols === "function") { | |
ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) { | |
return Object.getOwnPropertyDescriptor(source, sym).enumerable; | |
})); | |
} | |
ownKeys.forEach(function(key) { | |
_define_property(target, key, source[key]); | |
}); | |
} | |
return target; | |
} | |
function _ts_generator(thisArg, body) { | |
var f, y, t, g, _ = { | |
label: 0, | |
sent: function() { | |
if (t[0] & 1) throw t[1]; | |
return t[1]; | |
}, | |
trys: [], | |
ops: [] | |
}; | |
return g = { | |
next: verb(0), | |
"throw": verb(1), | |
"return": verb(2) | |
}, typeof Symbol === "function" && (g[Symbol.iterator] = function() { | |
return this; | |
}), g; | |
function verb(n) { | |
return function(v) { | |
return step([ | |
n, | |
v | |
]); | |
}; | |
} | |
function step(op) { | |
if (f) throw new TypeError("Generator is already executing."); | |
while(_)try { | |
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; | |
if (y = 0, t) op = [ | |
op[0] & 2, | |
t.value | |
]; | |
switch(op[0]){ | |
case 0: | |
case 1: | |
t = op; | |
break; | |
case 4: | |
_.label++; | |
return { | |
value: op[1], | |
done: false | |
}; | |
case 5: | |
_.label++; | |
y = op[1]; | |
op = [ | |
0 | |
]; | |
continue; | |
case 7: | |
op = _.ops.pop(); | |
_.trys.pop(); | |
continue; | |
default: | |
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { | |
_ = 0; | |
continue; | |
} | |
if (op[0] === 3 && (!t || op[1] > t[0] && op[1] < t[3])) { | |
_.label = op[1]; | |
break; | |
} | |
if (op[0] === 6 && _.label < t[1]) { | |
_.label = t[1]; | |
t = op; | |
break; | |
} | |
if (t && _.label < t[2]) { | |
_.label = t[2]; | |
_.ops.push(op); | |
break; | |
} | |
if (t[2]) _.ops.pop(); | |
_.trys.pop(); | |
continue; | |
} | |
op = body.call(thisArg, _); | |
} catch (e) { | |
op = [ | |
6, | |
e | |
]; | |
y = 0; | |
} finally{ | |
f = t = 0; | |
} | |
if (op[0] & 5) throw op[1]; | |
return { | |
value: op[0] ? op[1] : void 0, | |
done: true | |
}; | |
} | |
} | |
import * as THREE from 'three'; | |
import { GLTFLoader } from 'three/loaders/GLTFLoader.js'; | |
import { HandLandmarker, FilesetResolver } from 'https://esm.sh/@mediapipe/[email protected]'; | |
import { AudioManager } from './audioManager.js'; // Import the AudioManager | |
import { SpeechManager } from './SpeechManager.js'; // Import SpeechManager | |
export var Game = /*#__PURE__*/ function() { | |
"use strict"; | |
function Game(renderDiv) { | |
var _this = this; | |
_class_call_check(this, Game); | |
this.renderDiv = renderDiv; | |
this.scene = null; | |
this.camera = null; | |
this.renderer = null; | |
this.videoElement = null; | |
this.handLandmarker = null; | |
this.lastVideoTime = -1; | |
this.hands = []; // Stores data about detected hands (landmarks, anchor position, line group) | |
this.handLineMaterial = null; // Material for hand lines | |
this.fingertipMaterialHand1 = null; // Material for first hand's fingertip circles (blue) | |
this.fingertipMaterialHand2 = null; // Material for second hand's fingertip circles (green) | |
this.fingertipLandmarkIndices = [ | |
0, | |
4, | |
8, | |
12, | |
16, | |
20 | |
]; // WRIST + TIP landmarks | |
this.handConnections = null; // Landmark connection definitions | |
// this.handCollisionRadius = 30; // Conceptual radius for hand collision, was 25 (sphere radius) - Not needed for template | |
this.gameState = 'loading'; // loading, ready, tracking, error | |
this.gameOverText = null; // Will be repurposed or simplified | |
this.clock = new THREE.Clock(); | |
this.audioManager = new AudioManager(); // Create an instance of AudioManager | |
this.lastLandmarkPositions = [ | |
[], | |
[] | |
]; // Store last known smoothed positions for each hand's landmarks | |
this.smoothingFactor = 0.4; // Alpha for exponential smoothing (0 < alpha <= 1). Smaller = more smoothing. | |
this.loadedModels = {}; | |
this.pandaModel = null; // Add reference for the Panda model | |
this.animationMixer = null; // For Stan model animations | |
this.animationClips = []; // To store all animation clips from the model | |
this.animationActions = {}; // To store animation actions by name or index | |
this.currentAction = null; // To keep track of the currently playing animation action | |
this.speechManager = null; | |
this.speechBubble = null; | |
this.speechBubbleTimeout = null; | |
this.isSpeechActive = false; // Track if speech recognition is active for styling | |
this.grabbingHandIndex = -1; // -1: no hand, 0: first hand, 1: second hand grabbing | |
this.pickedUpModel = null; // Reference to the model being dragged | |
this.modelDragOffset = new THREE.Vector3(); // Offset between model and pinch point in 3D | |
this.modelGrabStartDepth = 0; // To store the model's Z depth when grabbed | |
this.interactionMode = 'drag'; // 'drag', 'rotate', 'scale', 'animate' - Default to drag | |
this.interactionModeButtons = {}; // To store references to mode buttons | |
this.loadedDroppedModelData = null; // To temporarily store parsed GLTF data | |
this.interactionModeColors = { | |
drag: { | |
base: '#00FFFF', | |
text: '#000000', | |
hand: new THREE.Color('#00FFFF') | |
}, | |
rotate: { | |
base: '#FF00FF', | |
text: '#FFFFFF', | |
hand: new THREE.Color('#FF00FF') | |
}, | |
scale: { | |
base: '#FFFF00', | |
text: '#000000', | |
hand: new THREE.Color('#FFFF00') | |
}, | |
animate: { | |
base: '#FFA500', | |
text: '#000000', | |
hand: new THREE.Color('#FFA500') | |
} // Orange | |
}; | |
this.rotateLastHandX = null; // Stores the last hand X position for rotation calculation | |
this.rotateSensitivity = 0.02; // Adjust for faster/slower rotation | |
this.scaleInitialPinchDistance = null; // Stores the initial distance between two pinching hands | |
this.scaleInitialModelScale = null; // Stores the model's scale when scaling starts | |
this.scaleSensitivity = 0.05; // Adjust for faster/slower scaling - Increased from 0.02 to 0.05 | |
this.grabbingPulseSpeed = 8; // Speed of the grab pulse animation | |
this.grabbingPulseAmplitude = 0.5; // How much the scale increases (e.g., 0.5 means 50% bigger at peak) | |
this.pulseBaseScale = 1.0; // Base scale for non-pulsing and start of pulse | |
this.fingertipDefaultOpacity = 0.3; // Default opacity for hand landmarks (Reduced from 0.6) | |
this.fingertipGrabOpacity = 1.0; // Opacity when hand is actively grabbing/interacting | |
this.instructionTextElement = document.querySelector("#instruction-text"); // DOM element for instruction text | |
this.interactionModeInstructions = { | |
drag: "Pinch to grab and move the model", | |
rotate: "Pinch and move hand left/right to rotate", | |
scale: "Use two hands. Pinch with both and move hands closer/farther", | |
animate: "Pinch and move hand up/down to cycle animations" // Updated instruction | |
}; | |
this.animationControlHandIndex = -1; // Index of the hand controlling animation scrolling | |
this.animationControlInitialPinchY = null; // Initial Y position of the pinch for animation scrolling | |
this.animationScrollThreshold = 40; // Pixels of vertical movement to trigger an animation change (Reduced from 50) | |
// Initialize asynchronously | |
this._init().catch(function(error) { | |
console.error("Initialization failed:", error); | |
_this._showError("Initialization failed. Check console."); | |
}); | |
} | |
_create_class(Game, [ | |
{ | |
key: "_init", | |
value: function _init() { | |
var _this = this; | |
return _async_to_generator(function() { | |
return _ts_generator(this, function(_state) { | |
switch(_state.label){ | |
case 0: | |
_this._setupDOM(); // Sets up basic DOM, including speech bubble container | |
_this._setupThree(); | |
_this._setupSpeechRecognition(); // Initialize SpeechManager | |
return [ | |
4, | |
_this._loadAssets() | |
]; | |
case 1: | |
_state.sent(); // Add asset loading step | |
return [ | |
4, | |
_this._setupHandTracking() | |
]; | |
case 2: | |
_state.sent(); // This needs to complete before we can proceed | |
// Ensure webcam is playing before starting game logic dependent on it | |
return [ | |
4, | |
_this.videoElement.play() | |
]; | |
case 3: | |
_state.sent(); | |
_this.audioManager.resumeContext(); // Resume audio context as game starts automatically | |
_this.speechManager.requestPermissionAndStart(); // Start speech recognition | |
_this.clock.start(); // Start the main clock as game starts automatically | |
window.addEventListener('resize', _this._onResize.bind(_this)); | |
_this.gameState = 'tracking'; // Change state to tracking to start immediately | |
_this._animate(); // Start the animation loop (it will check state) | |
return [ | |
2 | |
]; | |
} | |
}); | |
})(); | |
} | |
}, | |
{ | |
key: "_setupDOM", | |
value: function _setupDOM() { | |
var _this = this; | |
this.renderDiv.style.position = 'relative'; | |
this.renderDiv.style.width = '100vw'; // Use viewport units for fullscreen | |
this.renderDiv.style.height = '100vh'; | |
this.renderDiv.style.overflow = 'hidden'; | |
this.renderDiv.style.background = '#111'; // Fallback background | |
// Start Screen Overlay and related DOM elements (title, instructions, loading text) removed. | |
// --- End Start Screen Overlay --- | |
this.videoElement = document.createElement('video'); | |
this.videoElement.style.position = 'absolute'; | |
this.videoElement.style.top = '0'; | |
this.videoElement.style.left = '0'; | |
this.videoElement.style.width = '100%'; | |
this.videoElement.style.height = '100%'; | |
this.videoElement.style.objectFit = 'cover'; | |
this.videoElement.style.transform = 'scaleX(-1)'; // Mirror view for intuitive control | |
this.videoElement.autoplay = true; | |
this.videoElement.muted = true; // Mute video to avoid feedback loops if audio was captured | |
this.videoElement.playsInline = true; | |
this.videoElement.style.zIndex = '0'; // Ensure video is behind THREE canvas | |
this.renderDiv.appendChild(this.videoElement); | |
// Container for Status text (formerly Game Over) and restart hint | |
this.gameOverContainer = document.createElement('div'); | |
this.gameOverContainer.style.position = 'absolute'; | |
this.gameOverContainer.style.top = '50%'; | |
this.gameOverContainer.style.left = '50%'; | |
this.gameOverContainer.style.transform = 'translate(-50%, -50%)'; | |
this.gameOverContainer.style.zIndex = '10'; | |
this.gameOverContainer.style.display = 'none'; // Hidden initially | |
this.gameOverContainer.style.pointerEvents = 'none'; // Don't block clicks | |
this.gameOverContainer.style.textAlign = 'center'; // Center text elements within | |
this.gameOverContainer.style.color = 'white'; // Default color, can be changed by _showError | |
// this.gameOverContainer.style.textShadow = '2px 2px 4px black'; // Removed for flatter look | |
this.gameOverContainer.style.fontFamily = '"Arial", "Helvetica Neue", Helvetica, sans-serif'; // Cleaner, modern sans-serif | |
// Main Status Text (formerly Game Over Text) | |
this.gameOverText = document.createElement('div'); // Will be 'gameOverText' internally | |
this.gameOverText.innerText = 'STATUS'; // Generic placeholder | |
this.gameOverText.style.fontSize = 'clamp(36px, 10vw, 72px)'; // Responsive font size | |
this.gameOverText.style.fontWeight = 'bold'; | |
this.gameOverText.style.marginBottom = '10px'; // Space below main text | |
this.gameOverContainer.appendChild(this.gameOverText); | |
// Restart Hint Text (may or may not be shown depending on context) | |
this.restartHintText = document.createElement('div'); | |
this.restartHintText.innerText = '(click to restart tracking)'; | |
this.restartHintText.style.fontSize = 'clamp(16px, 3vw, 24px)'; | |
this.restartHintText.style.fontWeight = 'normal'; | |
this.restartHintText.style.opacity = '0.8'; // Slightly faded | |
this.gameOverContainer.appendChild(this.restartHintText); | |
this.renderDiv.appendChild(this.gameOverContainer); | |
// --- Speech Bubble --- | |
this.speechBubble = document.createElement('div'); | |
this.speechBubble.id = 'speech-bubble'; | |
this.speechBubble.style.position = 'absolute'; | |
this.speechBubble.style.top = '10px'; // Changed from 20px to 10px | |
this.speechBubble.style.left = '50%'; | |
this.speechBubble.style.transform = 'translateX(-50%)'; | |
this.speechBubble.style.padding = '15px 25px'; | |
this.speechBubble.style.backgroundColor = 'rgba(255, 255, 255, 0.9)'; | |
this.speechBubble.style.border = '2px solid black'; // Solid black border | |
this.speechBubble.style.borderRadius = '4px'; // Sharper corners | |
this.speechBubble.style.boxShadow = '4px 4px 0px rgba(0,0,0,1)'; // Hard shadow | |
this.speechBubble.style.color = '#333'; | |
this.speechBubble.style.fontFamily = '"Arial", "Helvetica Neue", Helvetica, sans-serif'; // Consistent modern sans-serif | |
this.speechBubble.style.fontSize = 'clamp(16px, 3vw, 22px)'; | |
this.speechBubble.style.maxWidth = '80%'; | |
this.speechBubble.style.textAlign = 'center'; | |
this.speechBubble.style.zIndex = '25'; // Above most things but below modal popups if any | |
this.speechBubble.style.opacity = '0'; // Hidden initially, fade in | |
// Added boxShadow, border, padding, fontSize, top to transition for smooth active state changes | |
this.speechBubble.style.transition = 'opacity 0.5s ease-in-out, transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out, border 0.3s ease-in-out, padding 0.3s ease-in-out, font-size 0.3s ease-in-out, top 0.3s ease-in-out'; | |
this.speechBubble.style.pointerEvents = 'none'; // Not interactive | |
this.speechBubble.innerHTML = "..."; // Default text | |
this.renderDiv.appendChild(this.speechBubble); | |
// Animation buttons container | |
this.animationButtonsContainer = document.createElement('div'); | |
this.animationButtonsContainer.id = 'animation-buttons-container'; | |
this.animationButtonsContainer.style.position = 'absolute'; | |
this.animationButtonsContainer.style.bottom = 'auto'; // Remove bottom positioning | |
this.animationButtonsContainer.style.top = '10px'; // Position from the top, changed from 20px | |
this.animationButtonsContainer.style.left = '10px'; // Position from the left, changed from 20px | |
this.animationButtonsContainer.style.transform = 'none'; // Remove centering transform | |
this.animationButtonsContainer.style.zIndex = '30'; // Above speech bubble | |
this.animationButtonsContainer.style.display = 'flex'; | |
this.animationButtonsContainer.style.flexDirection = 'column'; // Arrange buttons in a column | |
this.animationButtonsContainer.style.gap = '4px'; // Reduced gap for tighter vertical layout | |
this.animationButtonsContainer.style.opacity = '0'; // Start fully transparent for fade-in | |
this.animationButtonsContainer.style.transition = 'opacity 0.3s ease-in-out'; // Smooth fade transition | |
this.animationButtonsContainer.style.display = 'none'; // Initially hidden (will be set to flex by logic) | |
this.renderDiv.appendChild(this.animationButtonsContainer); | |
// Interaction Mode UI Container | |
this.interactionModeContainer = document.createElement('div'); | |
this.interactionModeContainer.id = 'interaction-mode-container'; | |
this.interactionModeContainer.style.position = 'absolute'; | |
this.interactionModeContainer.style.top = '10px'; // Changed from 20px | |
this.interactionModeContainer.style.right = '10px'; // Changed from 20px | |
this.interactionModeContainer.style.zIndex = '30'; | |
this.interactionModeContainer.style.display = 'flex'; | |
this.interactionModeContainer.style.flexDirection = 'column'; | |
this.interactionModeContainer.style.gap = '4px'; | |
this.renderDiv.appendChild(this.interactionModeContainer); | |
// Create interaction mode buttons | |
[ | |
'Drag', | |
'Rotate', | |
'Scale', | |
'Animate' | |
].forEach(function(mode) { | |
var button = document.createElement('button'); | |
button.innerText = mode; | |
button.id = "interaction-mode-".concat(mode.toLowerCase()); | |
button.style.padding = '10px 22px'; // Increased padding | |
button.style.fontSize = '18px'; // Increased font size further | |
button.style.border = '2px solid black'; // Consistent black border | |
button.style.borderRadius = '4px'; // Sharper corners | |
button.style.cursor = 'pointer'; | |
button.style.fontWeight = 'bold'; // Always bold | |
button.style.transition = 'background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease'; // Faster transition, added shadow | |
button.style.boxShadow = '2px 2px 0px black'; // Default shadow for inactive | |
button.addEventListener('click', function() { | |
return _this._setInteractionMode(mode.toLowerCase()); | |
}); | |
_this.interactionModeContainer.appendChild(button); | |
_this.interactionModeButtons[mode.toLowerCase()] = button; // Store button reference | |
}); | |
this._updateInteractionModeButtonStyles(); // Apply initial styles | |
this._updateInstructionText(); // Set initial instruction text | |
this._setupDragAndDrop(); // Add drag and drop listeners | |
} | |
}, | |
{ | |
key: "_setupThree", | |
value: function _setupThree() { | |
var _this_interactionModeColors_this_interactionMode; | |
var width = this.renderDiv.clientWidth; | |
var height = this.renderDiv.clientHeight; | |
this.scene = new THREE.Scene(); | |
// Using OrthographicCamera for a 2D-like overlay effect | |
this.camera = new THREE.OrthographicCamera(width / -2, width / 2, height / 2, height / -2, 1, 2000); // Increased far plane | |
this.camera.position.z = 100; // Position along Z doesn't change scale in Ortho | |
this.renderer = new THREE.WebGLRenderer({ | |
alpha: true, | |
antialias: true | |
}); | |
this.renderer.setSize(width, height); | |
this.renderer.setPixelRatio(window.devicePixelRatio); | |
this.renderer.domElement.style.position = 'absolute'; | |
this.renderer.domElement.style.top = '0'; | |
this.renderer.domElement.style.left = '0'; | |
this.renderer.domElement.style.zIndex = '1'; // Canvas on top of video | |
this.renderDiv.appendChild(this.renderer.domElement); | |
var ambientLight = new THREE.AmbientLight(0xffffff, 1.5); // Increased intensity | |
this.scene.add(ambientLight); | |
var directionalLight = new THREE.DirectionalLight(0xffffff, 1.8); // Increased intensity | |
directionalLight.position.set(0, 0, 100); // Pointing from behind camera | |
this.scene.add(directionalLight); | |
// Setup hand visualization (palm circles removed, lines will be added later) | |
for(var i = 0; i < 2; i++){ | |
var lineGroup = new THREE.Group(); | |
lineGroup.visible = false; | |
this.scene.add(lineGroup); | |
this.hands.push({ | |
landmarks: null, | |
anchorPos: new THREE.Vector3(), | |
lineGroup: lineGroup, | |
isPinching: false, | |
pinchPointScreen: new THREE.Vector2(), | |
isFist: false // True if hand is detected as a fist | |
}); | |
} | |
this.handLineMaterial = new THREE.LineBasicMaterial({ | |
color: 0x00ccff, | |
linewidth: 8 | |
}); // Kept line material default for now | |
var initialModeHandColor = ((_this_interactionModeColors_this_interactionMode = this.interactionModeColors[this.interactionMode]) === null || _this_interactionModeColors_this_interactionMode === void 0 ? void 0 : _this_interactionModeColors_this_interactionMode.hand) || new THREE.Color(0x00ccff); | |
this.fingertipMaterialHand1 = new THREE.MeshBasicMaterial({ | |
color: initialModeHandColor.clone(), | |
side: THREE.DoubleSide, | |
transparent: true, | |
opacity: this.fingertipDefaultOpacity | |
}); | |
this.fingertipMaterialHand2 = new THREE.MeshBasicMaterial({ | |
color: initialModeHandColor.clone(), | |
side: THREE.DoubleSide, | |
transparent: true, | |
opacity: this.fingertipDefaultOpacity | |
}); | |
// Define connections for MediaPipe hand landmarks | |
// See: https://developers.google.com/mediapipe/solutions/vision/hand_landmarker#hand_landmarks | |
this.handConnections = [ | |
// Thumb | |
[ | |
0, | |
1 | |
], | |
[ | |
1, | |
2 | |
], | |
[ | |
2, | |
3 | |
], | |
[ | |
3, | |
4 | |
], | |
// Index finger | |
[ | |
0, | |
5 | |
], | |
[ | |
5, | |
6 | |
], | |
[ | |
6, | |
7 | |
], | |
[ | |
7, | |
8 | |
], | |
// Middle finger | |
[ | |
0, | |
9 | |
], | |
[ | |
9, | |
10 | |
], | |
[ | |
10, | |
11 | |
], | |
[ | |
11, | |
12 | |
], | |
// Ring finger | |
[ | |
0, | |
13 | |
], | |
[ | |
13, | |
14 | |
], | |
[ | |
14, | |
15 | |
], | |
[ | |
15, | |
16 | |
], | |
// Pinky | |
[ | |
0, | |
17 | |
], | |
[ | |
17, | |
18 | |
], | |
[ | |
18, | |
19 | |
], | |
[ | |
19, | |
20 | |
], | |
// Palm | |
[ | |
5, | |
9 | |
], | |
[ | |
9, | |
13 | |
], | |
[ | |
13, | |
17 | |
] // Connect base of fingers | |
]; | |
} | |
}, | |
{ | |
key: "_loadAssets", | |
value: function _loadAssets() { | |
var _this = this; | |
return _async_to_generator(function() { | |
var gltfLoader, error; | |
return _ts_generator(this, function(_state) { | |
switch(_state.label){ | |
case 0: | |
console.log("Loading assets..."); | |
gltfLoader = new GLTFLoader(); // Changed from FBXLoader | |
_state.label = 1; | |
case 1: | |
_state.trys.push([ | |
1, | |
3, | |
, | |
4 | |
]); | |
return [ | |
4, | |
new Promise(function(resolve, reject) { | |
gltfLoader.load('assets/Stan.gltf', function(gltf) { | |
_this.pandaModel = gltf.scene; // GLTFLoader returns an object with a 'scene' property | |
_this.animationMixer = new THREE.AnimationMixer(_this.pandaModel); | |
_this.animationClips = gltf.animations; | |
if (_this.animationClips && _this.animationClips.length) { | |
_this.animationClips.forEach(function(clip, index) { | |
var action = _this.animationMixer.clipAction(clip); | |
var actionName = clip.name || "Animation ".concat(index + 1); | |
_this.animationActions[actionName] = action; | |
// Create a button for this animation | |
var button = document.createElement('button'); | |
button.innerText = actionName; | |
button.style.padding = '5px 10px'; // Adjusted padding | |
button.style.fontSize = '13px'; // Consistent font size | |
button.style.backgroundColor = '#f0f0f0'; // Light grey default | |
button.style.color = 'black'; | |
button.style.border = '2px solid black'; // Black border | |
button.style.borderRadius = '4px'; // Sharper corners | |
button.style.cursor = 'pointer'; | |
button.style.transition = 'background-color 0.2s ease, box-shadow 0.2s ease'; | |
button.style.boxShadow = '2px 2px 0px black'; // Default shadow | |
button.addEventListener('click', function() { | |
return _this._playAnimation(actionName); | |
}); | |
_this.animationButtonsContainer.appendChild(button); | |
console.log("Loaded animation and created button for: ".concat(actionName)); | |
}); | |
// Play the first animation by default | |
// Try to find and play an "idle" animation by default | |
var defaultActionName = Object.keys(_this.animationActions)[0]; // Fallback to the first animation | |
var idleActionKey = Object.keys(_this.animationActions).find(function(name) { | |
return name.toLowerCase().includes('idle'); | |
}); | |
if (idleActionKey) { | |
defaultActionName = idleActionKey; | |
console.log("Found idle animation: ".concat(defaultActionName)); | |
} else if (defaultActionName) { | |
console.log("No specific idle animation found, defaulting to first animation: ".concat(defaultActionName)); | |
} | |
if (defaultActionName && _this.animationActions[defaultActionName]) { | |
_this.currentAction = _this.animationActions[defaultActionName]; | |
_this.currentAction.play(); | |
console.log("Playing default animation: ".concat(defaultActionName)); | |
_this._updateButtonStyles(defaultActionName); | |
} else { | |
console.log("No animations found or default animation could not be played."); | |
} | |
} else { | |
console.log("Stan model has no embedded animations."); | |
} | |
// Scale and position the model | |
// These values might need adjustment based on the model's original size and pivot | |
var scale = 80; // This scale might need adjustment for Stan model | |
_this.pandaModel.scale.set(scale, scale, scale); | |
// Position the model: X=center, Y=roughly bottom, Z=in front of hands | |
var sceneHeight = _this.renderDiv.clientHeight; | |
_this.pandaModel.position.set(0, sceneHeight * -0.45, -1000); // Updated Z to -1000 | |
_this.scene.add(_this.pandaModel); | |
console.log("Stan GLTF model loaded and added to scene."); | |
resolve(); | |
}, undefined, function(error) { | |
console.error('An error occurred while loading the Stan GLTF model:', error); // Updated log | |
reject(error); | |
}); | |
}) | |
]; | |
case 2: | |
_state.sent(); | |
console.log("All specified assets loaded."); | |
return [ | |
3, | |
4 | |
]; | |
case 3: | |
error = _state.sent(); | |
console.error("Error loading assets:", error); | |
_this._showError("Failed to load 3D model."); | |
throw error; // Stop initialization | |
case 4: | |
return [ | |
2 | |
]; | |
} | |
}); | |
})(); | |
} | |
}, | |
{ | |
key: "_setupHandTracking", | |
value: function _setupHandTracking() { | |
var _this = this; | |
return _async_to_generator(function() { | |
var vision, stream, error; | |
return _ts_generator(this, function(_state) { | |
switch(_state.label){ | |
case 0: | |
_state.trys.push([ | |
0, | |
4, | |
, | |
5 | |
]); | |
console.log("Setting up Hand Tracking..."); | |
return [ | |
4, | |
FilesetResolver.forVisionTasks('https://cdn.jsdelivr.net/npm/@mediapipe/[email protected]/wasm') | |
]; | |
case 1: | |
vision = _state.sent(); | |
return [ | |
4, | |
HandLandmarker.createFromOptions(vision, { | |
baseOptions: { | |
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task", | |
delegate: 'GPU' | |
}, | |
numHands: 2, | |
runningMode: 'VIDEO' | |
}) | |
]; | |
case 2: | |
_this.handLandmarker = _state.sent(); | |
console.log("HandLandmarker created."); | |
console.log("Requesting webcam access..."); | |
return [ | |
4, | |
navigator.mediaDevices.getUserMedia({ | |
video: { | |
facingMode: 'user', | |
width: { | |
ideal: 1920 | |
}, | |
height: { | |
ideal: 1080 | |
} // Request Full HD height | |
}, | |
audio: false | |
}) | |
]; | |
case 3: | |
stream = _state.sent(); | |
_this.videoElement.srcObject = stream; | |
console.log("Webcam stream obtained."); | |
// Wait for video metadata to load to ensure dimensions are available | |
return [ | |
2, | |
new Promise(function(resolve) { | |
_this.videoElement.onloadedmetadata = function() { | |
console.log("Webcam metadata loaded."); | |
// Adjust video size slightly after metadata is loaded if needed, but CSS handles most | |
_this.videoElement.style.width = _this.renderDiv.clientWidth + 'px'; | |
_this.videoElement.style.height = _this.renderDiv.clientHeight + 'px'; | |
resolve(); | |
}; | |
}) | |
]; | |
case 4: | |
error = _state.sent(); | |
console.error('Error setting up Hand Tracking or Webcam:', error); | |
_this._showError("Webcam/Hand Tracking Error: ".concat(error.message, ". Please allow camera access.")); | |
throw error; // Re-throw to stop initialization | |
case 5: | |
return [ | |
2 | |
]; | |
} | |
}); | |
})(); | |
} | |
}, | |
{ | |
key: "_updateHands", | |
value: function _updateHands() { | |
var _this = this; | |
if (!this.handLandmarker || !this.videoElement.srcObject || this.videoElement.readyState < 2 || this.videoElement.videoWidth === 0) return; | |
// this.isAnyHandHovering = false; // Reset hover state each frame - REMOVED | |
var videoTime = this.videoElement.currentTime; | |
if (videoTime > this.lastVideoTime) { | |
this.lastVideoTime = videoTime; | |
try { | |
var _this1, _loop = function(i) { | |
var hand = _this1.hands[i]; | |
if (results.landmarks && results.landmarks[i]) { | |
var currentRawLandmarks = results.landmarks[i]; | |
if (!_this1.lastLandmarkPositions[i] || _this1.lastLandmarkPositions[i].length !== currentRawLandmarks.length) { | |
_this1.lastLandmarkPositions[i] = currentRawLandmarks.map(function(lm) { | |
return _object_spread({}, lm); | |
}); | |
} | |
var smoothedLandmarks = currentRawLandmarks.map(function(lm, lmIndex) { | |
var prevLm = _this.lastLandmarkPositions[i][lmIndex]; | |
return { | |
x: _this.smoothingFactor * lm.x + (1 - _this.smoothingFactor) * prevLm.x, | |
y: _this.smoothingFactor * lm.y + (1 - _this.smoothingFactor) * prevLm.y, | |
z: _this.smoothingFactor * lm.z + (1 - _this.smoothingFactor) * prevLm.z | |
}; | |
}); | |
_this1.lastLandmarkPositions[i] = smoothedLandmarks.map(function(lm) { | |
return _object_spread({}, lm); | |
}); // Update last positions with new smoothed ones | |
hand.landmarks = smoothedLandmarks; | |
var palm = smoothedLandmarks[9]; // MIDDLE_FINGER_MCP | |
var lmOriginalX = palm.x * videoParams.videoNaturalWidth; | |
var lmOriginalY = palm.y * videoParams.videoNaturalHeight; | |
var normX_visible = (lmOriginalX - videoParams.offsetX) / videoParams.visibleWidth; | |
var normY_visible = (lmOriginalY - videoParams.offsetY) / videoParams.visibleHeight; | |
var handX = (1 - normX_visible) * canvasWidth - canvasWidth / 2; | |
var handY = (1 - normY_visible) * canvasHeight - canvasHeight / 2; | |
hand.anchorPos.set(handX, handY, 1); | |
// Hover detection logic REMOVED | |
var prevIsPinching = hand.isPinching; // Store previous pinch state | |
// Pinch detection logic | |
var thumbTipLm = smoothedLandmarks[4]; // THUMB_TIP landmark index | |
var indexTipLm = smoothedLandmarks[8]; // INDEX_FINGER_TIP landmark index | |
if (thumbTipLm && indexTipLm) { | |
// Convert landmark coordinates to screen space for pinch detection | |
var convertToScreenSpace = function(lm) { | |
var originalX = lm.x * videoParams.videoNaturalWidth; | |
var originalY = lm.y * videoParams.videoNaturalHeight; | |
var normX_visible = (originalX - videoParams.offsetX) / videoParams.visibleWidth; | |
var normY_visible = (originalY - videoParams.offsetY) / videoParams.visibleHeight; | |
return { | |
x: (1 - normX_visible) * canvasWidth - canvasWidth / 2, | |
y: (1 - normY_visible) * canvasHeight - canvasHeight / 2 | |
}; | |
}; | |
var thumbTipScreen = convertToScreenSpace(thumbTipLm); | |
var indexTipScreen = convertToScreenSpace(indexTipLm); | |
var distanceX = thumbTipScreen.x - indexTipScreen.x; | |
var distanceY = thumbTipScreen.y - indexTipScreen.y; | |
var pinchDistance = Math.sqrt(distanceX * distanceX + distanceY * distanceY); | |
var pinchThreshold = 45; // Increased from 35. Distance in screen pixels to consider a pinch. | |
if (pinchDistance < pinchThreshold) { | |
hand.isPinching = true; | |
hand.pinchPointScreen.set((thumbTipScreen.x + indexTipScreen.x) / 2, (thumbTipScreen.y + indexTipScreen.y) / 2); | |
} else { | |
hand.isPinching = false; | |
} | |
} else { | |
hand.isPinching = false; | |
} | |
// Fist detection logic (simple version based on finger curl) | |
// This is a basic fist detection. More robust methods might involve checking distances | |
// of all fingertips to the palm or wrist. | |
var isTipNearMCP = function(tipLandmark, mcpLandmark) { | |
var threshold = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : 0.1; | |
if (!tipLandmark || !mcpLandmark) return false; | |
// Using 3D distance, but could simplify to 2D if performance is an issue | |
// and Z-depth isn't significantly varying for this gesture. | |
var dx = tipLandmark.x - mcpLandmark.x; | |
var dy = tipLandmark.y - mcpLandmark.y; | |
// const dz = tipLandmark.z - mcpLandmark.z; // Can include Z if needed | |
var distance = Math.sqrt(dx * dx + dy * dy /* + dz*dz */ ); | |
return distance < threshold; | |
}; | |
var indexFingerTip = smoothedLandmarks[8]; | |
var indexFingerMcp = smoothedLandmarks[5]; | |
var middleFingerTip = smoothedLandmarks[12]; | |
var middleFingerMcp = smoothedLandmarks[9]; | |
var ringFingerTip = smoothedLandmarks[16]; | |
var ringFingerMcp = smoothedLandmarks[13]; | |
var pinkyTip = smoothedLandmarks[20]; | |
var pinkyMcp = smoothedLandmarks[17]; | |
// Check if at least 3 fingers are curled (tip near MCP joint) | |
var curledFingers = 0; | |
if (isTipNearMCP(indexFingerTip, indexFingerMcp, 0.08)) curledFingers++; | |
if (isTipNearMCP(middleFingerTip, middleFingerMcp, 0.08)) curledFingers++; | |
if (isTipNearMCP(ringFingerTip, ringFingerMcp, 0.08)) curledFingers++; | |
if (isTipNearMCP(pinkyTip, pinkyMcp, 0.08)) curledFingers++; | |
var prevIsFist = hand.isFist; | |
hand.isFist = curledFingers >= 3; // Requires at least 3 fingers to be curled | |
// Interaction Logic | |
if (_this1.interactionMode === 'animate') { | |
// Release any model grab from other modes | |
if (_this1.grabbingHandIndex !== -1 && _this1.pickedUpModel) { | |
// console.log(`Switched to Animate mode or model grab was active. Releasing.`); | |
_this1.grabbingHandIndex = -1; | |
_this1.pickedUpModel = null; | |
// if (this.grabMarker && this.pandaModel) this.grabMarker.visible = true; // Grab marker removed | |
// Reset other mode-specific states | |
_this1.rotateLastHandX = null; | |
_this1.scaleInitialPinchDistance = null; | |
_this1.scaleInitialModelScale = null; | |
} | |
if (hand.isPinching) { | |
if (!prevIsPinching && _this1.animationControlHandIndex === -1) { | |
_this1.animationControlHandIndex = i; | |
_this1.animationControlInitialPinchY = hand.pinchPointScreen.y; | |
console.log("Hand ".concat(i, " started pinch for animation control at Y: ").concat(_this1.animationControlInitialPinchY)); | |
} else if (_this1.animationControlHandIndex === i && _this1.animationControlInitialPinchY !== null) { | |
// Pinch continues with the controlling hand | |
var deltaY = hand.pinchPointScreen.y - _this1.animationControlInitialPinchY; | |
if (Math.abs(deltaY) > _this1.animationScrollThreshold) { | |
var animationNames = Object.keys(_this1.animationActions); | |
if (animationNames.length > 0) { | |
var currentIndex = -1; | |
// Find the index of the currently playing animation action | |
if (_this1.currentAction) { | |
for(var j = 0; j < animationNames.length; j++){ | |
if (_this1.animationActions[animationNames[j]] === _this1.currentAction) { | |
currentIndex = j; | |
break; | |
} | |
} | |
} | |
var nextIndex = currentIndex; | |
if (deltaY < 0) { | |
nextIndex = (currentIndex + 1) % animationNames.length; // Now scrolls to next | |
console.log("Scrolling animation UP (to next)"); | |
} else { | |
nextIndex = (currentIndex - 1 + animationNames.length) % animationNames.length; // Now scrolls to previous | |
console.log("Scrolling animation DOWN (to previous)"); | |
} | |
if (nextIndex !== currentIndex) { | |
_this1._playAnimation(animationNames[nextIndex]); | |
} | |
} | |
// Reset initial Y to require another full threshold movement | |
_this1.animationControlInitialPinchY = hand.pinchPointScreen.y; | |
} | |
} | |
} else { | |
if (prevIsPinching && _this1.animationControlHandIndex === i) { | |
console.log("Hand ".concat(i, " ended pinch for animation control.")); | |
_this1.animationControlHandIndex = -1; | |
_this1.animationControlInitialPinchY = null; | |
} | |
} | |
} else if (_this1.interactionMode === 'drag') { | |
if (hand.isPinching) { | |
if (!prevIsPinching && _this1.grabbingHandIndex === -1 && _this1.pandaModel) { | |
// REMOVED: Bounding box check - drag can be initiated from anywhere if not scaling | |
_this1.grabbingHandIndex = i; | |
_this1.pickedUpModel = _this1.pandaModel; | |
// Convert 2D screen pinch point to 3D world point on a plane | |
// The plane is at the model's current Z depth | |
_this1.modelGrabStartDepth = _this1.pickedUpModel.position.z; // Store initial depth | |
var pinchX = hand.pinchPointScreen.x; | |
var pinchY = hand.pinchPointScreen.y; | |
// Convert 2D screen pinch point (origin center) to NDC (Normalized Device Coords, -1 to 1) | |
var ndcX = pinchX / (_this1.renderDiv.clientWidth / 2); | |
var ndcY = pinchY / (_this1.renderDiv.clientHeight / 2); | |
var pinchPoint3DWorld = new THREE.Vector3(ndcX, ndcY, 0.5); // Start with a neutral NDC Z | |
pinchPoint3DWorld.unproject(_this1.camera); | |
pinchPoint3DWorld.z = _this1.modelGrabStartDepth; // Force Z to the grab depth | |
console.log("Grab screen: (".concat(pinchX.toFixed(2), ", ").concat(pinchY.toFixed(2), "), NDC: (").concat(ndcX.toFixed(2), ", ").concat(ndcY.toFixed(2), ")")); | |
console.log("Grab 3D World (pre-offset): ".concat(pinchPoint3DWorld.x.toFixed(2), ", ").concat(pinchPoint3DWorld.y.toFixed(2), ", ").concat(pinchPoint3DWorld.z.toFixed(2))); | |
_this1.modelDragOffset.subVectors(_this1.pickedUpModel.position, pinchPoint3DWorld); | |
console.log("Hand ".concat(i, " GRABBED model for DRAG at depth ").concat(_this1.modelGrabStartDepth, ". Offset:"), _this1.modelDragOffset.x.toFixed(2), _this1.modelDragOffset.y.toFixed(2), _this1.modelDragOffset.z.toFixed(2)); | |
} else if (_this1.grabbingHandIndex === i && _this1.pickedUpModel) { | |
// Update model position based on pinch | |
var currentPinchX = hand.pinchPointScreen.x; | |
var currentPinchY = hand.pinchPointScreen.y; | |
var currentNdcX = currentPinchX / (_this1.renderDiv.clientWidth / 2); | |
var currentNdcY = currentPinchY / (_this1.renderDiv.clientHeight / 2); | |
var newPinchPoint3DWorld = new THREE.Vector3(currentNdcX, currentNdcY, 0.5); | |
newPinchPoint3DWorld.unproject(_this1.camera); | |
newPinchPoint3DWorld.z = _this1.modelGrabStartDepth; // Force Z to the original grab depth plane | |
_this1.pickedUpModel.position.addVectors(newPinchPoint3DWorld, _this1.modelDragOffset); | |
var minZ = -200; | |
var maxZ = 50; | |
_this1.pickedUpModel.position.z = Math.max(minZ, Math.min(maxZ, _this1.pickedUpModel.position.z)); | |
} | |
} else { | |
if (prevIsPinching && _this1.grabbingHandIndex === i) { | |
console.log("Hand ".concat(i, " RELEASED Stan model (Drag mode) at position:"), _this1.pickedUpModel.position); | |
_this1.grabbingHandIndex = -1; | |
_this1.pickedUpModel = null; | |
// if (this.grabMarker && this.pandaModel) this.grabMarker.visible = true; // Show marker when released - Grab marker removed | |
} | |
} | |
} else if (_this1.interactionMode === 'rotate') { | |
if (hand.isPinching) { | |
if (!prevIsPinching && _this1.grabbingHandIndex === -1 && _this1.pandaModel) { | |
// REMOVED: Bounding box check - rotate can be initiated from anywhere if not scaling | |
_this1.grabbingHandIndex = i; | |
_this1.pickedUpModel = _this1.pandaModel; | |
_this1.rotateLastHandX = hand.pinchPointScreen.x; // Store initial pinch X for delta calculation | |
console.log("Hand ".concat(i, " INITIATED ROTATION on model via pinch from anywhere.")); | |
} else if (_this1.grabbingHandIndex === i && _this1.pickedUpModel && _this1.rotateLastHandX !== null) { | |
var currentHandX = hand.pinchPointScreen.x; // Use pinch point X for delta | |
var deltaX = currentHandX - _this1.rotateLastHandX; | |
if (_this1.pickedUpModel && Math.abs(deltaX) > 0.5) { | |
_this1.pickedUpModel.rotation.y -= deltaX * _this1.rotateSensitivity; | |
} | |
_this1.rotateLastHandX = currentHandX; | |
} | |
} else { | |
if (prevIsPinching && _this1.grabbingHandIndex === i) { | |
console.log("Hand ".concat(i, " RELEASED ROTATION on model (pinch ended).")); | |
_this1.grabbingHandIndex = -1; | |
_this1.pickedUpModel = null; | |
_this1.rotateLastHandX = null; | |
// if (this.grabMarker && this.pandaModel) this.grabMarker.visible = true; // Grab marker removed | |
} | |
} | |
} else if (_this1.interactionMode === 'scale') { | |
var hand0 = _this1.hands[0]; | |
var hand1 = _this1.hands[1]; | |
if (hand0 && hand1 && hand0.landmarks && hand1.landmarks && hand0.isPinching && hand1.isPinching) { | |
// Both hands are visible and pinching | |
var dist = hand0.pinchPointScreen.distanceTo(hand1.pinchPointScreen); | |
if (_this1.scaleInitialPinchDistance === null || _this1.scaleInitialModelScale === null) { | |
// Start of scaling gesture | |
_this1.scaleInitialPinchDistance = dist; | |
_this1.scaleInitialModelScale = _this1.pandaModel.scale.clone(); // Store initial scale vector | |
_this1.grabbingHandIndex = 0; // Mark as "grabbing" for scaling (using hand 0 as primary) | |
_this1.pickedUpModel = _this1.pandaModel; // Indicate model is being interacted with | |
// if(this.grabMarker) this.grabMarker.visible = false; // Grab marker removed | |
console.log("Scaling initiated. Initial pinch dist: ".concat(dist.toFixed(2), ", Initial scale: ").concat(_this1.scaleInitialModelScale.x.toFixed(2))); | |
} else { | |
// Continue scaling | |
var deltaDistance = dist - _this1.scaleInitialPinchDistance; | |
var scaleFactorChange = deltaDistance * _this1.scaleSensitivity; | |
var newScaleValue = _this1.scaleInitialModelScale.x + scaleFactorChange; | |
// Clamp scale to prevent extreme sizes or inversion | |
var minScale = 10; // Example min scale (adjust based on model's base size) | |
var maxScale = 300; // Example max scale | |
newScaleValue = Math.max(minScale, Math.min(maxScale, newScaleValue)); | |
_this1.pandaModel.scale.set(newScaleValue, newScaleValue, newScaleValue); | |
// console.log(`Scaling: Current pinch dist: ${dist.toFixed(2)}, Scale change: ${scaleFactorChange.toFixed(3)}, New scale value: ${newScaleValue.toFixed(2)}`); | |
} | |
} else { | |
// One or both hands are not pinching or not visible, or scaling was active | |
if (_this1.scaleInitialPinchDistance !== null) { | |
console.log("Scaling gesture ended."); | |
_this1.scaleInitialPinchDistance = null; | |
_this1.scaleInitialModelScale = null; | |
_this1.grabbingHandIndex = -1; | |
_this1.pickedUpModel = null; | |
// if(this.grabMarker && this.pandaModel) this.grabMarker.visible = true; // Grab marker removed | |
} | |
} | |
} | |
_this1._updateHandLines(i, smoothedLandmarks, videoParams, canvasWidth, canvasHeight); | |
} else { | |
if (hand.isPinching && _this1.grabbingHandIndex === i && _this1.interactionMode === 'drag') { | |
console.log("Hand ".concat(i, " (which was grabbing for drag) disappeared. Releasing model.")); | |
_this1.grabbingHandIndex = -1; | |
_this1.pickedUpModel = null; | |
// if (this.grabMarker && this.pandaModel) this.grabMarker.visible = true; // Grab marker removed | |
} else if (_this1.hands[i].isPinching && _this1.grabbingHandIndex === i && _this1.interactionMode === 'rotate') { | |
console.log("Hand ".concat(i, " (which was pinching for rotate) disappeared. Releasing model.")); | |
_this1.grabbingHandIndex = -1; | |
_this1.pickedUpModel = null; | |
_this1.rotateLastHandX = null; | |
// if (this.grabMarker && this.pandaModel) this.grabMarker.visible = true; // Grab marker removed | |
} else if (_this1.interactionMode === 'scale' && _this1.scaleInitialPinchDistance !== null && (i === 0 || i === 1)) { | |
var _this_hands_, _this_hands_1; | |
var hand0Exists = (_this_hands_ = _this1.hands[0]) === null || _this_hands_ === void 0 ? void 0 : _this_hands_.landmarks; | |
var hand1Exists = (_this_hands_1 = _this1.hands[1]) === null || _this_hands_1 === void 0 ? void 0 : _this_hands_1.landmarks; | |
if (!hand0Exists || !hand1Exists) { | |
console.log("Scaling gesture ended due to hand disappearance."); | |
_this1.scaleInitialPinchDistance = null; | |
_this1.scaleInitialModelScale = null; | |
_this1.grabbingHandIndex = -1; | |
_this1.pickedUpModel = null; | |
// if(this.grabMarker && this.pandaModel) this.grabMarker.visible = true; // Grab marker removed | |
} | |
} | |
hand.landmarks = null; | |
hand.isPinching = false; | |
hand.isFist = false; | |
if (hand.lineGroup) hand.lineGroup.visible = false; | |
} | |
// Play interaction click sound for this hand if applicable (not for scale, handled after loop) | |
var isThisHandActivelyInteractingForSound = false; | |
if (_this1.interactionMode === 'drag' || _this1.interactionMode === 'rotate') { | |
isThisHandActivelyInteractingForSound = _this1.grabbingHandIndex === i && _this1.pickedUpModel === _this1.pandaModel; | |
} else if (_this1.interactionMode === 'animate') { | |
isThisHandActivelyInteractingForSound = _this1.animationControlHandIndex === i; | |
} | |
if (hand.isPinching && isThisHandActivelyInteractingForSound && _this1.interactionMode !== 'scale') { | |
_this1.audioManager.playInteractionClickSound(); | |
} | |
}; | |
var results = this.handLandmarker.detectForVideo(this.videoElement, performance.now()); | |
var videoParams = this._getVisibleVideoParameters(); | |
if (!videoParams) return; | |
var canvasWidth = this.renderDiv.clientWidth; | |
var canvasHeight = this.renderDiv.clientHeight; | |
for(var i = 0; i < this.hands.length; i++)_this1 = this, _loop(i); | |
// End of hand loop | |
// After processing both hands, if in scale mode and one hand stops pinching, explicitly stop scaling. | |
if (this.interactionMode === 'scale' && this.scaleInitialPinchDistance !== null) { | |
var hand0 = this.hands[0]; | |
var hand1 = this.hands[1]; | |
var hand0PinchingAndVisible = hand0 && hand0.landmarks && hand0.isPinching; | |
var hand1PinchingAndVisible = hand1 && hand1.landmarks && hand1.isPinching; | |
if (hand0PinchingAndVisible && hand1PinchingAndVisible) { | |
// If scaling is active and both hands are pinching, play sound | |
this.audioManager.playInteractionClickSound(); | |
} else { | |
// If scaling was active but one hand stopped pinching or disappeared | |
if (this.scaleInitialPinchDistance !== null) { | |
console.log("Scaling gesture ended (one hand stopped pinching/disappeared - post-loop check)."); | |
this.scaleInitialPinchDistance = null; | |
this.scaleInitialModelScale = null; | |
this.grabbingHandIndex = -1; | |
this.pickedUpModel = null; | |
// if(this.grabMarker && this.pandaModel) this.grabMarker.visible = true; // Grab marker removed | |
} | |
} | |
} | |
} catch (error) { | |
console.error("Error during hand detection:", error); | |
} | |
} | |
} | |
}, | |
{ | |
key: "_getModelScreenBoundingBox", | |
value: function _getModelScreenBoundingBox() { | |
var _this = this; | |
if (!this.pandaModel || !this.camera || !this.renderer) { | |
return null; | |
} | |
// Ensure the model's world matrix is up to date | |
this.pandaModel.updateMatrixWorld(true); | |
var box = new THREE.Box3().setFromObject(this.pandaModel); | |
if (box.isEmpty()) { | |
return null; // Model might not be loaded or has no geometry | |
} | |
var corners = [ | |
new THREE.Vector3(box.min.x, box.min.y, box.min.z), | |
new THREE.Vector3(box.min.x, box.min.y, box.max.z), | |
new THREE.Vector3(box.min.x, box.max.y, box.min.z), | |
new THREE.Vector3(box.min.x, box.max.y, box.max.z), | |
new THREE.Vector3(box.max.x, box.min.y, box.min.z), | |
new THREE.Vector3(box.max.x, box.min.y, box.max.z), | |
new THREE.Vector3(box.max.x, box.max.y, box.min.z), | |
new THREE.Vector3(box.max.x, box.max.y, box.max.z) | |
]; | |
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; | |
var canvasWidth = this.renderDiv.clientWidth; | |
var canvasHeight = this.renderDiv.clientHeight; | |
corners.forEach(function(corner) { | |
// Apply model's world transformation to the local bounding box corners | |
corner.applyMatrix4(_this.pandaModel.matrixWorld); | |
// Project to Normalized Device Coordinates (NDC) | |
corner.project(_this.camera); | |
// Convert NDC to screen coordinates (origin at center of screen) | |
// This matches the coordinate system of pinchPointScreen | |
var screenX = corner.x * (canvasWidth / 2); | |
var screenY = corner.y * (canvasHeight / 2); // In Three.js NDC, +Y is up | |
minX = Math.min(minX, screenX); | |
maxX = Math.max(maxX, screenX); | |
minY = Math.min(minY, screenY); | |
maxY = Math.max(maxY, screenY); | |
}); | |
if (minX === Infinity) return null; // All points were behind camera or some other issue | |
return { | |
minX: minX, | |
minY: minY, | |
maxX: maxX, | |
maxY: maxY | |
}; | |
} | |
}, | |
{ | |
key: "_getVisibleVideoParameters", | |
value: function _getVisibleVideoParameters() { | |
if (!this.videoElement || this.videoElement.videoWidth === 0 || this.videoElement.videoHeight === 0) { | |
return null; | |
} | |
var vNatW = this.videoElement.videoWidth; | |
var vNatH = this.videoElement.videoHeight; | |
var rW = this.renderDiv.clientWidth; | |
var rH = this.renderDiv.clientHeight; | |
if (vNatW === 0 || vNatH === 0 || rW === 0 || rH === 0) return null; | |
var videoAR = vNatW / vNatH; | |
var renderDivAR = rW / rH; | |
var finalVideoPixelX, finalVideoPixelY; | |
var visibleVideoPixelWidth, visibleVideoPixelHeight; | |
if (videoAR > renderDivAR) { | |
// Video is wider than renderDiv, scaled to fit renderDiv height, cropped horizontally. | |
var scale = rH / vNatH; // Scale factor based on height. | |
var scaledVideoWidth = vNatW * scale; // Width of video if scaled to fit renderDiv height. | |
// Total original video pixels cropped horizontally (from both sides combined). | |
var totalCroppedPixelsX = (scaledVideoWidth - rW) / scale; | |
finalVideoPixelX = totalCroppedPixelsX / 2; // Pixels cropped from the left of original video. | |
finalVideoPixelY = 0; // No vertical cropping. | |
visibleVideoPixelWidth = vNatW - totalCroppedPixelsX; // Width of the visible part in original video pixels. | |
visibleVideoPixelHeight = vNatH; // Full height is visible. | |
} else { | |
// Video is taller than renderDiv (or same AR), scaled to fit renderDiv width, cropped vertically. | |
var scale1 = rW / vNatW; // Scale factor based on width. | |
var scaledVideoHeight = vNatH * scale1; // Height of video if scaled to fit renderDiv width. | |
// Total original video pixels cropped vertically (from top and bottom combined). | |
var totalCroppedPixelsY = (scaledVideoHeight - rH) / scale1; | |
finalVideoPixelX = 0; // No horizontal cropping. | |
finalVideoPixelY = totalCroppedPixelsY / 2; // Pixels cropped from the top of original video. | |
visibleVideoPixelWidth = vNatW; // Full width is visible. | |
visibleVideoPixelHeight = vNatH - totalCroppedPixelsY; // Height of the visible part in original video pixels. | |
} | |
// Safety check for degenerate cases (e.g., extreme aspect ratios leading to zero visible dimension) | |
if (visibleVideoPixelWidth <= 0 || visibleVideoPixelHeight <= 0) { | |
// Fallback or log error, this shouldn't happen in normal scenarios | |
console.warn("Calculated visible video dimension is zero or negative.", { | |
visibleVideoPixelWidth: visibleVideoPixelWidth, | |
visibleVideoPixelHeight: visibleVideoPixelHeight | |
}); | |
return { | |
offsetX: 0, | |
offsetY: 0, | |
visibleWidth: vNatW, | |
visibleHeight: vNatH, | |
videoNaturalWidth: vNatW, | |
videoNaturalHeight: vNatH | |
}; | |
} | |
return { | |
offsetX: finalVideoPixelX, | |
offsetY: finalVideoPixelY, | |
visibleWidth: visibleVideoPixelWidth, | |
visibleHeight: visibleVideoPixelHeight, | |
videoNaturalWidth: vNatW, | |
videoNaturalHeight: vNatH | |
}; | |
} | |
}, | |
{ | |
// _updateGhosts method removed. | |
key: "_showStatusScreen", | |
value: function _showStatusScreen(message) { | |
var color = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : 'white', showRestartHint = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : false; | |
this.gameOverContainer.style.display = 'block'; | |
this.gameOverText.innerText = message; | |
this.gameOverText.style.color = color; | |
this.restartHintText.style.display = showRestartHint ? 'block' : 'none'; | |
// No spawning to stop for template | |
} | |
}, | |
{ | |
key: "_showError", | |
value: function _showError(message) { | |
this.gameOverContainer.style.display = 'block'; | |
this.gameOverText.innerText = "ERROR: ".concat(message); | |
this.gameOverText.style.color = 'orange'; | |
this.restartHintText.style.display = 'true'; // Show restart hint on error | |
this.gameState = 'error'; | |
// No spawning to stop | |
this.hands.forEach(function(hand) { | |
if (hand.lineGroup) hand.lineGroup.visible = false; | |
}); | |
} | |
}, | |
{ | |
key: "_restartGame", | |
value: function _restartGame() { | |
console.log("Restarting tracking..."); | |
this.gameOverContainer.style.display = 'none'; | |
this.hands.forEach(function(hand) { | |
if (hand.lineGroup) { | |
hand.lineGroup.visible = false; | |
} | |
}); | |
// Ghost removal removed | |
// Score reset removed | |
// Visibility of game elements removed | |
this.gameState = 'tracking'; // Changed from 'playing' | |
this.lastVideoTime = -1; | |
this.clock.start(); | |
// Removed _startSpawning() | |
} | |
}, | |
{ | |
// _updateScoreDisplay method removed. | |
key: "_onResize", | |
value: function _onResize() { | |
var width = this.renderDiv.clientWidth; | |
var height = this.renderDiv.clientHeight; | |
// Update camera perspective | |
this.camera.left = width / -2; | |
this.camera.right = width / 2; | |
this.camera.top = height / 2; | |
this.camera.bottom = height / -2; | |
this.camera.updateProjectionMatrix(); | |
// Update renderer size | |
this.renderer.setSize(width, height); | |
// Update video element size | |
this.videoElement.style.width = width + 'px'; | |
this.videoElement.style.height = height + 'px'; | |
// Watermelon, Chad, GroundLine updates removed. | |
} | |
}, | |
{ | |
key: "_updateHandLines", | |
value: function _updateHandLines(handIndex, landmarks, videoParams, canvasWidth, canvasHeight) { | |
var _this = this; | |
var hand = this.hands[handIndex]; | |
var lineGroup = hand.lineGroup; | |
// Determine if this specific hand is currently involved in a grab/scale interaction | |
var isThisHandActivelyInteracting = false; | |
if (this.interactionMode === 'drag' || this.interactionMode === 'rotate') { | |
isThisHandActivelyInteracting = this.grabbingHandIndex === handIndex && this.pickedUpModel === this.pandaModel; | |
} else if (this.interactionMode === 'scale') { | |
// For scale, both hands involved show the effect if scaling is active | |
isThisHandActivelyInteracting = this.scaleInitialPinchDistance !== null && (handIndex === 0 || handIndex === 1); | |
} else if (this.interactionMode === 'animate') { | |
// For animate, the hand controlling animation scrolling (via pinch) shows the effect | |
isThisHandActivelyInteracting = this.animationControlHandIndex === handIndex; | |
} | |
var currentHandMaterial = handIndex === 0 ? this.fingertipMaterialHand1 : this.fingertipMaterialHand2; | |
if (currentHandMaterial) { | |
currentHandMaterial.opacity = isThisHandActivelyInteracting ? this.fingertipGrabOpacity : this.fingertipDefaultOpacity; | |
} | |
while(lineGroup.children.length){ | |
var child = lineGroup.children[0]; | |
lineGroup.remove(child); | |
if (child.geometry) child.geometry.dispose(); | |
// Materials are shared, no need to dispose them here unless they are unique per line/circle | |
} | |
if (!landmarks || landmarks.length === 0 || !videoParams) { | |
lineGroup.visible = false; | |
return; | |
} | |
var isAnyLandmarkOffScreen = false; | |
var _iteratorNormalCompletion = true, _didIteratorError = false, _iteratorError = undefined; | |
try { | |
// First, check if any landmark is off-screen based on unclamped normalized coordinates | |
for(var _iterator = landmarks[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true){ | |
var lm = _step.value; | |
var lmOriginalX = lm.x * videoParams.videoNaturalWidth; | |
var lmOriginalY = lm.y * videoParams.videoNaturalHeight; | |
var normX_visible = (lmOriginalX - videoParams.offsetX) / videoParams.visibleWidth; | |
var normY_visible = (lmOriginalY - videoParams.offsetY) / videoParams.visibleHeight; | |
if (normX_visible < 0 || normX_visible > 1 || normY_visible < 0 || normY_visible > 1) { | |
isAnyLandmarkOffScreen = true; | |
break; | |
} | |
} | |
} catch (err) { | |
_didIteratorError = true; | |
_iteratorError = err; | |
} finally{ | |
try { | |
if (!_iteratorNormalCompletion && _iterator.return != null) { | |
_iterator.return(); | |
} | |
} finally{ | |
if (_didIteratorError) { | |
throw _iteratorError; | |
} | |
} | |
} | |
if (isAnyLandmarkOffScreen) { | |
lineGroup.visible = false; | |
return; | |
} | |
// If all landmarks are on-screen (or would be, before clamping), proceed to calculate points3D for drawing. | |
// These points will use clamped coordinates to ensure they are drawn within canvas bounds if very close to edge. | |
var points3D = landmarks.map(function(lm) { | |
var lmOriginalX = lm.x * videoParams.videoNaturalWidth; | |
var lmOriginalY = lm.y * videoParams.videoNaturalHeight; | |
var normX_visible = (lmOriginalX - videoParams.offsetX) / videoParams.visibleWidth; | |
var normY_visible = (lmOriginalY - videoParams.offsetY) / videoParams.visibleHeight; | |
// Clamp values FOR DRAWING purposes | |
normX_visible = Math.max(0, Math.min(1, normX_visible)); | |
normY_visible = Math.max(0, Math.min(1, normY_visible)); | |
var x = (1 - normX_visible) * canvasWidth - canvasWidth / 2; | |
var y = (1 - normY_visible) * canvasHeight - canvasHeight / 2; | |
return new THREE.Vector3(x, y, 1.1); // Z for fingertip circles, slightly in front of lines | |
}); | |
var lineZ = 1; // Z for connection lines | |
this.handConnections.forEach(function(conn) { | |
var p1 = points3D[conn[0]]; | |
var p2 = points3D[conn[1]]; | |
if (p1 && p2) { | |
// Create points for the line with the correct Z | |
var lineP1 = p1.clone().setZ(lineZ); | |
var lineP2 = p2.clone().setZ(lineZ); | |
var geometry = new THREE.BufferGeometry().setFromPoints([ | |
lineP1, | |
lineP2 | |
]); | |
var line = new THREE.Line(geometry, _this.handLineMaterial); | |
lineGroup.add(line); | |
} | |
}); | |
// Draw fingertip circles | |
var fingertipRadius = 8; // Radius of the circle for fingertips | |
var wristRadius = 12; // Larger radius for the wrist | |
var circleSegments = 16; // Smoothness of the circle | |
this.fingertipLandmarkIndices.forEach(function(index) { | |
var landmarkPosition = points3D[index]; | |
if (landmarkPosition) { | |
var radius = index === 0 ? wristRadius : fingertipRadius; // Use wristRadius for landmark 0 | |
var circleGeometry = new THREE.CircleGeometry(radius, circleSegments); | |
// The 'currentHandMaterial' (fetched and opacity-updated above) is used here. | |
var landmarkCircle = new THREE.Mesh(circleGeometry, currentHandMaterial); | |
landmarkCircle.position.copy(landmarkPosition); // Already has Z=1.1 | |
// Pulse scaling also depends on 'isThisHandActivelyInteracting' | |
if (isThisHandActivelyInteracting) { | |
// Apply pulsing effect to scale | |
// (1 + sin) / 2 gives a 0-1 range, perfect for modulating amplitude | |
var currentPulseProgress = (1 + Math.sin(_this.clock.elapsedTime * _this.grabbingPulseSpeed)) / 2; | |
var scaleValue = _this.pulseBaseScale + currentPulseProgress * _this.grabbingPulseAmplitude; | |
landmarkCircle.scale.set(scaleValue, scaleValue, 1); | |
} else { | |
landmarkCircle.scale.set(_this.pulseBaseScale, _this.pulseBaseScale, 1); // Reset scale | |
} | |
lineGroup.add(landmarkCircle); | |
} | |
}); | |
lineGroup.visible = true; | |
} | |
}, | |
{ | |
key: "_animate", | |
value: function _animate() { | |
requestAnimationFrame(this._animate.bind(this)); | |
var deltaTime = this.clock.getDelta(); | |
// Update hands if tracking | |
if (this.gameState === 'tracking') { | |
this._updateHands(); | |
} | |
// Update animation mixer | |
if (this.animationMixer) { | |
this.animationMixer.update(deltaTime); | |
} | |
// Bounding box helper visibility logic REMOVED | |
// _updateGhosts and _updateParticles calls removed. | |
// Always render the scene | |
this.renderer.render(this.scene, this.camera); | |
} | |
}, | |
{ | |
key: "start", | |
value: function start() { | |
var _this = this; | |
// Add click listener for resuming audio context and potentially restarting on error | |
this.renderDiv.addEventListener('click', function() { | |
_this.audioManager.resumeContext(); | |
if (_this.gameState === 'error' || _this.gameState === 'paused') { | |
_this._restartGame(); // Restart tracking | |
} | |
}); | |
console.log('Game setup initiated. Waiting for async operations...'); | |
// Note: Game interaction now starts automatically after _init completes. | |
} | |
}, | |
{ | |
key: "_updateSpeechBubbleAppearance", | |
value: function _updateSpeechBubbleAppearance() { | |
if (!this.speechBubble) return; | |
var isPlaceholder = this.speechBubble.innerHTML === "..." || this.speechBubble.innerText === "..."; | |
// Apply active styling only if recognition is generally active AND we are not displaying the placeholder. | |
// This means interim/final text will get the active style, but the "..." placeholder will not, | |
// even if the recognition service itself is still running in the background. | |
var showActiveStyling = this.isSpeechActive && !isPlaceholder; | |
var translateY = isPlaceholder ? '-5px' : '0px'; | |
var scale = showActiveStyling ? '1.15' : '1.0'; | |
this.speechBubble.style.transform = "translateX(-50%) translateY(".concat(translateY, ") scale(").concat(scale, ")"); | |
if (showActiveStyling) { | |
// Cyan glow, blue drop shadow, enhanced original shadow | |
// Active speech bubble: brighter color, stronger shadow | |
this.speechBubble.style.boxShadow = '5px 5px 0px #007bff'; // Active blue shadow | |
this.speechBubble.style.border = '2px solid black'; // Keep black border | |
this.speechBubble.style.padding = '18px 28px'; // Slightly larger padding | |
this.speechBubble.style.fontSize = 'clamp(20px, 3.5vw, 26px)'; // Larger font when active | |
this.speechBubble.style.top = '15px'; // Increased top margin when active, reduced from 30px to complement base 10px | |
} else { | |
// Default/inactive styling | |
// Default/inactive speech bubble styling | |
this.speechBubble.style.boxShadow = '4px 4px 0px rgba(0,0,0,1)'; // Hard black shadow | |
this.speechBubble.style.border = '2px solid black'; // Black border | |
this.speechBubble.style.padding = '15px 25px'; | |
this.speechBubble.style.fontSize = 'clamp(16px, 3vw, 22px)'; // Original font size | |
this.speechBubble.style.top = '10px'; // Original top margin, changed from 20px | |
} | |
} | |
}, | |
{ | |
key: "_setupSpeechRecognition", | |
value: function _setupSpeechRecognition() { | |
var _this = this; | |
this.speechManager = new SpeechManager(function(finalTranscript, interimTranscript) { | |
if (_this.speechBubble) { | |
clearTimeout(_this.speechBubbleTimeout); | |
if (finalTranscript) { | |
_this.speechBubble.innerHTML = finalTranscript; | |
_this.speechBubble.style.opacity = '1'; | |
_this.speechBubbleTimeout = setTimeout(function() { | |
_this.speechBubble.innerHTML = "..."; | |
_this.speechBubble.style.opacity = '0.7'; | |
_this._updateSpeechBubbleAppearance(); // Update appearance for "..." | |
}, 2000); | |
} else if (interimTranscript) { | |
_this.speechBubble.innerHTML = '<i style="color: #888;">'.concat(interimTranscript, "</i>"); | |
_this.speechBubble.style.opacity = '1'; | |
} else { | |
_this.speechBubbleTimeout = setTimeout(function() { | |
if (_this.speechBubble.innerHTML !== "...") { | |
_this.speechBubble.innerHTML = "..."; | |
} | |
_this.speechBubble.style.opacity = '0.7'; | |
_this._updateSpeechBubbleAppearance(); // Update appearance for "..." | |
}, 500); | |
} | |
_this._updateSpeechBubbleAppearance(); | |
} | |
}, function(isActive) { | |
_this.isSpeechActive = isActive; | |
_this._updateSpeechBubbleAppearance(); | |
}, function(command) { | |
console.log("Game received command: ".concat(command)); | |
var validCommands = [ | |
'drag', | |
'rotate', | |
'scale', | |
'animate' | |
]; | |
if (validCommands.includes(command.toLowerCase())) { | |
_this._setInteractionMode(command.toLowerCase()); | |
} else { | |
console.warn("Unrecognized command via speech: ".concat(command)); | |
} | |
}); | |
// Initialize speech bubble with "..." and apply initial appearance | |
if (this.speechBubble) { | |
this.speechBubble.innerHTML = "..."; | |
this.speechBubble.style.opacity = '0.7'; | |
this._updateSpeechBubbleAppearance(); // Apply initial styles (isSpeechActive will be false) | |
} | |
// We will call requestPermissionAndStart() on user interaction (e.g., start button) | |
} | |
}, | |
{ | |
key: "_playAnimation", | |
value: function _playAnimation(name) { | |
if (!this.animationActions[name]) { | |
console.warn('Animation "'.concat(name, '" not found.')); | |
return; | |
} | |
var newAction = this.animationActions[name]; | |
if (this.currentAction === newAction && newAction.isRunning()) { | |
console.log('Animation "'.concat(name, '" is already playing.')); | |
return; // Already playing this animation | |
} | |
if (this.currentAction) { | |
this.currentAction.fadeOut(0.5); // Fade out current animation over 0.5 seconds | |
} | |
newAction.reset().fadeIn(0.5).play(); // Reset, fade in and play new animation | |
this.currentAction = newAction; | |
console.log("Playing animation: ".concat(name)); | |
this._updateButtonStyles(name); | |
} | |
}, | |
{ | |
key: "_updateButtonStyles", | |
value: function _updateButtonStyles(activeAnimationName) { | |
var buttons = this.animationButtonsContainer.children; | |
for(var i = 0; i < buttons.length; i++){ | |
var button = buttons[i]; | |
var isActive = button.innerText === activeAnimationName; | |
button.style.backgroundColor = isActive ? '#007bff' : '#f0f0f0'; // Blue if active, light grey if not | |
button.style.color = isActive ? 'white' : 'black'; | |
button.style.fontWeight = isActive ? 'bold' : 'normal'; | |
// Active button has its shadow "pressed" | |
button.style.boxShadow = isActive ? '1px 1px 0px black' : '2px 2px 0px black'; | |
} | |
} | |
}, | |
{ | |
key: "_setInteractionMode", | |
value: function _setInteractionMode(mode) { | |
var _this = this; | |
if (this.interactionMode === mode) return; // No change | |
console.log("Setting interaction mode to: ".concat(mode)); | |
this.interactionMode = mode; | |
// If currently grabbing, release the model | |
if (this.grabbingHandIndex !== -1 && this.pickedUpModel) { | |
console.log("Interaction mode changed while grabbing. Releasing model from hand ".concat(this.grabbingHandIndex, ".")); | |
this.grabbingHandIndex = -1; | |
this.pickedUpModel = null; | |
this.rotateLastHandX = null; | |
this.scaleInitialPinchDistance = null; // Reset scaling variables | |
this.scaleInitialModelScale = null; | |
// if (this.grabMarker && this.pandaModel) this.grabMarker.visible = true; // Grab marker removed | |
} | |
this._updateHandMaterialsForMode(mode); // Update hand colors for new mode | |
this._updateInteractionModeButtonStyles(); | |
// Show/hide animation buttons container based on mode | |
if (this.animationButtonsContainer) { | |
if (mode === 'animate') { | |
this.animationButtonsContainer.style.display = 'flex'; | |
requestAnimationFrame(function() { | |
_this.animationButtonsContainer.style.opacity = '1'; | |
}); | |
} else { | |
this.animationButtonsContainer.style.opacity = '0'; | |
// Wait for transition to complete before setting display to none | |
setTimeout(function() { | |
if (_this.interactionMode !== 'animate') { | |
_this.animationButtonsContainer.style.display = 'none'; | |
} | |
}, 300); // Corresponds to transition duration | |
} | |
} | |
this._updateInstructionText(); // Update instruction text when mode changes | |
} | |
}, | |
{ | |
key: "_updateInstructionText", | |
value: function _updateInstructionText() { | |
if (this.instructionTextElement) { | |
var instruction = this.interactionModeInstructions[this.interactionMode] || "Use hand gestures to interact."; | |
this.instructionTextElement.innerText = instruction; | |
// The instruction text should always be 10px from the bottom. | |
// The animation buttons are positioned from the top-left and should not affect this. | |
this.instructionTextElement.style.bottom = '10px'; // Decreased bottom margin | |
} | |
} | |
}, | |
{ | |
key: "_updateHandMaterialsForMode", | |
value: function _updateHandMaterialsForMode(mode) { | |
var modeConfig = this.interactionModeColors[mode]; | |
var colorToSet = modeConfig ? modeConfig.hand : new THREE.Color(0x00ccff); // Fallback color | |
if (this.fingertipMaterialHand1) { | |
this.fingertipMaterialHand1.color.set(colorToSet); | |
} | |
if (this.fingertipMaterialHand2) { | |
this.fingertipMaterialHand2.color.set(colorToSet); | |
} | |
} | |
}, | |
{ | |
key: "_updateInteractionModeButtonStyles", | |
value: function _updateInteractionModeButtonStyles() { | |
var _this = this; | |
for(var modeKey in this.interactionModeButtons){ | |
var button = this.interactionModeButtons[modeKey]; | |
var modeConfig = this.interactionModeColors[modeKey]; | |
var fallbackColor = '#6c757d'; | |
var fallbackTextColor = 'white'; | |
if (modeKey === this.interactionMode) { | |
button.style.border = '2px solid black'; // All buttons have black border | |
if (modeConfig) { | |
button.style.backgroundColor = modeConfig.base; | |
button.style.color = modeConfig.text; | |
} else { | |
button.style.backgroundColor = fallbackColor; | |
button.style.color = fallbackTextColor; | |
} | |
button.style.fontWeight = 'bold'; // Already bold from initial setup, but ensure it stays | |
button.style.boxShadow = '1px 1px 0px black'; // "Pressed" shadow for active button | |
} else { | |
button.style.backgroundColor = 'rgba(255, 255, 255, 0.5)'; // More opaque transparent white background | |
button.style.border = '2px solid black'; // Black border for inactive | |
if (modeConfig) { | |
button.style.color = modeConfig.base; // Neon text color | |
} else { | |
button.style.color = fallbackColor; // Fallback text color for inactive | |
} | |
button.style.fontWeight = 'bold'; // Always bold | |
button.style.boxShadow = '2px 2px 0px black'; // Default shadow for inactive | |
} | |
} | |
// Explicitly set display for animationButtonsContainer based on current mode | |
// This ensures it's correct even on initial load if default mode isn't 'animate' | |
if (this.animationButtonsContainer) { | |
if (this.interactionMode === 'animate') { | |
this.animationButtonsContainer.style.display = 'flex'; | |
requestAnimationFrame(function() { | |
_this.animationButtonsContainer.style.opacity = '1'; | |
}); | |
} else { | |
this.animationButtonsContainer.style.opacity = '0'; | |
this.animationButtonsContainer.style.display = 'none'; // Set display none immediately if not animate | |
} | |
} | |
this._updateInstructionText(); // Also call here to adjust position if animation buttons are shown/hidden | |
} | |
}, | |
{ | |
key: "_setupDragAndDrop", | |
value: function _setupDragAndDrop() { | |
var _this = this; | |
this.renderDiv.addEventListener('dragover', function(event) { | |
event.preventDefault(); // Prevent default behavior to allow drop | |
event.dataTransfer.dropEffect = 'copy'; // Show a copy icon | |
_this.renderDiv.style.border = '2px dashed #007bff'; // Visual feedback | |
}); | |
this.renderDiv.addEventListener('dragleave', function(event) { | |
_this.renderDiv.style.border = 'none'; // Remove visual feedback | |
}); | |
this.renderDiv.addEventListener('drop', function(event) { | |
event.preventDefault(); | |
_this.renderDiv.style.border = 'none'; // Remove visual feedback | |
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) { | |
var file = event.dataTransfer.files[0]; | |
var fileName = file.name.toLowerCase(); | |
var fileType = file.type.toLowerCase(); | |
if (fileName.endsWith('.gltf') || fileName.endsWith('.glb') || fileType === 'model/gltf+json' || fileType === 'model/gltf-binary') { | |
console.log("GLTF file dropped: ".concat(file.name), file); | |
// Next step: Process and load this file. | |
_this._loadDroppedModel(file); | |
} else { | |
console.warn('Dropped file is not a recognized GLTF format:', file.name, file.type); | |
_this._showStatusScreen('"'.concat(file.name, '" is not a GLTF model.'), 'orange', false); | |
setTimeout(function() { | |
if (_this.gameOverContainer.style.display === 'block' && _this.gameOverText.innerText.includes(file.name)) { | |
_this.gameOverContainer.style.display = 'none'; | |
} | |
}, 3000); | |
} | |
event.dataTransfer.clearData(); | |
} | |
}); | |
} | |
}, | |
{ | |
key: "_loadDroppedModel", | |
value: function _loadDroppedModel(file) { | |
var _this = this; | |
console.log("Processing dropped model:", file.name, file.type); | |
var reader = new FileReader(); | |
reader.onload = function(e) { | |
// Pass file.type as well, it might be useful for _parseAndLoadGltf context | |
_this._parseAndLoadGltf(e.target.result, file.name, file.type); | |
}; | |
reader.onerror = function(error) { | |
console.error("FileReader error for ".concat(file.name, ":"), error); | |
_this._showError("Error reading file ".concat(file.name, ".")); | |
// Ensure loading message is hidden if it was shown by this function | |
if (_this.gameOverContainer.style.display === 'block' && _this.gameOverText.innerText.startsWith('Loading "'.concat(file.name, '"'))) { | |
_this.gameOverContainer.style.display = 'none'; | |
} | |
}; | |
var fileNameLower = file.name.toLowerCase(); | |
var fileTypeLower = file.type ? file.type.toLowerCase() : ''; | |
if (fileNameLower.endsWith('.glb') || fileTypeLower === 'model/gltf-binary') { | |
console.log("Reading ".concat(file.name, " as ArrayBuffer.")); | |
reader.readAsArrayBuffer(file); | |
} else if (fileNameLower.endsWith('.gltf') || fileTypeLower === 'model/gltf+json') { | |
console.log("Reading ".concat(file.name, " as text.")); | |
reader.readAsText(file); | |
} else { | |
var message = file.type ? "Unsupported file type: ".concat(file.type) : 'Cannot determine file type.'; | |
console.warn("Unknown file format for GLTF loader: ".concat(file.name, ", Type: ").concat(file.type)); | |
this._showError("".concat(message, " for ").concat(file.name, ". Please drop a .gltf or .glb file.")); | |
// Ensure loading message is hidden | |
if (this.gameOverContainer.style.display === 'block' && this.gameOverText.innerText.startsWith('Loading "'.concat(file.name, '"'))) { | |
this.gameOverContainer.style.display = 'none'; | |
} | |
} | |
} | |
}, | |
{ | |
key: "_parseAndLoadGltf", | |
value: function _parseAndLoadGltf(content, fileName, fileType) { | |
var _this = this; | |
var loader = new GLTFLoader(); // GLTFLoader is already imported at the top | |
try { | |
// The 'path' argument is for resolving relative paths for external resources like .bin or textures. | |
// For a single file drop, this is typically empty. If it's a .gltf with external files, | |
// those files would need to be handled separately (e.g., by being dropped together and identified). | |
// This setup works best for self-contained .glb files or .gltf files using data URIs. | |
loader.parse(content, '', function(gltf) { | |
console.log("Successfully parsed GLTF model: ".concat(fileName), gltf); | |
// 1. If a previous model exists, remove it and clean up its animations | |
if (_this.pandaModel) { | |
_this.scene.remove(_this.pandaModel); | |
// Consider disposing geometry/materials of this.pandaModel here for memory management in a larger app | |
console.log("Removed previous model from scene."); | |
if (_this.animationMixer) { | |
_this.animationMixer.stopAllAction(); | |
_this.currentAction = null; | |
} | |
// Clear out old animation buttons | |
while(_this.animationButtonsContainer.firstChild){ | |
_this.animationButtonsContainer.removeChild(_this.animationButtonsContainer.firstChild); | |
} | |
_this.animationActions = {}; | |
_this.animationClips = []; | |
} | |
// 2. Set the new model as the current model | |
_this.pandaModel = gltf.scene; | |
// 3. Scale and position the new model | |
var scale = 80; | |
_this.pandaModel.scale.set(scale, scale, scale); | |
var sceneHeight = _this.renderDiv.clientHeight; | |
_this.pandaModel.position.set(0, sceneHeight * -0.45, -1000); | |
// 4. Add the new model to the scene | |
_this.scene.add(_this.pandaModel); | |
console.log('Added new model "'.concat(fileName, '" to scene.')); | |
// 5. Setup animations for the new model | |
_this.animationMixer = new THREE.AnimationMixer(_this.pandaModel); | |
_this.animationClips = gltf.animations; | |
_this.animationActions = {}; // Ensure it's clean for new actions | |
if (_this.animationClips && _this.animationClips.length) { | |
_this.animationClips.forEach(function(clip, index) { | |
var action = _this.animationMixer.clipAction(clip); | |
var actionName = clip.name || "Animation ".concat(index + 1); | |
_this.animationActions[actionName] = action; | |
var button = document.createElement('button'); | |
button.innerText = actionName; | |
button.style.padding = '5px 10px'; | |
button.style.fontSize = '13px'; | |
button.style.backgroundColor = '#f0f0f0'; | |
button.style.color = 'black'; | |
button.style.border = '2px solid black'; | |
button.style.borderRadius = '4px'; | |
button.style.cursor = 'pointer'; | |
button.style.transition = 'background-color 0.2s ease, box-shadow 0.2s ease'; | |
button.style.boxShadow = '2px 2px 0px black'; | |
button.addEventListener('click', function() { | |
return _this._playAnimation(actionName); | |
}); | |
_this.animationButtonsContainer.appendChild(button); | |
}); | |
var defaultActionName = Object.keys(_this.animationActions)[0]; | |
var idleActionKey = Object.keys(_this.animationActions).find(function(name) { | |
return name.toLowerCase().includes('idle'); | |
}); | |
if (idleActionKey) { | |
defaultActionName = idleActionKey; | |
} | |
if (defaultActionName && _this.animationActions[defaultActionName]) { | |
_this.currentAction = _this.animationActions[defaultActionName]; | |
_this.currentAction.reset().play(); | |
_this._updateButtonStyles(defaultActionName); | |
} else { | |
_this.currentAction = null; | |
} | |
} else { | |
console.log('New model "'.concat(fileName, '" has no embedded animations.')); | |
_this.currentAction = null; | |
} | |
// 6. Reset interaction states | |
_this.grabbingHandIndex = -1; | |
_this.pickedUpModel = null; | |
_this.rotateLastHandX = null; | |
_this.scaleInitialPinchDistance = null; | |
_this.scaleInitialModelScale = null; | |
_this.animationControlHandIndex = -1; | |
_this.animationControlInitialPinchY = null; | |
// This will ensure animation buttons are shown/hidden correctly based on current mode | |
_this._updateInteractionModeButtonStyles(); | |
_this.loadedDroppedModelData = null; // Clear the temp storage | |
}, function(error) { | |
console.error("Error parsing GLTF model ".concat(fileName, ":"), error); | |
_this._showError('Failed to parse "'.concat(fileName, '". Model might be corrupt or unsupported. Check console.')); | |
}); | |
} catch (e) { | |
// This catch is for synchronous errors during loader.parse() setup, though most errors are async. | |
console.error("Critical error during GLTF parsing setup for ".concat(fileName, ":"), e); | |
this._showError('Error setting up parser for "'.concat(fileName, '".')); | |
} | |
} | |
} | |
]); | |
return Game; | |
}(); |