<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wow Crossy Road - Three.js</title>
<!-- Link External CSS -->
<link rel="stylesheet" href="style.css">
<!-- Google Font -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@700&display=swap" rel="stylesheet">
</head>
<body>
<!-- UI Elements -->
<div id="ui-container">
<div id="info">
Score: <span id="score">0</span>
<br> <!-- New line for coins -->
Coins: <span id="coins">0</span>
</div>
<div id="game-over" style="display: none;"> <!-- Use style initially -->
<h2>Game Over!</h2>
<p>Final Score: <span id="final-score">0</span></p>
<p>Total Coins: <span id="final-coins">0</span></p>
<button id="restart-button">Restart</button>
</div>
</div>
<!-- Three.js canvas will be added here by script -->
<!-- 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="game.js"></script>
</body>
</html>
body {
margin: 0;
overflow: hidden;
background-color: #51a7f9; /* Lighter sky blue */
font-family: 'Nunito', sans-serif; /* Apply custom font */
color: #fff; /* Default text color */
}
canvas {
display: block;
}
#ui-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* Allow clicks to pass through to the canvas */
display: flex;
flex-direction: column;
align-items: center; /* Center game over screen */
}
#info {
position: absolute;
top: 15px;
left: 15px;
font-size: 1.5em; /* Larger score */
background-color: rgba(0, 40, 80, 0.6); /* Darker, semi-transparent */
padding: 10px 15px;
border-radius: 8px;
line-height: 1.4; /* Spacing for score/coins */
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
pointer-events: auto; /* Allow interaction if needed later */
z-index: 10; /* Above canvas */
}
#game-over {
margin-top: 25vh; /* Position lower than exact center */
background-color: rgba(255, 255, 255, 0.9);
color: #333; /* Dark text on light background */
padding: 30px 40px;
border-radius: 15px;
text-align: center;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
pointer-events: auto; /* Allow button clicks */
z-index: 10;
}
#game-over h2 {
color: #e74c3c; /* Reddish color for heading */
margin-bottom: 15px;
font-size: 2.5em;
}
#game-over p {
font-size: 1.2em;
margin: 10px 0;
}
#game-over button {
font-family: 'Nunito', sans-serif;
font-size: 1.1em;
padding: 12px 25px;
margin-top: 20px;
cursor: pointer;
background-color: #2ecc71; /* Green */
color: white;
border: none;
border-radius: 8px;
transition: background-color 0.2s ease, transform 0.1s ease;
box-shadow: 0 3px 5px rgba(0,0,0,0.2);
}
#game-over button:hover {
background-color: #27ae60; /* Darker green */
}
#game-over button:active {
transform: translateY(1px);
box-shadow: 0 2px 3px rgba(0,0,0,0.2);
}
import * as THREE from "https://esm.sh/three";
// --- Basic Setup ---
const scene = new THREE.Scene();
const FOG_COLOR = 0x6ab0de; // Slightly desaturated blue
scene.background = new THREE.Color(FOG_COLOR); // Match fog color
scene.fog = new THREE.Fog(FOG_COLOR, 10, 25); // Add fog for depth
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Softer shadows
document.body.appendChild(renderer.domElement);
// --- Camera ---
const aspectRatio = window.innerWidth / window.innerHeight;
const cameraWidth = 15;
const cameraHeight = cameraWidth / aspectRatio;
const camera = new THREE.OrthographicCamera(
cameraWidth / -2, cameraWidth / 2, cameraHeight / 2, cameraHeight / -2, 0.1, 100
);
camera.position.set(4, 5, 5); // Slightly higher angle
camera.lookAt(0, 0, 0);
// --- Lighting ---
// Use HemisphereLight for softer ambient light
const hemisphereLight = new THREE.HemisphereLight(0xB1E1FF, 0xB97A20, 0.6); // Sky, Ground, Intensity
scene.add(hemisphereLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 0.8); // Main sun light
sunLight.position.set(8, 12, 8); // Adjust position
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 2048; // Higher res shadows
sunLight.shadow.mapSize.height = 2048;
sunLight.shadow.camera.near = 0.5;
sunLight.shadow.camera.far = 50;
sunLight.shadow.camera.left = -cameraWidth; // Match camera view bounds roughly
sunLight.shadow.camera.right = cameraWidth;
sunLight.shadow.camera.top = cameraHeight * 2; // Allow shadows from higher up
sunLight.shadow.camera.bottom = -cameraHeight * 2;
sunLight.shadow.bias = -0.001; // Adjust to prevent shadow artifacts
scene.add(sunLight);
scene.add(sunLight.target);
// --- Texture Loading ---
const textureLoader = new THREE.TextureLoader();
const textures = {
grass: textureLoader.load('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/grasslight-big.jpg'),
road: textureLoader.load('https://cdn.jsdelivrnet.gh/mrdoob/three.js/examples/textures/terrain/brick_roughness.jpg'), // Placeholder road
water: textureLoader.load('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/waternormals.jpg'),
log: textureLoader.load('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/hardwood2_diffuse.jpg'),
chicken: textureLoader.load('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/crate.gif'), // Placeholder chicken
car_side: textureLoader.load('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/brick_diffuse.jpg') // Placeholder car
};
// Texture settings
for (const key in textures) {
textures[key].wrapS = THREE.RepeatWrapping;
textures[key].wrapT = THREE.RepeatWrapping;
if (key === 'grass') {
textures[key].repeat.set(3, 3); // Repeat grass texture more
}
if (key === 'road') {
textures[key].repeat.set(5, 1); // Repeat road texture horizontally
}
if (key === 'log') {
textures[key].repeat.set(1, 0.5);
}
if (key === 'water') {
textures[key].repeat.set(2, 2); // Animate water later maybe
}
}
// --- Materials (Using textures) ---
const materials = {
grass: new THREE.MeshLambertMaterial({ map: textures.grass }),
road: new THREE.MeshLambertMaterial({ map: textures.road, color: 0x555555 }), // Darken road base color
water: new THREE.MeshLambertMaterial({ map: textures.water, opacity: 0.8, transparent: true, color: 0x55aaff }), // Make water transparent
log: new THREE.MeshLambertMaterial({ map: textures.log }),
chicken: new THREE.MeshLambertMaterial({ map: textures.chicken }),
coin: new THREE.MeshStandardMaterial({ color: 0xffd700, metalness: 0.5, roughness: 0.6 }), // Shiny gold
car: [ // Array for multi-material cars
new THREE.MeshLambertMaterial({ map: textures.car_side, color: 0xdddddd }), // Sides (placeholder)
new THREE.MeshLambertMaterial({ map: textures.car_side, color: 0xcccccc }), // Top/Bottom (placeholder)
new THREE.MeshLambertMaterial({ color: 0x333333 }), // Wheels (dark grey)
],
carColors: [0xff4444, 0x4444ff, 0x44ff44, 0xffff44, 0xff44ff] // Base colors to tint car materials
};
// --- Constants ---
const TILE_SIZE = 1;
const LANE_WIDTH = 15;
const MOVE_DURATION = 0.15;
const OBSTACLE_BASE_SPEED = 2.5; // Slightly faster base speed
const LANE_TYPES = ['grass', 'road', 'water'];
const COIN_CHANCE = 0.2; // Chance for a coin to spawn on a grass tile
// --- Game State ---
let score = 0;
let coins = 0; // Coin counter
let currentLaneIndex = 0;
let playerX = 0;
let isMoving = false;
let moveTimer = 0;
let isGameOver = false;
let activeLanes = {};
let activeCoins = {}; // { 'x_z': coinMesh }
let playerStartPos = new THREE.Vector3();
let playerTargetPos = new THREE.Vector3();
let cameraTargetPos = new THREE.Vector3();
const clock = new THREE.Clock();
let playerAttachedLog = null;
let lastLogSpeed = 0;
// --- DOM Elements ---
const scoreElement = document.getElementById('score');
const coinsElement = document.getElementById('coins'); // Coin UI element
const gameOverElement = document.getElementById('game-over');
const restartButton = document.getElementById('restart-button');
const finalScoreElement = document.getElementById('final-score');
const finalCoinsElement = document.getElementById('final-coins');
// --- Player (Chicken - Slightly more shape) ---
const chickenBodyGeo = new THREE.BoxGeometry(TILE_SIZE * 0.5, TILE_SIZE * 0.5, TILE_SIZE * 0.5);
const chickenHeadGeo = new THREE.BoxGeometry(TILE_SIZE * 0.25, TILE_SIZE * 0.25, TILE_SIZE * 0.25);
const chickenBeakGeo = new THREE.ConeGeometry(TILE_SIZE * 0.1, TILE_SIZE * 0.15, 4);
const chickenBody = new THREE.Mesh(chickenBodyGeo, materials.chicken);
const chickenHead = new THREE.Mesh(chickenHeadGeo, materials.chicken);
const chickenBeak = new THREE.Mesh(chickenBeakGeo, new THREE.MeshLambertMaterial({color: 0xffaa00}));
chickenBody.position.y = TILE_SIZE * 0.25; // Center body part
chickenHead.position.y = TILE_SIZE * 0.55; // Head above body
chickenHead.position.z = -TILE_SIZE * 0.1; // Head slightly forward
chickenBeak.position.y = TILE_SIZE * 0.55;
chickenBeak.position.z = -TILE_SIZE * 0.25; // Beak forward
chickenBeak.rotation.x = Math.PI / 2;
const chicken = new THREE.Group(); // Use a Group to hold chicken parts
chicken.add(chickenBody);
chicken.add(chickenHead);
chicken.add(chickenBeak);
chicken.position.y = TILE_SIZE * 0.25; // Base of the chicken group sits on the ground
chicken.castShadow = true; // Group doesn't cast shadow, apply to meshes if needed individually later
chickenBody.castShadow = true;
chickenHead.castShadow = true;
playerTargetPos.copy(chicken.position);
playerStartPos.copy(chicken.position);
scene.add(chicken);
// --- World Generation ---
const visibleLanes = 18; // See a bit further
const initialLanes = 6;
function generateInitialWorld() {
// Start with more grass
for (let i = -initialLanes; i <= initialLanes; i++) {
createLane(i, i <= 1 ? 'grass' : getRandomLaneType()); // More initial grass
}
updateCameraTarget();
camera.position.z = cameraTargetPos.z;
camera.position.x = cameraTargetPos.x;
camera.position.y = cameraTargetPos.y + 1; // Start slightly higher
}
function getRandomLaneType() {
return LANE_TYPES[Math.floor(Math.random() * LANE_TYPES.length)];
}
function createLane(index, type = getRandomLaneType()) {
if (activeLanes[index]) return;
const positionZ = index * TILE_SIZE;
let laneMaterial;
if (type === 'grass') laneMaterial = materials.grass;
else if (type === 'road') laneMaterial = materials.road;
else if (type === 'water') laneMaterial = materials.water;
else laneMaterial = materials.grass; // Default fallback
const laneGeometry = new THREE.BoxGeometry(LANE_WIDTH, TILE_SIZE * 0.2, TILE_SIZE);
const laneMesh = new THREE.Mesh(laneGeometry, laneMaterial);
laneMesh.position.set(0, -TILE_SIZE * 0.1, positionZ);
laneMesh.receiveShadow = true;
scene.add(laneMesh);
const laneData = { type, mesh: laneMesh, obstacles: [] };
activeLanes[index] = laneData;
const direction = Math.random() < 0.5 ? 1 : -1;
const speedVariation = (Math.random() * 0.6 + 0.7); // 0.7x to 1.3x base speed
const speed = OBSTACLE_BASE_SPEED * speedVariation * direction;
if (type === 'road') {
const numCars = Math.floor(Math.random() * 3) + 1;
for (let i = 0; i < numCars; i++) {
createCar(laneData, positionZ, direction, speed);
}
} else if (type === 'water') {
const numLogs = Math.floor(Math.random() * 2) + 2;
for (let i = 0; i < numLogs; i++) {
createLog(laneData, positionZ, direction, speed);
}
} else if (type === 'grass') {
// Maybe spawn coins?
for(let x = -Math.floor(LANE_WIDTH/2) + 1; x < Math.floor(LANE_WIDTH/2); x++) {
if (Math.random() < COIN_CHANCE) {
createCoin(x * TILE_SIZE, positionZ);
}
}
}
return laneData;
}
function createCar(laneData, positionZ, direction, speed) {
// Simple car shape using multiple boxes
const carBodyWidth = TILE_SIZE * (Math.random() * 1.0 + 1.0); // 1 to 2
const carBodyHeight = TILE_SIZE * 0.4;
const carBodyDepth = TILE_SIZE * 0.8;
const wheelRadius = TILE_SIZE * 0.15;
const wheelDepth = TILE_SIZE * 0.1;
// Clone base materials and apply random color tint
const bodyMaterial = materials.car[0].clone();
const topMaterial = materials.car[1].clone();
bodyMaterial.color.set(materials.carColors[Math.floor(Math.random() * materials.carColors.length)]);
topMaterial.color.copy(bodyMaterial.color).multiplyScalar(0.8); // Slightly darker top
const car = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(carBodyWidth, carBodyHeight, carBodyDepth), bodyMaterial);
body.position.y = wheelRadius + carBodyHeight / 2; // Body above wheels
body.castShadow = true;
car.add(body);
// Add simple wheels (cylinders)
const wheelGeo = new THREE.CylinderGeometry(wheelRadius, wheelRadius, wheelDepth, 16);
const wheelMat = materials.car[2]; // Dark wheel material
const wheelPositions = [
{ x: carBodyWidth / 2 - wheelRadius*1.2, y: wheelRadius, z: carBodyDepth / 2 - wheelRadius },
{ x: -carBodyWidth / 2 + wheelRadius*1.2, y: wheelRadius, z: carBodyDepth / 2 - wheelRadius },
{ x: carBodyWidth / 2 - wheelRadius*1.2, y: wheelRadius, z: -carBodyDepth / 2 + wheelRadius },
{ x: -carBodyWidth / 2 + wheelRadius*1.2, y: wheelRadius, z: -carBodyDepth / 2 + wheelRadius },
];
wheelPositions.forEach(pos => {
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
wheel.rotation.z = Math.PI / 2; // Orient cylinder horizontally
wheel.position.set(pos.x, pos.y, pos.z);
wheel.castShadow = true;
car.add(wheel);
});
const startOffset = (laneData.obstacles.length * (LANE_WIDTH / 2.5)) % LANE_WIDTH;
const initialX = (direction > 0 ? -LANE_WIDTH / 1.5 : LANE_WIDTH / 1.5) + startOffset;
car.position.set(initialX, 0, positionZ); // Base of the group at y=0
const obstacleData = { mesh: car, type: 'car', speed, direction, width: carBodyWidth };
laneData.obstacles.push(obstacleData);
scene.add(car);
}
function createLog(laneData, positionZ, direction, speed) {
// Rounded box for log shape
const logWidth = TILE_SIZE * (Math.random() * 2.5 + 2.0); // 2.0 to 4.5
const logHeight = TILE_SIZE * 0.18;
const logDepth = TILE_SIZE * 0.9;
// Use BoxGeometry and apply texture
const logGeometry = new THREE.BoxGeometry(logWidth, logHeight, logDepth);
const log = new THREE.Mesh(logGeometry, materials.log);
log.castShadow = true;
log.receiveShadow = true;
const startOffset = (laneData.obstacles.length * (LANE_WIDTH / 2.0)) % LANE_WIDTH;
const initialX = (direction > 0 ? -LANE_WIDTH / 1.5 : LANE_WIDTH / 1.5) + startOffset;
// Position log slightly lower so player lands *on* it visually
log.position.set(initialX, logHeight / 2 - TILE_SIZE * 0.12, positionZ);
const obstacleData = { mesh: log, type: 'log', speed, direction, width: logWidth };
laneData.obstacles.push(obstacleData);
scene.add(log);
}
function createCoin(positionX, positionZ) {
const coinSize = TILE_SIZE * 0.3;
const coinThickness = TILE_SIZE * 0.08;
const coinGeometry = new THREE.CylinderGeometry(coinSize, coinSize, coinThickness, 16);
const coin = new THREE.Mesh(coinGeometry, materials.coin);
coin.position.set(positionX, coinSize + coinThickness, positionZ); // Position above ground
coin.rotation.x = Math.PI / 2; // Lay flat initially
coin.castShadow = true;
const key = `${positionX}_${positionZ}`;
activeCoins[key] = coin;
scene.add(coin);
}
function manageLanes() {
const playerLogicZ = Math.round(chicken.position.z / TILE_SIZE);
const forwardLaneIndex = playerLogicZ + Math.floor(visibleLanes / 2);
if (!activeLanes[forwardLaneIndex]) {
createLane(forwardLaneIndex);
}
const backwardLaneIndex = playerLogicZ - Math.ceil(visibleLanes / 2);
if (activeLanes[backwardLaneIndex]) {
const laneToRemove = activeLanes[backwardLaneIndex];
if (laneToRemove) {
laneToRemove.obstacles.forEach(obstacle => scene.remove(obstacle.mesh));
scene.remove(laneToRemove.mesh);
delete activeLanes[backwardLaneIndex];
// Also remove coins from that lane
for (const key in activeCoins) {
if (Math.round(activeCoins[key].position.z / TILE_SIZE) === backwardLaneIndex) {
scene.remove(activeCoins[key]);
delete activeCoins[key];
}
}
}
}
}
// --- Movement & Input (handleKeyDown remains similar) ---
document.addEventListener('keydown', handleKeyDown);
function handleKeyDown(event) {
if (isMoving || isGameOver) return;
let moveDirection = null;
let targetX = playerX;
let targetZIndex = currentLaneIndex;
switch (event.key) {
case 'ArrowUp': case 'w': targetZIndex++; moveDirection = 'forward'; break;
case 'ArrowDown': case 's': if (currentLaneIndex > -initialLanes + 1) { targetZIndex--; moveDirection = 'backward'; } break;
case 'ArrowLeft': case 'a': targetX--; moveDirection = 'left'; break;
case 'ArrowRight': case 'd': targetX++; moveDirection = 'right'; break;
}
if (moveDirection) {
if(playerAttachedLog) {
// Detach player visually slightly before jump
chicken.position.y = TILE_SIZE * 0.25;
lastLogSpeed = 0;
playerAttachedLog = null;
}
// --- continue handleKeyDown ---
playerStartPos.copy(chicken.position);
// Adjust target Y based on whether landing on water (aim for log height)
const targetLane = activeLanes[targetZIndex];
const targetY = (targetLane && targetLane.type === 'water') ? (TILE_SIZE * 0.1) : (TILE_SIZE * 0.25); // Lower target Y for water lanes
playerTargetPos.set(targetX * TILE_SIZE, targetY, targetZIndex * TILE_SIZE);
isMoving = true;
moveTimer = 0;
// Update logical positions immediately
if (moveDirection === 'forward' && targetZIndex > currentLaneIndex) {
score++;
scoreElement.textContent = score;
}
currentLaneIndex = targetZIndex;
playerX = targetX;
manageLanes();
updateCameraTarget();
}
}
// --- Update Functions ---
function updatePlayerPosition(delta) {
// --- Handle Log Riding ---
if (!isMoving && playerAttachedLog && !isGameOver) {
// Apply log speed from last frame
const logDeltaX = lastLogSpeed * delta;
chicken.position.x += logDeltaX;
// Ensure player doesn't slide off the sides of the log
const logBox = new THREE.Box3().setFromObject(playerAttachedLog.mesh);
chicken.position.x = Math.max(logBox.min.x + TILE_SIZE * 0.2, Math.min(logBox.max.x - TILE_SIZE * 0.2, chicken.position.x));
playerStartPos.copy(chicken.position); // Update start for potential next jump
playerTargetPos.copy(chicken.position); // Keep target aligned while riding
// Keep player visually ON the log
chicken.position.y = playerAttachedLog.mesh.position.y + TILE_SIZE * 0.25 + (TILE_SIZE * 0.18 / 2); // log center Y + chicken height offset + half log height
} else if (!isMoving && !isGameOver) {
// If not moving and not on log, ensure player is at standard ground height
chicken.position.y = TILE_SIZE * 0.25;
}
// Update lastLogSpeed for next frame AFTER applying current movement
if (playerAttachedLog) {
lastLogSpeed = playerAttachedLog.speed;
} else {
lastLogSpeed = 0;
}
// --- Handle Jumping Animation ---
if (!isMoving) return;
moveTimer += delta;
let t = Math.min(moveTimer / MOVE_DURATION, 1.0);
// Ease out cubic easing function: 1 - Math.pow(1 - t, 3)
const easedT = 1 - Math.pow(1 - t, 3);
chicken.position.lerpVectors(playerStartPos, playerTargetPos, easedT);
// More pronounced hop animation
const hopHeight = TILE_SIZE * 0.4;
chicken.position.y = playerStartPos.y + Math.sin(t * Math.PI) * hopHeight;
// Ensure hop respects target Y for water landings
if (t > 0.5) { // On the way down
const landingY = playerTargetPos.y; // Target Y already adjusted for water/ground
const peakY = playerStartPos.y + hopHeight;
const descentT = (t - 0.5) * 2; // Normalize descent phase (0 to 1)
chicken.position.y = peakY + (landingY - peakY) * (1 - Math.pow(1 - descentT, 2)); // Ease in descent
}
// --- Movement Finished ---
if (t >= 1.0) {
isMoving = false;
moveTimer = 0;
chicken.position.y = playerTargetPos.y; // Snap to final target Y
// Check for coin collection AT destination
checkCoinCollection();
// Final check for landing situation (water, road hazards)
checkLanding();
}
}
function updateCameraTarget() {
// Camera follows player's X, stays offset Z and Y
cameraTargetPos.x = chicken.position.x; // Follow X more directly for smoother feel
cameraTargetPos.z = chicken.position.z + 5; // Keep camera Z offset from player Z
cameraTargetPos.y = 5; // Keep camera height fixed
}
function updateCameraPosition(delta) {
// Smoother camera lerp
const lerpFactor = 1.0 - Math.exp(-delta * 4.0); // Adjust multiplier for speed (4.0 is smoother)
camera.position.lerp(cameraTargetPos, lerpFactor);
// Look slightly down towards where the player is going
const lookAtPos = chicken.position.clone();
lookAtPos.y -= 1.0; // Look lower than the chicken's feet
camera.lookAt(lookAtPos);
}
function updateObstacles(delta) {
const wrapMargin = TILE_SIZE * 3; // Increase margin for larger/faster objects
const screenBounds = {
left: -LANE_WIDTH / 2 - wrapMargin,
right: LANE_WIDTH / 2 + wrapMargin
};
// Animate Coins
for (const key in activeCoins) {
activeCoins[key].rotation.z += delta * 2; // Rotate coin around its cylinder axis
activeCoins[key].position.y = TILE_SIZE * 0.4 + Math.sin(clock.elapsedTime * 3 + activeCoins[key].position.x) * 0.1; // Bobbing effect
}
// Animate Obstacles
for (const laneIndex in activeLanes) {
const lane = activeLanes[laneIndex];
lane.obstacles.forEach(obstacle => {
obstacle.mesh.position.x += obstacle.speed * delta;
// Rotate car wheels if it's a car
if (obstacle.type === 'car') {
const wheelRotation = -obstacle.speed * delta * 25; // Adjust multiplier for realistic speed
obstacle.mesh.children.forEach(child => {
// Crude check if it's a wheel based on geometry type
if (child.geometry instanceof THREE.CylinderGeometry) {
child.rotation.y += wheelRotation;
}
});
}
// Wrapping logic
const objRightEdge = obstacle.mesh.position.x + obstacle.width / 2;
const objLeftEdge = obstacle.mesh.position.x - obstacle.width / 2;
if (obstacle.direction > 0 && objLeftEdge > screenBounds.right) {
obstacle.mesh.position.x = screenBounds.left - obstacle.width / 2;
} else if (obstacle.direction < 0 && objRightEdge < screenBounds.left) {
obstacle.mesh.position.x = screenBounds.right + obstacle.width / 2;
}
});
}
}
// --- Collision & Collection ---
// Check for coin at player's current rounded position
function checkCoinCollection() {
if (isGameOver) return;
const playerGridX = Math.round(chicken.position.x / TILE_SIZE) * TILE_SIZE;
const playerGridZ = Math.round(chicken.position.z / TILE_SIZE) * TILE_SIZE;
const key = `${playerGridX}_${playerGridZ}`;
if (activeCoins[key]) {
collectCoin(key);
}
}
function collectCoin(key) {
const coinMesh = activeCoins[key];
// TODO: Add particle effect or sound
scene.remove(coinMesh);
delete activeCoins[key];
coins++;
coinsElement.textContent = coins;
// Simple scale animation on score
coinsElement.parentElement.style.transform = 'scale(1.15)';
setTimeout(() => { coinsElement.parentElement.style.transform = 'scale(1)'; }, 150);
}
function checkLanding() {
if (isGameOver) return;
const landingLaneIndex = Math.round(chicken.position.z / TILE_SIZE);
const currentLaneData = activeLanes[landingLaneIndex];
if (!currentLaneData) {
console.error("Landed on non-existent lane:", landingLaneIndex);
gameOver(true); // Assume fell off world = splash
return;
}
playerAttachedLog = null; // Reset attachment by default
lastLogSpeed = 0;
const playerBox = new THREE.Box3().setFromObject(chicken.children[0]); // Use body for collision
playerBox.expandByScalar( - TILE_SIZE * 0.1 ); // Make hitbox slightly smaller than visual
if (currentLaneData.type === 'road') {
for (const obstacle of currentLaneData.obstacles) {
const carBox = new THREE.Box3().setFromObject(obstacle.mesh.children[0]); // Collide with car body
carBox.expandByScalar( - TILE_SIZE * 0.05 ); // Slightly smaller car hitbox
if (playerBox.intersectsBox(carBox)) {
console.log("Hit by car on landing!");
gameOver();
return;
}
}
// Landed safely on road
chicken.position.y = TILE_SIZE * 0.25; // Ensure correct height
} else if (currentLaneData.type === 'water') {
let onLog = false;
for (const obstacle of currentLaneData.obstacles) {
if (obstacle.type !== 'log') continue;
const logBox = new THREE.Box3().setFromObject(obstacle.mesh);
// Check if player center X is within log X bounds, and Z matches
if (chicken.position.x >= logBox.min.x && chicken.position.x <= logBox.max.x) {
onLog = true;
playerAttachedLog = obstacle;
lastLogSpeed = obstacle.speed; // Store speed for next frame
// Snap player Y to be correctly on top of this log
chicken.position.y = obstacle.mesh.position.y + TILE_SIZE * 0.25 + (TILE_SIZE * 0.18 / 2);
console.log("Landed on log");
break;
}
}
if (!onLog) {
console.log("Fell in water on landing!");
gameOver(true); // Splash death
return;
}
} else if (currentLaneData.type === 'grass') {
// Landed safely on grass
chicken.position.y = TILE_SIZE * 0.25; // Ensure correct height
console.log("Landed on grass");
}
}
function checkContinuousCollision() {
if (isGameOver || isMoving || playerAttachedLog) return; // Only check when idle on ground/road
const currentLaneData = activeLanes[currentLaneIndex];
if (!currentLaneData || (currentLaneData.type !== 'road' && currentLaneData.type !== 'grass')) return; // Only roads/grass matter
const playerBox = new THREE.Box3().setFromObject(chicken.children[0]); // Body hitbox
playerBox.expandByScalar( - TILE_SIZE * 0.1 );
for (const obstacle of currentLaneData.obstacles) {
if (obstacle.type !== 'car') continue;
const carBox = new THREE.Box3().setFromObject(obstacle.mesh.children[0]); // Car body hitbox
carBox.expandByScalar( - TILE_SIZE * 0.05 );
if (playerBox.intersectsBox(carBox)) {
console.log("Hit by car while idle!");
gameOver();
return;
}
}
}
// --- Game Over & Restart ---
function gameOver(splash = false) {
if (isGameOver) return;
isGameOver = true;
isMoving = false;
playerAttachedLog = null;
// Update final scores display
finalScoreElement.textContent = score;
finalCoinsElement.textContent = coins;
gameOverElement.style.display = 'flex'; // Use flex for potential centering later
// Add a slight delay before showing the screen for impact
setTimeout(() => {
gameOverElement.style.opacity = '1'; // Fade in effect (needs CSS transition)
}, 100);
// Visual feedback
if (splash) {
// Splash effect - maybe tween scale and position down
const sinkDuration = 0.5;
let elapsed = 0;
const startY = chicken.position.y;
const endY = -TILE_SIZE; // Sink below ground/water level
function sinkAnimation() {
if (!isGameOver) return; // Stop if restarted quickly
elapsed += clock.getDelta();
const t = Math.min(elapsed / sinkDuration, 1.0);
chicken.position.y = startY + (endY - startY) * t;
chicken.rotation.z += 0.1; // Spin while sinking
if (t < 1.0) requestAnimationFrame(sinkAnimation);
}
sinkAnimation();
chickenMaterial.color.set(WATER_COLOR); // Turn blueish
} else {
// Hit effect - quick flatten and grey out
chicken.scale.set(1.2, 0.1, 1.2); // Flatten
chickenMaterial.color.set(0x777777); // Grey out
// Maybe add a tiny shake?
const startX = chicken.position.x;
setTimeout(() => { chicken.position.x = startX + 0.05; }, 50);
setTimeout(() => { chicken.position.x = startX - 0.05; }, 100);
setTimeout(() => { chicken.position.x = startX; }, 150);
}
}
restartButton.addEventListener('click', () => {
// Reset State
score = 0;
coins = 0;
currentLaneIndex = 0;
playerX = 0;
isMoving = false;
isGameOver = false;
playerAttachedLog = null;
lastLogSpeed = 0;
moveTimer = 0;
// Clear World Objects
for (const index in activeLanes) {
if (activeLanes[index]) {
activeLanes[index].obstacles.forEach(o => scene.remove(o.mesh));
scene.remove(activeLanes[index].mesh);
}
}
activeLanes = {};
for (const key in activeCoins) {
scene.remove(activeCoins[key]);
}
activeCoins = {};
// Reset Player
chicken.position.set(0, TILE_SIZE * 0.25, 0);
playerTargetPos.copy(chicken.position);
playerStartPos.copy(chicken.position);
chickenMaterial.color.set(0xFFFFFF); // Restore original color tint if needed
chicken.scale.set(1, 1, 1);
chicken.rotation.set(0,0,0);
// Reset UI
scoreElement.textContent = score;
coinsElement.textContent = coins;
gameOverElement.style.display = 'none';
gameOverElement.style.opacity = '0'; // Hide for fade-in next time
// Regenerate World & Reset Camera
generateInitialWorld();
updateCameraTarget(); // Recalculate target based on reset position
camera.position.set(playerX * TILE_SIZE + 4, 5, currentLaneIndex * TILE_SIZE + 5); // Snap camera near player start
camera.lookAt(playerX * TILE_SIZE, 0, currentLaneIndex * TILE_SIZE);
// Restart clock if needed
clock.start(); // Ensure delta time is correct after potential pause
});
// --- Animation Loop ---
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
// Only update game logic fully if not game over
if (!isGameOver) {
updatePlayerPosition(delta);
checkContinuousCollision();
}
// These can run even during game over for visual effects or world cleanup
// --- continue animate function ---
updateObstacles(delta);
// Keep managing lanes even slightly after game over to clean up distant ones
manageLanes();
// Always update camera for smooth movement and potential game over views
updateCameraPosition(delta);
renderer.render(scene, camera);
}
// --- Handle Window Resize ---
window.addEventListener('resize', () => {
const newAspectRatio = window.innerWidth / window.innerHeight;
const newCameraHeight = cameraWidth / newAspectRatio;
camera.top = newCameraHeight / 2;
camera.bottom = newCameraHeight / -2;
// camera.left and camera.right remain fixed by cameraWidth
camera.updateProjectionMatrix(); // Important after changing camera properties
renderer.setSize(window.innerWidth, window.innerHeight);
});
// --- Initial Setup Call & Start Animation ---
// Create the initial set of lanes around the player start position
generateInitialWorld();
// Start the main game loop
animate();
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.