<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>3D 过马路游戏</title>
<style>
@import url('https://fonts.googleapis.com/css?family=Press+Start+2P');
body {
margin: 0;
display: flex;
font-family: 'Press Start 2P', cursive;
}
#controls {
position: absolute;
bottom: 20px;
min-width: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
}
#controls div {
display: grid;
grid-template-columns: 50px 50px 50px;
gap: 10px;
}
#controls button {
width: 100%;
height: 40px;
background-color: white;
border: 1px solid lightgray;
box-shadow: 3px 5px 0px 0px rgba(0, 0, 0, 0.75);
cursor: pointer;
outline: none;
}
#controls button:first-of-type {
grid-column: 1/-1;
}
#score {
position: absolute;
top: 20px;
left: 20px;
font-size: 2em;
color: white;
}
#result-container {
position: absolute;
min-width: 100%;
min-height: 100%;
top: 0;
display: flex;
align-items: center;
justify-content: center;
visibility: hidden;
#result {
display: flex;
flex-direction: column;
align-items: center;
background-color: white;
padding: 20px;
}
button {
background-color: red;
padding: 20px 50px 20px 50px;
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
}
#youtube,
#youtube-card {
display: none;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: black;
}
@media (min-height: 425px) {
#youtube {
z-index: 50;
width: 100px;
display: block;
height: 70px;
position: fixed;
bottom: 20px;
right: 20px;
background: red;
border-radius: 50% / 11%;
transform: scale(0.8);
transition: transform 0.5s;
}
#youtube:hover,
#youtube:focus {
transform: scale(0.9);
color: black;
}
#youtube::before {
content: '';
display: block;
position: absolute;
top: 7.5%;
left: -6%;
width: 112%;
height: 85%;
background: red;
border-radius: 9% / 50%;
}
#youtube::after {
content: '';
display: block;
position: absolute;
top: 20px;
left: 40px;
width: 45px;
height: 30px;
border: 15px solid transparent;
box-sizing: border-box;
border-left: 30px solid white;
}
#youtube span {
font-size: 0;
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
#youtube:hover + #youtube-card {
z-index: 49;
display: block;
position: fixed;
bottom: 12px;
right: 10px;
padding: 25px 130px 25px 25px;
width: 300px;
background-color: white;
}
}
</style>
</head>
<body>
<canvas class="game"></canvas>
<div id="controls">
<div>
<button id="forward">▲</button>
<button id="left">◀</button>
<button id="backward">▼</button>
<button id="right">▶</button>
</div>
</div>
<div id="score">0</div>
<div id="result-container">
<div id="result">
<h1>Game Over</h1>
<p>Your score: <span id="final-score"></span></p>
<button id="retry">Retry</button>
</div>
</div>
<script type="module">
import * as THREE from 'https://esm.sh/three';
const minTileIndex = -8;
const maxTileIndex = 8;
const tilesPerRow = maxTileIndex - minTileIndex + 1;
const tileSize = 42;
function Camera() {
const size = 300;
const viewRatio = window.innerWidth / window.innerHeight;
const width = viewRatio < 1 ? size : size * viewRatio;
const height = viewRatio < 1 ? size / viewRatio : size;
const camera = new THREE.OrthographicCamera(
width / -2,
width / 2,
height / 2,
height / -2,
100,
900
);
camera.up.set(0, 0, 1);
camera.position.set(300, -300, 300);
camera.lookAt(0, 0, 0);
return camera;
}
function Texture(width, height, rects) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
context.fillStyle = '#ffffff';
context.fillRect(0, 0, width, height);
context.fillStyle = 'rgba(0,0,0,0.6)';
rects.forEach((rect) => {
context.fillRect(rect.x, rect.y, rect.w, rect.h);
});
return new THREE.CanvasTexture(canvas);
}
const carFrontTexture = new Texture(40, 80, [
{ x: 0, y: 10, w: 30, h: 60 },
]);
const carBackTexture = new Texture(40, 80, [
{ x: 10, y: 10, w: 30, h: 60 },
]);
const carRightSideTexture = new Texture(110, 40, [
{ x: 10, y: 0, w: 50, h: 30 },
{ x: 70, y: 0, w: 30, h: 30 },
]);
const carLeftSideTexture = new Texture(110, 40, [
{ x: 10, y: 10, w: 50, h: 30 },
{ x: 70, y: 10, w: 30, h: 30 },
]);
export const truckFrontTexture = Texture(30, 30, [
{ x: 5, y: 0, w: 10, h: 30 },
]);
export const truckRightSideTexture = Texture(25, 30, [
{ x: 15, y: 5, w: 10, h: 10 },
]);
export const truckLeftSideTexture = Texture(25, 30, [
{ x: 15, y: 15, w: 10, h: 10 },
]);
function Car(initialTileIndex, direction, color) {
const car = new THREE.Group();
car.position.x = initialTileIndex * tileSize;
if (!direction) car.rotation.z = Math.PI;
const main = new THREE.Mesh(
new THREE.BoxGeometry(60, 30, 15),
new THREE.MeshLambertMaterial({ color, flatShading: true })
);
main.position.z = 12;
main.castShadow = true;
main.receiveShadow = true;
car.add(main);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(33, 24, 12), [
new THREE.MeshPhongMaterial({
color: 0xcccccc,
flatShading: true,
map: carBackTexture,
}),
new THREE.MeshPhongMaterial({
color: 0xcccccc,
flatShading: true,
map: carFrontTexture,
}),
new THREE.MeshPhongMaterial({
color: 0xcccccc,
flatShading: true,
map: carRightSideTexture,
}),
new THREE.MeshPhongMaterial({
color: 0xcccccc,
flatShading: true,
map: carLeftSideTexture,
}),
new THREE.MeshPhongMaterial({ color: 0xcccccc, flatShading: true }),
new THREE.MeshPhongMaterial({ color: 0xcccccc, flatShading: true }),
]);
cabin.position.x = -6;
cabin.position.z = 25.5;
cabin.castShadow = true;
cabin.receiveShadow = true;
car.add(cabin);
const frontWheel = Wheel(18);
car.add(frontWheel);
const backWheel = Wheel(-18);
car.add(backWheel);
return car;
}
function DirectionalLight() {
const dirLight = new THREE.DirectionalLight();
dirLight.position.set(-100, -100, 200);
dirLight.up.set(0, 0, 1);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.up.set(0, 0, 1);
dirLight.shadow.camera.left = -400;
dirLight.shadow.camera.right = 400;
dirLight.shadow.camera.top = 400;
dirLight.shadow.camera.bottom = -400;
dirLight.shadow.camera.near = 50;
dirLight.shadow.camera.far = 400;
return dirLight;
}
function Grass(rowIndex) {
const grass = new THREE.Group();
grass.position.y = rowIndex * tileSize;
const createSection = (color) =>
new THREE.Mesh(
new THREE.BoxGeometry(tilesPerRow * tileSize, tileSize, 3),
new THREE.MeshLambertMaterial({ color })
);
const middle = createSection(0xbaf455);
middle.receiveShadow = true;
grass.add(middle);
const left = createSection(0x99c846);
left.position.x = -tilesPerRow * tileSize;
grass.add(left);
const right = createSection(0x99c846);
right.position.x = tilesPerRow * tileSize;
grass.add(right);
return grass;
}
const metadata = [];
const map = new THREE.Group();
function initializeMap() {
metadata.length = 0;
map.remove(...map.children);
for (let rowIndex = 0; rowIndex > -10; rowIndex--) {
const grass = Grass(rowIndex);
map.add(grass);
}
addRows();
}
function addRows() {
const newMetadata = generateRows(20);
const startIndex = metadata.length;
metadata.push(...newMetadata);
newMetadata.forEach((rowData, index) => {
const rowIndex = startIndex + index + 1;
if (rowData.type === 'forest') {
const row = Grass(rowIndex);
rowData.trees.forEach(({ tileIndex, height }) => {
const three = Tree(tileIndex, height);
row.add(three);
});
map.add(row);
}
if (rowData.type === 'car') {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const car = Car(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
vehicle.ref = car;
row.add(car);
});
map.add(row);
}
if (rowData.type === 'truck') {
const row = Road(rowIndex);
rowData.vehicles.forEach((vehicle) => {
const truck = Truck(
vehicle.initialTileIndex,
rowData.direction,
vehicle.color
);
vehicle.ref = truck;
row.add(truck);
});
map.add(row);
}
});
}
const player = Player();
function Player() {
const player = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: 'white',
flatShading: true,
})
);
body.position.z = 10;
body.castShadow = true;
body.receiveShadow = true;
player.add(body);
const cap = new THREE.Mesh(
new THREE.BoxGeometry(2, 4, 2),
new THREE.MeshLambertMaterial({
color: 0xf0619a,
flatShading: true,
})
);
cap.position.z = 21;
cap.castShadow = true;
cap.receiveShadow = true;
player.add(cap);
const playerContainer = new THREE.Group();
playerContainer.add(player);
return playerContainer;
}
const position = {
currentRow: 0,
currentTile: 0,
};
const movesQueue = [];
function initializePlayer() {
player.position.x = 0;
player.position.y = 0;
player.children[0].position.z = 0;
position.currentRow = 0;
position.currentTile = 0;
movesQueue.length = 0;
}
function queueMove(direction) {
const isValidMove = endsUpInValidPosition(
{
rowIndex: position.currentRow,
tileIndex: position.currentTile,
},
[...movesQueue, direction]
);
if (!isValidMove) return;
movesQueue.push(direction);
}
function stepCompleted() {
const direction = movesQueue.shift();
if (direction === 'forward') position.currentRow += 1;
if (direction === 'backward') position.currentRow -= 1;
if (direction === 'left') position.currentTile -= 1;
if (direction === 'right') position.currentTile += 1;
if (position.currentRow > metadata.length - 10) addRows();
const scoreDOM = document.getElementById('score');
if (scoreDOM) scoreDOM.innerText = position.currentRow.toString();
}
function Renderer() {
const canvas = document.querySelector('canvas.game');
if (!canvas) throw new Error('Canvas not found');
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
return renderer;
}
function Road(rowIndex) {
const road = new THREE.Group();
road.position.y = rowIndex * tileSize;
const createSection = (color) =>
new THREE.Mesh(
new THREE.PlaneGeometry(tilesPerRow * tileSize, tileSize),
new THREE.MeshLambertMaterial({ color })
);
const middle = createSection(0x454a59);
middle.receiveShadow = true;
road.add(middle);
const left = createSection(0x393d49);
left.position.x = -tilesPerRow * tileSize;
road.add(left);
const right = createSection(0x393d49);
right.position.x = tilesPerRow * tileSize;
road.add(right);
return road;
}
function Tree(tileIndex, height) {
const tree = new THREE.Group();
tree.position.x = tileIndex * tileSize;
const trunk = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: 0x4d2926,
flatShading: true,
})
);
trunk.position.z = 10;
tree.add(trunk);
const crown = new THREE.Mesh(
new THREE.BoxGeometry(30, 30, height),
new THREE.MeshLambertMaterial({
color: 0x7aa21d,
flatShading: true,
})
);
crown.position.z = height / 2 + 20;
crown.castShadow = true;
crown.receiveShadow = true;
tree.add(crown);
return tree;
}
function Truck(initialTileIndex, direction, color) {
const truck = new THREE.Group();
truck.position.x = initialTileIndex * tileSize;
if (!direction) truck.rotation.z = Math.PI;
const cargo = new THREE.Mesh(
new THREE.BoxGeometry(70, 35, 35),
new THREE.MeshLambertMaterial({
color: 0xb4c6fc,
flatShading: true,
})
);
cargo.position.x = -15;
cargo.position.z = 25;
cargo.castShadow = true;
cargo.receiveShadow = true;
truck.add(cargo);
const cabin = new THREE.Mesh(new THREE.BoxGeometry(30, 30, 30), [
new THREE.MeshLambertMaterial({
color,
flatShading: true,
map: truckFrontTexture,
}),
new THREE.MeshLambertMaterial({
color,
flatShading: true,
}),
new THREE.MeshLambertMaterial({
color,
flatShading: true,
map: truckLeftSideTexture,
}),
new THREE.MeshLambertMaterial({
color,
flatShading: true,
map: truckRightSideTexture,
}),
new THREE.MeshPhongMaterial({ color, flatShading: true }),
new THREE.MeshPhongMaterial({ color, flatShading: true }),
]);
cabin.position.x = 35;
cabin.position.z = 20;
cabin.castShadow = true;
cabin.receiveShadow = true;
truck.add(cabin);
const frontWheel = Wheel(37);
truck.add(frontWheel);
const middleWheel = Wheel(5);
truck.add(middleWheel);
const backWheel = Wheel(-35);
truck.add(backWheel);
return truck;
}
function Wheel(x) {
const wheel = new THREE.Mesh(
new THREE.BoxGeometry(12, 33, 12),
new THREE.MeshLambertMaterial({
color: 0x333333,
flatShading: true,
})
);
wheel.position.x = x;
wheel.position.z = 6;
return wheel;
}
function calculateFinalPosition(currentPosition, moves) {
return moves.reduce((position, direction) => {
if (direction === 'forward')
return {
rowIndex: position.rowIndex + 1,
tileIndex: position.tileIndex,
};
if (direction === 'backward')
return {
rowIndex: position.rowIndex - 1,
tileIndex: position.tileIndex,
};
if (direction === 'left')
return {
rowIndex: position.rowIndex,
tileIndex: position.tileIndex - 1,
};
if (direction === 'right')
return {
rowIndex: position.rowIndex,
tileIndex: position.tileIndex + 1,
};
return position;
}, currentPosition);
}
function endsUpInValidPosition(currentPosition, moves) {
const finalPosition = calculateFinalPosition(currentPosition, moves);
if (
finalPosition.rowIndex === -1 ||
finalPosition.tileIndex === minTileIndex - 1 ||
finalPosition.tileIndex === maxTileIndex + 1
) {
return false;
}
const finalRow = metadata[finalPosition.rowIndex - 1];
if (
finalRow &&
finalRow.type === 'forest' &&
finalRow.trees.some(
(tree) => tree.tileIndex === finalPosition.tileIndex
)
) {
return false;
}
return true;
}
function generateRows(amount) {
const rows = [];
for (let i = 0; i < amount; i++) {
const rowData = generateRow();
rows.push(rowData);
}
return rows;
}
function generateRow() {
const type = randomElement(['car', 'truck', 'forest']);
if (type === 'car') return generateCarLaneMetadata();
if (type === 'truck') return generateTruckLaneMetadata();
return generateForesMetadata();
}
function randomElement(array) {
return array[Math.floor(Math.random() * array.length)];
}
function generateForesMetadata() {
const occupiedTiles = new Set();
const trees = Array.from({ length: 4 }, () => {
let tileIndex;
do {
tileIndex = THREE.MathUtils.randInt(minTileIndex, maxTileIndex);
} while (occupiedTiles.has(tileIndex));
occupiedTiles.add(tileIndex);
const height = randomElement([20, 45, 60]);
return { tileIndex, height };
});
return { type: 'forest', trees };
}
function generateCarLaneMetadata() {
const direction = randomElement([true, false]);
const speed = randomElement([125, 156, 188]);
const occupiedTiles = new Set();
const vehicles = Array.from({ length: 3 }, () => {
let initialTileIndex;
do {
initialTileIndex = THREE.MathUtils.randInt(
minTileIndex,
maxTileIndex
);
} while (occupiedTiles.has(initialTileIndex));
occupiedTiles.add(initialTileIndex - 1);
occupiedTiles.add(initialTileIndex);
occupiedTiles.add(initialTileIndex + 1);
const color = randomElement([0xa52523, 0xbdb638, 0x78b14b]);
return { initialTileIndex, color };
});
return { type: 'car', direction, speed, vehicles };
}
function generateTruckLaneMetadata() {
const direction = randomElement([true, false]);
const speed = randomElement([125, 156, 188]);
const occupiedTiles = new Set();
const vehicles = Array.from({ length: 2 }, () => {
let initialTileIndex;
do {
initialTileIndex = THREE.MathUtils.randInt(
minTileIndex,
maxTileIndex
);
} while (occupiedTiles.has(initialTileIndex));
occupiedTiles.add(initialTileIndex - 2);
occupiedTiles.add(initialTileIndex - 1);
occupiedTiles.add(initialTileIndex);
occupiedTiles.add(initialTileIndex + 1);
occupiedTiles.add(initialTileIndex + 2);
const color = randomElement([0xa52523, 0xbdb638, 0x78b14b]);
return { initialTileIndex, color };
});
return { type: 'truck', direction, speed, vehicles };
}
const moveClock = new THREE.Clock(false);
function animatePlayer() {
if (!movesQueue.length) return;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2;
const progress = Math.min(1, moveClock.getElapsedTime() / stepTime);
setPosition(progress);
setRotation(progress);
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
}
function setPosition(progress) {
const startX = position.currentTile * tileSize;
const startY = position.currentRow * tileSize;
let endX = startX;
let endY = startY;
if (movesQueue[0] === 'left') endX -= tileSize;
if (movesQueue[0] === 'right') endX += tileSize;
if (movesQueue[0] === 'forward') endY += tileSize;
if (movesQueue[0] === 'backward') endY -= tileSize;
player.position.x = THREE.MathUtils.lerp(startX, endX, progress);
player.position.y = THREE.MathUtils.lerp(startY, endY, progress);
player.children[0].position.z = Math.sin(progress * Math.PI) * 8;
}
function setRotation(progress) {
let endRotation = 0;
if (movesQueue[0] == 'forward') endRotation = 0;
if (movesQueue[0] == 'left') endRotation = Math.PI / 2;
if (movesQueue[0] == 'right') endRotation = -Math.PI / 2;
if (movesQueue[0] == 'backward') endRotation = Math.PI;
player.children[0].rotation.z = THREE.MathUtils.lerp(
player.children[0].rotation.z,
endRotation,
progress
);
}
const clock = new THREE.Clock();
function animateVehicles() {
const delta = clock.getDelta();
metadata.forEach((rowData) => {
if (rowData.type === 'car' || rowData.type === 'truck') {
const beginningOfRow = (minTileIndex - 2) * tileSize;
const endOfRow = (maxTileIndex + 2) * tileSize;
rowData.vehicles.forEach(({ ref }) => {
if (!ref) throw Error('Vehicle reference is missing');
if (rowData.direction) {
ref.position.x =
ref.position.x > endOfRow
? beginningOfRow
: ref.position.x + rowData.speed * delta;
} else {
ref.position.x =
ref.position.x < beginningOfRow
? endOfRow
: ref.position.x - rowData.speed * delta;
}
});
}
});
}
document
.getElementById('forward')
?.addEventListener('click', () => queueMove('forward'));
document
.getElementById('backward')
?.addEventListener('click', () => queueMove('backward'));
document
.getElementById('left')
?.addEventListener('click', () => queueMove('left'));
document
.getElementById('right')
?.addEventListener('click', () => queueMove('right'));
window.addEventListener('keydown', (event) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
queueMove('forward');
} else if (event.key === 'ArrowDown') {
event.preventDefault();
queueMove('backward');
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
queueMove('left');
} else if (event.key === 'ArrowRight') {
event.preventDefault();
queueMove('right');
}
});
function hitTest() {
const row = metadata[position.currentRow - 1];
if (!row) return;
if (row.type === 'car' || row.type === 'truck') {
const playerBoundingBox = new THREE.Box3();
playerBoundingBox.setFromObject(player);
row.vehicles.forEach(({ ref }) => {
if (!ref) throw Error('Vehicle reference is missing');
const vehicleBoundingBox = new THREE.Box3();
vehicleBoundingBox.setFromObject(ref);
if (playerBoundingBox.intersectsBox(vehicleBoundingBox)) {
if (!resultDOM || !finalScoreDOM) return;
resultDOM.style.visibility = 'visible';
finalScoreDOM.innerText = position.currentRow.toString();
}
});
}
}
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
dirLight.target = player;
player.add(dirLight);
const camera = Camera();
player.add(camera);
const scoreDOM = document.getElementById('score');
const resultDOM = document.getElementById('result-container');
const finalScoreDOM = document.getElementById('final-score');
initializeGame();
document
.querySelector('#retry')
?.addEventListener('click', initializeGame);
function initializeGame() {
initializePlayer();
initializeMap();
if (scoreDOM) scoreDOM.innerText = '0';
if (resultDOM) resultDOM.style.visibility = 'hidden';
}
const renderer = Renderer();
renderer.setAnimationLoop(animate);
function animate() {
animateVehicles();
animatePlayer();
hitTest();
renderer.render(scene, camera);
}
</script>
</body>
</html>