Browse Source

pre-items

master
cailean 1 day ago
parent
commit
b6765cfa4b
  1. 4
      .gitignore
  2. 69
      css/style.css
  3. 2
      data/drawings.json
  4. 16
      index.html
  5. 170
      js/Item.js
  6. 10
      js/ItemManager.js
  7. 54
      js/ModelLoader.js
  8. 467
      js/Player.js
  9. 67
      js/main.js
  10. 75
      js/titleAnimation.js
  11. 42
      js/ui.js
  12. BIN
      public/images/smoke.png
  13. BIN
      public/models/demo-world_repositioned_2511.glb
  14. BIN
      public/models/spray-can.glb

4
.gitignore

@ -141,3 +141,7 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
public/models/demo-world_2411_01.glb
public/models/demo-world_2411.glb
public/models/left_hand.glb
public/models/lp_hand.glb

69
css/style.css

@ -54,6 +54,75 @@ canvas {
transition: transform 0s ease-in-out;
}
#other {
display: flex;
flex-direction: row;
gap: 20px;
font-size: 30px;
}
#about {
cursor: pointer;
}
h2 span {
display: inline-block;
transition: transform 0s ease-in-out;
}
#controls {
cursor: pointer;
}
.modal {
position: fixed;
top: 40%;
left: 50%;
transform: translate(-50%, -50%) scale(0.95);
width: 1600px; /* Fixed pixel width */
height: 600px; /* Fixed pixel height */
background: rgba(255, 255, 255, 0.1);
z-index: 20;
backdrop-filter: blur(10px);
opacity: 0;
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
border-radius: 10px;
padding: 20px 40px;
box-sizing: border-box;
display: flex;
flex-direction: column;
color: #000000;
font-family: 'Redacted70';
pointer-events: none; /* Prevent interaction when hidden */
}
.modal.show {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
pointer-events: auto; /* Allow interaction when shown */
}
.modal h2 {
text-align: center;
margin-top: 10px;
font-size: 40px;
text-transform: uppercase;
letter-spacing: 10px;
}
.modal p {
font-size: 30px;
}
.modal .close-modal {
margin-top: auto;
padding: 10px 20px;
border: 1px solid #333;
background: transparent;
cursor: pointer;
align-self: center;
font-size: 16px;
}
iframe {
width: 100%;

2
data/drawings.json

File diff suppressed because one or more lines are too long

16
index.html

@ -13,8 +13,24 @@
<div id="instructions">
Loading...
</div>
<div id="other">
<div id="about">about</div>
<div>|</div>
<div id="controls">controls</div>
</div>
</div>
<div id="about-modal" class="modal">
<h2 id="about-title">About Emancipate XR</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed et accumsan dolor, quis viverra tellus. Morbi blandit nisl in nibh ornare tristique eu in enim. Mauris lacus elit, ullamcorper nec bibendum in, varius et ante. Praesent varius facilisis elit, eu porta ante varius sed. Praesent posuere porttitor dui ut viverra. Sed quis lectus sed nulla commodo tempor. Vivamus nec sem mollis, lobortis dui id, rhoncus nisl.
</div>
<div id="controls-modal" class="modal">
<h2 id="controls-title">Controls</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed et accumsan dolor, quis viverra tellus. Morbi blandit nisl in nibh ornare tristique eu in enim. Mauris lacus elit, ullamcorper nec bibendum in, varius et ante. Praesent varius facilisis elit, eu porta ante varius sed. Praesent posuere porttitor dui ut viverra. Sed quis lectus sed nulla commodo tempor. Vivamus nec sem mollis, lobortis dui id, rhoncus nisl.</p>
<p><u>Movement (DESKTOP):</u> WASD</p>
<script type="module" src="./js/main.js"></script>
<script src="./js/titleAnimation.js"></script>
<script src="./js/ui.js"></script>
</body>
</html>

170
js/Item.js

@ -45,6 +45,8 @@ export class Item {
this._init();
this.isVisible = true;
this.outlineMesh = null;
this.pulseTime = 0;
}
_init() {
@ -63,6 +65,8 @@ export class Item {
this.object.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
this.spawnedObjects.push( { mesh: child, body: null} );
}
});
@ -71,20 +75,53 @@ export class Item {
this._addObject();
if(!this.isCollider) {
if(!this.isCollider || this.object.name.startsWith('TRIGGER') || this.object.name.startsWith('stContainer')) {
this.loadState = 'loaded';
return;
}
const boundingBox = new THREE.Box3().setFromObject(this.object);
const size = new THREE.Vector3();
boundingBox.getSize(size);
// Calculate the world center of the bounding box
const worldCenter = new THREE.Vector3();
boundingBox.getCenter(worldCenter);
if (this.object.name.startsWith('stCollider')) {
this.object.visible = false;
this.object.layers.disableAll();
console.log(this.object);
}
// --- Simplified Convex Hull Generation ---
const vertices = [];
// Decimation rate: 1 = use all vertices, 10 = use 1 in every 10. Higher is faster.
const decimation = 1;
let vertexCount = 0;
this.object.updateWorldMatrix(true, true);
// gets vertices
this.object.traverse((child) => {
if (child.isMesh) {
const geometry = child.geometry;
if (geometry.attributes.position) {
const positionAttribute = geometry.attributes.position;
for (let i = 0; i < positionAttribute.count; i++) {
// Only process every Nth vertex to simplify the mesh
if (vertexCount % decimation === 0) {
const vertex = new THREE.Vector3();
vertex.fromBufferAttribute(positionAttribute, i);
// Transform vertex to world space
vertex.applyMatrix4(child.matrixWorld);
// Then to parent's local space (unscaled)
vertex.sub(this.object.position);
vertex.applyQuaternion(this.object.quaternion.clone().invert());
vertices.push(vertex.x, vertex.y, vertex.z);
}
vertexCount++;
}
}
}
});
// Calculate the local center by subtracting the object's world position
this.centerOffset.subVectors(worldCenter, this.object.position);
const verticesFloat32Array = new Float32Array(vertices);
let rbDesc;
if (this.content === null) {
@ -95,22 +132,29 @@ export class Item {
rbDesc.setTranslation(this.object.position.x, this.object.position.y, this.object.position.z);
rbDesc.setRotation({
x: this.object.quaternion.x,
y: this.object.quaternion.y,
z: this.object.quaternion.z,
w: this.object.quaternion.w
});
this.rb = this.rapierWorld.createRigidBody(rbDesc);
const colDesc = ColliderDesc.cuboid(size.x / 2, size.y / 2, size.z / 2)
.setTranslation(this.centerOffset.x, this.centerOffset.y, this.centerOffset.z); // Use the calculated local center offset
let colDesc;
if (verticesFloat32Array.length > 0) {
colDesc = ColliderDesc.convexHull(verticesFloat32Array);
} else {
// Fallback to a cuboid if simplification results in no vertices
console.warn(`Could not generate convex hull for ${this.name}, falling back to cuboid.`);
const boundingBox = new THREE.Box3().setFromObject(this.object);
const size = new THREE.Vector3();
boundingBox.getSize(size);
const center = new THREE.Vector3();
boundingBox.getCenter(center).sub(this.object.position);
colDesc = ColliderDesc.cuboid(size.x / 2, size.y / 2, size.z / 2)
.setTranslation(center.x, center.y, center.z);
}
this.coll = this.rapierWorld.createCollider(colDesc, this.rb);
this.loadState = 'loaded';
} catch (error) {
console.error(`Failed to load model for tiem: ${this.name}`);
console.error(`Failed to load model for item: ${this.name}`);
this.loadState = 'unloaded';
}
@ -323,11 +367,97 @@ export class Item {
}
}
showOutline() {
if (this.hoverIndicator || !this.object) return;
function getRandomColor() {
const hexChars = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += hexChars[Math.floor(Math.random() * 16)];
}
return color;
}
// Create a circular texture using a canvas
const canvas = document.createElement('canvas');
const size = 128;
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
const centerX = size / 2;
const centerY = size / 2;
const radius = size / 2;
// Create a radial gradient
// The gradient goes from the center to the outer edge of the canvas
const gradient = context.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
// Add color stops to control opacity
gradient.addColorStop(0, 'rgba(0, 0, 0, 0)'); // Center: transparent
gradient.addColorStop(0.75, getRandomColor()); // 75% of the way out: fully opaque
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); // Edge: transparent
// Apply the gradient and draw the circle
context.fillStyle = gradient;
context.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
// Create a sprite material with the new texture
const material = new THREE.SpriteMaterial({
map: texture,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: true, // Renders on top of everything
transparent: true
});
this.hoverIndicator = new THREE.Sprite(material);
// Calculate the size and position for the indicator
const boundingBox = new THREE.Box3().setFromObject(this.object);
const boundingSphere = new THREE.Sphere();
boundingBox.getBoundingSphere(boundingSphere);
// Set the desired world scale for the indicator based on the object's size
const desiredScale = boundingSphere.radius * 2.5;
this.hoverIndicator.scale.set(desiredScale, desiredScale, desiredScale);
// Counteract the parent's scale by dividing the indicator's scale by the parent's scale.
if (this.object.scale.x !== 0 && this.object.scale.y !== 0 && this.object.scale.z !== 0) {
this.hoverIndicator.scale.divide(this.object.scale);
}
// Position the sprite at the center of the object, in local space.
this.hoverIndicator.position.copy(boundingSphere.center).sub(this.object.position);
this.object.add(this.hoverIndicator);
}
hideOutline() {
if (!this.hoverIndicator) return;
this.object.remove(this.hoverIndicator);
this.hoverIndicator.material.map.dispose();
this.hoverIndicator.material.dispose();
this.hoverIndicator = null;
}
async update(delta) {
this._drawBoundingBox();
//this._drawBoundingBox();
if (this.loadState !== 'loaded') return;
if (this.hoverIndicator) {
this.pulseTime += delta * 5; // Adjust the multiplier to change pulse speed
const minOpacity = 0.1;
const maxOpacity = 0.8;
// Create a sine wave that oscillates between 0 and 1
const oscillation = (Math.sin(this.pulseTime) + 1) / 4;
// Apply the oscillation to the desired opacity range
this.hoverIndicator.material.opacity = minOpacity + oscillation * (maxOpacity - minOpacity);
}
this.lastPosition = this.object.position.clone();
await this._moveActiveObject(delta);

10
js/ItemManager.js

@ -149,7 +149,7 @@ export class ItemManager {
const line = new THREE.Line(geometry, material);
line.visible = false; // Initially hidden
this.scene.add(line);
//this.scene.add(line);
// Store the line and the group it belongs to, keyed by the item's ID
this.groupCenterLinks.set(item.id, { line, groupId: item.groupId });
@ -182,7 +182,7 @@ export class ItemManager {
});
const line = new Line2(lineGeometry, lineMaterial);
this.scene.add(line);
//this.scene.add(line);
const linkKey = `${groupA_ID}-${groupB_ID}`;
this.interGroupLinks.set(linkKey, { line, groupA_ID, groupB_ID });
@ -274,8 +274,8 @@ export class ItemManager {
}
});
this._updateGroupCenterLinks();
this._updateGroupCentres();
this._updateInterGroupLinks();
//this._updateGroupCenterLinks();
//this._updateGroupCentres();
//this._updateInterGroupLinks();
}
}

