Feature/Refactor to use ecs architecture (#14)
All checks were successful
Build and Publish Docker Image / Build and Validate (push) Successful in 7s
Build and Publish Docker Image / Publish to Registry (push) Successful in 9s

Reviewed-on: #14
Co-authored-by: Juan Sebastian Montoya <juansmm@outlook.com>
Co-committed-by: Juan Sebastian Montoya <juansmm@outlook.com>
This commit is contained in:
Juan Sebastián Montoya 2025-11-26 15:39:05 -05:00 committed by Juan Sebastián Montoya
parent e0436e7769
commit cec1fccc22
23 changed files with 1709 additions and 650 deletions

View file

@ -278,7 +278,7 @@ jobs:
# Check if there are changes to commit
if git diff --quiet VERSION portainer.yml; then
echo " No changes to commit (files already up to date)"
echo " No changes to commit (files already up to date)"
else
# Stage and commit with [skip ci] to prevent infinite loop
# Note: Forgejo Actions should respect [skip ci] in commit messages

View file

@ -7,12 +7,12 @@ ARG BUILD_DATE=unknown
# Create version.json file with build information
RUN printf '{"version":"%s","buildDate":"%s"}\n' "${VERSION}" "${BUILD_DATE}" > /usr/share/nginx/html/version.json
# Copy the HTML file
COPY index.html /usr/share/nginx/html/index.html
# Copy HTML and source files
COPY index.html /usr/share/nginx/html/
COPY src/ /usr/share/nginx/html/src/
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80

125
README.md
View file

@ -1,15 +1,28 @@
# 3D Coin Collector Game
A fun and engaging 3D game built with Three.js where you control a player to collect coins while avoiding obstacles. Test your reflexes and see how high you can score!
A fun and engaging 3D game built with **Three.js** and **Entity Component System (ECS)** architecture. Test your reflexes as you collect coins while avoiding obstacles!
## 🎮 Game Overview
Navigate your character (a blue cube) through a 3D arena to collect golden coins while avoiding dangerous red obstacles. Each coin collected increases your score, but colliding with obstacles reduces your health. The game ends when your health reaches zero.
## 🏗️ Architecture
This game is built using the **Entity Component System (ECS)** pattern, making it highly scalable and maintainable. See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed documentation.
### Why ECS?
- **Scalable**: Easily add new features without modifying existing code
- **Performant**: Data-oriented design for better cache utilization
- **Flexible**: Mix and match components to create new entity types
- **Maintainable**: Clean separation of data (components) and logic (systems)
- **Testable**: Systems can be tested independently
## ✨ Features
### Game Features
- **3D Graphics**: Beautiful 3D environment with shadows, lighting, and fog effects
- **Smooth Controls**: Responsive keyboard and touch controls
- **Smooth Controls**: Responsive keyboard and touch controls with easing
- **Dynamic Gameplay**: Moving obstacles that bounce off boundaries
- **Animated Coins**: Coins rotate and float with smooth animations
- **Health System**: Start with 100 health, lose 1 point per obstacle collision
@ -18,6 +31,14 @@ Navigate your character (a blue cube) through a 3D arena to collect golden coins
- **Mobile Support**: Full touch control support for smartphones and tablets
- **Responsive Design**: Adapts to different screen sizes
### Technical Features
- **ECS Architecture**: Scalable entity component system
- **Zero Dependencies**: No npm packages, no build step, no bullshit
- **Native ES6 Modules**: Modern JavaScript, works directly in browsers
- **CDN-based**: Three.js loaded from CDN
- **Docker Support**: Simple nginx deployment
- **CI/CD**: Automated versioning and deployment pipeline
## 🎯 How to Play
1. **Objective**: Collect as many yellow coins as possible while avoiding red obstacles
@ -64,23 +85,41 @@ Navigate your character (a blue cube) through a 3D arena to collect golden coins
## 🛠️ Technical Details
### Built With
- **Three.js r128**: 3D graphics library
- **Vanilla JavaScript**: No frameworks required
- **Three.js**: 3D graphics library (from CDN)
- **Native ES6 Modules**: No bundler needed
- **HTML5 Canvas**: WebGL rendering
- **CSS3**: Modern styling and responsive design
- **Pure JavaScript**: No frameworks, no dependencies
### Game Architecture
- **Object-Oriented Design**: Uses ES6 classes with inheritance
- **GameObject Base Class**: Common functionality for all game entities
- **Modular Classes**: Player, Coin, and Obstacle extend GameObject
- **Game Loop**: Smooth 60 FPS animation using requestAnimationFrame
### ECS Architecture
```
├── src/
│ ├── ecs/ # Core ECS implementation
│ ├── components/ # Data containers (Transform, Velocity, etc.)
│ ├── systems/ # Logic processors (Movement, Collision, etc.)
│ ├── game/ # Game-specific code
│ └── main.js # Entry point
```
**Components (Data)**:
- `Transform`: Position, rotation, scale
- `Velocity`: Movement speed with easing
- `MeshComponent`: Three.js mesh reference
- `Collidable`: Collision detection data
- `Health`: Hit points
- `Tags`: Entity type markers (Player, Coin, Obstacle)
**Systems (Logic)**:
- `InputSystem`: Keyboard and touch input
- `PlayerControlSystem`: Player movement with easing
- `MovementSystem`: Apply velocity to position
- `CollisionSystem`: Detect and handle collisions
- `CoinSystem`: Coin rotation and bobbing
- `ObstacleSystem`: Obstacle movement and bouncing
- `BoundarySystem`: Keep entities in bounds
- `RenderSystem`: Sync Three.js with ECS
### Features Implementation
- **Shadow Mapping**: PCF soft shadows for realistic lighting
- **Fog Effect**: Atmospheric depth with distance fog
- **Collision Detection**: Distance-based collision system
- **Boundary Constraints**: Prevents player and obstacles from leaving the arena
- **Touch Event Handling**: Full support for multi-touch devices
## 📋 Requirements
@ -88,27 +127,53 @@ Navigate your character (a blue cube) through a 3D arena to collect golden coins
- JavaScript enabled
- Internet connection (for Three.js CDN)
## 🚀 Getting Started
1. **Clone or Download** this repository
2. **Open** `index.html` in your web browser
3. **Start Playing** immediately - no installation required!
### Local Server (Optional)
For best performance, you can run a local server:
### Development
1. **Clone the repository**
```bash
# Using Python 3
python -m http.server 8000
# Using Python 2
python -m SimpleHTTPServer 8000
# Using Node.js (with http-server)
npx http-server
git clone <repository-url>
cd threejs-test
```
Then open `http://localhost:8000` in your browser.
2. **Open in browser**
Uuse a simple HTTP server:
```bash
# Option 1: Python
python -m http.server 3000
# Option 2: Node (if you have it)
npx serve .
# Option 3: PHP
php -S localhost:3000
```
Then navigate to `http://localhost:3000`
**That's it!** The game uses:
- Native ES6 modules (no bundler needed)
- Three.js from CDN (no npm install)
### Docker
Build and run with Docker:
```bash
# Build
docker build -t threejs-game .
# Run
docker run -p 80:80 threejs-game
```
Or use docker-compose:
```bash
docker-compose up
```
## 📱 Mobile Compatibility

View file

@ -15,9 +15,6 @@
font-family: 'Arial', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
#gameCanvas {
display: block;
}
#ui {
position: absolute;
top: 20px;
@ -82,6 +79,26 @@
z-index: 100;
font-family: 'Courier New', monospace;
}
#perfMonitor {
position: absolute;
top: 60px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
padding: 10px;
border-radius: 5px;
color: #00ff00;
font-size: 12px;
font-family: 'Courier New', monospace;
z-index: 100;
display: none;
min-width: 120px;
}
#perfMonitor.visible {
display: block;
}
#perfMonitor .label {
color: #888;
}
@media (max-width: 768px) {
#instructions {
font-size: 14px;
@ -91,6 +108,11 @@
top: 10px;
right: 10px;
}
#perfMonitor {
top: 40px;
right: 10px;
font-size: 11px;
}
}
</style>
</head>
@ -102,6 +124,12 @@
<div id="version">v<span id="versionNumber">-</span></div>
<div id="perfMonitor">
<div><span class="label">FPS:</span> <span id="fps">60</span></div>
<div><span class="label">Frame:</span> <span id="frameTime">16.7</span>ms</div>
<div><span class="label">Entities:</span> <span id="entityCount">0</span></div>
</div>
<div id="gameOver">
<h1>Game Over!</h1>
<p>Final Score: <span id="finalScore">0</span></p>
@ -110,623 +138,22 @@
<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>
<p style="margin-top: 5px; font-size: 11px; opacity: 0.7;">Press "T" or shake device to toggle performance monitor</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);
});
})();
<script type="module">
// Import Three.js from CDN
// 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();
}
}
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js';
// 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;
}
}
// Make THREE globally available for our modules
window.THREE = THREE;
// 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();
// Load the game after THREE is loaded
import('./src/main.js').catch(err => {
console.error('Failed to load game:', err);
});
</script>
</body>
</html>
</html>

