Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Procedural 3D Dungeon Explorer</title> | |
<style> | |
body { margin: 0; overflow: hidden; background-color: #000; color: white; font-family: monospace; } | |
canvas { display: block; } | |
#blocker { /* For Pointer Lock API */ | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
background-color: rgba(0,0,0,0.5); | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
cursor: pointer; | |
} | |
#instructions { | |
width: 50%; | |
text-align: center; | |
padding: 20px; | |
background: rgba(20, 20, 20, 0.8); | |
border-radius: 10px; | |
} | |
#crosshair { | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
width: 10px; | |
height: 10px; | |
border: 1px solid white; | |
border-radius: 50%; | |
transform: translate(-50%, -50%); | |
pointer-events: none; /* Don't interfere with clicks */ | |
mix-blend-mode: difference; /* Make visible on most backgrounds */ | |
display: none; /* Hidden until pointer lock */ | |
} | |
</style> | |
</head> | |
<body> | |
<div id="blocker"> | |
<div id="instructions"> | |
<h1>Dungeon Explorer</h1> | |
<p>Click to Enter</p> | |
<p>(W, A, S, D = Move, MOUSE = Look)</p> | |
</div> | |
</div> | |
<div id="crosshair">+</div> | |
<script type="importmap"> | |
{ | |
"imports": { | |
"three": "https://unpkg.com/[email protected]/build/three.module.js", | |
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/" | |
} | |
} | |
</script> | |
<script type="module"> | |
import * as THREE from 'three'; | |
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; | |
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js'; | |
// --- Config --- | |
const DUNGEON_WIDTH = 30; // Size of grid (cells) | |
const DUNGEON_HEIGHT = 30; | |
const CELL_SIZE = 5; // Size of one cell in world units | |
const WALL_HEIGHT = 4; | |
const PLAYER_HEIGHT = 1.6; // Camera height offset from ground | |
const PLAYER_RADIUS = 0.4; // For collision | |
const PLAYER_SPEED = 5.0; // Units per second | |
const GENERATION_STEPS = 1500; // How long the random walk carves | |
// --- Three.js Setup --- | |
let scene, camera, renderer; | |
let controls; // PointerLockControls | |
let clock; | |
// --- Player State --- | |
const playerVelocity = new THREE.Vector3(); | |
const playerDirection = new THREE.Vector3(); // Forward direction based on camera | |
let moveForward = false, moveBackward = false, moveLeft = false, moveRight = false; | |
let playerOnGround = true; // Basic ground check for potential jump later | |
// --- World Data --- | |
let dungeonLayout = []; // 2D grid: 0 = wall, 1 = floor | |
let floorMesh = null; | |
let wallMesh = null; | |
// --- DOM Elements --- | |
const blocker = document.getElementById('blocker'); | |
const instructions = document.getElementById('instructions'); | |
const crosshair = document.getElementById('crosshair'); | |
// --- Initialization --- | |
function init() { | |
console.log("Initializing..."); | |
clock = new THREE.Clock(); | |
// Scene | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x111111); | |
scene.fog = new THREE.Fog(0x111111, 5, CELL_SIZE * 5); // Fog based on cell size | |
// Camera (First Person) | |
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
camera.position.y = PLAYER_HEIGHT; // Set initial height | |
console.log("Camera created"); | |
// Renderer | |
renderer = new THREE.WebGLRenderer({ antialias: true }); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
renderer.setPixelRatio(window.devicePixelRatio); | |
renderer.shadowMap.enabled = true; | |
renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
document.body.appendChild(renderer.domElement); | |
console.log("Renderer created"); | |
// Lighting | |
scene.add(new THREE.AmbientLight(0x404040, 0.5)); // Dim ambient light | |
const flashlight = new THREE.SpotLight(0xffffff, 3, 25, Math.PI / 6, 0.3, 1); // Intensity, Distance, Angle, Penumbra, Decay | |
flashlight.position.set(0, 0, 0); // Position relative to camera | |
flashlight.target.position.set(0, 0, -1); // Target relative to camera | |
flashlight.castShadow = true; | |
flashlight.shadow.mapSize.width = 1024; | |
flashlight.shadow.mapSize.height = 1024; | |
flashlight.shadow.camera.near = 0.5; | |
flashlight.shadow.camera.far = 25; | |
camera.add(flashlight); // Attach light to camera | |
camera.add(flashlight.target); | |
scene.add(camera); // Add camera (with light) to scene | |
console.log("Lighting setup"); | |
// Pointer Lock Controls | |
controls = new PointerLockControls(camera, renderer.domElement); | |
scene.add(controls.getObject()); // Add the camera controls group | |
blocker.addEventListener('click', () => { controls.lock(); }); | |
controls.addEventListener('lock', () => { | |
instructions.style.display = 'none'; | |
blocker.style.display = 'none'; | |
crosshair.style.display = 'block'; | |
}); | |
controls.addEventListener('unlock', () => { | |
blocker.style.display = 'flex'; | |
instructions.style.display = ''; | |
crosshair.style.display = 'none'; | |
}); | |
// Keyboard Listeners | |
document.addEventListener('keydown', onKeyDown); | |
document.addEventListener('keyup', onKeyUp); | |
// Resize Listener | |
window.addEventListener('resize', onWindowResize); | |
// Generate and Build Dungeon | |
console.log("Generating dungeon layout..."); | |
dungeonLayout = generateDungeonLayout(DUNGEON_WIDTH, DUNGEON_HEIGHT); | |
console.log("Layout generated, creating meshes..."); | |
createDungeonMeshes(dungeonLayout); | |
console.log("Dungeon meshes created."); | |
// Find starting position for player | |
const startPos = findStartPosition(dungeonLayout); | |
if (startPos) { | |
camera.position.x = startPos.x; | |
camera.position.z = startPos.z; | |
console.log("Player start position set:", startPos); | |
} else { | |
console.error("Could not find valid start position!"); | |
camera.position.x = (DUNGEON_WIDTH / 2) * CELL_SIZE; // Fallback | |
camera.position.z = (DUNGEON_HEIGHT / 2) * CELL_SIZE; | |
} | |
console.log("Initialization Complete."); | |
animate(); // Start the loop | |
} | |
// --- Dungeon Generation (Random Walk) --- | |
function generateDungeonLayout(width, height) { | |
const grid = Array(height).fill(null).map(() => Array(width).fill(0)); // 0 = wall | |
let x = Math.floor(width / 2); | |
let y = Math.floor(height / 2); | |
let currentSteps = 0; | |
grid[y][x] = 1; // Start point is floor | |
const directions = [ [0, -1], [0, 1], [-1, 0], [1, 0] ]; // N, S, W, E | |
while (currentSteps < GENERATION_STEPS) { | |
const dir = directions[Math.floor(Math.random() * directions.length)]; | |
const nextX = x + dir[0]; | |
const nextY = y + dir[1]; | |
// Check bounds (stay within grid, maybe leave border walls?) | |
if (nextX > 0 && nextX < width - 1 && nextY > 0 && nextY < height - 1) { | |
x = nextX; | |
y = nextY; | |
if (grid[y][x] === 0) { | |
grid[y][x] = 1; // Carve floor | |
currentSteps++; | |
} | |
// Allow walker to backtrack or carve adjacent cells more freely? | |
// Maybe carve a 2x1 area sometimes? For now, simple walk. | |
} else { | |
// Hit edge, pick new random start point on existing floor? | |
// Or just pick new direction? Let's just pick new direction. | |
// To avoid getting stuck, could randomly jump to another floor tile. | |
} | |
// Randomly change direction bias? (e.g. tend to go straight) | |
// Keep it simple for now. | |
} | |
return grid; | |
} | |
// --- Find Start Position --- | |
function findStartPosition(grid) { | |
// Find the first floor tile encountered, starting from center | |
const startY = Math.floor(grid.length / 2); | |
const startX = Math.floor(grid[0].length / 2); | |
// Simple search outwards from center | |
for (let r = 0; r < Math.max(startX, startY); r++) { | |
for (let y = startY - r; y <= startY + r; y++) { | |
for (let x = startX - r; x <= startX + r; x++) { | |
// Check only perimeter of radius r or center itself | |
if (Math.abs(y - startY) === r || Math.abs(x - startX) === r || r === 0) { | |
if (y >= 0 && y < grid.length && x >= 0 && x < grid[0].length && grid[y][x] === 1) { | |
return { x: x * CELL_SIZE + CELL_SIZE / 2, z: y * CELL_SIZE + CELL_SIZE / 2 }; // Center of cell | |
} | |
} | |
} | |
} | |
} | |
return null; // Should not happen if generation works | |
} | |
// --- Dungeon Meshing --- | |
function createDungeonMeshes(grid) { | |
console.log("Creating merged geometries..."); | |
const floorGeometries = []; | |
const wallGeometries = []; | |
const floorGeo = new THREE.PlaneGeometry(CELL_SIZE, CELL_SIZE); | |
const wallGeoN = new THREE.BoxGeometry(CELL_SIZE, WALL_HEIGHT, 0.1); | |
const wallGeoS = new THREE.BoxGeometry(CELL_SIZE, WALL_HEIGHT, 0.1); | |
const wallGeoE = new THREE.BoxGeometry(0.1, WALL_HEIGHT, CELL_SIZE); | |
const wallGeoW = new THREE.BoxGeometry(0.1, WALL_HEIGHT, CELL_SIZE); | |
// Load Textures (Replace with actual URLs) | |
const textureLoader = new THREE.TextureLoader(); | |
const floorTexture = textureLoader.load('https://threejs.org/examples/textures/hardwood2_diffuse.jpg'); // Placeholder wood floor | |
const wallTexture = textureLoader.load('https://threejs.org/examples/textures/brick_diffuse.jpg'); // Placeholder brick wall | |
floorTexture.wrapS = floorTexture.wrapT = THREE.RepeatWrapping; | |
wallTexture.wrapS = wallTexture.wrapT = THREE.RepeatWrapping; | |
// Adjust texture repeats based on CELL_SIZE if needed | |
// floorTexture.repeat.set(CELL_SIZE / 2, CELL_SIZE / 2); | |
// wallTexture.repeat.set(CELL_SIZE / 2, WALL_HEIGHT / 2); | |
const floorMaterial = new THREE.MeshStandardMaterial({ map: floorTexture, roughness: 0.8, metalness: 0.1 }); | |
const wallMaterial = new THREE.MeshStandardMaterial({ map: wallTexture, roughness: 0.9, metalness: 0.0 }); | |
for (let y = 0; y < grid.length; y++) { | |
for (let x = 0; x < grid[y].length; x++) { | |
if (grid[y][x] === 1) { // If it's a floor cell | |
// Create Floor Tile Geometry | |
const floorInstance = floorGeo.clone(); | |
floorInstance.rotateX(-Math.PI / 2); | |
floorInstance.translate(x * CELL_SIZE + CELL_SIZE / 2, 0, y * CELL_SIZE + CELL_SIZE / 2); | |
floorGeometries.push(floorInstance); | |
// Check neighbors for Walls | |
// North Wall | |
if (y === 0 || grid[y - 1][x] === 0) { | |
const wallInstance = wallGeoN.clone(); | |
wallInstance.translate(x * CELL_SIZE + CELL_SIZE / 2, WALL_HEIGHT / 2, y * CELL_SIZE); | |
wallGeometries.push(wallInstance); | |
} | |
// South Wall | |
if (y === grid.length - 1 || grid[y + 1][x] === 0) { | |
const wallInstance = wallGeoS.clone(); | |
wallInstance.translate(x * CELL_SIZE + CELL_SIZE / 2, WALL_HEIGHT / 2, y * CELL_SIZE + CELL_SIZE); | |
wallGeometries.push(wallInstance); | |
} | |
// West Wall | |
if (x === 0 || grid[y][x - 1] === 0) { | |
const wallInstance = wallGeoW.clone(); | |
wallInstance.translate(x * CELL_SIZE, WALL_HEIGHT / 2, y * CELL_SIZE + CELL_SIZE / 2); | |
wallGeometries.push(wallInstance); | |
} | |
// East Wall | |
if (x === grid[y].length - 1 || grid[y][x + 1] === 0) { | |
const wallInstance = wallGeoE.clone(); | |
wallInstance.translate(x * CELL_SIZE + CELL_SIZE, WALL_HEIGHT / 2, y * CELL_SIZE + CELL_SIZE / 2); | |
wallGeometries.push(wallInstance); | |
} | |
} | |
} | |
} | |
// Merge Geometries | |
if (floorGeometries.length > 0) { | |
const mergedFloorGeometry = BufferGeometryUtils.mergeGeometries(floorGeometries, false); | |
if (mergedFloorGeometry) { | |
floorMesh = new THREE.Mesh(mergedFloorGeometry, floorMaterial); | |
floorMesh.receiveShadow = true; | |
scene.add(floorMesh); | |
console.log("Merged floor mesh added."); | |
} else { console.error("Floor geometry merging failed."); } | |
} | |
if (wallGeometries.length > 0) { | |
const mergedWallGeometry = BufferGeometryUtils.mergeGeometries(wallGeometries, false); | |
if (mergedWallGeometry) { | |
wallMesh = new THREE.Mesh(mergedWallGeometry, wallMaterial); | |
wallMesh.castShadow = true; | |
wallMesh.receiveShadow = true; // Walls can receive shadows from other parts | |
scene.add(wallMesh); | |
console.log("Merged wall mesh added."); | |
} else { console.error("Wall geometry merging failed."); } | |
} | |
// Dispose of individual geometries to save memory | |
floorGeo.dispose(); | |
wallGeoN.dispose(); | |
wallGeoS.dispose(); | |
wallGeoE.dispose(); | |
wallGeoW.dispose(); | |
floorGeometries.forEach(g => g.dispose()); | |
wallGeometries.forEach(g => g.dispose()); | |
console.log("Individual geometries disposed."); | |
} | |
// --- Player Movement & Collision --- | |
function handleInputAndMovement(deltaTime) { | |
if (!controls.isLocked) return; | |
const speed = PLAYER_SPEED * deltaTime; | |
playerVelocity.x = 0; // Reset velocity each frame (using direct position change) | |
playerVelocity.z = 0; | |
// Get camera direction | |
camera.getWorldDirection(playerDirection); | |
playerDirection.y = 0; // Ignore vertical component for movement | |
playerDirection.normalize(); | |
const rightDirection = new THREE.Vector3().crossVectors(camera.up, playerDirection).normalize(); | |
// Calculate movement based on keys | |
if (moveForward) playerVelocity.add(playerDirection); | |
if (moveBackward) playerVelocity.sub(playerDirection); | |
if (moveLeft) playerVelocity.sub(rightDirection); // Strafe left | |
if (moveRight) playerVelocity.add(rightDirection); // Strafe right | |
// Normalize diagonal movement speed if necessary (optional but good) | |
if (playerVelocity.lengthSq() > 0) { | |
playerVelocity.normalize().multiplyScalar(speed); | |
} | |
// --- Basic Collision Detection --- | |
const currentPos = controls.getObject().position; | |
const nextPos = currentPos.clone().add(playerVelocity); // Potential next position | |
// Check X movement collision | |
const targetGridX_X = Math.floor((nextPos.x + Math.sign(playerVelocity.x) * PLAYER_RADIUS) / CELL_SIZE); | |
const currentGridZ_X = Math.floor(currentPos.z / CELL_SIZE); | |
if (dungeonLayout[currentGridZ_X]?.[targetGridX_X] === 0) { // Hit wall in X direction | |
playerVelocity.x = 0; // Stop X movement | |
console.log("Collision X"); | |
} | |
// Check Z movement collision | |
const currentGridX_Z = Math.floor(currentPos.x / CELL_SIZE); | |
const targetGridZ_Z = Math.floor((nextPos.z + Math.sign(playerVelocity.z) * PLAYER_RADIUS) / CELL_SIZE); | |
if (dungeonLayout[targetGridZ_Z]?.[currentGridX_Z] === 0) { // Hit wall in Z direction | |
playerVelocity.z = 0; // Stop Z movement | |
console.log("Collision Z"); | |
} | |
// Apply final velocity to camera controls object | |
controls.moveRight(playerVelocity.x); // Use moveRight for strafing based on calculated velocity X | |
controls.moveForward(playerVelocity.z); // Use moveForward for Z based on calculated velocity Z | |
// Keep player at fixed height (no gravity/jump yet) | |
controls.getObject().position.y = PLAYER_HEIGHT; | |
} | |
// --- Event Handlers --- | |
function onKeyDown(event) { | |
switch (event.code) { | |
case 'ArrowUp': case 'KeyW': moveForward = true; break; | |
case 'ArrowLeft': case 'KeyA': moveLeft = true; break; | |
case 'ArrowDown': case 'KeyS': moveBackward = true; break; | |
case 'ArrowRight': case 'KeyD': moveRight = true; break; | |
// Add jump/other actions later | |
} | |
} | |
function onKeyUp(event) { | |
switch (event.code) { | |
case 'ArrowUp': case 'KeyW': moveForward = false; break; | |
case 'ArrowLeft': case 'KeyA': moveLeft = false; break; | |
case 'ArrowDown': case 'KeyS': moveBackward = false; break; | |
case 'ArrowRight': case 'KeyD': moveRight = false; break; | |
} | |
} | |
function onWindowResize() { | |
camera.aspect = window.innerWidth / window.innerHeight; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
} | |
// --- Animation Loop --- | |
function animate() { | |
animationFrameId = requestAnimationFrame(animate); // Store frame ID | |
const delta = clock.getDelta(); | |
handleInputAndMovement(delta); | |
// Update other game elements (monsters, etc.) here later | |
renderer.render(scene, camera); | |
} | |
// --- Start --- | |
init(); | |
</script> | |
</body> | |
</html> |