54
js/ModelLoader.js

@ -1,5 +1,5 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/Addons.js';
import { GLTFLoader, Projector } from 'three/examples/jsm/Addons.js';
import { DRACOLoader } from 'three/examples/jsm/Addons.js';
import { createCustomJitterMaterial } from './materials/CustomJitterMaterial';
import { TextureLoader } from 'three';
@ -43,7 +43,7 @@ export class ModelLoader {
drawableTexture.needsUpdate = true;
drawableTexture.encoding = THREE.sRGBEncoding;
object.material = createCustomJitterMaterial(100, drawableTexture);
object.material = createCustomJitterMaterial(1000, drawableTexture);
}
loadStaticWorld(modelURL, worldScale) {
@ -56,17 +56,24 @@ export class ModelLoader {
// We need to handle children carefully as we might be reparenting them
const children = [...staticGroup.children];
children.forEach(object => {
if (object.isMesh) {
// 1. Apply world scale to position and object scale
object.position.multiplyScalar(worldScale);
object.scale.multiplyScalar(worldScale);
object.castShadow = true;
object.receiveShadow = true;
// Detach from the original parent ('Static' group)
// so it can be added directly to the scene later.
gltf.scene.attach(object);
this._applyCustomMaterial(object);
if (object.name.startsWith('TP') || object.name.startsWith('TRIGGER')) {
if (object.isMesh) {
object.material = new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true });
}
} else {
this._applyCustomMaterial(object);
}
processedObjects.push(object);
}
});
resolve(processedObjects);
} else {
@ -106,7 +113,7 @@ export class ModelLoader {
// --- Preserve rotation before re-parenting ---
const savedQuaternion = object.quaternion.clone();
console.log(savedQuaternion);
//console.log(savedQuaternion);
// 2. Detach from original parent and apply material
gltf.scene.attach(object);
@ -140,7 +147,7 @@ export class ModelLoader {
if (child.isMesh) {
texture.encoding = THREE.sRGBEncoding;
texture.needsUpdate = true;
child.material = createCustomJitterMaterial(100, texture);
child.material = createCustomJitterMaterial(10000, texture);
}
});
};
@ -217,4 +224,37 @@ export class ModelLoader {
}
});
}
loadModel(modelURL, scale = 1.0) {
return new Promise((resolve, reject) => {
this.gltfLoader.load(modelURL, (gltf) => {
const object = gltf.scene;
// Ensure correct texture encoding and properties
object.traverse((child) => {
if (child.isMesh && child.material) {
if (child.material.map) {
child.material.map.encoding = THREE.sRGBEncoding;
child.material.map.needsUpdate = true;
}
// Retain original material
child.material.needsUpdate = true;
}
});
// Scale and position the model
object.scale.set(scale, scale, scale);
const box = new THREE.Box3().setFromObject(object);
const height = box.max.y - box.min.y;
object.position.y = - (height / 3) * 3; // Center pivot vertically
object.position.x = 0;
resolve(object);
}, undefined, (error) => {
console.error(`An error happened while loading model: ${modelURL}`, error);
reject(error);
});
});
}
}