View file

@ -0,0 +1,20 @@
/**
* Collidable component - defines collision properties for an entity
*/
export class Collidable {
/**
* @param {number} [radius=1.0] - Collision radius
* @param {string} [layer='default'] - Collision layer (e.g., 'player', 'coin', 'obstacle')
*/
constructor(radius = 1.0, layer = 'default') {
/** @type {number} Collision detection radius */
this.radius = radius;
/** @type {string} Collision layer identifier */
this.layer = layer;
/** @type {string[]} Array of layer names this entity can collide with */
this.collidesWith = [];
}
}

50
src/components/Health.js Normal file
View file

@ -0,0 +1,50 @@
/**
* Health component - health points and damage/healing logic for entities
*/
export class Health {
/**
* @param {number} [maxHealth=100] - Maximum health points
*/
constructor(maxHealth = 100) {
/** @type {number} Maximum health value */
this.maxHealth = maxHealth;
/** @type {number} Current health value */
this.currentHealth = maxHealth;
}
/**
* Apply damage to the entity
* @param {number} amount - Damage amount
* @returns {boolean} True if entity is dead (health <= 0)
*/
damage(amount) {
this.currentHealth = Math.max(0, this.currentHealth - amount);
return this.currentHealth <= 0;
}
/**
* Heal the entity
* @param {number} amount - Heal amount
*/
heal(amount) {
this.currentHealth = Math.min(this.maxHealth, this.currentHealth + amount);
}
/**
* Check if entity is alive
* @returns {boolean} True if health > 0
*/
isAlive() {
return this.currentHealth > 0;
}
/**
* Get health as a percentage
* @returns {number} Health percentage (0-100)
*/
getPercentage() {
return (this.currentHealth / this.maxHealth) * 100;
}
}

View file

@ -0,0 +1,19 @@
/**
* Mesh component - holds reference to Three.js mesh for rendering
*/
export class MeshComponent {
/**
* @param {THREE.Mesh} mesh - The Three.js mesh to render
*/
constructor(mesh) {
/** @type {THREE.Mesh} The Three.js mesh object */
this.mesh = mesh;
/** @type {boolean} Whether this mesh casts shadows */
this.castShadow = true;
/** @type {boolean} Whether this mesh receives shadows */
this.receiveShadow = true;
}
}

66
src/components/Tags.js Normal file
View file

@ -0,0 +1,66 @@
/**
* Tag components - simple markers and metadata for entity types.
* These are lightweight components used for filtering and entity-specific data.
*/
/**
* PlayerTag - marks the player entity
*/
export class PlayerTag {}
/**
* CoinTag - marks collectible coin entities with animation properties
*/
export class CoinTag {
/**
* @param {number} [index=0] - Unique index for this coin
*/
constructor(index = 0) {
/** @type {number} Unique identifier for animation offset */
this.index = index;
/** @type {number} Rotation speed for coin spin */
this.rotationSpeed = 0.02;
/** @type {number} Bobbing animation speed */
this.bobSpeed = 0.003;
/** @type {number} Bobbing animation height */
this.bobAmount = 0.2;
}
}
/**
* ObstacleTag - marks obstacle entities with movement properties
*/
export class ObstacleTag {
constructor() {
/** @type {number} Movement speed for obstacle */
this.speed = 0.05;
}
}
/**
* BoundaryConstrained - marks entities that should stay within boundaries
*/
export class BoundaryConstrained {
/**
* @param {number} [boundarySize=30] - Total size of the boundary area
*/
constructor(boundarySize = 30) {
/** @type {number} Total boundary size */
this.boundarySize = boundarySize;
/** @type {number} Safety margin from edges */
this.margin = 0.5;
}
/**
* Get the effective boundary accounting for margin
* @returns {number} Usable boundary from center
*/
getBoundary() {
return this.boundarySize / 2 - this.margin;
}
}

