<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Endless Runner 3D</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Left Side: How to Play -->
<div id="about-section">
<h2>How to Play</h2>
<p><strong>Objective:</strong> Run as far as possible and avoid obstacles!</p>
<p><strong>Controls:</strong></p>
<ul>
<li><strong>A / Left Arrow:</strong> Move Left Lane</li>
<li><strong>D / Right Arrow:</strong> Move Right Lane</li>
</ul>
<p>Speed increases over time. Good luck!</p>
</div>
<!-- Top Right: Score and In-Game Controls -->
<div id="ui-container">
<div id="score">Score: 0</div>
<div id="ingame-buttons">
<button id="replay-button" class="ingame-btn">Replay</button>
<button id="end-game-button" class="ingame-btn">End Game</button>
</div>
</div>
<!-- Center Overlay: Start/Game Over -->
<div id="overlay-screen">
<h1 id="overlay-title">Endless Runner</h1>
<div id="overlay-score"></div>
<button id="start-button">Start Game</button>
<button id="restart-button">Restart</button> <!-- Keep restart for game over state -->
</div>
<!-- Import Three.js -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module" src="script.js"></script>
</body>
</html>
body {
margin: 0;
overflow: hidden; /* Prevent scrollbars */
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* Nicer font */
color: white;
background-color: #222;
}
canvas {
display: block; /* Prevent extra space below canvas */
}
/* Left Side Panel */
#about-section {
position: absolute;
top: 15px;
left: 15px;
width: 200px;
background-color: rgba(0, 0, 0, 0.6);
padding: 15px;
border-radius: 8px;
z-index: 100;
font-size: 14px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
#about-section h2 {
margin-top: 0;
margin-bottom: 10px;
color: #66ccff; /* Light blue title */
font-size: 18px;
text-align: center;
}
#about-section ul {
padding-left: 20px;
}
#about-section li {
margin-bottom: 5px;
}
/* Top Right UI */
#ui-container {
position: absolute;
top: 15px;
right: 15px; /* Position to the right */
z-index: 100;
background-color: rgba(0, 0, 0, 0.6);
padding: 10px 15px;
border-radius: 8px;
text-align: right; /* Align text/buttons to the right */
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
#score {
font-size: 24px;
font-weight: bold;
margin-bottom: 8px; /* Space below score */
color: #ffd700; /* Gold score */
}
#ingame-buttons {
display: none; /* Initially hidden, shown during gameplay */
}
.ingame-btn {
padding: 5px 10px;
font-size: 13px;
cursor: pointer;
background-color: #555;
color: white;
border: none;
border-radius: 4px;
margin-left: 8px; /* Space between buttons */
transition: background-color 0.2s;
}
.ingame-btn:hover {
background-color: #777;
}
/* Center Overlay Screen */
#overlay-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85); /* Darker overlay */
color: white;
display: flex; /* Use flex to center content */
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
z-index: 200; /* Ensure it's on top */
}
#overlay-screen h1 { /* Style for overlay-title */
font-size: 48px;
margin-bottom: 15px;
}
#overlay-score { /* Style for final score / initial prompt */
font-size: 24px;
margin-bottom: 30px;
line-height: 1.5; /* Better spacing for multi-line text */
}
#start-button, #restart-button { /* Style for main buttons */
padding: 15px 30px;
font-size: 20px;
cursor: pointer;
background-color: #4CAF50; /* Green */
color: white;
border: none;
border-radius: 5px;
transition: background-color 0.2s;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
#start-button:hover, #restart-button:hover {
background-color: #45a049;
}
#restart-button {
display: none; /* Initially hidden, shown only on Game Over */
}
import * as THREE from 'three';
// --- Basic Setup ---
let scene, camera, renderer;
let clock = new THREE.Clock();
// --- Game Elements ---
let player;
let groundSegments = [];
let obstacles = [];
const groundSegmentLength = 50;
const groundWidth = 10;
const numInitialSegments = 10;
// --- Game State & Controls ---
let gameState = 'waiting'; // waiting, playing, gameOver
let score = 0;
let playerSpeed = 15.0; // Units per second
let acceleration = 0.1; // Speed increase per second
let targetLane = 0; // -1: left, 0: middle, 1: right
const laneWidth = 3;
const laneSwitchSpeed = 15.0;
// --- Obstacle Config ---
let nextObstacleSpawnZ = -groundSegmentLength; // Start spawning obstacles early
const obstacleSpawnDistance = 15; // Min distance between obstacles
const obstacleTypes = [
{ width: 1, height: 1, depth: 1, color: 0xff0000 }, // Small cube
{ width: laneWidth * 0.8, height: 2, depth: 1, color: 0xff8800 }, // Wall segment
{ width: 1, height: 3, depth: 1, color: 0xffff00 }, // Tall pole
];
// --- UI Elements ---
let scoreElement;
let overlayScreen;
let overlayTitleElement;
let overlayScoreElement;
let startButton;
let restartButton;
let replayButton; // In-game replay
let endGameButton; // In-game end
let ingameButtonsContainer;
init();
// animate() is called after init
function init() {
// --- Scene, Camera, Renderer, Lighting (Same as before) ---
scene = new THREE.Scene();
scene.background = new THREE.Color(0x4488cc); // Blue sky
scene.fog = new THREE.Fog(0x4488cc, groundSegmentLength * 2, groundSegmentLength * numInitialSegments * 0.7);
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 10);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(10, 20, 5);
directionalLight.castShadow = true;
// ... (shadow map settings same as before)
scene.add(directionalLight);
// --- Player (Same as before) ---
const playerGeometry = new THREE.SphereGeometry(0.5, 32, 16);
const playerMaterial = new THREE.MeshStandardMaterial({ color: 0x00ff00 }); // Green sphere
player = new THREE.Mesh(playerGeometry, playerMaterial);
player.position.set(0, 0.5, 0);
player.castShadow = true;
scene.add(player);
// --- Ground (Same as before - createInitialGround is called later) ---
// createInitialGround(); // Moved to showStartScreen
// --- Get UI Elements ---
scoreElement = document.getElementById('score');
overlayScreen = document.getElementById('overlay-screen');
overlayTitleElement = document.getElementById('overlay-title');
overlayScoreElement = document.getElementById('overlay-score');
startButton = document.getElementById('start-button');
restartButton = document.getElementById('restart-button');
replayButton = document.getElementById('replay-button');
endGameButton = document.getElementById('end-game-button');
ingameButtonsContainer = document.getElementById('ingame-buttons');
// --- Event Listeners ---
window.addEventListener('resize', onWindowResize);
document.addEventListener('keydown', onKeyDown);
startButton.addEventListener('click', startGame);
restartButton.addEventListener('click', startGame); // Restart also calls startGame
replayButton.addEventListener('click', startGame); // Replay also calls startGame
endGameButton.addEventListener('click', gameOver); // End game calls gameOver
// --- Initial State ---
showStartScreen();
createInitialGround(); // Create ground geometry even before start
animate(); // Start the animation loop
}
function showStartScreen() {
gameState = 'waiting';
overlayScreen.style.display = 'flex';
overlayTitleElement.innerText = "Endless Runner";
overlayScoreElement.innerHTML = ""; // Clear score area
startButton.style.display = 'block'; // Show start button
restartButton.style.display = 'none'; // Hide restart button
ingameButtonsContainer.style.display = 'none'; // Hide in-game buttons
score = 0; // Reset score display variable too
scoreElement.innerText = `Score: 0`;
playerSpeed = 15.0; // Reset speed for next potential start
// Reset player position visually for the start screen
if (player) player.position.set(0, 0.5, 0);
if(camera) camera.position.set(0, 5, 10);
if (player) camera.lookAt(player.position);
clock.stop(); // Stop clock if running
}
function startGame() {
console.log("Starting game...");
// Reset game state variables
score = 0;
playerSpeed = 15.0; // Reset speed
targetLane = 0;
nextObstacleSpawnZ = -groundSegmentLength / 2; // Reset spawn point
// Reset player position
player.position.set(0, 0.5, 0);
player.visible = true; // Ensure player is visible
// Reset camera position smoothly (optional, snapping is fine too)
camera.position.set(0, 5, 10);
camera.lookAt(player.position); // Look at player start pos
// Reset obstacles
obstacles.forEach(obstacle => scene.remove(obstacle));
obstacles = [];
// Reset ground (important if obstacles modify it or if segments vary)
groundSegments.forEach(segment => scene.remove(segment));
groundSegments = [];
createInitialGround();
// Update UI for playing state
overlayScreen.style.display = 'none'; // Hide overlay
startButton.style.display = 'none';
restartButton.style.display = 'none';
ingameButtonsContainer.style.display = 'block'; // Show in-game buttons
scoreElement.innerText = `Score: 0`;
gameState = 'playing'; // Set state to playing
clock.start(); // Start/restart the clock
}
// --- createInitialGround, createGroundSegment (remain the same as before) ---
function createInitialGround() {
for (let i = 0; i < numInitialSegments; i++) {
createGroundSegment(i * -groundSegmentLength); // Create segments extending backwards
}
}
function createGroundSegment(zPos) {
const groundGeometry = new THREE.PlaneGeometry(groundWidth, groundSegmentLength);
const groundMaterial = new THREE.MeshStandardMaterial({
color: Math.random() > 0.5 ? 0x555555 : 0x5a5a5a,
side: THREE.DoubleSide
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.set(0, 0, zPos - groundSegmentLength / 2);
ground.receiveShadow = true;
scene.add(ground);
groundSegments.push(ground);
}
// --- spawnObstacle (remains the same as before) ---
function spawnObstacle(zPos) {
const type = obstacleTypes[Math.floor(Math.random() * obstacleTypes.length)];
const geometry = new THREE.BoxGeometry(type.width, type.height, type.depth);
const material = new THREE.MeshStandardMaterial({ color: type.color });
const obstacle = new THREE.Mesh(geometry, material);
const lane = Math.floor(Math.random() * 3) - 1; // -1, 0, or 1
obstacle.position.set(lane * laneWidth, type.height / 2, zPos);
obstacle.castShadow = true;
obstacle.receiveShadow = true;
scene.add(obstacle);
obstacles.push(obstacle);
}
function gameOver() {
// Prevent multiple calls if already game over
if (gameState === 'gameOver') return;
console.log("Game Over!");
gameState = 'gameOver';
clock.stop(); // Stop the clock
// Update UI for game over state
overlayScreen.style.display = 'flex'; // Show overlay
overlayTitleElement.innerText = "Game Over!";
overlayScoreElement.innerText = `Final Score: ${Math.floor(score)}`;
startButton.style.display = 'none'; // Hide start button
restartButton.style.display = 'block'; // Show restart button
ingameButtonsContainer.style.display = 'none'; // Hide in-game buttons
// Optional: Hide player or make semi-transparent
// player.visible = false;
}
// --- onWindowResize, onKeyDown (remain the same, but removed Space check for start) ---
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function onKeyDown(event) {
// Removed Space check for starting game
if (gameState !== 'playing') return;
switch (event.code) {
case 'KeyA':
case 'ArrowLeft':
targetLane = Math.max(-1, targetLane - 1);
break;
case 'KeyD':
case 'ArrowRight':
targetLane = Math.min(1, targetLane + 1);
break;
}
}
// --- updatePlayer (remains the same) ---
function updatePlayer(delta) {
const targetX = targetLane * laneWidth;
const diffX = targetX - player.position.x;
player.position.x += diffX * laneSwitchSpeed * delta;
}
// --- updateWorld (remains the same) ---
function updateWorld(delta) {
const distanceMoved = playerSpeed * delta;
player.position.z -= distanceMoved; // Keep player moving conceptually "forward" into negative Z
groundSegments.forEach(segment => { segment.position.z += distanceMoved; });
obstacles.forEach(obstacle => { obstacle.position.z += distanceMoved; });
score += distanceMoved;
// Manage ground segments
const firstSegment = groundSegments[0];
if (firstSegment && firstSegment.position.z - groundSegmentLength / 2 > camera.position.z + 10) {
scene.remove(firstSegment);
groundSegments.shift();
const lastSegmentZ = groundSegments.length > 0 ? groundSegments[groundSegments.length - 1].position.z : player.position.z;
createGroundSegment(lastSegmentZ - groundSegmentLength);
}
// Manage obstacles
obstacles = obstacles.filter(obstacle => {
if (obstacle.position.z > camera.position.z + 10) {
scene.remove(obstacle);
return false;
}
return true;
});
// Spawn new obstacles
const spawnTriggerZ = player.position.z - groundSegmentLength * 1.5; // Adjust spawn distance ahead
if (nextObstacleSpawnZ > spawnTriggerZ) {
// Ensure obstacles don't spawn too close to current ones
let safeToSpawn = true;
for(const obs of obstacles) {
if (Math.abs(obs.position.z - nextObstacleSpawnZ) < obstacleSpawnDistance / 2) {
safeToSpawn = false;
break;
}
}
if (safeToSpawn) {
spawnObstacle(nextObstacleSpawnZ);
nextObstacleSpawnZ -= obstacleSpawnDistance * (0.8 + Math.random() * 0.4); // Smaller random factor
} else {
// If too close, try spawning a bit further next frame
nextObstacleSpawnZ -= 5;
}
}
// Increase speed
playerSpeed += acceleration * delta;
}
// --- checkCollisions (remains the same) ---
function checkCollisions() {
const playerCollider = new THREE.Box3().setFromObject(player);
playerCollider.expandByScalar(-0.1); // Make slightly smaller
for (const obstacle of obstacles) {
// Only check obstacles that are close to the player on Z axis
if (Math.abs(obstacle.position.z - player.position.z) < 1.0) {
const obstacleCollider = new THREE.Box3().setFromObject(obstacle);
if (playerCollider.intersectsBox(obstacleCollider)) {
gameOver();
break;
}
}
}
}
// --- updateCamera (remains the same) ---
function updateCamera(delta) {
const targetCamPos = new THREE.Vector3(
player.position.x * 0.5,
camera.position.y,
player.position.z + 10
);
camera.position.lerp(targetCamPos, 0.1);
camera.lookAt(player.position.x, player.position.y, player.position.z - 5);
}
// --- animate (remains largely the same, checks gameState) ---
function animate() {
requestAnimationFrame(animate);
// Only get delta if clock is running (playing state)
const delta = (gameState === 'playing') ? clock.getDelta() : 0;
if (gameState === 'playing') {
updatePlayer(delta);
updateWorld(delta);
checkCollisions(); // Check collisions AFTER movement
updateCamera(delta);
// Update Score UI
scoreElement.innerText = `Score: ${Math.floor(score)}`;
} else {
// Optional: Could add idle animations or effects for 'waiting' or 'gameOver' states here
}
renderer.render(scene, camera);
}
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.