383 lines
15 KiB
HTML
383 lines
15 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Simple 3D Dungeon Crawler</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
overflow: hidden;
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
|
|
"Helvetica Neue", sans-serif;
|
|
background-color: #111;
|
|
color: #fff;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
#info-container {
|
|
position: absolute;
|
|
top: 10px;
|
|
left: 10px;
|
|
background-color: rgba(0, 0, 0, 0.7);
|
|
padding: 15px;
|
|
border-radius: 10px;
|
|
max-width: 350px;
|
|
border: 1px solid #444;
|
|
}
|
|
h1 {
|
|
margin-top: 0;
|
|
font-size: 1.2em;
|
|
}
|
|
p,
|
|
li {
|
|
font-size: 0.9em;
|
|
line-height: 1.5;
|
|
}
|
|
ul {
|
|
padding-left: 20px;
|
|
}
|
|
#crosshair {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: 4px;
|
|
height: 4px;
|
|
background-color: white;
|
|
border-radius: 50%;
|
|
transform: translate(-50%, -50%);
|
|
pointer-events: none; /* So it doesn't interfere with mouse lock */
|
|
}
|
|
#game-canvas {
|
|
display: block;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="info-container">
|
|
<h1>First-Person Dungeon Crawler</h1>
|
|
<p>Create a <strong>map.txt</strong> file to load your own dungeon:</p>
|
|
<ul>
|
|
<li><code>#</code> = Wall</li>
|
|
<li><code> </code> (space) = Floor</li>
|
|
<li><code>P</code> = Player Start</li>
|
|
</ul>
|
|
<p><strong>Controls:</strong></p>
|
|
<ul>
|
|
<li><strong>Click Screen:</strong> Lock mouse for camera control</li>
|
|
<li><strong>W / S:</strong> Move Forward / Backward</li>
|
|
<li><strong>A / D:</strong> Strafe Left / Right</li>
|
|
<li><strong>Mouse:</strong> Look Around</li>
|
|
<li><strong>ESC:</strong> Unlock mouse</li>
|
|
</ul>
|
|
<input type="file" id="map-upload" accept=".txt" />
|
|
</div>
|
|
|
|
<div id="crosshair"></div>
|
|
<canvas id="game-canvas"></canvas>
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
|
|
<script>
|
|
/**
|
|
* This type represents configuration settings of `AsciiEffect`.
|
|
*
|
|
* @typedef {Object} AsciiEffect~Options
|
|
* @property {number} [resolution=0.15] - A higher value leads to more details.
|
|
* @property {number} [scale=1] - The scale of the effect.
|
|
* @property {boolean} [color=false] - Whether colors should be enabled or not. Better quality but slows down rendering.
|
|
* @property {boolean} [alpha=false] - Whether transparency should be enabled or not.
|
|
* @property {boolean} [block=false] - Whether blocked characters should be enabled or not.
|
|
* @property {boolean} [invert=false] - Whether colors should be inverted or not.
|
|
* @property {('low'|'medium'|'high')} [strResolution='low'] - The string resolution.
|
|
**/
|
|
|
|
// --- Basic Setup ---
|
|
let scene, camera, renderer;
|
|
let mapData = [],
|
|
mapWidth,
|
|
mapHeight;
|
|
const TILE_SIZE = 5; // Size of each grid square in the world
|
|
const WALL_HEIGHT = 5;
|
|
|
|
// --- Player State ---
|
|
const player = {
|
|
height: WALL_HEIGHT / 2,
|
|
speed: 0.15,
|
|
turnSpeed: 0.05,
|
|
velocity: new THREE.Vector3(),
|
|
controls: {
|
|
moveForward: false,
|
|
moveBackward: false,
|
|
moveLeft: false,
|
|
moveRight: false,
|
|
},
|
|
};
|
|
|
|
// --- Initialization ---
|
|
function init() {
|
|
// Scene
|
|
scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x1a1a1a);
|
|
scene.fog = new THREE.Fog(0x1a1a1a, 10, 50);
|
|
|
|
// Camera (First-person view)
|
|
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
camera.position.y = player.height;
|
|
|
|
// Renderer
|
|
renderer = new THREE.WebGLRenderer({ canvas: document.getElementById("game-canvas"), antialias: true });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.setPixelRatio(window.devicePixelRatio);
|
|
document.body.appendChild(renderer.domElement);
|
|
|
|
// Lighting
|
|
const ambientLight = new THREE.AmbientLight(0x404040, 2);
|
|
scene.add(ambientLight);
|
|
|
|
const pointLight = new THREE.PointLight(0xffffff, 1.5, 100);
|
|
pointLight.position.set(0, WALL_HEIGHT * 1.5, 0); // Light is attached to player
|
|
camera.add(pointLight); // Attach light to camera
|
|
scene.add(camera); // Add camera to scene to ensure light is added
|
|
|
|
// Event Listeners
|
|
document.getElementById("map-upload").addEventListener("change", handleMapUpload);
|
|
window.addEventListener("resize", onWindowResize);
|
|
document.addEventListener("keydown", onKeyDown);
|
|
document.addEventListener("keyup", onKeyUp);
|
|
|
|
// Mouse Look Controls
|
|
setupPointerLock();
|
|
|
|
// Load a default map
|
|
loadMap(getDefaultMap());
|
|
|
|
// Start the game loop
|
|
animate();
|
|
}
|
|
|
|
// --- Map Handling ---
|
|
function getDefaultMap() {
|
|
return [
|
|
"##########",
|
|
"#P # #",
|
|
"# # ### #",
|
|
"#### # # #",
|
|
"# # #",
|
|
"# ###### #",
|
|
"# # #",
|
|
"# # ######",
|
|
"# # #",
|
|
"##########",
|
|
].join("\n");
|
|
}
|
|
|
|
function handleMapUpload(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
loadMap(e.target.result);
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
function loadMap(data) {
|
|
// Clear existing map objects from scene
|
|
const objectsToRemove = [];
|
|
scene.children.forEach((child) => {
|
|
if (child.userData.isMapTile) {
|
|
objectsToRemove.push(child);
|
|
}
|
|
});
|
|
objectsToRemove.forEach((obj) => scene.remove(obj));
|
|
|
|
// Parse new map data
|
|
mapData = data.split("\n").map((row) => row.split(""));
|
|
mapHeight = mapData.length;
|
|
mapWidth = mapData[0].length;
|
|
|
|
// Create geometry and materials once
|
|
const wallGeometry = new THREE.BoxGeometry(TILE_SIZE, WALL_HEIGHT, TILE_SIZE);
|
|
const wallMaterial = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8 });
|
|
|
|
const floorGeometry = new THREE.PlaneGeometry(TILE_SIZE, TILE_SIZE);
|
|
const floorMaterial = new THREE.MeshStandardMaterial({ color: 0x444444, side: THREE.DoubleSide });
|
|
|
|
// Build the scene from the map data
|
|
for (let y = 0; y < mapHeight; y++) {
|
|
for (let x = 0; x < mapWidth; x++) {
|
|
const char = mapData[y][x];
|
|
const worldX = (x - mapWidth / 2) * TILE_SIZE;
|
|
const worldZ = (y - mapHeight / 2) * TILE_SIZE;
|
|
|
|
if (char === "#") {
|
|
const wall = new THREE.Mesh(wallGeometry, wallMaterial);
|
|
wall.position.set(worldX, WALL_HEIGHT / 2, worldZ);
|
|
wall.userData.isMapTile = true;
|
|
scene.add(wall);
|
|
} else {
|
|
// Add floor for every non-wall tile
|
|
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
|
|
floor.rotation.x = -Math.PI / 2;
|
|
floor.position.set(worldX, 0, worldZ);
|
|
floor.userData.isMapTile = true;
|
|
scene.add(floor);
|
|
}
|
|
|
|
if (char === "@") {
|
|
camera.position.x = worldX;
|
|
camera.position.z = worldZ;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add a ceiling
|
|
const ceilingGeometry = new THREE.PlaneGeometry(mapWidth * TILE_SIZE, mapHeight * TILE_SIZE);
|
|
const ceilingMaterial = new THREE.MeshStandardMaterial({ color: 0x555555, side: THREE.DoubleSide });
|
|
const ceiling = new THREE.Mesh(ceilingGeometry, ceilingMaterial);
|
|
ceiling.rotation.x = Math.PI / 2;
|
|
ceiling.position.y = WALL_HEIGHT;
|
|
ceiling.userData.isMapTile = true;
|
|
scene.add(ceiling);
|
|
}
|
|
|
|
function isWall(x, z) {
|
|
const mapX = Math.floor(x / TILE_SIZE + mapWidth / 2);
|
|
const mapY = Math.floor(z / TILE_SIZE + mapHeight / 2);
|
|
|
|
if (mapX < 0 || mapX >= mapWidth || mapY < 0 || mapY >= mapHeight) {
|
|
return true; // Treat out of bounds as a wall
|
|
}
|
|
return mapData[mapY][mapX] === "#";
|
|
}
|
|
|
|
// --- Controls & Movement ---
|
|
function setupPointerLock() {
|
|
const canvas = renderer.domElement;
|
|
canvas.addEventListener("click", () => {
|
|
canvas.requestPointerLock();
|
|
});
|
|
|
|
document.addEventListener("pointerlockchange", () => {
|
|
if (document.pointerLockElement === canvas) {
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
} else {
|
|
document.removeEventListener("mousemove", onMouseMove);
|
|
}
|
|
});
|
|
}
|
|
|
|
function onMouseMove(event) {
|
|
if (document.pointerLockElement !== renderer.domElement) return;
|
|
const movementX = event.movementX || 0;
|
|
camera.rotation.y -= movementX * 0.002;
|
|
}
|
|
|
|
function onKeyDown(event) {
|
|
switch (event.code) {
|
|
case "KeyW":
|
|
case "ArrowUp":
|
|
player.controls.moveForward = true;
|
|
break;
|
|
case "KeyS":
|
|
case "ArrowDown":
|
|
player.controls.moveBackward = true;
|
|
break;
|
|
case "KeyA":
|
|
case "ArrowLeft":
|
|
player.controls.moveLeft = true;
|
|
break;
|
|
case "KeyD":
|
|
case "ArrowRight":
|
|
player.controls.moveRight = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
function onKeyUp(event) {
|
|
switch (event.code) {
|
|
case "KeyW":
|
|
case "ArrowUp":
|
|
player.controls.moveForward = false;
|
|
break;
|
|
case "KeyS":
|
|
case "ArrowDown":
|
|
player.controls.moveBackward = false;
|
|
break;
|
|
case "KeyA":
|
|
case "ArrowLeft":
|
|
player.controls.moveLeft = false;
|
|
break;
|
|
case "KeyD":
|
|
case "ArrowRight":
|
|
player.controls.moveRight = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
function updatePlayerPosition() {
|
|
const direction = new THREE.Vector3();
|
|
camera.getWorldDirection(direction);
|
|
|
|
const right = new THREE.Vector3();
|
|
right.crossVectors(camera.up, direction).normalize();
|
|
|
|
player.velocity.set(0, 0, 0);
|
|
|
|
if (player.controls.moveForward) {
|
|
player.velocity.add(direction);
|
|
}
|
|
if (player.controls.moveBackward) {
|
|
player.velocity.sub(direction);
|
|
}
|
|
if (player.controls.moveLeft) {
|
|
player.velocity.add(right);
|
|
}
|
|
if (player.controls.moveRight) {
|
|
player.velocity.sub(right);
|
|
}
|
|
|
|
if (player.velocity.length() > 0) {
|
|
player.velocity.normalize().multiplyScalar(player.speed);
|
|
}
|
|
|
|
// Collision detection
|
|
const collisionMargin = TILE_SIZE / 4;
|
|
let moveX = true,
|
|
moveZ = true;
|
|
|
|
if (isWall(camera.position.x + player.velocity.x * collisionMargin, camera.position.z)) {
|
|
moveX = false;
|
|
}
|
|
if (isWall(camera.position.x, camera.position.z + player.velocity.z * collisionMargin)) {
|
|
moveZ = false;
|
|
}
|
|
|
|
if (moveX) camera.position.x += player.velocity.x;
|
|
if (moveZ) camera.position.z += player.velocity.z;
|
|
}
|
|
|
|
// --- Main Loop ---
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
updatePlayerPosition();
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
// --- Utility ---
|
|
function onWindowResize() {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
}
|
|
|
|
// --- Start everything ---
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|