View file

@ -0,0 +1,39 @@
/**
* Transform component - position, rotation, and scale in 3D space
*/
export class Transform {
/**
* @param {number} [x=0] - X position
* @param {number} [y=0] - Y position
* @param {number} [z=0] - Z position
*/
constructor(x = 0, y = 0, z = 0) {
/** @type {THREE.Vector3} */
this.position = new window.THREE.Vector3(x, y, z);
/** @type {THREE.Euler} */
this.rotation = new window.THREE.Euler(0, 0, 0);
/** @type {THREE.Vector3} */
this.scale = new window.THREE.Vector3(1, 1, 1);
}
/**
* Set the position
* @param {number} x - X position
* @param {number} y - Y position
* @param {number} z - Z position
*/
setPosition(x, y, z) {
this.position.set(x, y, z);
}
/**
* Get a clone of the position
* @returns {THREE.Vector3} Cloned position vector
*/
getPosition() {
return this.position.clone();
}
}

View file

@ -0,0 +1,67 @@
/**
* Velocity component - movement speed, direction, and physics properties
*/
export class Velocity {
/**
* @param {number} [x=0] - Initial X velocity
* @param {number} [y=0] - Initial Y velocity
* @param {number} [z=0] - Initial Z velocity
*/
constructor(x = 0, y = 0, z = 0) {
/** @type {THREE.Vector3} */
this.velocity = new window.THREE.Vector3(x, y, z);
/** @type {number} Maximum speed magnitude */
this.maxSpeed = 0.15;
/** @type {number} Acceleration rate */
this.acceleration = 0.08;
/** @type {number} Deceleration rate */
this.deceleration = 0.12;
}
/**
* Set velocity directly
* @param {number} x - X velocity
* @param {number} y - Y velocity
* @param {number} z - Z velocity
*/
set(x, y, z) {
this.velocity.set(x, y, z);
}
/**
* Add to current velocity
* @param {number} x - X velocity to add
* @param {number} y - Y velocity to add
* @param {number} z - Z velocity to add
*/
add(x, y, z) {
this.velocity.add(new window.THREE.Vector3(x, y, z));
}
/**
* Multiply velocity by scalar
* @param {number} scalar - Multiplier
*/
multiplyScalar(scalar) {
this.velocity.multiplyScalar(scalar);
}
/**
* Get the magnitude of velocity
* @returns {number} Velocity magnitude
*/
length() {
return this.velocity.length();
}
/**
* Reset velocity to zero
*/
reset() {
this.velocity.set(0, 0, 0);
}
}

53
src/ecs/System.js Normal file
View file

@ -0,0 +1,53 @@
/**
* Base class for all systems.
* Systems contain logic that operates on entities with specific components.
*
* @typedef {import('./World.js').EntityId} EntityId
* @typedef {import('./World.js').ComponentClass} ComponentClass
*/
export class System {
constructor() {
/** @type {import('./World.js').World|null} */
this.world = null;
/** @type {boolean} */
this.enabled = true;
}
/**
* Called once when the system is added to the world
*/
init() {}
/**
* Called every frame
* @param {number} deltaTime - Time since last frame in seconds
*/
update(_deltaTime) {}
/**
* Called when the system is removed or world is cleaned up
*/
cleanup() {}
/**
* Helper to get entities with specific components
* @param {...ComponentClass} componentClasses - The component classes to filter by
* @returns {EntityId[]} Array of entity IDs
*/
getEntities(...componentClasses) {
return this.world.getEntitiesWithComponents(...componentClasses);
}
/**
* Helper to get a component from an entity
* @template T
* @param {EntityId} entityId - The entity ID
* @param {new (...args: any[]) => T} componentClass - The component class
* @returns {T|undefined} The component instance or undefined
*/
getComponent(entityId, componentClass) {
return this.world.getComponent(entityId, componentClass);
}
}

170
src/ecs/World.js Normal file
View file

@ -0,0 +1,170 @@
/**
* World manages all entities, components, and systems in the ECS architecture.
*
* @typedef {number} EntityId - Unique identifier for an entity
* @typedef {Function} ComponentClass - A component class constructor
* @typedef {Object} Component - A component instance (data only)
*/
export class World {
constructor() {
/** @type {Map<EntityId, Set<ComponentClass>>} */
this.entities = new Map(); // entityId -> Set of component classes
/** @type {Map<ComponentClass, Map<EntityId, Component>>} */
this.components = new Map(); // componentClass -> Map(entityId -> component)
/** @type {import('./System.js').System[]} */
this.systems = [];
/** @type {EntityId} */
this.nextEntityId = 1;
}
/**
* Create a new entity and return its ID
* @returns {EntityId} The newly created entity ID
*/
createEntity() {
const id = this.nextEntityId++;
this.entities.set(id, new Set());
return id;
}
/**
* Remove an entity and all its components
* @param {EntityId} entityId - The entity to remove
*/
removeEntity(entityId) {
if (!this.entities.has(entityId)) return;
// Remove all components for this entity
const componentClasses = this.entities.get(entityId);
for (const componentClass of componentClasses) {
const componentMap = this.components.get(componentClass);
if (componentMap) {
componentMap.delete(entityId);
}
}
this.entities.delete(entityId);
}
/**
* Add a component instance to an entity. Component class is inferred from the instance.
* @param {EntityId} entityId - The entity to add the component to
* @param {Component} component - The component instance (must have a constructor)
* @throws {Error} If entity doesn't exist
*/
addComponent(entityId, component) {
if (!this.entities.has(entityId)) {
throw new Error(`Entity ${entityId} does not exist`);
}
const componentClass = component.constructor;
// Track that this entity has this component class
this.entities.get(entityId).add(componentClass);
// Store the component instance
if (!this.components.has(componentClass)) {
this.components.set(componentClass, new Map());
}
this.components.get(componentClass).set(entityId, component);
}
/**
* Get a component from an entity
* @template T
* @param {EntityId} entityId - The entity ID
* @param {new (...args: any[]) => T} componentClass - The component class
* @returns {T|undefined} The component instance or undefined
*/
getComponent(entityId, componentClass) {
const componentMap = this.components.get(componentClass);
return componentMap ? componentMap.get(entityId) : undefined;
}
/**
* Check if an entity has a component
* @param {EntityId} entityId - The entity ID
* @param {ComponentClass} componentClass - The component class
* @returns {boolean} True if the entity has the component
*/
hasComponent(entityId, componentClass) {
return this.entities.get(entityId)?.has(componentClass) || false;
}
/**
* Remove a component from an entity
* @param {EntityId} entityId - The entity ID
* @param {ComponentClass} componentClass - The component class to remove
*/
removeComponent(entityId, componentClass) {
const entityComponents = this.entities.get(entityId);
if (entityComponents) {
entityComponents.delete(componentClass);
}
const componentMap = this.components.get(componentClass);
if (componentMap) {
componentMap.delete(entityId);
}
}
/**
* Get all entities that have ALL of the specified component classes
* @param {...ComponentClass} componentClasses - The component classes to filter by
* @returns {EntityId[]} Array of entity IDs that have all specified components
*/
getEntitiesWithComponents(...componentClasses) {
const result = [];
for (const [entityId, entityComponentClasses] of this.entities.entries()) {
const hasAll = componentClasses.every(cls => entityComponentClasses.has(cls));
if (hasAll) {
result.push(entityId);
}
}
return result;
}
/**
* Add a system to the world
* @param {import('./System.js').System} system - The system to add
*/
addSystem(system) {
system.world = this;
this.systems.push(system);
if (system.init) {
system.init();
}
}
/**
* Update all systems with delta time
* @param {number} deltaTime - Time elapsed since last frame (in seconds)
*/
update(deltaTime) {
for (const system of this.systems) {
if (system.enabled !== false) {
system.update(deltaTime);
}
}
}
/**
* Cleanup all systems and clear all entities/components
*/
cleanup() {
for (const system of this.systems) {
if (system.cleanup) {
system.cleanup();
}
}
this.entities.clear();
this.components.clear();
this.systems = [];
}
}

