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); } }