You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

1086 lines
42 KiB

import * as THREE from 'three';
import { RigidBodyDesc, ColliderDesc } from '@dimforge/rapier3d-compat';
import { AudioContent, VideoContent } from './Content';
import { ModelLoader } from './ModelLoader';
export class Player {
constructor(rapierWorld, renderer, scene, spawnPosition = new THREE.Vector3(0, 1, 0), itemList, socket) {
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
this.position = spawnPosition.clone();
this.rotation = new THREE.Euler(0, 89.6, 0, 'YXZ');
this.velocity = new THREE.Vector3();
this.acceleration = new THREE.Vector3();
this.id = null;
this.name = null;
this.audioListener = new THREE.AudioListener();
this.attachSound = null;
this.detachSound = null;
this.hoverSound = null;
this.collectedItems = [];
this.numCollectedItems = [];
this.areaList = [];
this.currentArea = null;
this.areasVisited = 0;
this.itemCountDisplay = null;
this.itemCountAnim = {
active: false,
time: 0,
duration: 1.2, // seconds
fadeIn: 0.3,
fadeOut: 0.5,
startY: -1,
endY: -0.7
};
this.sprayCan = null;
this.hand = null;
this.currentSprayColor = '#FF0000';
this.modelLoader = new ModelLoader();
this.shakeData = {
lastPosition: new THREE.Vector3(),
lastVelocity: new THREE.Vector3(),
lastShakeTime: 0,
shakeThreshold: 1000, // Acceleration magnitude needed to trigger
shakeCooldown: 0.5 // Seconds between shakes
};
this.interactPrompt = null;
this.interactPromptTime = 0;
this.lastPromptItem = null;
this.renderer = renderer;
this.scene = scene;
this.rapierWorld = rapierWorld;
this.rigibody = null;
this.collider = null;
this.moveSpeed = 100; //40
this.mouseSensitivity = 0.002;
this.maxInteractionDistance = 200.0;
this.vrDrawing = false;
this.isDrawing = false;
this.raycast = new THREE.Raycaster();
this.pointer = new THREE.Vector2();
this.itemList = itemList;
this.currentIntItem = null;
this.attachedItem = null;
this.input = {
forward: false,
backwards: false,
left: false,
right:false,
up: false,
down: false,
mouseDelta: { x: 0, y: 0}
};
this.enableInput = false;
/* VR Stuff */
this.vrControllers = [];
this.vrGamepads = [null, null];
this.teleArc = null;
this.teleMarker = null;
this.teleporting = false;
this.teleportTarget = new THREE.Vector3();
this.playerRig = new THREE.Group();
this.teleportDistanceFactor = 1.0; // Default distance multiplier
this.minTeleportDistanceFactor = 0.1; // Minimum distance multiplier
this.maxTeleportDistanceFactor = 1.5; // Maximum distance multiplier
this.socket = socket;
this._loadSounds();
this._init();
this._setupInput();
this._bindEvents();
}
_bindEvents() {
window.addEventListener('keydown', (e) => this._onKeyDown(e));
window.addEventListener('wheel', (e) => this._onWheel(e));
}
_onWheel(e) {
if (this.attachedItem && this.attachedItem.content) {
if (e.deltaY < 0 && typeof this.attachedItem.content.scrollUp === 'function') {
this.attachedItem.content.scrollUp();
} else if (e.deltaY > 0 && typeof this.attachedItem.content.scrollDown === 'function') {
this.attachedItem.content.scrollDown();
}
}
}
_onKeyDown(e) {
if (e.code == 'KeyF' && this.attachedItem) {
console.log("Dettached item to player: ", this.attachedItem.object.name);
this.attachedItem.isActive = false;
this.attachedItem._removeContentDisplay();
this.attachedItem = null;
// Update player's position and rotation to match the camera's current state
this.position.copy(this.playerRig.position);
this.rotation.setFromQuaternion(this.playerRig.quaternion, 'YXZ');
if (this.detachSound && this.detachSound.isPlaying) this.detachSound.stop();
if (this.detachSound) {
this.detachSound.playbackRate = 0.5 + Math.random() * 0.5;
this.detachSound.play();
}
} else if(e.code == 'KeyF' && this.currentIntItem && !this.attachedItem){
this.attachedItem = this.currentIntItem;
this.attachedItem.isActive = true;
if (this.attachSound && this.attachSound.isPlaying) this.attachSound.stop();
if (this.attachSound){
this.attachSound.playbackRate = 0.5 + Math.random() * 0.5;
this.attachSound.play();
}
if (!this.collectedItems.includes(this.attachedItem.name)) {
this.collectedItems.push(this.attachedItem.name);
this.numCollectedItems++;
console.log("Number of items collected: ", this.numCollectedItems);
// Update item count display
this.camera.remove(this.itemCountDisplay);
this.itemCountDisplay = this._createItemCountDisplay();
this.camera.add(this.itemCountDisplay);
this.itemCountAnim.active = true;
this.itemCountAnim.time = 0;
this.itemCountDisplay.position.set(0, this.itemCountAnim.startY, -2);
this.itemCountDisplay.visible = true;
// Set initial opacity
this.itemCountDisplay.traverse(obj => {
if (obj.material) obj.material.opacity = 0;
});
}
console.log("Attached item to player: ", this.attachedItem.object.name);
}
if (e.code === 'Space' && this.attachedItem) {
if (this.attachedItem.content instanceof VideoContent) {
const video = this.attachedItem.content.video;
if (video.paused) {
video.play();
} else {
video.pause();
}
}
}
if (e.code === 'KeyM' && this.attachedItem) {
if (this.attachedItem.content instanceof VideoContent) {
const video = this.attachedItem.content.video;
if (video) {
video.muted = !video.muted;
}
}
}
}
_init() {
// Create rapier rb & coll
this.position.y = 10;
const tempPos = new THREE.Vector3(0, 20, 0);
const rbDesc = RigidBodyDesc.kinematicPositionBased().setTranslation(this.position.x, this.position.y, this.position.z);
this.rigibody = this.rapierWorld.createRigidBody(rbDesc);
const colliderDesc = ColliderDesc.capsule(3, 4);
this.collider = this.rapierWorld.createCollider(colliderDesc, this.rigibody);
// Initialize Character Controller
// offset: small gap to prevent snagging (0.1)
this.characterController = this.rapierWorld.createCharacterController(0.01);
//this.characterController.enableAutostep(1, 1, true); // Handle small steps/stairs
this.characterController.setApplyImpulsesToDynamicBodies(true); // Allow pushing dynamic objects
// Offset from ground
this.camera.position.copy(tempPos);
// Attach audio listener to the camera/player
this.camera.add(this.audioListener);
this.playerRig.add(this.camera);
}
_loadSprayCan(modelUrl, scale) {
return this.modelLoader.loadModel(modelUrl, scale)
.then((model) => {
console.log('loaded spraycan');
return model;
})
.catch((err) => {
console.error('Failed to load spray can model:', err);
return null;
});
}
_loadSounds() {
const audioLoader = new THREE.AudioLoader();
this.attachSound = new THREE.Audio(this.audioListener);
this.detachSound = new THREE.Audio(this.audioListener);
this.hoverSound = new THREE.Audio(this.audioListener);
audioLoader.load('sounds/item-int-open.wav', (buffer) => {
this.attachSound.setBuffer(buffer);
this.attachSound.setVolume(0.05);
});
audioLoader.load('sounds/item-int-close.wav', (buffer) => {
this.detachSound.setBuffer(buffer);
this.detachSound.setVolume(0.05);
});
audioLoader.load('sounds/hover-2.wav', (buffer) => {
this.hoverSound.setBuffer(buffer);
this.hoverSound.setVolume(0.05);
});
}
_initAreaTriggers() {
this.areaList = [];
const testLayers = new THREE.Layers();
testLayers.set(4);
this.scene.traverse((object) => {
// Find meshes on the AREA_LAYER
if (object.isMesh && object.layers.test(testLayers)) {
this.areaList.push(object);
object.visible = false; // Optionally make trigger volumes invisible
}
});
console.log(`Initialized ${this.areaList.length} area triggers.`);
}
_checkAreaTriggers() {
if (this.areaList.length == 0) return;
let inAnyArea = false;
for (const trigger of this.areaList) {
const triggerBox = new THREE.Box3().setFromObject(trigger);
if (triggerBox.containsPoint(this.position)) {
inAnyArea = true;
if (this.currentArea !== trigger) {
this.currentArea = trigger;
console.log(`Player entered area: ${trigger.name}`);
this.areasVisited++;
}
break;
}
}
// Check if the player has left an area
if (!inAnyArea && this.currentArea !== null) {
console.log(`Player left area: ${this.currentArea.name}`);
this.currentArea = null;
}
}
_setupInput() {
window.addEventListener('keydown', (e) => {
switch (e.code) {
case 'KeyW': this.input.forward = true; break;
case 'KeyS': this.input.backward = true; break;
case 'KeyA': this.input.left = true; break;
case 'KeyD': this.input.right = true; break;
case 'KeyQ': this.input.down = true; break;
case 'KeyE': this.input.up = true; break;
}
});
window.addEventListener('keyup', (e) => {
switch (e.code) {
case 'KeyW': this.input.forward = false; break;
case 'KeyS': this.input.backward = false; break;
case 'KeyA': this.input.left = false; break;
case 'KeyD': this.input.right = false; break;
case 'KeyQ': this.input.down = false; break;
case 'KeyE': this.input.up = false; break;
}
});
window.addEventListener('mousemove', (e) => {
this.input.mouseDelta.x += e.movementX;
this.input.mouseDelta.y += e.movementY;
});
document.addEventListener('pointerdown', this.onPointerDown.bind(this));
document.addEventListener('pointerup', this.onPointerUp.bind(this));
}
_createItemCountDisplay() {
const text = `${this.numCollectedItems} / 50`;
const group = new THREE.Group();
const fontSize = 0.25;
let totalWidth = 0;
const charWidths = [];
for (let i = 0; i < text.length; i++) {
const charWidth = fontSize * (text[i] === ' ' ? 0.3 : 0.4);
charWidths.push(charWidth);
totalWidth += charWidth;
}
let offsetX = -totalWidth / 2;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.font = '100px Redacted70';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#ffd500ff'; // Consistent color
ctx.fillText(char, canvas.width / 2, canvas.height / 2);
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
const geometry = new THREE.PlaneGeometry(fontSize, fontSize);
const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.x = offsetX + charWidths[i] / 2;
group.add(mesh);
offsetX += charWidths[i];
}
group.name = 'itemCountDisplay';
return group;
}
_createInteractPrompt() {
function getRandomColor() {
const hexChars = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += hexChars[Math.floor(Math.random() * 16)];
}
return color;
}
const text = '(F) Interact';
const charMeshes = [];
const group = new THREE.Group();
const fontSize = 0.25; // Adjust as needed
// First, calculate total width
let totalWidth = 0;
const charWidths = [];
for (let i = 0; i < text.length; i++) {
const charWidth = fontSize * (text[i] === ' ' ? 0.2 : 0.3); //Changes gap size in text
charWidths.push(charWidth);
totalWidth += charWidth;
}
// Now, create meshes and position them centered
let offsetX = -totalWidth / 2;
for (let i = 0; i < text.length; i++) {
const char = text[i];
// Create a canvas for each character
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.font = '100px Redacted70';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = getRandomColor();
ctx.fillText(char, canvas.width / 2, canvas.height / 2);
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
const geometry = new THREE.PlaneGeometry(fontSize, fontSize);
const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.x = offsetX + charWidths[i] / 2;
group.add(mesh);
charMeshes.push(mesh);
// --- SNAP ROTATION STATE ---
mesh.userData.snapRotation = 0;
mesh.userData.nextSnap = performance.now() + Math.random() * 600 + 300; // 100-700ms
offsetX += charWidths[i];
}
group.userData.charMeshes = charMeshes; // Store for animation
return group;
}
onPointerDown() {
if (document.pointerLockElement) {
this.isDrawing = true;
}
}
onPointerUp() {
this.isDrawing = false;
}
async _setupVR() {
const markerGeometry = new THREE.CircleGeometry(0.5, 32);
markerGeometry.rotateX(-Math.PI / 2);
const markerMat = new THREE.MeshBasicMaterial({color: 0x00ff00, transparent: false, opacity: 0.5});
this.teleMarker = new THREE.Mesh(markerGeometry, markerMat);
this.teleMarker.visible = false;
this.playerRig.add(this.teleMarker);
// Setup teleport arc
const arcMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00, linewidth: 2 });
const arcGeometry = new THREE.BufferGeometry();
this.teleArc = new THREE.Line(arcGeometry, arcMaterial);
this.teleArc.visible = false;
this.playerRig.add(this.teleArc);
// Controller Setup
for (let i = 0; i < 2; i++) {
const controller = this.renderer.xr.getController(i);
this.playerRig.add(controller);
this.vrControllers.push(controller);
// --- NEW: Add the 'connected' listener to get the Gamepad object ---
controller.addEventListener('connected', (event) => {
// The Gamepad object is in event.data.gamepad
this.vrGamepads[i] = event.data.gamepad;
console.log(`Controller ${i} connected, Gamepad stored.`);
});
// --- OPTIONAL: Handle disconnection ---
controller.addEventListener('disconnected', () => {
this.vrGamepads[i] = null;
console.log(`Controller ${i} disconnected.`);
});
// Add a debug sphere to the controller
// const sphereGeometry = new THREE.SphereGeometry(0.05, 8, 8);
// const sphereMaterial = new THREE.MeshBasicMaterial({ color: (i === 0 ? 0xff0000 : 0x0000ff) }); // Red for left, Blue for right
// const debugSphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
//controller.add(debugSphere);
const lineGeometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1)]);
const lineMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, opacity: 0.4, transparent: true }); // White color for the ray
const line = new THREE.Line(lineGeometry, lineMaterial);
line.name = 'controller-ray'; // Give the line a name to find it later
line.scale.z = this.maxInteractionDistance;
;
if (i == 0) {
this._loadSprayCan('models/spray-can.glb', 0.04).then((model) => {
if (model instanceof THREE.Object3D) {
controller.add(model);
this.sprayCan = model;
controller.add(line)
} else {
console.warn('Spray can model is not a THREE.Object3D:', model);
}
});
}
controller.addEventListener('selectstart', () => this._OnVRSelectStart(i));
controller.addEventListener('selectend', () => this._OnVRSelectEnd(i));
controller.addEventListener('squeezestart', () => this._OnVRSqueezeStart(i));
controller.addEventListener('squeezeend', () => this._OnVRSqueezeEnd(i));
}
}
_OnVRSelectStart(controllerIndex) {
const controller = this.vrControllers[controllerIndex];
// Right controller (index 1) for drawing/interaction
console.log(`Select Started: ${controllerIndex}`);
if (controllerIndex === 0) {
if (this.currentIntItem && !this.attachedItem) {
this.attachedItem = this.currentIntItem;
this.attachedItem.isActive = true;
console.log("Attaced Item to (VR) ", this.attachedItem.name);
} else {
this.isDrawing = true;
}
}
// Left controller (index 0) for teleporting
if (controllerIndex === 1) {
this.teleporting = true;
this.teleArc.visible = true;
}
}
_OnVRSelectEnd(controllerIndex) {
// Right controller
console.log(`Select End: ${controllerIndex}`);
if (controllerIndex === 0 && this.attachedItem) {
this.attachedItem.isActive = false;
this.attachedItem._removeContentDisplay();
this.attachedItem = null;
console.log("Dettached item from player (VR)");
}
// Left controller
if (controllerIndex === 1) {
this.teleporting = false;
this.teleArc.visible = false;
if (this.teleMarker.visible) {
const newPosition = this.teleportTarget.clone();
newPosition.y = 10; // Maintain height
this.playerRig.position.copy(newPosition);
this.rigibody.setNextKinematicTranslation({ x: newPosition.x, y: newPosition.y, z: newPosition.z });
this.position.copy(newPosition);
}
this.teleMarker.visible = false;
}
}
_OnVRSqueezeStart(controllerIndex) {
console.log(`Squeeze Started: ${controllerIndex}`);
// Use squeeze on right controller to detach item
if (controllerIndex === 0) {
if (!this.attachedItem) {
this.vrDrawing = true;
this.isDrawing = true;
} else {
// Existing detach logic
this.attachedItem.isActive = false;
this.attachedItem._removeContentDisplay();
this.attachedItem = null;
}
}
}
_OnVRSqueezeEnd(controllerIndex) {
if (controllerIndex === 0) {
this.vrDrawing = false;
this.isDrawing = false;
}
}
_handleVRJoystick() {
// --- Left Controller (Teleport Distance) ---
const leftGamepad = this.vrGamepads[1];
if (leftGamepad) {
const joystickVertical = leftGamepad.axes[3];
if (joystickVertical !== undefined) {
const normalizedValue = (-joystickVertical + 1) / 2;
this.teleportDistanceFactor = this.minTeleportDistanceFactor + normalizedValue * (this.maxTeleportDistanceFactor - this.minTeleportDistanceFactor);
const deadzone = 0.05;
if (Math.abs(joystickVertical) < deadzone) {
this.teleportDistanceFactor = (this.minTeleportDistanceFactor + this.maxTeleportDistanceFactor) / 2;
}
}
}
// --- Right Controller (Content Interaction) ---
const rightGamepad = this.vrGamepads[0];
if (rightGamepad && this.attachedItem && this.attachedItem.content) {
const joystickVertical = rightGamepad.axes[3]; // Y-axis of the thumbstick
const deadzone = 0.5; // Use a larger deadzone to prevent accidental scrolling
if (joystickVertical < -deadzone) { // Pushed Up
if (typeof this.attachedItem.content.scrollUp === 'function') {
this.attachedItem.content.scrollUp();
}
} else if (joystickVertical > deadzone) { // Pushed Down
if (typeof this.attachedItem.content.scrollDown === 'function') {
this.attachedItem.content.scrollDown();
}
}
}
}
_handleVRTeleport() {
if (!this.teleporting) {
this.teleMarker.visible = false;
this.teleArc.visible = false;
return;
}
const controller = this.vrControllers[1]; // Left controller for teleporting
const controllerMatrix = controller.matrixWorld;
const initialVelocity = 50 * this.teleportDistanceFactor;
const gravity = -9.8;
const timeStep = 0.03;
const numSegments = 100;
const points = [];
const startPoint = new THREE.Vector3().setFromMatrixPosition(controllerMatrix);
points.push(startPoint.clone());
const launchDirection = new THREE.Vector3(0, 0, -1).applyMatrix4(new THREE.Matrix4().extractRotation(controllerMatrix));
let lastPoint = startPoint.clone();
let hit = false;
for (let i = 1; i < numSegments; i++) {
const t = i * timeStep;
const currentPoint = new THREE.Vector3(
startPoint.x + launchDirection.x * initialVelocity * t,
startPoint.y + launchDirection.y * initialVelocity * t + 0.5 * gravity * t * t,
startPoint.z + launchDirection.z * initialVelocity * t
);
const ray = new THREE.Raycaster(lastPoint, currentPoint.clone().sub(lastPoint).normalize());
ray.far = lastPoint.distanceTo(currentPoint);
ray.layers.set(3); // TP_SURFACE Layer
// Intersect the whole scene, but only objects on the TP_OBJECT_LAYER will be hit
const intersects = ray.intersectObjects(this.scene.children, true);
console.log(intersects.length);
if (intersects.length > 0) {
const intersectPoint = intersects[0].point;
points.push(intersectPoint);
this.teleportTarget.copy(intersectPoint);
this.teleMarker.position.copy(this.playerRig.worldToLocal(this.teleportTarget.clone()));
this.teleMarker.position.y += 0.01; // Avoid z-fighting
this.teleMarker.visible = true;
hit = true;
break;
}
points.push(currentPoint);
lastPoint = currentPoint;
}
if (!hit) {
this.teleMarker.visible = false;
}
// Convert world-space points to local-space for the rig
const localPoints = points.map(p => this.playerRig.worldToLocal(p.clone()));
this.teleArc.geometry.dispose();
this.teleArc.geometry.setFromPoints(localPoints);
this.teleArc.geometry.computeBoundingSphere(); // Important for visibility
this.teleArc.visible = true;
}
_handleControllerShake(delta) {
if (!this.renderer.xr.isPresenting || !this.vrControllers[0] || delta === 0) {
return;
}
const controller = this.vrControllers[0];
const now = performance.now() / 1000;
// Check if cooldown has passed
if (now - this.shakeData.lastShakeTime < this.shakeData.shakeCooldown) {
// Update position even during cooldown to avoid a spike when it ends
this.shakeData.lastPosition.copy(controller.position);
this.shakeData.lastVelocity.set(0, 0, 0);
return;
}
const currentPosition = controller.position.clone();
// Velocity = (current_pos - last_pos) / time
const currentVelocity = currentPosition.clone().sub(this.shakeData.lastPosition).divideScalar(delta);
// Acceleration = (current_vel - last_vel) / time
const acceleration = currentVelocity.clone().sub(this.shakeData.lastVelocity).divideScalar(delta);
// Check if acceleration exceeds the threshold
if (acceleration.length() > this.shakeData.shakeThreshold) {
// Shake detected!
this.shakeData.lastShakeTime = now;
// Change to a new random color
const r = Math.floor(Math.random() * 256).toString(16).padStart(2, '0');
const g = Math.floor(Math.random() * 256).toString(16).padStart(2, '0');
const b = Math.floor(Math.random() * 256).toString(16).padStart(2, '0');
this.currentSprayColor = `#${r}${g}${b}`;
console.log(`Shake detected! New color: ${this.currentSprayColor}`);
// Optional: Play a sound effect here
}
// Update values for the next frame
this.shakeData.lastPosition.copy(currentPosition);
this.shakeData.lastVelocity.copy(currentVelocity);
}
_drawOnTexture(intersect, color = 'red') {
const object = intersect.object;
const objectId = object.userData.drawingId;
const uv = intersect.uv;
const texture = object.material.map;
const canvas = texture.image;
const context = canvas.getContext('2d');
// --- Dynamic Brush Size Calculation ---
const worldBrushRadius = 1;
const face = intersect.face;
const geometry = object.geometry;
const positionAttribute = geometry.attributes.position;
const uvAttribute = geometry.attributes.uv;
const vA = new THREE.Vector3().fromBufferAttribute(positionAttribute, face.a);
const vB = new THREE.Vector3().fromBufferAttribute(positionAttribute, face.b);
const vC = new THREE.Vector3().fromBufferAttribute(positionAttribute, face.c);
object.localToWorld(vA);
object.localToWorld(vB);
object.localToWorld(vC);
const uvA = new THREE.Vector2().fromBufferAttribute(uvAttribute, face.a);
const uvB = new THREE.Vector2().fromBufferAttribute(uvAttribute, face.b);
const uvC = new THREE.Vector2().fromBufferAttribute(uvAttribute, face.c);
const worldDistAB = vA.distanceTo(vB);
const uvDistAB = uvA.distanceTo(uvB) * canvas.width;
const texelsPerWorldUnit = uvDistAB / worldDistAB;
const pixelBrushRadius = worldBrushRadius * texelsPerWorldUnit;
// --- End Dynamic Calculation ---
const x = uv.x * canvas.width;
const y = uv.y * canvas.height;
const gradient = context.createRadialGradient(
x, y, 0,
x, y, Math.max(4, pixelBrushRadius * 2)
);
gradient.addColorStop(0, color);
gradient.addColorStop(1, 'rgba(0,0,0,0)');
context.globalCompositeOperation = 'source-over';
context.globalAlpha = 0.6; // tweak softness
context.fillStyle = gradient;
context.beginPath();
context.arc(x, y, Math.max(1, pixelBrushRadius), 0, 2 * Math.PI);
context.fill();
context.globalAlpha = 0.6;
texture.needsUpdate = true;
// Emit drawing data to server
if (this.socket && this.socket.connected) {
this.socket.emit('drawingUpdate', {
objectId: objectId,
drawData: {
x: x,
y: y,
radius: Math.max(1, pixelBrushRadius),
color: color
}
});
}
}
draw(drawableObjects) {
const meshesToIntersect = drawableObjects.map(obj => obj.mesh);
// Desktop drawing
if (this.isDrawing && !this.renderer.xr.isPresenting) {
this.pointer.x = 0;
this.pointer.y = 0;
this.raycast.setFromCamera(this.pointer, this.camera);
this.raycast.layers.set(1);
const intersections = this.raycast.intersectObjects(meshesToIntersect, true);
if (intersections.length > 0) {
const intersect = intersections[0];
if (intersect.object.material.map && intersect.object.material.map.isCanvasTexture) {
this._drawOnTexture(intersect, this.currentSprayColor);
}
}
}
// VR drawing (right controller)
const vrController = this.vrControllers[0];
if (this.isDrawing && vrController && this.renderer.xr.isPresenting) {
const controllerMatrix = vrController.matrixWorld;
const ray = new THREE.Raycaster();
ray.ray.origin.setFromMatrixPosition(controllerMatrix);
ray.ray.direction.set(0, 0, -1).applyMatrix4(new THREE.Matrix4().extractRotation(controllerMatrix));
ray.layers.set(1);
const intersections = ray.intersectObjects(meshesToIntersect, true);
if (intersections.length > 0) {
const intersect = intersections[0];
if (intersect.object.material.map && intersect.object.material.map.isCanvasTexture) {
this._drawOnTexture(intersect, this.currentSprayColor);
}
}
}
}
_checkForInteractableItems() {
const ray = new THREE.Raycaster();
let isVR = this.renderer.xr.isPresenting && this.vrControllers[0] && this.vrControllers[0].visible;
if (isVR) {
// Use right controller for interaction ray
const controller = this.vrControllers[0];
controller.getWorldPosition(ray.ray.origin);
ray.ray.direction.set(0, 0, -1).applyQuaternion(controller.getWorldQuaternion(new THREE.Quaternion()));
} else {
// Use camera for desktop interaction ray
ray.setFromCamera({ x: 0, y: 0 }, this.camera);
}
const nearbyItems = this.itemList.filter(item => item.object && this.position.distanceTo(item.object.position) < this.maxInteractionDistance);
const itemObj = nearbyItems.map(item => item.object);
const intersects = ray.intersectObjects(itemObj, true);
// Keep track of the previously hovered item
const previouslyHoveredItem = this.currentIntItem;
// Update ray visualization
const controllerRay = this.vrControllers[0]?.getObjectByName('controller-ray');
if (controllerRay) {
controllerRay.visible = isVR;
}
if (intersects.length > 0) {
let intersectedObject = intersects[0].object;
// Traverse up to find the root object that is in our itemObj list
let rootObject = intersectedObject;
while (rootObject.parent && itemObj.indexOf(rootObject) === -1) {
rootObject = rootObject.parent;
}
// Find the item that corresponds to this root object
const foundItem = nearbyItems.find(item => item.object === rootObject);
if (foundItem) {
if (this.currentIntItem !== foundItem) {
// Optional: Add some visual feedback for the newly highlighted item
console.log("Hovering over:", foundItem.object.name);
if (this.hoverSound && this.hoverSound.isPlaying) this.hoverSound.stop();
if (this.hoverSound) {
this.hoverSound.playbackRate = 0.9 + Math.random() * 0.1;
this.hoverSound.play();
}
}
this.currentIntItem = foundItem;
} else {
this.currentIntItem = null;
}
} else {
this.currentIntItem = null;
}
// If the hovered item has changed, update outlines
if (previouslyHoveredItem !== this.currentIntItem) {
if (previouslyHoveredItem) {
previouslyHoveredItem.hideOutline();
}
if (this.currentIntItem) {
this.currentIntItem.showOutline();
}
}
}
_lockCameraForAttachedItem() {
const itemCenter = new THREE.Vector3();
new THREE.Box3().setFromObject(this.attachedItem.object).getCenter(itemCenter);
// Get the camera's world position to correctly calculate the lookAt matrix
const cameraWorldPosition = new THREE.Vector3();
this.camera.getWorldPosition(cameraWorldPosition);
// Calculate the desired distance from the object
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.playerRig.quaternion);
const desiredCameraPosition = itemCenter.clone().add(forward.multiplyScalar(2));
// Calculate the target position for the rig by subtracting the camera's local offset
// from the desired world position of the camera.
const targetPosition = desiredCameraPosition.clone().sub(this.camera.position);
this.playerRig.position.lerp(targetPosition, 0.1);
// The target rotation should make the camera (not the rig) look at the item center.
const targetRotation = new THREE.Quaternion().setFromRotationMatrix(
new THREE.Matrix4().lookAt(cameraWorldPosition, itemCenter, this.playerRig.up)
);
this.playerRig.quaternion.slerp(targetRotation, 0.1);
}
_updatePlayerMovement(delta) {
// Normal movement and camera logic
this.rotation.y -= this.input.mouseDelta.x * this.mouseSensitivity;
this.rotation.x -= this.input.mouseDelta.y * this.mouseSensitivity;
this.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.rotation.x));
// Only update rotation here. Position will be updated in the main loop after the physics step.
this.playerRig.rotation.copy(this.rotation);
let direction = new THREE.Vector3();
if (this.input.forward) direction.z -= 1;
if (this.input.backward) direction.z += 1;
if (this.input.left) direction.x -= 1;
if (this.input.right) direction.x += 1;
// Only apply yaw (Y axis) rotation for WASD movement
const yaw = this.rotation.y;
const move = new THREE.Vector3(direction.x, 0, direction.z)
.applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw)
.normalize();
// Add vertical movement from Q/E
// let vertical = 0;
// if (this.input.up) vertical += 1;
// if (this.input.down) vertical -= 1;
// move.y = vertical;
move.multiplyScalar(this.moveSpeed * delta);
// --- COLLISION LOGIC START ---
// Calculate movement with collision detection
this.characterController.computeColliderMovement(
this.collider,
move
);
// Get the corrected movement vector (sliding against walls, stopping, etc.)
const correctedMovement = this.characterController.computedMovement();
// Updating the position of the RB based on the corrected movement
const newPosition = this.position.clone().add(correctedMovement);
// Tell the physics engine where we want to go in the next step.
this.rigibody.setNextKinematicTranslation({ x: newPosition.x, y: newPosition.y, z: newPosition.z });
// --- COLLISION LOGIC END ---
}
update(delta, spawnedObjects) {
if (this.renderer.xr.isPresenting) {
this._handleVRJoystick();
this._handleVRTeleport();
this._handleControllerShake(delta);
}
if (this.enableInput) {
if (this.attachedItem && !this.renderer.xr.isPresenting) { // Only lock camera for desktop
this._lockCameraForAttachedItem();
} else if (!this.renderer.xr.isPresenting) { // Only update movement if not in VR
this._updatePlayerMovement(delta);
// --- Head Sway Logic ---
const swayAmplitude = 0.2; // How far the head moves up/down
const swaySpeed = 0.7; // How fast the sway is (Hz)
const swayOffset = Math.sin(performance.now() * 0.001 * swaySpeed * Math.PI * 2) * swayAmplitude;
this.camera.position.y = 20 + swayOffset; // 20 is your default camera Y
}
}
this._checkForInteractableItems();
this._checkAreaTriggers();
// --- Interact Prompt Logic ---
if (!this.interactPrompt) {
this.interactPrompt = this._createInteractPrompt();
this.camera.add(this.interactPrompt);
this.interactPrompt.position.set(0, -0.5, -2);
this.interactPrompt.visible = false;
}
// Show prompt if hovering over item and not holding one
if (this.currentIntItem && !this.attachedItem) {
// Regenerate prompt if hovered item changed
if (this.lastPromptItem !== this.currentIntItem) {
// Remove old prompt mesh
this.camera.remove(this.interactPrompt);
// Dispose all character meshes in the group
if (this.interactPrompt.userData.charMeshes) {
this.interactPrompt.userData.charMeshes.forEach(mesh => {
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) {
if (mesh.material.map) mesh.material.map.dispose();
mesh.material.dispose();
}
});
}
// Create new prompt with new random colors
this.interactPrompt = this._createInteractPrompt();
this.camera.add(this.interactPrompt);
this.interactPrompt.position.set(0, -0.5, -2);
}
this.interactPrompt.visible = true;
this.interactPromptTime += delta;
const bob = Math.sin(this.interactPromptTime * 2) * 0.01;
this.interactPrompt.position.y = -0.5 + bob;
this.lastPromptItem = this.currentIntItem;
} else {
this.interactPrompt.visible = false;
this.interactPromptTime = 0;
this.lastPromptItem = null;
}
if (this.interactPrompt && this.interactPrompt.userData.charMeshes) {
const now = performance.now();
this.interactPrompt.userData.charMeshes.forEach((mesh, i) => {
// Don't animate spaces
if (mesh.material.map.image) {
const ctx = mesh.material.map.image.getContext && mesh.material.map.image.getContext('2d');
if (ctx && ctx.measureText && ctx.measureText(' ').width === 0) return;
}
// Snap rotation at random intervals
if (now > mesh.userData.nextSnap) {
mesh.userData.snapRotation = (Math.random() * 40 - 20) * Math.PI / 180; // -20 to +20 deg in radians
mesh.userData.nextSnap = now + Math.random() * 600 + 300; // 100-700ms
}
mesh.rotation.z = mesh.userData.snapRotation;
});
}
// --- Item Counter Animation ---
if (this.itemCountDisplay && this.itemCountAnim.active) {
this.itemCountAnim.time += delta;
const t = this.itemCountAnim.time;
const { duration, fadeIn, fadeOut, startY, endY } = this.itemCountAnim;
// Fade in
let opacity = 1;
if (t < fadeIn) {
opacity = t / fadeIn;
}
// Fade out
else if (t > duration - fadeOut) {
opacity = Math.max(0, 1 - (t - (duration - fadeOut)) / fadeOut);
}
// Move up
let y = startY;
if (t < duration) {
y = startY + (endY - startY) * (t / duration);
} else {
y = endY;
}
this.itemCountDisplay.position.set(0, y, -2);
this.itemCountDisplay.traverse(obj => {
if (obj.material) obj.material.opacity = opacity;
});
// Hide after animation
if (t > duration) {
this.itemCountDisplay.visible = false;
this.itemCountAnim.active = false;
}
}
this.input.mouseDelta.x = 0;
this.input.mouseDelta.y = 0;
}
}