157
src/game/EntityFactory.js Normal file
View file

@ -0,0 +1,157 @@
import { Transform } from '../components/Transform.js';
import { Velocity } from '../components/Velocity.js';
import { MeshComponent } from '../components/MeshComponent.js';
import { Collidable } from '../components/Collidable.js';
import { Health } from '../components/Health.js';
import { PlayerTag, CoinTag, ObstacleTag, BoundaryConstrained } from '../components/Tags.js';
/**
* EntityFactory - creates pre-configured game entities with appropriate components.
* Centralizes entity creation logic for consistency.
*
* @typedef {import('../ecs/World.js').EntityId} EntityId
*/
export class EntityFactory {
/**
* @param {import('../ecs/World.js').World} world - The ECS world
* @param {THREE.Scene} scene - The Three.js scene
*/
constructor(world, scene) {
/** @type {import('../ecs/World.js').World} */
this.world = world;
/** @type {THREE.Scene} */
this.scene = scene;
/** @type {number} Size of the game ground/play area */
this.groundSize = 30;
}
/**
* Create the player entity
* @returns {EntityId} The player entity ID
*/
createPlayer() {
const entity = this.world.createEntity();
// Create mesh
const geometry = new window.THREE.BoxGeometry(1, 1, 1);
const material = new window.THREE.MeshStandardMaterial({
color: 0x4169E1,
metalness: 0.3,
roughness: 0.4
});
const mesh = new window.THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
this.scene.add(mesh);
// Add components
this.world.addComponent(entity, new Transform(0, 0.5, 0));
this.world.addComponent(entity, new Velocity());
this.world.addComponent(entity, new MeshComponent(mesh));
this.world.addComponent(entity, new Collidable(0, 'player')); // Player center point (original behavior)
this.world.addComponent(entity, new Health(100));
this.world.addComponent(entity, new PlayerTag());
this.world.addComponent(entity, new BoundaryConstrained(this.groundSize));
return entity;
}
/**
* Create a collectible coin entity
* @param {number} [index=0] - Unique index for animation offset
* @returns {EntityId} The coin entity ID
*/
createCoin(index = 0) {
const entity = this.world.createEntity();
// Create mesh
const geometry = new window.THREE.SphereGeometry(0.3, 16, 16);
const material = new window.THREE.MeshStandardMaterial({
color: 0xFFD700,
metalness: 0.8,
roughness: 0.2,
emissive: 0xFFD700,
emissiveIntensity: 0.3
});
const mesh = new window.THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
this.scene.add(mesh);
// Random position
const x = (Math.random() - 0.5) * (this.groundSize - 4);
const z = (Math.random() - 0.5) * (this.groundSize - 4);
// Add components
this.world.addComponent(entity, new Transform(x, 0.5, z));
this.world.addComponent(entity, new MeshComponent(mesh));
this.world.addComponent(entity, new Collidable(0.8, 'coin'));
this.world.addComponent(entity, new CoinTag(index));
return entity;
}
/**
* Create an obstacle entity
* @returns {EntityId} The obstacle entity ID
*/
createObstacle() {
const entity = this.world.createEntity();
// Create mesh
const geometry = new window.THREE.BoxGeometry(1.5, 2, 1.5);
const material = new window.THREE.MeshStandardMaterial({
color: 0xFF4500,
metalness: 0.3,
roughness: 0.7
});
const mesh = new window.THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
this.scene.add(mesh);
// Random position (away from center)
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);
// Random velocity
const velocity = new Velocity(
(Math.random() - 0.5) * 0.05,
0,
(Math.random() - 0.5) * 0.05
);
// Add components
this.world.addComponent(entity, new Transform(posX, 1, posZ));
this.world.addComponent(entity, velocity);
this.world.addComponent(entity, new MeshComponent(mesh));
this.world.addComponent(entity, new Collidable(1.5, 'obstacle'));
this.world.addComponent(entity, new ObstacleTag());
this.world.addComponent(entity, new BoundaryConstrained(this.groundSize));
return entity;
}
/**
* Remove entity and its mesh from scene
* @param {EntityId} entityId - The entity to destroy
*/
destroyEntity(entityId) {
// Remove mesh from scene if it exists
const meshComp = this.world.getComponent(entityId, MeshComponent);
if (meshComp) {
this.scene.remove(meshComp.mesh);
meshComp.mesh.geometry.dispose();
meshComp.mesh.material.dispose();
}
// Remove entity from world
this.world.removeEntity(entityId);
}
}

486
src/game/Game.js Normal file
View file