467
js/Player.js

@ -1,12 +1,13 @@
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, 0, 0, 'YXZ');
this.rotation = new THREE.Euler(0, 89.6, 0, 'YXZ');
this.velocity = new THREE.Vector3();
this.acceleration = new THREE.Vector3();
this.id = null;
@ -16,6 +17,35 @@ export class Player {
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;
@ -27,7 +57,7 @@ export class Player {
this.rigibody = null;
this.collider = null;
this.moveSpeed = 40;
this.moveSpeed = 100; //40
this.mouseSensitivity = 0.002;
this.maxInteractionDistance = 200.0;
@ -112,6 +142,26 @@ export class Player {
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);
}
@ -140,13 +190,21 @@ export class Player {
// 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(7.5, 1);
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(this.position);
this.camera.position.copy(tempPos);
// Attach audio listener to the camera/player
this.camera.add(this.audioListener);
@ -154,6 +212,18 @@ export class Player {
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);
@ -174,6 +244,47 @@ export class Player {
});
}
_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) {
@ -204,73 +315,113 @@ export class Player {
document.addEventListener('pointerup', this.onPointerUp.bind(this));
}
// ...existing code...
_createInteractPrompt() {
function getRandomColor() {
const hexChars = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += hexChars[Math.floor(Math.random() * 16)];
_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;
}
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;
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;
}
// 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;
_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 geometry = new THREE.PlaneGeometry(fontSize, fontSize);
const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true });
const mesh = new THREE.Mesh(geometry, material);
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;
}
mesh.position.x = offsetX + charWidths[i] / 2;
group.add(mesh);
charMeshes.push(mesh);
// 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];
}
// --- SNAP ROTATION STATE ---
mesh.userData.snapRotation = 0;
mesh.userData.nextSnap = performance.now() + Math.random() * 600 + 300; // 100-700ms
group.userData.charMeshes = charMeshes; // Store for animation
offsetX += charWidths[i];
return group;
}
group.userData.charMeshes = charMeshes; // Store for animation
return group;
}
onPointerDown() {
if (document.pointerLockElement) {
this.isDrawing = true;
@ -281,7 +432,7 @@ _createInteractPrompt() {
this.isDrawing = false;
}
_setupVR() {
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});
@ -316,18 +467,29 @@ _createInteractPrompt() {
});
// 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 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 }); // White color for the ray
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;
controller.add(line);
;
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));
@ -439,7 +601,7 @@ _createInteractPrompt() {
}
}
_handleVRTeleport(floorObjects) {
_handleVRTeleport() {
if (!this.teleporting) {
this.teleMarker.visible = false;
this.teleArc.visible = false;
@ -449,7 +611,7 @@ _createInteractPrompt() {
const controller = this.vrControllers[1]; // Left controller for teleporting
const controllerMatrix = controller.matrixWorld;
const initialVelocity = 50 * this.teleportDistanceFactor;;
const initialVelocity = 50 * this.teleportDistanceFactor;
const gravity = -9.8;
const timeStep = 0.03;
const numSegments = 100;
@ -472,9 +634,11 @@ _createInteractPrompt() {
const ray = new THREE.Raycaster(lastPoint, currentPoint.clone().sub(lastPoint).normalize());
ray.far = lastPoint.distanceTo(currentPoint);
const floorMeshes = floorObjects.map(obj => obj.mesh);
const intersects = ray.intersectObjects(floorMeshes);
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);
@ -502,6 +666,51 @@ _createInteractPrompt() {
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;
@ -511,7 +720,7 @@ _createInteractPrompt() {
const context = canvas.getContext('2d');
// --- Dynamic Brush Size Calculation ---
const worldBrushRadius = 0.1;
const worldBrushRadius = 1;
const face = intersect.face;
const geometry = object.geometry;
const positionAttribute = geometry.attributes.position;
@ -538,10 +747,20 @@ _createInteractPrompt() {
const x = uv.x * canvas.width;
const y = uv.y * canvas.height;
context.fillStyle = color;
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;
@ -560,7 +779,6 @@ _createInteractPrompt() {
}
}
draw(drawableObjects) {
const meshesToIntersect = drawableObjects.map(obj => obj.mesh);
@ -569,28 +787,29 @@ _createInteractPrompt() {
this.pointer.x = 0;
this.pointer.y = 0;
this.raycast.setFromCamera(this.pointer, this.camera);
const intersections = this.raycast.intersectObjects(meshesToIntersect);
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._drawOnTexture(intersect, this.currentSprayColor);
}
}
}
// VR drawing (right controller)
const vrController = this.vrControllers[0];
if (this.vrDrawing && vrController && this.renderer.xr.isPresenting) {
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));
const intersections = ray.intersectObjects(meshesToIntersect);
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._drawOnTexture(intersect, this.currentSprayColor);
}
}
}
@ -617,6 +836,9 @@ _createInteractPrompt() {
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) {
@ -652,6 +874,16 @@ _createInteractPrompt() {
} 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() {
@ -702,27 +934,38 @@ _createInteractPrompt() {
.normalize();
// Add vertical movement from Q/E
let vertical = 0;
if (this.input.up) vertical += 1;
if (this.input.down) vertical -= 1;
// let vertical = 0;
// if (this.input.up) vertical += 1;
// if (this.input.down) vertical -= 1;
move.y = vertical;
// move.y = vertical;
move.multiplyScalar(this.moveSpeed * delta);
// Updating the position of the RB based on the position calcuted by Rapier
const newPosition = this.position.clone().add(move);
if( newPosition.y <= 10 ) newPosition.y = 10;
// --- 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(spawnedObjects);
this._handleVRTeleport();
this._handleControllerShake(delta);
}
if (this.enableInput) {
@ -730,10 +973,16 @@ _createInteractPrompt() {
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) {
@ -795,6 +1044,42 @@ _createInteractPrompt() {
});
}
// --- 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;
}

67
js/main.js

@ -46,9 +46,14 @@ const stats = new Stats();
const footstepMeshes = [];
const footsteps = {};
const footStepInterval = 2.0;
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();
@ -59,9 +64,9 @@ async function init() {
scene = new THREE.Scene();
scene.background = new THREE.Color( 'white' );
scene.fog = new THREE.FogExp2( new THREE.Color( 'white' ), 0.02 );
scene.fog = new THREE.FogExp2( new THREE.Color( 'white' ), 0.005 );
const light = new THREE.HemisphereLight( 0xeeeeff, 0x777788, 2.5 );
const light = new THREE.HemisphereLight( 0xeeeeff, 0x777788, 2.5 ); //2.5
light.position.set( 0.5, 1, 0.75 );
scene.add( light );
@ -78,13 +83,16 @@ async function init() {
const blocker = document.getElementById( 'blocker' );
const instructions = document.getElementById('instructions');
fragmentedFloor(floorGridSize.x * floorGridSize.y);
//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 );
@ -92,12 +100,12 @@ async function init() {
socket = setupSocketIO();
player = new Player(rapierWorld, renderer, scene, new THREE.Vector3(0, 1, 0), interactableItems, socket);
player = new Player(rapierWorld, renderer, scene, new THREE.Vector3(466, 10, 32), interactableItems, socket);
player._setupVR(renderer);
scene.add(player.playerRig);;
scene.add(player.playerRig);
/* Load Items/Content */
loadWorldModel("models/wip-world-exr.glb");
await loadWorldModel("models/demo-world_repositioned_2511.glb");
instructions.innerHTML = "Click to play";
instructions.addEventListener( 'click', function () {
@ -155,12 +163,12 @@ async function animate() {
}
// Update managers
if (itemManager) itemManager.update();
//if (itemManager) itemManager.update();
// (2) Run a step of the physics sim
rapierWorld.step();
// // (3) Update the camera position, after physics step has run.
// (3) Update the camera position, after physics step has run.
const newPosition = player.rigibody.translation();
player.position.set(newPosition.x, newPosition.y, newPosition.z);
@ -195,8 +203,7 @@ async function animate() {
console.log("Player Count:", lastPlayerCount);
}
stats.update();
//stats.update();
}
//-- Other functions --//
@ -438,8 +445,11 @@ function createTiledFloor(gridSize = 20, tileSize = 50) {
// 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 });
@ -451,7 +461,7 @@ function createTiledFloor(gridSize = 20, tileSize = 50) {
floorTile.position.z
);
const floorBody = rapierWorld.createRigidBody(rbDesc);
const floorCollider = RAPIER.ColliderDesc.cuboid(tileSize / 2, 0.1, tileSize / 2);
const floorCollider = RAPIER.ColliderDesc.cuboid(tileSize / 2, 1, tileSize / 2);
rapierWorld.createCollider(floorCollider, floorBody);
}
}
@ -471,7 +481,7 @@ async function loadWorldModel(modelUrl) {
rapierWorld,
staticWorldGroup,
player,
false,
true,
object.position,
1,
object.name || 'static-world-object',
@ -481,13 +491,27 @@ async function loadWorldModel(modelUrl) {
null,
object
);
item.loadModel().then(() => resolve());
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 objects to be loaded
await Promise.all(staticLoadPromises);
console.log("Finished loading static world");
// 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);
@ -501,6 +525,7 @@ async function loadWorldModel(modelUrl) {
dynamicGroupsMap.set(groupData.name, threeGroup);
groupData.objects.forEach(object => {
object.layers.enable(DYNAMIC_OBJECT_LAYER);
preloadedObjectsMap.set(object.name, object);
});
});
@ -538,7 +563,9 @@ function updateFootstepsForPlayer(playerId, playerPosition) {
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 offsetAmount = (Math.random() - 0.5) * 2; // Adjust for wider/narrower steps
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;
@ -549,12 +576,12 @@ function updateFootstepsForPlayer(playerId, playerPosition) {
}
// Calculate left/right offset vector
const direction = new THREE.Vector3(Math.sin(rotationY), 0, Math.cos(rotationY));
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(0.3, 16);
const geometry = new THREE.CircleGeometry(1, 16);
const material = new THREE.MeshBasicMaterial({
color: 0xFFFFFF,
transparent: true,

75
js/titleAnimation.js

@ -1,49 +1,52 @@
document.addEventListener('DOMContentLoaded', () => {
const title = document.getElementById('title');
const text = title.innerText;
title.innerHTML = '';
// Wrap each letter in a span
for (let i = 0; i < text.length; i++) {
const char = text[i];
const span = document.createElement('span');
// Use non-breaking space for spaces to ensure they are not collapsed
span.innerHTML = char === ' ' ? '&nbsp;' : char;
title.appendChild(span);
}
function animateTitle(elementId) {
const titleElement = document.getElementById(elementId);
if (!titleElement) return;
const text = titleElement.innerText;
titleElement.innerHTML = '';
// Wrap each letter in a span
for (let i = 0; i < text.length; i++) {
const char = text[i];
const span = document.createElement('span');
span.innerHTML = char === ' ' ? '&nbsp;' : char;
titleElement.appendChild(span);
}
const letters = title.getElementsByTagName('span');
const letters = titleElement.children; // Use .children to get direct descendants
function getRandomColor() {
const hexChars = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += hexChars[Math.floor(Math.random() * 16)];
function getRandomColor() {
const hexChars = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += hexChars[Math.floor(Math.random() * 16)];
}
return color;
}
return color;
}
// Animate each letter independently
for (let i = 0; i < letters.length; i++) {
const letter = letters[i];
// Animate each letter independently
for (let i = 0; i < letters.length; i++) {
const letter = letters[i];
// Don't animate spaces
if (letter.innerHTML === '&nbsp;') continue;
if (letter.innerHTML === '&nbsp;') continue;
letter.style.color = getRandomColor();
letter.style.color = getRandomColor();
// Assign a persistent random interval for this letter's animation loop
const randomInterval = Math.random() * 500 + 500; // 100ms to 600ms
const randomInterval = Math.random() * 500 + 500;
function animateLetter() {
const randomRotation = Math.random() * 40 - 20; // -20 to +20 degrees
letter.style.transform = `rotate(${randomRotation}deg)`;
function animateLetter() {
const randomRotation = Math.random() * 40 - 20;
letter.style.transform = `rotate(${randomRotation}deg)`;
setTimeout(animateLetter, randomInterval);
}
// Continue the loop for this specific letter
setTimeout(animateLetter, randomInterval);
setTimeout(animateLetter, Math.random() * 1000);
}
// Start the animation for this letter after a random initial delay
setTimeout(animateLetter, Math.random() * 1000);
}
// Animate both titles
animateTitle('title');
animateTitle('about-title');
animateTitle('controls-title');
});

42
js/ui.js

@ -0,0 +1,42 @@
document.addEventListener('DOMContentLoaded', () => {
const aboutLink = document.getElementById('about');
const controlsLink = document.getElementById('controls');
const aboutModal = document.getElementById('about-modal');
const controlsModal = document.getElementById('controls-modal');
const closeButtons = document.querySelectorAll('.close-modal');
function showModal(modal) {
// Hide any other open modals first
hideAllModals();
modal.classList.add('show');
}
function hideAllModals() {
document.querySelectorAll('.modal.show').forEach(m => m.classList.remove('show'));
}
aboutLink.addEventListener('click', (e) => {
e.stopPropagation();
showModal(aboutModal);
});
controlsLink.addEventListener('click', (e) => {
e.stopPropagation();
showModal(controlsModal);
});
closeButtons.forEach(button => {
button.addEventListener('click', () => {
hideAllModals();
});
});
// Optional: Close modal if clicking outside of it
document.addEventListener('click', (e) => {
if (!e.target.closest('.modal')) {
hideAllModals();
}
});
});

BIN
public/images/smoke.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
public/models/demo-world_repositioned_2511.glb (Stored with Git LFS)

Binary file not shown.

BIN
public/models/spray-can.glb (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save