threejs-test/index.html
Juan Sebastian Montoya 28c820488e
All checks were successful
Build and Publish Docker Image / Build and Validate (push) Successful in 8s
Build and Publish Docker Image / Publish to Registry (push) Successful in 7s
Feature/Update versioning logic (#7)
Reviewed-on: #7
Co-authored-by: Juan Sebastian Montoya <juansmm@outlook.com>
Co-committed-by: Juan Sebastian Montoya <juansmm@outlook.com>
2025-11-26 11:22:30 -05:00

732 lines
No EOL
26 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Coin Collector Game</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
font-family: 'Arial', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
#gameCanvas {
display: block;
}
#ui {
position: absolute;
top: 20px;
left: 20px;
color: white;
font-size: 24px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
z-index: 100;
}
#gameOver {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.8);
padding: 40px;
border-radius: 20px;
text-align: center;
color: white;
display: none;
z-index: 200;
}
#gameOver h1 {
font-size: 48px;
margin-bottom: 20px;
color: #ff6b6b;
}
#gameOver p {
font-size: 24px;
margin-bottom: 30px;
}
#restartBtn {
background: #4CAF50;
border: none;
color: white;
padding: 15px 40px;
font-size: 20px;
border-radius: 10px;
cursor: pointer;
transition: all 0.3s;
}
#restartBtn:hover {
background: #45a049;
transform: scale(1.1);
}
#instructions {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
color: white;
text-align: center;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}
#version {
position: absolute;
top: 20px;
right: 20px;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
z-index: 100;
font-family: 'Courier New', monospace;
}
@media (max-width: 768px) {
#instructions {
font-size: 14px;
}
#version {
font-size: 12px;
top: 10px;
right: 10px;
}
}
</style>
</head>
<body>
<div id="ui">
<div>Score: <span id="score">0</span></div>
<div>Health: <span id="health">100</span></div>
</div>
<div id="version">v<span id="versionNumber">-</span></div>
<div id="gameOver">
<h1>Game Over!</h1>
<p>Final Score: <span id="finalScore">0</span></p>
<button id="restartBtn">Play Again</button>
</div>
<div id="instructions">
<p><strong>Controls:</strong> WASD or Arrow Keys to move | Touch and drag to move (mobile) | Collect yellow coins | Avoid red obstacles!</p>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// Load and display version information
(function() {
fetch('/version.json')
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error('Version file not found');
})
.then(data => {
const versionElement = document.getElementById('versionNumber');
if (versionElement && data.version) {
versionElement.textContent = data.version;
}
})
.catch(error => {
// Silently fail if version.json doesn't exist (e.g., in development)
console.debug('Version information not available:', error.message);
});
})();
// Base GameObject class
class GameObject {
constructor(scene, groundSize) {
this.scene = scene;
this.groundSize = groundSize;
this.mesh = this.createMesh();
this.initialize();
scene.add(this.mesh);
}
// Override in child classes
createMesh() {
throw new Error('createMesh() must be implemented by child class');
}
// Override in child classes for initialization
initialize() {
// Default implementation - can be overridden
}
// Override in child classes for update logic
update(...args) {
// Default implementation - can be overridden
}
// Common collision detection
checkCollision(otherPosition, collisionRadius) {
const distance = this.mesh.position.distanceTo(otherPosition);
return distance < collisionRadius;
}
// Common position getter
getPosition() {
return this.mesh.position;
}
// Remove from scene
remove() {
this.scene.remove(this.mesh);
}
// Set position
setPosition(x, y, z) {
this.mesh.position.set(x, y, z);
}
// Get position vector
getPositionVector() {
return this.mesh.position.clone();
}
}
// Player class extends GameObject
class Player extends GameObject {
constructor(scene, groundSize) {
super(scene, groundSize);
this.maxSpeed = 0.15;
this.acceleration = 0.08;
this.deceleration = 0.12;
this.velocity = new THREE.Vector3(0, 0, 0);
}
createMesh() {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({
color: 0x4169E1,
metalness: 0.3,
roughness: 0.4
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
initialize() {
// Initialize velocity (needed because initialize is called from parent constructor)
if (!this.velocity) {
this.velocity = new THREE.Vector3(0, 0, 0);
}
this.reset();
}
reset() {
this.setPosition(0, 0.5, 0);
this.mesh.rotation.y = 0;
if (this.velocity) {
this.velocity.set(0, 0, 0);
}
}
update(keys) {
// Calculate target velocity based on input
const targetVelocity = new THREE.Vector3(0, 0, 0);
if (keys['w'] || keys['arrowup']) targetVelocity.z -= this.maxSpeed;
if (keys['s'] || keys['arrowdown']) targetVelocity.z += this.maxSpeed;
if (keys['a'] || keys['arrowleft']) targetVelocity.x -= this.maxSpeed;
if (keys['d'] || keys['arrowright']) targetVelocity.x += this.maxSpeed;
// Smoothly interpolate velocity towards target
const isMoving = targetVelocity.length() > 0;
const accelRate = isMoving ? this.acceleration : this.deceleration;
// Calculate velocity difference for each axis
const velDiffX = targetVelocity.x - this.velocity.x;
const velDiffZ = targetVelocity.z - this.velocity.z;
// Apply easing to each axis independently for smoother diagonal movement
if (Math.abs(velDiffX) > 0.001) {
this.velocity.x += velDiffX * accelRate;
} else {
this.velocity.x = targetVelocity.x;
}
if (Math.abs(velDiffZ) > 0.001) {
this.velocity.z += velDiffZ * accelRate;
} else {
this.velocity.z = targetVelocity.z;
}
// Apply velocity to position
this.mesh.position.add(this.velocity);
// Boundary checks
const boundary = this.groundSize / 2 - 0.5;
this.mesh.position.x = Math.max(-boundary, Math.min(boundary, this.mesh.position.x));
this.mesh.position.z = Math.max(-boundary, Math.min(boundary, this.mesh.position.z));
// Stop velocity if hitting boundary
if (Math.abs(this.mesh.position.x) >= boundary) {
this.velocity.x = 0;
}
if (Math.abs(this.mesh.position.z) >= boundary) {
this.velocity.z = 0;
}
// Rotate player based on movement
if (this.velocity.length() > 0.01) {
this.mesh.rotation.y += 0.1;
}
}
handleCollision(obstaclePosition) {
const pushDirection = this.mesh.position.clone().sub(obstaclePosition);
pushDirection.y = 0;
pushDirection.normalize();
this.mesh.position.add(pushDirection.multiplyScalar(0.3));
this.mesh.position.y = 0.5;
}
}
// Coin class extends GameObject
class Coin extends GameObject {
constructor(scene, groundSize, index = 0) {
super(scene, groundSize);
this.index = index;
this.rotationSpeed = 0.02;
this.collisionRadius = 0.8;
}
createMesh() {
const geometry = new THREE.SphereGeometry(0.3, 16, 16);
const material = new THREE.MeshStandardMaterial({
color: 0xFFD700,
metalness: 0.8,
roughness: 0.2,
emissive: 0xFFD700,
emissiveIntensity: 0.3
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
initialize() {
this.setRandomPosition();
}
setRandomPosition() {
const x = (Math.random() - 0.5) * (this.groundSize - 4);
const z = (Math.random() - 0.5) * (this.groundSize - 4);
this.setPosition(x, 0.5, z);
}
update() {
this.mesh.rotation.y += this.rotationSpeed;
this.mesh.position.y = 0.5 + Math.sin(Date.now() * 0.003 + this.index) * 0.2;
}
checkCollisionWithPlayer(playerPosition) {
return this.checkCollision(playerPosition, this.collisionRadius);
}
}
// Obstacle class extends GameObject
class Obstacle extends GameObject {
constructor(scene, groundSize) {
super(scene, groundSize);
this.direction = this.createRandomDirection();
this.collisionRadius = 1.5;
}
createMesh() {
const geometry = new THREE.BoxGeometry(1.5, 2, 1.5);
const material = new THREE.MeshStandardMaterial({
color: 0xFF4500,
metalness: 0.3,
roughness: 0.7
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
initialize() {
this.setRandomPosition();
this.mesh.position.y = 1;
}
createRandomDirection() {
return new THREE.Vector3(
(Math.random() - 0.5) * 0.05,
0,
(Math.random() - 0.5) * 0.05
);
}
setRandomPosition() {
let posX, posZ;
do {
posX = (Math.random() - 0.5) * (this.groundSize - 4);
posZ = (Math.random() - 0.5) * (this.groundSize - 4);
} while (Math.abs(posX) < 3 && Math.abs(posZ) < 3);
this.setPosition(posX, 1, posZ);
}
update() {
this.mesh.position.add(this.direction);
// Bounce off boundaries
const boundary = this.groundSize / 2 - 1;
if (Math.abs(this.mesh.position.x) > boundary) {
this.direction.x *= -1;
}
if (Math.abs(this.mesh.position.z) > boundary) {
this.direction.z *= -1;
}
}
checkCollisionWithPlayer(playerPosition) {
return this.checkCollision(playerPosition, this.collisionRadius);
}
}
// Game class
class Game {
constructor() {
this.groundSize = 30;
this.score = 0;
this.health = 100;
this.gameActive = true;
this.keys = {};
this.coins = [];
this.obstacles = [];
this.touchActive = false;
this.touchStartX = 0;
this.touchStartY = 0;
this.touchCurrentX = 0;
this.touchCurrentY = 0;
this.touchId = null;
this.init();
this.setupEventListeners();
this.animate();
}
init() {
this.setupScene();
this.setupCamera();
this.setupRenderer();
this.setupLights();
this.setupGround();
this.setupPlayer();
this.createGameObjects();
}
setupScene() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x87CEEB);
this.scene.fog = new THREE.Fog(0x87CEEB, 0, 50);
}
setupCamera() {
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.camera.position.set(0, 10, 15);
this.camera.lookAt(0, 0, 0);
}
setupRenderer() {
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(this.renderer.domElement);
}
setupLights() {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(10, 20, 10);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.left = -20;
directionalLight.shadow.camera.right = 20;
directionalLight.shadow.camera.top = 20;
directionalLight.shadow.camera.bottom = -20;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 50;
this.scene.add(directionalLight);
}
setupGround() {
const groundGeometry = new THREE.PlaneGeometry(this.groundSize, this.groundSize);
const groundMaterial = new THREE.MeshStandardMaterial({
color: 0x90EE90,
roughness: 0.8
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
this.scene.add(ground);
const gridHelper = new THREE.GridHelper(this.groundSize, 20, 0x000000, 0x000000);
gridHelper.material.opacity = 0.2;
gridHelper.material.transparent = true;
this.scene.add(gridHelper);
}
setupPlayer() {
this.player = new Player(this.scene, this.groundSize);
}
createGameObjects() {
this.createCoins(10);
this.createObstacles(8);
}
createCoins(count) {
for (let i = 0; i < count; i++) {
const coin = new Coin(this.scene, this.groundSize, this.coins.length);
this.coins.push(coin);
}
}
createObstacles(count) {
for (let i = 0; i < count; i++) {
const obstacle = new Obstacle(this.scene, this.groundSize);
this.obstacles.push(obstacle);
}
}
setupEventListeners() {
window.addEventListener('keydown', (e) => {
this.keys[e.key.toLowerCase()] = true;
});
window.addEventListener('keyup', (e) => {
this.keys[e.key.toLowerCase()] = false;
});
window.addEventListener('resize', () => this.onWindowResize());
document.getElementById('restartBtn').addEventListener('click', () => this.restart());
// Touch event listeners
this.setupTouchControls();
}
setupTouchControls() {
const canvas = this.renderer.domElement;
// Prevent default touch behaviors
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
this.handleTouchStart(e);
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
this.handleTouchMove(e);
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
this.handleTouchEnd(e);
}, { passive: false });
canvas.addEventListener('touchcancel', (e) => {
e.preventDefault();
this.handleTouchEnd(e);
}, { passive: false });
}
handleTouchStart(e) {
if (this.touchActive) return;
const touch = e.touches[0];
const rect = this.renderer.domElement.getBoundingClientRect();
// Use center of screen as reference point
this.touchStartX = rect.left + rect.width / 2;
this.touchStartY = rect.top + rect.height / 2;
this.touchId = touch.identifier;
this.touchCurrentX = touch.clientX;
this.touchCurrentY = touch.clientY;
this.touchActive = true;
}
handleTouchMove(e) {
if (!this.touchActive) return;
const touch = Array.from(e.touches).find(t => t.identifier === this.touchId);
if (!touch) return;
this.touchCurrentX = touch.clientX;
this.touchCurrentY = touch.clientY;
this.updateTouchMovement();
}
handleTouchEnd(e) {
if (!this.touchActive) return;
// Check if the touch that ended is our tracked touch
const touch = Array.from(e.changedTouches).find(t => t.identifier === this.touchId);
if (!touch) return;
this.touchActive = false;
this.touchId = null;
this.touchCurrentX = this.touchStartX;
this.touchCurrentY = this.touchStartY;
this.updateTouchMovement();
}
updateTouchMovement() {
if (!this.touchActive) {
// Reset all movement keys
this.keys['w'] = false;
this.keys['s'] = false;
this.keys['a'] = false;
this.keys['d'] = false;
return;
}
const deltaX = this.touchCurrentX - this.touchStartX;
const deltaY = this.touchCurrentY - this.touchStartY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// Normalize movement direction
const threshold = 10; // Minimum movement to register
if (distance < threshold) {
this.keys['w'] = false;
this.keys['s'] = false;
this.keys['a'] = false;
this.keys['d'] = false;
return;
}
// Calculate direction
const normalizedX = deltaX / distance;
const normalizedY = deltaY / distance;
// Update movement keys based on direction
this.keys['w'] = normalizedY < -0.3;
this.keys['s'] = normalizedY > 0.3;
this.keys['a'] = normalizedX < -0.3;
this.keys['d'] = normalizedX > 0.3;
}
updatePlayer() {
if (!this.gameActive) return;
this.player.update(this.keys);
}
updateCoins() {
this.coins.forEach((coin, index) => {
coin.update();
if (coin.checkCollisionWithPlayer(this.player.getPosition())) {
coin.remove();
this.coins.splice(index, 1);
this.score += 10;
this.updateUI();
this.createCoins(1);
}
});
}
updateObstacles() {
if (!this.gameActive) return;
this.obstacles.forEach(obstacle => {
obstacle.update();
if (obstacle.checkCollisionWithPlayer(this.player.getPosition())) {
this.health -= 1;
this.updateUI();
this.player.handleCollision(obstacle.getPosition());
if (this.health <= 0) {
this.gameOver();
}
}
});
}
updateCamera() {
const playerPos = this.player.getPosition();
this.camera.position.x = playerPos.x;
this.camera.position.z = playerPos.z + 15;
this.camera.lookAt(playerPos);
}
updateUI() {
document.getElementById('score').textContent = this.score;
document.getElementById('health').textContent = Math.max(0, this.health);
}
gameOver() {
this.gameActive = false;
document.getElementById('finalScore').textContent = this.score;
document.getElementById('gameOver').style.display = 'block';
}
restart() {
// Remove all coins and obstacles
this.coins.forEach(coin => coin.remove());
this.obstacles.forEach(obstacle => obstacle.remove());
this.coins = [];
this.obstacles = [];
// Reset player
this.player.reset();
// Reset game state
this.score = 0;
this.health = 100;
this.gameActive = true;
// Recreate game objects
this.createGameObjects();
// Hide game over screen
document.getElementById('gameOver').style.display = 'none';
this.updateUI();
}
onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
animate() {
requestAnimationFrame(() => this.animate());
// Update touch movement if active
if (this.touchActive) {
this.updateTouchMovement();
}
this.updatePlayer();
this.updateCoins();
this.updateObstacles();
this.updateCamera();
this.renderer.render(this.scene, this.camera);
}
}
// Start the game
const game = new Game();
</script>
</body>
</html>