@ -0,0 +1,486 @@
import { World } from '../ecs/World.js';
import { EntityFactory } from './EntityFactory.js';
// Systems
import { InputSystem } from '../systems/InputSystem.js';
import { PlayerControlSystem } from '../systems/PlayerControlSystem.js';
import { MovementSystem } from '../systems/MovementSystem.js';
import { BoundarySystem } from '../systems/BoundarySystem.js';
import { CoinSystem } from '../systems/CoinSystem.js';
import { ObstacleSystem } from '../systems/ObstacleSystem.js';
import { CollisionSystem } from '../systems/CollisionSystem.js';
import { RenderSystem } from '../systems/RenderSystem.js';
// Components
import { Transform } from '../components/Transform.js';
import { Health } from '../components/Health.js';
/**
* Main Game class - manages the game loop and coordinates all systems.
* Orchestrates the ECS architecture and Three.js rendering.
*
* @typedef {import('../ecs/World.js').EntityId} EntityId
*/
export class Game {
constructor() {
/** @type {number} Size of the game play area */
this.groundSize = 30;
/** @type {number} Current game score */
this.score = 0;
/** @type {boolean} Whether the game is currently active */
this.gameActive = true;
/** @type {EntityId|null} The player entity ID */
this.playerEntity = null;
/** @type {EntityId[]} Array of coin entity IDs */
this.coins = [];
/** @type {EntityId[]} Array of obstacle entity IDs */
this.obstacles = [];
/** @type {number} Last frame timestamp for deltaTime calculation */
this.lastTime = performance.now();
/** @type {number} Maximum deltaTime cap (in seconds) to prevent huge jumps */
this.maxDeltaTime = 0.1; // 100ms cap
/** @type {number} Smoothed FPS for display */
this.smoothedFPS = 60;
/** @type {number} Last time performance monitor was updated */
this.lastPerfUpdate = performance.now();
/** @type {boolean} Whether the game is paused (e.g., tab not visible) */
this.isPaused = false;
/** @type {boolean} Whether performance monitor is visible */
this.perfMonitorVisible = false;
/** @type {Object} Shake detection state */
this.shakeDetection = {
lastX: 0,
lastY: 0,
lastZ: 0,
shakeThreshold: 15,
shakeCount: 0,
lastShakeTime: 0
};
this.init();
this.setupEventListeners();
this.animate();
}
/**
* Initialize the game (ECS, Three.js, entities)
*/
init() {
// Initialize ECS
this.world = new World();
// Setup Three.js
this.setupScene();
this.setupCamera();
this.setupRenderer();
this.setupLights();
this.setupGround();
// Create entity factory
this.entityFactory = new EntityFactory(this.world, this.scene);
// Initialize systems
this.setupSystems();
// Create game entities
this.createGameEntities();
}
setupScene() {
this.scene = new window.THREE.Scene();
this.scene.background = new window.THREE.Color(0x87CEEB);
this.scene.fog = new window.THREE.Fog(0x87CEEB, 0, 50);
}
setupCamera() {
this.camera = new window.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 window.THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = window.THREE.PCFSoftShadowMap;
document.body.appendChild(this.renderer.domElement);
}
setupLights() {
// Increased ambient light for brighter scene (was 0.6)
const ambientLight = new window.THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
// Increased directional light for better clarity (was 0.8)
const directionalLight = new window.THREE.DirectionalLight(0xffffff, 3.0);
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 window.THREE.PlaneGeometry(this.groundSize, this.groundSize);
const groundMaterial = new window.THREE.MeshStandardMaterial({
color: 0x90EE90,
roughness: 0.8
});
const ground = new window.THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
this.scene.add(ground);
const gridHelper = new window.THREE.GridHelper(this.groundSize, 20, 0x000000, 0x000000);
gridHelper.material.opacity = 0.2;
gridHelper.material.transparent = true;
this.scene.add(gridHelper);
}
setupSystems() {
// Input system (must be first)
this.inputSystem = new InputSystem();
this.world.addSystem(this.inputSystem);
// Player control
this.world.addSystem(new PlayerControlSystem(this.inputSystem));
// Movement and physics
this.world.addSystem(new MovementSystem());
this.world.addSystem(new BoundarySystem());
this.world.addSystem(new ObstacleSystem());
// Game-specific behavior
this.world.addSystem(new CoinSystem());
// Collision detection
this.collisionSystem = new CollisionSystem();
this.collisionSystem.onCollision((entity1, entity2, layer1, layer2) => {
this.handleCollision(entity1, entity2, layer1, layer2);
});
this.world.addSystem(this.collisionSystem);
// Rendering (must be last to sync transforms)
this.world.addSystem(new RenderSystem(this.scene));
}
createGameEntities() {
// Create player
this.playerEntity = this.entityFactory.createPlayer();
// Create coins
for (let i = 0; i < 10; i++) {
const coin = this.entityFactory.createCoin(this.coins.length);
this.coins.push(coin);
}
// Create obstacles
for (let i = 0; i < 8; i++) {
const obstacle = this.entityFactory.createObstacle();
this.obstacles.push(obstacle);
}
}
handleCollision(entity1, entity2, layer1, layer2) {
if (!this.gameActive) return;
// Player-Coin collision
if ((layer1 === 'player' && layer2 === 'coin') || (layer1 === 'coin' && layer2 === 'player')) {
const coinEntity = layer1 === 'coin' ? entity1 : entity2;
this.collectCoin(coinEntity);
}
// Player-Obstacle collision
if ((layer1 === 'player' && layer2 === 'obstacle') || (layer1 === 'obstacle' && layer2 === 'player')) {
const playerEntity = layer1 === 'player' ? entity1 : entity2;
const obstacleEntity = layer1 === 'obstacle' ? entity1 : entity2;
this.handleObstacleCollision(playerEntity, obstacleEntity);
}
}
collectCoin(coinEntity) {
// Remove coin
this.entityFactory.destroyEntity(coinEntity);
const index = this.coins.indexOf(coinEntity);
if (index > -1) {
this.coins.splice(index, 1);
}
// Update score
this.score += 10;
this.updateUI();
// Spawn new coin
const newCoin = this.entityFactory.createCoin(this.coins.length);
this.coins.push(newCoin);
}
handleObstacleCollision(playerEntity, obstacleEntity) {
const health = this.world.getComponent(playerEntity, Health);
const playerTransform = this.world.getComponent(playerEntity, Transform);
const obstacleTransform = this.world.getComponent(obstacleEntity, Transform);
// Damage player
const isDead = health.damage(1);
// Push player back
const pushDirection = playerTransform.position.clone().sub(obstacleTransform.position);
pushDirection.y = 0;
pushDirection.normalize();
playerTransform.position.add(pushDirection.multiplyScalar(0.3));
playerTransform.position.y = 0.5;
this.updateUI();
if (isDead) {
this.gameOver();
}
}
updateCamera() {
if (!this.playerEntity) return;
const playerTransform = this.world.getComponent(this.playerEntity, Transform);
if (playerTransform) {
this.camera.position.x = playerTransform.position.x;
this.camera.position.z = playerTransform.position.z + 15;
this.camera.lookAt(playerTransform.position);
}
}
updateUI() {
document.getElementById('score').textContent = this.score;
const health = this.world.getComponent(this.playerEntity, Health);
if (health) {
document.getElementById('health').textContent = Math.max(0, health.currentHealth);
}
}
gameOver() {
this.gameActive = false;
document.getElementById('finalScore').textContent = this.score;
document.getElementById('gameOver').style.display = 'block';
}
restart() {
// Clean up old entities
[...this.coins].forEach(coin => this.entityFactory.destroyEntity(coin));
[...this.obstacles].forEach(obstacle => this.entityFactory.destroyEntity(obstacle));
if (this.playerEntity) {
this.entityFactory.destroyEntity(this.playerEntity);
}
this.coins = [];
this.obstacles = [];
// Reset game state
this.score = 0;
this.gameActive = true;
this.lastTime = performance.now(); // Reset timer to prevent deltaTime spike
// Recreate entities
this.createGameEntities();
// Hide game over screen
document.getElementById('gameOver').style.display = 'none';
this.updateUI();
}
setupEventListeners() {
window.addEventListener('resize', () => this.onWindowResize());
document.getElementById('restartBtn').addEventListener('click', () => this.restart());
// Toggle performance monitor with 'T' key
window.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 't') {
this.togglePerformanceMonitor();
}
});
// Shake detection for mobile
if (window.DeviceMotionEvent) {
window.addEventListener('devicemotion', (e) => this.handleDeviceMotion(e), false);
}
// Pause game when tab loses focus
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.isPaused = true;
console.log('Game paused (tab hidden)');
} else {
this.isPaused = false;
// Reset timer to prevent deltaTime spike
this.lastTime = performance.now();
console.log('Game resumed');
}
});
// Also handle window blur/focus as fallback
window.addEventListener('blur', () => {
this.isPaused = true;
});
window.addEventListener('focus', () => {
if (!document.hidden) {
this.isPaused = false;
this.lastTime = performance.now();
}
});
// Load version
this.loadVersion();
}
/**
* Toggle performance monitor visibility
*/
togglePerformanceMonitor() {
this.perfMonitorVisible = !this.perfMonitorVisible;
const monitor = document.getElementById('perfMonitor');
if (this.perfMonitorVisible) {
monitor.classList.add('visible');
console.log('Performance monitor enabled');
} else {
monitor.classList.remove('visible');
console.log('Performance monitor disabled');
}
}
/**
* Handle device motion for shake detection
* @param {DeviceMotionEvent} event
*/
handleDeviceMotion(event) {
const acceleration = event.accelerationIncludingGravity;
if (!acceleration) return;
const currentTime = Date.now();
const timeDiff = currentTime - this.shakeDetection.lastShakeTime;
if (timeDiff > 100) { // Check every 100ms
const { x = 0, y = 0, z = 0 } = acceleration;
const deltaX = Math.abs(x - this.shakeDetection.lastX);
const deltaY = Math.abs(y - this.shakeDetection.lastY);
const deltaZ = Math.abs(z - this.shakeDetection.lastZ);
if (deltaX + deltaY + deltaZ > this.shakeDetection.shakeThreshold) {
this.shakeDetection.shakeCount++;
// Toggle after 2 shakes within 500ms
if (this.shakeDetection.shakeCount >= 2) {
this.togglePerformanceMonitor();
this.shakeDetection.shakeCount = 0;
}
} else {
this.shakeDetection.shakeCount = 0;
}
this.shakeDetection.lastX = x;
this.shakeDetection.lastY = y;
this.shakeDetection.lastZ = z;
this.shakeDetection.lastShakeTime = currentTime;
}
}
loadVersion() {
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 => {
console.debug('Version information not available:', error.message);
});
}
onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
/**
* Main game loop - calculates deltaTime and updates all systems
* @param {number} [currentTime] - Current timestamp from requestAnimationFrame
*/
animate(currentTime = performance.now()) {
requestAnimationFrame((time) => this.animate(time));
// If paused, skip updates but keep rendering
if (this.isPaused) {
this.renderer.render(this.scene, this.camera);
return;
}
// Calculate deltaTime in seconds
const deltaTime = Math.min((currentTime - this.lastTime) / 1000, this.maxDeltaTime);
this.lastTime = currentTime;
// Update performance monitor with smoothed values
if (this.perfMonitorVisible) {
// Calculate instant FPS from deltaTime
const instantFPS = 1 / deltaTime;
// Smooth FPS using exponential moving average for stability
this.smoothedFPS = this.smoothedFPS * 0.9 + instantFPS * 0.1;
// Update display every 100ms for real-time feel without flickering
if (currentTime - this.lastPerfUpdate >= 100) {
const frameTime = (deltaTime * 1000).toFixed(1);
const entityCount = this.world.entities.size;
document.getElementById('fps').textContent = Math.round(this.smoothedFPS);
document.getElementById('frameTime').textContent = frameTime;
document.getElementById('entityCount').textContent = entityCount;
this.lastPerfUpdate = currentTime;
}
}
if (this.gameActive) {
// Update ECS world with actual deltaTime
this.world.update(deltaTime);
// Update camera
this.updateCamera();
}
// Render scene
this.renderer.render(this.scene, this.camera);
}
}

