449 lines
16 KiB
JavaScript
449 lines
16 KiB
JavaScript
import * as THREE from 'three';
|
|
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
|
|
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
|
|
|
|
/**
|
|
* Creates an obstacle course with:
|
|
* - Tall wall (jump over)
|
|
* - Laser wires (crouch/crawl under)
|
|
* - Balance beam
|
|
* - "OBSTACLE COURSE" title text
|
|
* - Goal platform with "GOAL" sign
|
|
*/
|
|
export function createObstacleCourse(scene) {
|
|
const courseGroup = new THREE.Group();
|
|
courseGroup.name = 'obstacleCourse';
|
|
|
|
// Course layout along negative Z axis (character faces -Z to go through course)
|
|
const courseStartZ = -2;
|
|
const obstacleSpacing = 4;
|
|
|
|
// Materials
|
|
const wallMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x8B4513,
|
|
roughness: 0.8
|
|
});
|
|
const laserMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xff0000,
|
|
emissive: 0xff0000,
|
|
emissiveIntensity: 0.8
|
|
});
|
|
const beamMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xDEB887,
|
|
roughness: 0.6
|
|
});
|
|
const platformMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x228B22,
|
|
roughness: 0.5
|
|
});
|
|
const postMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x444444,
|
|
metalness: 0.8,
|
|
roughness: 0.2
|
|
});
|
|
const signMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xFFD700
|
|
});
|
|
|
|
// ============================================
|
|
// OBSTACLE 1: Tall Wall (Jump Over)
|
|
// ============================================
|
|
const wallZ = courseStartZ;
|
|
const wallHeight = 0.8;
|
|
const wallWidth = 2;
|
|
const wallThickness = 0.15;
|
|
|
|
const wallGeometry = new THREE.BoxGeometry(wallWidth, wallHeight, wallThickness);
|
|
const wall = new THREE.Mesh(wallGeometry, wallMaterial);
|
|
wall.position.set(0, wallHeight / 2, wallZ);
|
|
wall.castShadow = true;
|
|
wall.receiveShadow = true;
|
|
wall.name = 'obstacle_wall';
|
|
courseGroup.add(wall);
|
|
|
|
// Wall side supports
|
|
const supportGeometry = new THREE.BoxGeometry(0.1, wallHeight, 0.3);
|
|
const leftSupport = new THREE.Mesh(supportGeometry, wallMaterial);
|
|
leftSupport.position.set(-wallWidth / 2 - 0.05, wallHeight / 2, wallZ);
|
|
leftSupport.castShadow = true;
|
|
courseGroup.add(leftSupport);
|
|
|
|
const rightSupport = new THREE.Mesh(supportGeometry, wallMaterial);
|
|
rightSupport.position.set(wallWidth / 2 + 0.05, wallHeight / 2, wallZ);
|
|
rightSupport.castShadow = true;
|
|
courseGroup.add(rightSupport);
|
|
|
|
// ============================================
|
|
// OBSTACLE 2: Laser Wires (Crouch/Crawl)
|
|
// ============================================
|
|
const laserZ = wallZ - obstacleSpacing;
|
|
const laserCount = 3;
|
|
const laserSpacing = 0.8;
|
|
const laserWidth = 2;
|
|
const laserHeight = 0.4; // Low enough to require crawling
|
|
|
|
// Posts for laser wires
|
|
const postHeight = 1.0;
|
|
const postRadius = 0.05;
|
|
const postGeometry = new THREE.CylinderGeometry(postRadius, postRadius, postHeight, 8);
|
|
|
|
const leftPost = new THREE.Mesh(postGeometry, postMaterial);
|
|
leftPost.position.set(-laserWidth / 2 - 0.1, postHeight / 2, laserZ);
|
|
leftPost.castShadow = true;
|
|
courseGroup.add(leftPost);
|
|
|
|
const rightPost = new THREE.Mesh(postGeometry, postMaterial);
|
|
rightPost.position.set(laserWidth / 2 + 0.1, postHeight / 2, laserZ);
|
|
rightPost.castShadow = true;
|
|
courseGroup.add(rightPost);
|
|
|
|
// Laser wires (thin glowing cylinders)
|
|
for (let i = 0; i < laserCount; i++) {
|
|
const wireZ = laserZ + (i - 1) * laserSpacing;
|
|
const wireGeometry = new THREE.CylinderGeometry(0.01, 0.01, laserWidth, 8);
|
|
const wire = new THREE.Mesh(wireGeometry, laserMaterial);
|
|
wire.rotation.z = Math.PI / 2; // Rotate to horizontal
|
|
wire.position.set(0, laserHeight, wireZ);
|
|
wire.name = `laser_wire_${i}`;
|
|
courseGroup.add(wire);
|
|
|
|
// Add glow effect with a slightly larger transparent cylinder
|
|
const glowGeometry = new THREE.CylinderGeometry(0.03, 0.03, laserWidth, 8);
|
|
const glowMaterial = new THREE.MeshBasicMaterial({
|
|
color: 0xff0000,
|
|
transparent: true,
|
|
opacity: 0.3
|
|
});
|
|
const glow = new THREE.Mesh(glowGeometry, glowMaterial);
|
|
glow.rotation.z = Math.PI / 2;
|
|
glow.position.set(0, laserHeight, wireZ);
|
|
courseGroup.add(glow);
|
|
|
|
// Additional posts for each wire row
|
|
if (i !== 1) { // Skip middle, already have posts
|
|
const miniPostGeometry = new THREE.CylinderGeometry(0.03, 0.03, postHeight * 0.6, 6);
|
|
const leftMiniPost = new THREE.Mesh(miniPostGeometry, postMaterial);
|
|
leftMiniPost.position.set(-laserWidth / 2 - 0.1, postHeight * 0.3, wireZ);
|
|
courseGroup.add(leftMiniPost);
|
|
|
|
const rightMiniPost = new THREE.Mesh(miniPostGeometry, postMaterial);
|
|
rightMiniPost.position.set(laserWidth / 2 + 0.1, postHeight * 0.3, wireZ);
|
|
courseGroup.add(rightMiniPost);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// OBSTACLE 3: Balance Beam over Spike Pit
|
|
// ============================================
|
|
const beamZ = laserZ - obstacleSpacing;
|
|
const beamLength = 3;
|
|
const beamWidth = 0.18;
|
|
const pitDepth = 1.2;
|
|
const pitWidth = 2.5;
|
|
const pitLength = beamLength + 1;
|
|
|
|
// Pit materials
|
|
const pitWallMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x3d3d3d,
|
|
roughness: 0.9
|
|
});
|
|
const pitFloorMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x1a1a1a,
|
|
roughness: 1.0
|
|
});
|
|
const spikeMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x888888,
|
|
metalness: 0.7,
|
|
roughness: 0.3
|
|
});
|
|
|
|
// Pit floor (bottom of the pit)
|
|
const pitFloorGeometry = new THREE.BoxGeometry(pitWidth, 0.1, pitLength);
|
|
const pitFloor = new THREE.Mesh(pitFloorGeometry, pitFloorMaterial);
|
|
pitFloor.position.set(0, -pitDepth, beamZ);
|
|
pitFloor.receiveShadow = true;
|
|
pitFloor.name = 'pit_floor';
|
|
courseGroup.add(pitFloor);
|
|
|
|
// Pit walls (4 sides)
|
|
const wallThicknessPit = 0.15;
|
|
|
|
// Left wall
|
|
const leftWallGeometry = new THREE.BoxGeometry(wallThicknessPit, pitDepth, pitLength);
|
|
const leftWall = new THREE.Mesh(leftWallGeometry, pitWallMaterial);
|
|
leftWall.position.set(-pitWidth / 2 - wallThicknessPit / 2, -pitDepth / 2, beamZ);
|
|
leftWall.castShadow = true;
|
|
leftWall.receiveShadow = true;
|
|
courseGroup.add(leftWall);
|
|
|
|
// Right wall
|
|
const rightWall = new THREE.Mesh(leftWallGeometry, pitWallMaterial);
|
|
rightWall.position.set(pitWidth / 2 + wallThicknessPit / 2, -pitDepth / 2, beamZ);
|
|
rightWall.castShadow = true;
|
|
rightWall.receiveShadow = true;
|
|
courseGroup.add(rightWall);
|
|
|
|
// Front wall (with gap for beam)
|
|
const frontWallSideWidth = (pitWidth - beamWidth) / 2;
|
|
const frontWallGeometry = new THREE.BoxGeometry(frontWallSideWidth, pitDepth, wallThicknessPit);
|
|
|
|
const frontWallLeft = new THREE.Mesh(frontWallGeometry, pitWallMaterial);
|
|
frontWallLeft.position.set(-beamWidth / 2 - frontWallSideWidth / 2, -pitDepth / 2, beamZ + pitLength / 2 + wallThicknessPit / 2);
|
|
frontWallLeft.castShadow = true;
|
|
courseGroup.add(frontWallLeft);
|
|
|
|
const frontWallRight = new THREE.Mesh(frontWallGeometry, pitWallMaterial);
|
|
frontWallRight.position.set(beamWidth / 2 + frontWallSideWidth / 2, -pitDepth / 2, beamZ + pitLength / 2 + wallThicknessPit / 2);
|
|
frontWallRight.castShadow = true;
|
|
courseGroup.add(frontWallRight);
|
|
|
|
// Back wall (with gap for beam)
|
|
const backWallLeft = new THREE.Mesh(frontWallGeometry, pitWallMaterial);
|
|
backWallLeft.position.set(-beamWidth / 2 - frontWallSideWidth / 2, -pitDepth / 2, beamZ - pitLength / 2 - wallThicknessPit / 2);
|
|
backWallLeft.castShadow = true;
|
|
courseGroup.add(backWallLeft);
|
|
|
|
const backWallRight = new THREE.Mesh(frontWallGeometry, pitWallMaterial);
|
|
backWallRight.position.set(beamWidth / 2 + frontWallSideWidth / 2, -pitDepth / 2, beamZ - pitLength / 2 - wallThicknessPit / 2);
|
|
backWallRight.castShadow = true;
|
|
courseGroup.add(backWallRight);
|
|
|
|
// Spikes at the bottom of the pit
|
|
const spikeHeight = 0.4;
|
|
const spikeRadius = 0.06;
|
|
const spikeGeometry = new THREE.ConeGeometry(spikeRadius, spikeHeight, 6);
|
|
|
|
// Create grid of spikes
|
|
const spikeSpacingX = 0.25;
|
|
const spikeSpacingZ = 0.3;
|
|
const spikesPerRowX = Math.floor((pitWidth - 0.3) / spikeSpacingX);
|
|
const spikesPerRowZ = Math.floor((pitLength - 0.3) / spikeSpacingZ);
|
|
|
|
for (let ix = 0; ix < spikesPerRowX; ix++) {
|
|
for (let iz = 0; iz < spikesPerRowZ; iz++) {
|
|
const spikeX = -pitWidth / 2 + 0.2 + ix * spikeSpacingX;
|
|
const spikeZPos = beamZ - pitLength / 2 + 0.2 + iz * spikeSpacingZ;
|
|
|
|
// Skip spikes directly under the beam
|
|
if (Math.abs(spikeX) < beamWidth / 2 + 0.05) continue;
|
|
|
|
const spike = new THREE.Mesh(spikeGeometry, spikeMaterial);
|
|
spike.position.set(spikeX, -pitDepth + spikeHeight / 2 + 0.05, spikeZPos);
|
|
spike.castShadow = true;
|
|
spike.name = 'spike';
|
|
courseGroup.add(spike);
|
|
}
|
|
}
|
|
|
|
// Balance beam (at floor level, spanning the pit)
|
|
const beamGeometry = new THREE.BoxGeometry(beamWidth, 0.08, beamLength + 0.6);
|
|
const beam = new THREE.Mesh(beamGeometry, beamMaterial);
|
|
beam.position.set(0, 0.04, beamZ);
|
|
beam.castShadow = true;
|
|
beam.receiveShadow = true;
|
|
beam.name = 'balance_beam';
|
|
courseGroup.add(beam);
|
|
|
|
// Entry/exit ramps (slight incline to beam)
|
|
const rampLength = 0.4;
|
|
const rampGeometry = new THREE.BoxGeometry(beamWidth + 0.1, 0.08, rampLength);
|
|
|
|
const entryRamp = new THREE.Mesh(rampGeometry, beamMaterial);
|
|
entryRamp.position.set(0, 0.04, beamZ + beamLength / 2 + 0.5);
|
|
entryRamp.castShadow = true;
|
|
courseGroup.add(entryRamp);
|
|
|
|
const exitRamp = new THREE.Mesh(rampGeometry, beamMaterial);
|
|
exitRamp.position.set(0, 0.04, beamZ - beamLength / 2 - 0.5);
|
|
exitRamp.castShadow = true;
|
|
courseGroup.add(exitRamp);
|
|
|
|
// Warning stripes on the edges
|
|
const warningMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xffcc00,
|
|
roughness: 0.6
|
|
});
|
|
const warningGeometry = new THREE.BoxGeometry(pitWidth + wallThicknessPit * 2, 0.02, 0.1);
|
|
|
|
const frontWarning = new THREE.Mesh(warningGeometry, warningMaterial);
|
|
frontWarning.position.set(0, 0.01, beamZ + pitLength / 2 + wallThicknessPit + 0.05);
|
|
courseGroup.add(frontWarning);
|
|
|
|
const backWarning = new THREE.Mesh(warningGeometry, warningMaterial);
|
|
backWarning.position.set(0, 0.01, beamZ - pitLength / 2 - wallThicknessPit - 0.05);
|
|
courseGroup.add(backWarning);
|
|
|
|
// ============================================
|
|
// GOAL: Finish Platform with Button
|
|
// ============================================
|
|
const goalZ = beamZ - obstacleSpacing;
|
|
|
|
// Main platform
|
|
const platformGeometry = new THREE.CylinderGeometry(0.8, 0.9, 0.15, 16);
|
|
const platform = new THREE.Mesh(platformGeometry, platformMaterial);
|
|
platform.position.set(0, 0.075, goalZ);
|
|
platform.receiveShadow = true;
|
|
platform.name = 'goal_platform';
|
|
courseGroup.add(platform);
|
|
|
|
// Button on platform
|
|
const buttonBaseGeometry = new THREE.CylinderGeometry(0.25, 0.3, 0.1, 16);
|
|
const buttonBaseMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x333333,
|
|
metalness: 0.5
|
|
});
|
|
const buttonBase = new THREE.Mesh(buttonBaseGeometry, buttonBaseMaterial);
|
|
buttonBase.position.set(0, 0.2, goalZ);
|
|
buttonBase.castShadow = true;
|
|
courseGroup.add(buttonBase);
|
|
|
|
const buttonTopGeometry = new THREE.CylinderGeometry(0.2, 0.2, 0.08, 16);
|
|
const buttonTopMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xff3333,
|
|
emissive: 0xff0000,
|
|
emissiveIntensity: 0.3
|
|
});
|
|
const buttonTop = new THREE.Mesh(buttonTopGeometry, buttonTopMaterial);
|
|
buttonTop.position.set(0, 0.29, goalZ);
|
|
buttonTop.castShadow = true;
|
|
buttonTop.name = 'goal_button';
|
|
courseGroup.add(buttonTop);
|
|
|
|
// Goal sign post
|
|
const signPostGeometry = new THREE.CylinderGeometry(0.05, 0.05, 1.5, 8);
|
|
const signPost = new THREE.Mesh(signPostGeometry, postMaterial);
|
|
signPost.position.set(0, 0.75, goalZ - 0.8);
|
|
signPost.castShadow = true;
|
|
courseGroup.add(signPost);
|
|
|
|
// Goal sign board
|
|
const signBoardGeometry = new THREE.BoxGeometry(0.8, 0.3, 0.05);
|
|
const signBoard = new THREE.Mesh(signBoardGeometry, signMaterial);
|
|
signBoard.position.set(0, 1.4, goalZ - 0.8);
|
|
signBoard.castShadow = true;
|
|
signBoard.name = 'goal_sign';
|
|
courseGroup.add(signBoard);
|
|
|
|
// ============================================
|
|
// TITLE TEXT: "OBSTACLE COURSE" in distance
|
|
// ============================================
|
|
const titleZ = goalZ - 5;
|
|
|
|
// Load font and create 3D text
|
|
const fontLoader = new FontLoader();
|
|
fontLoader.load(
|
|
'https://threejs.org/examples/fonts/helvetiker_bold.typeface.json',
|
|
(font) => {
|
|
// Main title
|
|
const titleGeometry = new TextGeometry('OBSTACLE COURSE', {
|
|
font: font,
|
|
size: 0.4,
|
|
depth: 0.1,
|
|
curveSegments: 12,
|
|
bevelEnabled: true,
|
|
bevelThickness: 0.02,
|
|
bevelSize: 0.01,
|
|
bevelOffset: 0,
|
|
bevelSegments: 3
|
|
});
|
|
titleGeometry.computeBoundingBox();
|
|
titleGeometry.center();
|
|
|
|
const titleMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xFFD700,
|
|
metalness: 0.6,
|
|
roughness: 0.3
|
|
});
|
|
const titleMesh = new THREE.Mesh(titleGeometry, titleMaterial);
|
|
titleMesh.position.set(0, 2, titleZ);
|
|
titleMesh.castShadow = true;
|
|
titleMesh.name = 'title_text';
|
|
courseGroup.add(titleMesh);
|
|
|
|
// GOAL text on the sign
|
|
const goalTextGeometry = new TextGeometry('GOAL', {
|
|
font: font,
|
|
size: 0.12,
|
|
depth: 0.02,
|
|
curveSegments: 8,
|
|
bevelEnabled: false
|
|
});
|
|
goalTextGeometry.computeBoundingBox();
|
|
goalTextGeometry.center();
|
|
|
|
const goalTextMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x222222
|
|
});
|
|
const goalTextMesh = new THREE.Mesh(goalTextGeometry, goalTextMaterial);
|
|
goalTextMesh.position.set(0, 1.4, goalZ - 0.75);
|
|
goalTextMesh.name = 'goal_text';
|
|
courseGroup.add(goalTextMesh);
|
|
|
|
console.log('✅ Obstacle course text loaded');
|
|
},
|
|
undefined,
|
|
(error) => {
|
|
console.warn('Could not load font for obstacle course text:', error);
|
|
// Fallback: Create simple plane signs
|
|
createFallbackSigns(courseGroup, goalZ, titleZ);
|
|
}
|
|
);
|
|
|
|
// Add to scene
|
|
scene.add(courseGroup);
|
|
|
|
console.log('✅ Obstacle course created');
|
|
|
|
return {
|
|
group: courseGroup,
|
|
// Expose obstacle positions for collision detection
|
|
obstacles: {
|
|
wall: { position: new THREE.Vector3(0, wallHeight / 2, wallZ), size: new THREE.Vector3(wallWidth, wallHeight, wallThickness) },
|
|
lasers: { position: new THREE.Vector3(0, laserHeight, laserZ), height: laserHeight, width: laserWidth },
|
|
beam: { position: new THREE.Vector3(0, 0.04, beamZ), length: beamLength, width: beamWidth, height: 0.08 },
|
|
pit: { position: new THREE.Vector3(0, -pitDepth / 2, beamZ), width: pitWidth, length: pitLength, depth: pitDepth },
|
|
goal: { position: new THREE.Vector3(0, 0, goalZ), radius: 0.8 }
|
|
}
|
|
};
|
|
}
|
|
|
|
// Fallback signs if font fails to load
|
|
function createFallbackSigns(group, goalZ, titleZ) {
|
|
// Title sign (plane with color)
|
|
const titleSignGeometry = new THREE.PlaneGeometry(3, 0.6);
|
|
const titleSignMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xFFD700,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const titleSign = new THREE.Mesh(titleSignGeometry, titleSignMaterial);
|
|
titleSign.position.set(0, 2, titleZ);
|
|
titleSign.name = 'title_fallback';
|
|
group.add(titleSign);
|
|
|
|
// Goal sign already exists as signBoard
|
|
console.log('⚠️ Using fallback signs (font not loaded)');
|
|
}
|
|
|
|
/**
|
|
* Optional: Animate laser wires for visual effect
|
|
*/
|
|
export function animateObstacleCourse(courseGroup, deltaTime, elapsedTime) {
|
|
if (!courseGroup) return;
|
|
|
|
// Pulse the laser wires
|
|
courseGroup.children.forEach(child => {
|
|
if (child.name && child.name.startsWith('laser_wire_')) {
|
|
const pulse = 0.8 + 0.2 * Math.sin(elapsedTime * 5 + parseInt(child.name.split('_')[2]) * 0.5);
|
|
if (child.material.emissiveIntensity !== undefined) {
|
|
child.material.emissiveIntensity = pulse;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Gently pulse the goal button
|
|
const goalButton = courseGroup.getObjectByName('goal_button');
|
|
if (goalButton && goalButton.material.emissiveIntensity !== undefined) {
|
|
goalButton.material.emissiveIntensity = 0.2 + 0.15 * Math.sin(elapsedTime * 3);
|
|
}
|
|
}
|