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.
 
 
 

623 lines
19 KiB

//--- Imports ---//
import * as THREE from 'three';
import Stats from 'three/examples/jsm/libs/stats.module.js';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import RAPIER from '@dimforge/rapier3d-compat';
import { GLTFLoader } from 'three/examples/jsm/Addons.js';
import { DRACOLoader } from 'three/examples/jsm/Addons.js';
import { createCustomJitterMaterial } from './materials/CustomJitterMaterial';
import { TextureLoader } from 'three';
import { io } from "socket.io-client";
import { Player } from './Player';
import { Item } from './Item';
import { TextContent, ImageContent, VideoContent, AudioContent } from './Content';
import { ItemManager } from './ItemManager';
import { ModelLoader } from './ModelLoader';
//-- Variables ---//
let scene, renderer;
let player;
let rapierWorld, debugLines, itemManager;
let debugRapier = false;
let isPaused = false;
let floorGridSize = new THREE.Vector2(10, 200);
const worldScale = 20.0;
let socket;
let pendingDrawings = null;
let drawings = {};
const currentPlayers = {};
let lastPlayerCount = 0;
const spawnedObjects = [];
const interactableItems = [];
const clock = new THREE.Clock();
const vertex = new THREE.Vector3();
const color = new THREE.Color();
const stats = new Stats();
const footstepMeshes = [];
const footsteps = {};
const footStepInterval = 10.0;
const footstepFade = 5000; //ms
const STATIC_OBJECT_LAYER = 1;
const DYNAMIC_OBJECT_LAYER = 2;
const TP_OBJECT_LAYER = 3;
const AREA_LAYER = 4;
init();
async function init() {
await RAPIER.init();
const gravity = { x: 0, y: -9.81, z: 0 };
rapierWorld = new RAPIER.World(gravity);
scene = new THREE.Scene();
scene.background = new THREE.Color( 'white' );
scene.fog = new THREE.FogExp2( new THREE.Color( 'white' ), 0.005 );
const light = new THREE.HemisphereLight( 0xeeeeff, 0x777788, 2.5 ); //2.5
light.position.set( 0.5, 1, 0.75 );
scene.add( light );
// --- Debug Renderer Setup ---
const material = new THREE.LineBasicMaterial({
color: 0xffffff,
vertexColors: true,
});
const geometry = new THREE.BufferGeometry();
debugLines = new THREE.LineSegments(geometry, material);
scene.add(debugLines);
// --- End Debug Renderer Setup ---
const blocker = document.getElementById( 'blocker' );
const instructions = document.getElementById('instructions');
fragmentedFloor(floorGridSize.x * floorGridSize.y);
createTiledFloor(floorGridSize.x, floorGridSize.y);
renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.xr.enabled = true; // Enable XR
renderer.shadowMap.enabled = true; // <-- Add this line
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Optional: softer shadows
renderer.setAnimationLoop( animate );
document.body.appendChild( renderer.domElement );
document.body.appendChild( VRButton.createButton( renderer ) );
socket = setupSocketIO();
player = new Player(rapierWorld, renderer, scene, new THREE.Vector3(466, 10, 32), interactableItems, socket);
player._setupVR(renderer);
scene.add(player.playerRig);
/* Load Items/Content */
await loadWorldModel("/models/demo-world_repositioned_2711_transforms.glb");
instructions.innerHTML = "Click to play";
instructions.addEventListener( 'click', function () {
document.body.requestPointerLock();
} );
document.body.appendChild( stats.dom );
document.addEventListener('pointerlockchange', () => {
if (document.pointerLockElement === document.body) {
// Pointer is locked: Unpause the game
isPaused = false;
player.enableInput = true;
blocker.style.opacity = '0';
blocker.addEventListener('transitionend', () => {
blocker.style.display = 'none';
}, { once: true });
} else {
// Pointer is unlocked: Pause the game
isPaused = true;
player.enableInput = false;
blocker.style.display = 'flex';
// Use a short timeout to ensure 'display' is set before starting the transition
setTimeout(() => {
blocker.style.opacity = '1';
}, 10);
}
});
window.addEventListener( 'resize', onWindowResize );
window.addEventListener('keydown', function(event) {
if(event.key === 'Escape') {
if (!isPaused) {
renderer.setAnimationLoop(null);
isPaused = true;
} else {
renderer.setAnimationLoop(animate);
isPaused = false;
}
}
});
}
async function animate() {
const delta = clock.getDelta();
drawDebugRapier();
// (1) Update the player positions
player.update(delta, spawnedObjects);
for (const item of interactableItems) {
await item.update(delta);
}
// Update managers
//if (itemManager) itemManager.update();
// (2) Run a step of the physics sim
rapierWorld.step();
// (3) Update the camera position, after physics step has run.
const newPosition = player.rigibody.translation();
player.position.set(newPosition.x, newPosition.y, newPosition.z);
// Only copy the rigidbody position to the camera when not in VR
if (!renderer.xr.isPresenting) {
player.playerRig.position.copy(player.position);
}
player.draw(spawnedObjects);
// (A) Update footsteps for main player
updateFootstepsForPlayer(socket.id, player.playerRig.position);
// (B) Update footsteps for other players
for (const [id, obj] of Object.entries(currentPlayers)) {
if (obj.mesh) {
updateFootstepsForPlayer(id, obj.mesh.position);
}
}
// (C) Draw and fade footsteps
drawAndFadeFootsteps();
sendPlayerDataToServer();
renderer.render(scene, player.camera); // No post-processing in XR
const numberOfPlayers = Object.keys(currentPlayers).length;
if( lastPlayerCount != numberOfPlayers) {
lastPlayerCount = numberOfPlayers;
console.log("Player Count:", lastPlayerCount);
}
//stats.update();
}
//-- Other functions --//
function fragmentedFloor(size) {
// floor
let floorGeometry = new THREE.PlaneGeometry( size, size, 100, 100 );
floorGeometry.rotateX( - Math.PI / 2 );
// vertex displacement
let position = floorGeometry.attributes.position;
for ( let i = 0, l = position.count; i < l; i ++ ) {
vertex.fromBufferAttribute( position, i );
vertex.x += Math.random() * 20 - 10;
vertex.y += Math.random() * 2;
vertex.z += Math.random() * 20 - 10;
position.setXYZ( i, vertex.x, vertex.y, vertex.z );
}
floorGeometry = floorGeometry.toNonIndexed(); // ensure each face has unique vertices
position = floorGeometry.attributes.position;
const colorsFloor = [];
for ( let i = 0, l = position.count; i < l; i ++ ) {
color.setHSL( Math.random() * 0.3 + 0.5, 0.75, Math.random() * 0.25 + 0.75, THREE.SRGBColorSpace );
colorsFloor.push( color.r, color.g, color.b );
}
floorGeometry.setAttribute( 'color', new THREE.Float32BufferAttribute( colorsFloor, 3 ) );
const material = new THREE.MeshBasicMaterial({ color: 0x888888, wireframe: true });
const floor = new THREE.Mesh( floorGeometry, material );
scene.add( floor );
}
function onWindowResize() {
player.camera.aspect = window.innerWidth / window.innerHeight;
player.camera.updateProjectionMatrix();
// Only set size if not in XR
if (!renderer.xr.isPresenting) {
renderer.setSize(window.innerWidth, window.innerHeight);
}
}
function setupSocketIO() {
socket = io();
socket.on('connect', async () => {
console.log('Client connected:', socket.id);
console.log('Sending initial drawings:', drawings);
socket.emit('initialDrawings', drawings);
});
socket.on('currentPlayers', (players) => {
Object.keys(players).forEach((id) => {
if (id !== socket.id) {
const playerData = players[id];
addOtherPlayer(playerData);
}
});
});
socket.on('newPlayer', (playerData) => {
addOtherPlayer(playerData);
});
socket.on('playerDisconnected', (id) => {
if (currentPlayers[id]) {
scene.remove(currentPlayers[id].mesh);
delete currentPlayers[id];
}
});
socket.on('playerMoved', (playerData) => {
if (currentPlayers[playerData.id]) {
const playerMesh = currentPlayers[playerData.id].mesh;
playerMesh.position.set(playerData.position.x, playerData.position.y, playerData.position.z);
playerMesh.rotation.set(playerData.rotation.x, playerData.rotation.y, playerData.rotation.z);
}
});
socket.on('initialDrawings', (serverDrawings) => {
console.log("Received initial drawings:", serverDrawings);
drawings = serverDrawings; // Store the drawings
pendingDrawings = drawings;
});
socket.on('drawingUpdated', (data) => {
// Look for matching object in spawnedObjects instead of scene
let object = spawnedObjects.find(obj =>
obj.mesh.userData && obj.mesh.userData.drawingId === data.objectId
)?.mesh;
if (object && object.material && object.material.map) {
const texture = object.material.map;
const canvas = texture.image;
const context = canvas.getContext('2d');
context.fillStyle = data.drawData.color;
context.beginPath();
context.arc(
data.drawData.x,
data.drawData.y,
data.drawData.radius,
0,
2 * Math.PI
);
context.fill();
texture.needsUpdate = true;
}
});
return socket;
}
function applyDrawings(drawings) {
console.log("Applying drawings:", drawings);
Object.entries(drawings).forEach(([objectId, drawingArray]) => {
console.log(`Processing object ${objectId} with ${drawingArray.length} drawings`);
// Look for matching object in spawnedObjects instead of scene
let object = spawnedObjects.find(obj =>
obj.mesh.userData && obj.mesh.userData.drawingId === objectId
)?.mesh;
if (object && object.material && object.material.map) {
//console.log("Found matching spawned object:", objectId);
const texture = object.material.map;
const canvas = texture.image;
const context = canvas.getContext('2d');
drawingArray.forEach(drawData => {
//console.log("Drawing:", drawData);
context.fillStyle = drawData.color || '#ffffff';
context.beginPath();
context.arc(
drawData.x,
drawData.y,
drawData.radius || 5,
0,
2 * Math.PI
);
context.fill();
});
texture.needsUpdate = true;
} else {
console.warn("Could not find or invalid object for ID:", objectId);
if (object) {
console.log("Object details:", {
hasMaterial: !!object.material,
hasMap: !!(object.material && object.material.map)
});
}
}
});
}
function addOtherPlayer(playerData) {
const geometry = new THREE.BoxGeometry(1, 2, 1);
const material = new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff, transparent: true, opacity: 0 });
const playerMesh = new THREE.Mesh(geometry, material);
playerMesh.position.set(playerData.position.x, playerData.position.y, playerData.position.z);
scene.add(playerMesh);
currentPlayers[playerData.id] = { mesh: playerMesh };
}
function sendPlayerDataToServer() {
// Send player data to the server
if (socket && socket.connected) {
socket.emit('playerMovement', {
position: player.playerRig.position,
rotation: {
x: player.playerRig.rotation.x,
y: player.playerRig.rotation.y,
z: player.playerRig.rotation.z
}
});
}
}
function drawDebugRapier() {
const buffers = rapierWorld.debugRender();
if (debugRapier) {
debugLines.geometry.setAttribute(
"color",
new THREE.BufferAttribute(buffers.colors, 4)
);
debugLines.geometry.setAttribute(
"position",
new THREE.BufferAttribute(buffers.vertices, 3)
);
}
}
function createTiledFloor(gridSize = 20, tileSize = 50) {
const textureResolution = 1024;
for (let i = -gridSize / 2; i < gridSize / 2; i++) {
for (let j = -gridSize / 2; j < gridSize / 2; j++) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = textureResolution;
const context = canvas.getContext('2d');
context.fillStyle = '#000000ff';
context.fillRect(0, 0, textureResolution, textureResolution);
const canvasTexture = new THREE.CanvasTexture(canvas);
canvasTexture.flipY = false;
canvasTexture.needsUpdate = true;
const floorGeometry = new THREE.PlaneGeometry(tileSize, tileSize);
floorGeometry.rotateX(-Math.PI / 2);
const floorMaterial = new THREE.MeshStandardMaterial({
map: canvasTexture,
color: 0xffffff,
transparent: false
});
const floorTile = new THREE.Mesh(floorGeometry, floorMaterial);
floorTile.position.set(i * tileSize + tileSize / 2, 1, j * tileSize + tileSize / 2);
// Add unique drawing ID to the floor tile
const drawingId = `floor_${i}_${j}`;
floorTile.userData.drawingId = drawingId;
floorTile.castShadow = true;
floorTile.receiveShadow = true;
scene.add(floorTile);
floorTile.layers.enable(STATIC_OBJECT_LAYER);
// Make it drawable
spawnedObjects.push({ mesh: floorTile, body: null });
// --- Rapier Physics Body ---
const rbDesc = RAPIER.RigidBodyDesc.fixed().setTranslation(
floorTile.position.x,
floorTile.position.y,
floorTile.position.z
);
const floorBody = rapierWorld.createRigidBody(rbDesc);
const floorCollider = RAPIER.ColliderDesc.cuboid(tileSize / 2, 1, tileSize / 2);
rapierWorld.createCollider(floorCollider, floorBody);
}
}
}
async function loadWorldModel(modelUrl) {
const modelLoader = new ModelLoader();
const staticWorldGroup = new THREE.Group();
scene.add(staticWorldGroup);
try {
// Load and add static objects
const staticObjects = await modelLoader.loadStaticWorld(modelUrl, worldScale);
const staticLoadPromises = staticObjects.map(object => {
return new Promise((resolve) => {
const item = new Item(
rapierWorld,
staticWorldGroup,
player,
true,
object.position,
1,
object.name || 'static-world-object',
null,
null,
spawnedObjects,
null,
object
);
item.loadModel().then(() => {
// Set the layer on the item's actual mesh AFTER it's loaded
if (item.name === 'TP_SURFACE') {
item.object.layers.enable(TP_OBJECT_LAYER);
} else {
if (item.name.startsWith('TRIGGER')) {
item.object.layers.enable(AREA_LAYER);
} else {
item.object.layers.enable(STATIC_OBJECT_LAYER);
}
}
resolve();
});
});
});
// Wait for all static and other objects to be loaded
await Promise.all([...staticLoadPromises]);
player._initAreaTriggers();
console.log("Finished loading static and other world objects");
// Load dynamic objects
const dynamicGroups = await modelLoader.loadDynamicGroups(modelUrl, worldScale);
const preloadedObjectsMap = new Map();
const dynamicGroupsMap = new Map();
dynamicGroups.forEach(groupData => {
const threeGroup = new THREE.Group();
threeGroup.name = groupData.name;
scene.add(threeGroup);
dynamicGroupsMap.set(groupData.name, threeGroup);
groupData.objects.forEach(object => {
object.layers.enable(DYNAMIC_OBJECT_LAYER);
preloadedObjectsMap.set(object.name, object);
});
});
itemManager = new ItemManager(
"json/ItemsNew.json",
preloadedObjectsMap,
dynamicGroupsMap,
scene,
rapierWorld,
player,
interactableItems
);
// Wait a frame to ensure all objects are properly initialized
await new Promise(resolve => setTimeout(resolve, 100));
// Now apply any pending drawings after everything is loaded
if (pendingDrawings) {
console.log("All objects loaded, applying pending drawings");
console.log("Current spawnedObjects count:", spawnedObjects.length);
applyDrawings(pendingDrawings);
pendingDrawings = null;
}
} catch (error) {
console.error("Failed to load world:", error);
}
}
function updateFootstepsForPlayer(playerId, playerPosition) {
if (!footsteps[playerId]) footsteps[playerId] = [];
const steps = footsteps[playerId];
const last = steps[steps.length - 1];
if (!last || playerPosition.distanceTo(new THREE.Vector3(last.x, playerPosition.y, last.z)) > footStepInterval) {
// Determine step index for left/right alternation
const stepIndex = steps.length;
const baseOffset = 1; // Base distance from the center
const randomOffset = (Math.random() - 0.5) * 1; // Smaller random variation
const offsetAmount = baseOffset + randomOffset;
// Determine facing direction (Y rotation)
let rotationY = 0;
if (playerId === socket.id) {
rotationY = player.playerRig.rotation.y;
} else if (currentPlayers[playerId] && currentPlayers[playerId].mesh) {
rotationY = currentPlayers[playerId].mesh.rotation.y;
}
// Calculate left/right offset vector
const direction = new THREE.Vector3(Math.sin(rotationY), 0, Math.cos(rotationY));
const left = new THREE.Vector3().crossVectors(direction, new THREE.Vector3(0, 1, 0)).normalize();
const offset = left.multiplyScalar((stepIndex % 2 === 0 ? 1 : -1) * offsetAmount);
// Create a flat circle mesh for the footstep
const geometry = new THREE.CircleGeometry(1, 16);
const material = new THREE.MeshBasicMaterial({
color: 0xFFFFFF,
transparent: true,
opacity: 1
});
const footstep = new THREE.Mesh(geometry, material);
footstep.rotation.x = -Math.PI / 2;
footstep.position.set(
playerPosition.x + offset.x,
1.05,
playerPosition.z + offset.z
);
footstep.userData = { time: performance.now() };
scene.add(footstep);
footstepMeshes.push(footstep);
steps.push({
x: playerPosition.x,
z: playerPosition.z,
time: performance.now()
});
}
}
function drawAndFadeFootsteps() {
const now = performance.now();
for (let i = footstepMeshes.length - 1; i >= 0; i--) {
const mesh = footstepMeshes[i];
const age = now - mesh.userData.time;
const alpha = 1 - age / footstepFade;
mesh.material.opacity = Math.max(0, alpha);
if (age > footstepFade) {
scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
footstepMeshes.splice(i, 1);
}
}
}