18
src/main.js Normal file
View file

@ -0,0 +1,18 @@
import { Game } from './game/Game.js';
// Start the game immediately (script is at end of body, DOM is ready)
console.log('Starting 3D Coin Collector Game with ECS Architecture');
console.log('Press "T" to toggle performance monitor (or shake device on mobile)');
// Wait a tick to ensure everything is loaded
setTimeout(() => {
try {
const game = new Game();
// Make game accessible from console for debugging
window.game = game;
} catch (error) {
console.error('Game initialization failed:', error);
}
}, 0);

View file

@ -0,0 +1,43 @@
import { System } from '../ecs/System.js';
import { BoundaryConstrained } from '../components/Tags.js';
import { Transform } from '../components/Transform.js';
import { Velocity } from '../components/Velocity.js';
/**
* BoundarySystem - keeps entities within defined boundaries
*/
export class BoundarySystem extends System {
update(_deltaTime) {
const entities = this.getEntities(BoundaryConstrained, Transform);
for (const entityId of entities) {
const boundary = this.getComponent(entityId, BoundaryConstrained);
const transform = this.getComponent(entityId, Transform);
const velocity = this.getComponent(entityId, Velocity);
const limit = boundary.getBoundary();
// Clamp position to boundaries
const clamped = {
x: Math.max(-limit, Math.min(limit, transform.position.x)),
z: Math.max(-limit, Math.min(limit, transform.position.z))
};
// If clamped, update position and stop velocity
if (clamped.x !== transform.position.x) {
transform.position.x = clamped.x;
if (velocity) {
velocity.velocity.x = 0;
}
}
if (clamped.z !== transform.position.z) {
transform.position.z = clamped.z;
if (velocity) {
velocity.velocity.z = 0;
}
}
}
}
}

33
src/systems/CoinSystem.js Normal file
View file

@ -0,0 +1,33 @@
import { System } from '../ecs/System.js';
import { CoinTag } from '../components/Tags.js';
import { Transform } from '../components/Transform.js';
/**
* CoinSystem - handles coin-specific behavior (rotation and bobbing)
*/
export class CoinSystem extends System {
constructor() {
super();
this.elapsedTime = 0;
}
update(deltaTime) {
this.elapsedTime += deltaTime;
const coins = this.getEntities(CoinTag, Transform);
for (const entityId of coins) {
const coinTag = this.getComponent(entityId, CoinTag);
const transform = this.getComponent(entityId, Transform);
// Rotate coin (frame-rate independent)
// rotationSpeed is radians per second
transform.rotation.y += coinTag.rotationSpeed * 60 * deltaTime;
// Bob up and down using accumulated time
const baseY = 0.5;
const timeScale = 3; // Speed multiplier (3 matches original 0.003 * 1000ms)
transform.position.y = baseY + Math.sin(this.elapsedTime * timeScale + coinTag.index) * coinTag.bobAmount;
}
}
}

View file

@ -0,0 +1,80 @@
import { System } from '../ecs/System.js';
import { Transform } from '../components/Transform.js';
import { Collidable } from '../components/Collidable.js';
/**
* CollisionSystem - detects and reports collisions
*/
export class CollisionSystem extends System {
constructor() {
super();
this.collisionCallbacks = [];
}
/**
* Register a callback for collision events
* @param {Function} callback - (entity1Id, entity2Id, layer1, layer2) => void
*/
onCollision(callback) {
this.collisionCallbacks.push(callback);
}
/**
* Update collision detection
* Note: Entity list is captured at the start of update, but entities may be
* destroyed during collision callbacks, so we need defensive null checks.
* @param {number} _deltaTime - Time since last frame (unused - collision is instantaneous)
*/
update(_deltaTime) {
const entities = this.getEntities(Transform, Collidable);
// Track checked pairs to avoid duplicate collision callbacks this frame
const checkedPairs = new Set();
// Check all pairs of collidable entities
for (let i = 0; i < entities.length; i++) {
for (let j = i + 1; j < entities.length; j++) {
const entity1 = entities[i];
const entity2 = entities[j];
// Create unique pair ID
const pairId = `${Math.min(entity1, entity2)}-${Math.max(entity1, entity2)}`;
if (checkedPairs.has(pairId)) continue;
checkedPairs.add(pairId);
const transform1 = this.getComponent(entity1, Transform);
const transform2 = this.getComponent(entity2, Transform);
const collidable1 = this.getComponent(entity1, Collidable);
const collidable2 = this.getComponent(entity2, Collidable);
// Skip if entity was destroyed during collision handling this frame
if (!transform1 || !transform2 || !collidable1 || !collidable2) {
continue;
}
// Calculate distance between entities
const distance = transform1.position.distanceTo(transform2.position);
// Determine which radius to use (use non-player radius, or sum if both non-player)
let collisionRadius;
if (collidable1.layer === 'player') {
collisionRadius = collidable2.radius; // Use other entity's radius
} else if (collidable2.layer === 'player') {
collisionRadius = collidable1.radius; // Use other entity's radius
} else {
// Both are non-player, use sum of radii
collisionRadius = collidable1.radius + collidable2.radius;
}
// Check if colliding
if (distance < collisionRadius) {
// Notify all collision callbacks
for (const callback of this.collisionCallbacks) {
callback(entity1, entity2, collidable1.layer, collidable2.layer);
}
}
}
}
}
}

105
src/systems/InputSystem.js Normal file
View file

@ -0,0 +1,105 @@
import { System } from '../ecs/System.js';
/**
* InputSystem - manages keyboard and touch input
*/
export class InputSystem extends System {
constructor() {
super();
this.keys = {};
this.touch = {
active: false,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
id: null
};
}
init() {
// Keyboard events
window.addEventListener('keydown', (e) => {
this.keys[e.key.toLowerCase()] = true;
});
window.addEventListener('keyup', (e) => {
this.keys[e.key.toLowerCase()] = false;
});
// Touch events
const canvas = document.querySelector('canvas');
if (canvas) {
canvas.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false });
canvas.addEventListener('touchmove', (e) => this.handleTouchMove(e), { passive: false });
canvas.addEventListener('touchend', (e) => this.handleTouchEnd(e), { passive: false });
canvas.addEventListener('touchcancel', (e) => this.handleTouchEnd(e), { passive: false });
}
}
handleTouchStart(e) {
e.preventDefault();
if (this.touch.active) return;
const touch = e.touches[0];
const rect = e.target.getBoundingClientRect();
this.touch.startX = rect.left + rect.width / 2;
this.touch.startY = rect.top + rect.height / 2;
this.touch.currentX = touch.clientX;
this.touch.currentY = touch.clientY;
this.touch.id = touch.identifier;
this.touch.active = true;
}
handleTouchMove(e) {
e.preventDefault();
if (!this.touch.active) return;
const touch = Array.from(e.touches).find(t => t.identifier === this.touch.id);
if (!touch) return;
this.touch.currentX = touch.clientX;
this.touch.currentY = touch.clientY;
}
handleTouchEnd(e) {
e.preventDefault();
if (!this.touch.active) return;
const touch = Array.from(e.changedTouches).find(t => t.identifier === this.touch.id);
if (!touch) return;
this.touch.active = false;
this.touch.id = null;
}
isKeyPressed(key) {
return this.keys[key] || this.keys['arrow' + key] || false;
}
getTouchDirection() {
if (!this.touch.active) {
return { x: 0, y: 0 };
}
const deltaX = this.touch.currentX - this.touch.startX;
const deltaY = this.touch.currentY - this.touch.startY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const threshold = 10;
if (distance < threshold) {
return { x: 0, y: 0 };
}
const normalizedX = deltaX / distance;
const normalizedY = deltaY / distance;
return { x: normalizedX, y: normalizedY };
}
update(_deltaTime) {
// Input is passive, just stores state for other systems to query
}
}

View file

@ -0,0 +1,23 @@
import { System } from '../ecs/System.js';
import { Transform } from '../components/Transform.js';
import { Velocity } from '../components/Velocity.js';
/**
* MovementSystem - applies velocity to transform (frame-rate independent)
*/
export class MovementSystem extends System {
update(deltaTime) {
const entities = this.getEntities(Transform, Velocity);
for (const entityId of entities) {
const transform = this.getComponent(entityId, Transform);
const velocity = this.getComponent(entityId, Velocity);
// Apply velocity scaled by deltaTime for frame-rate independence
// Velocity is in units per second, deltaTime converts to units per frame
const displacement = velocity.velocity.clone().multiplyScalar(deltaTime * 60);
transform.position.add(displacement);
}
}
}

View file

@ -0,0 +1,34 @@
import { System } from '../ecs/System.js';
import { ObstacleTag, BoundaryConstrained } from '../components/Tags.js';
import { Transform } from '../components/Transform.js';
import { Velocity } from '../components/Velocity.js';
/**
* ObstacleSystem - handles obstacle-specific behavior
*/
export class ObstacleSystem extends System {
update(_deltaTime) {
const obstacles = this.getEntities(ObstacleTag, Transform, Velocity, BoundaryConstrained);
for (const entityId of obstacles) {
const transform = this.getComponent(entityId, Transform);
const velocity = this.getComponent(entityId, Velocity);
const boundary = this.getComponent(entityId, BoundaryConstrained);
const boundaryLimit = boundary.getBoundary() - 1;
// Bounce off boundaries
if (Math.abs(transform.position.x) > boundaryLimit) {
velocity.velocity.x *= -1;
// Clamp position
transform.position.x = Math.sign(transform.position.x) * boundaryLimit;
}
if (Math.abs(transform.position.z) > boundaryLimit) {
velocity.velocity.z *= -1;
// Clamp position
transform.position.z = Math.sign(transform.position.z) * boundaryLimit;
}
}
}
}

View file

@ -0,0 +1,76 @@
import { System } from '../ecs/System.js';
import { PlayerTag } from '../components/Tags.js';
import { Velocity } from '../components/Velocity.js';
import { Transform } from '../components/Transform.js';
/**
* PlayerControlSystem - handles player input and applies to velocity
*/
export class PlayerControlSystem extends System {
constructor(inputSystem) {
super();
this.inputSystem = inputSystem;
}
update(deltaTime) {
const players = this.getEntities(PlayerTag, Velocity, Transform);
for (const entityId of players) {
const velocity = this.getComponent(entityId, Velocity);
const transform = this.getComponent(entityId, Transform);
// Calculate target velocity from input
const targetVelocity = new window.THREE.Vector3(0, 0, 0);
// Keyboard input
if (this.inputSystem.isKeyPressed('w') || this.inputSystem.isKeyPressed('up')) {
targetVelocity.z -= velocity.maxSpeed;
}
if (this.inputSystem.isKeyPressed('s') || this.inputSystem.isKeyPressed('down')) {
targetVelocity.z += velocity.maxSpeed;
}
if (this.inputSystem.isKeyPressed('a') || this.inputSystem.isKeyPressed('left')) {
targetVelocity.x -= velocity.maxSpeed;
}
if (this.inputSystem.isKeyPressed('d') || this.inputSystem.isKeyPressed('right')) {
targetVelocity.x += velocity.maxSpeed;
}
// Touch input
const touch = this.inputSystem.getTouchDirection();
if (Math.abs(touch.x) > 0.3) {
targetVelocity.x = touch.x * velocity.maxSpeed;
}
if (Math.abs(touch.y) > 0.3) {
targetVelocity.z = touch.y * velocity.maxSpeed;
}
// Apply smooth acceleration/deceleration
const isMoving = targetVelocity.length() > 0;
const accelRate = isMoving ? velocity.acceleration : velocity.deceleration;
// Smooth interpolation for each axis
const velDiffX = targetVelocity.x - velocity.velocity.x;
const velDiffZ = targetVelocity.z - velocity.velocity.z;
if (Math.abs(velDiffX) > 0.001) {
velocity.velocity.x += velDiffX * accelRate;
} else {
velocity.velocity.x = targetVelocity.x;
}
if (Math.abs(velDiffZ) > 0.001) {
velocity.velocity.z += velDiffZ * accelRate;
} else {
velocity.velocity.z = targetVelocity.z;
}
// Rotate player when moving (frame-rate independent)
// Original was 0.1 per frame at ~60fps = 6 rad/s
if (velocity.velocity.length() > 0.01) {
transform.rotation.y += 6 * deltaTime;
}
}
}
}

View file

@ -0,0 +1,28 @@
import { System } from '../ecs/System.js';
import { Transform } from '../components/Transform.js';
import { MeshComponent } from '../components/MeshComponent.js';
/**
* RenderSystem - syncs Three.js mesh positions with Transform components
*/
export class RenderSystem extends System {
constructor(scene) {
super();
this.scene = scene;
}
update(_deltaTime) {
const entities = this.getEntities(Transform, MeshComponent);
for (const entityId of entities) {
const transform = this.getComponent(entityId, Transform);
const meshComp = this.getComponent(entityId, MeshComponent);
// Sync mesh transform with component
meshComp.mesh.position.copy(transform.position);
meshComp.mesh.rotation.copy(transform.rotation);
meshComp.mesh.scale.copy(transform.scale);
}
}
}