Feature/Refactor to use ecs architecture (#14)
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:
parent
e0436e7769
commit
cec1fccc22
23 changed files with 1709 additions and 650 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
125
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
659
index.html
659
index.html
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
20
src/components/Collidable.js
Normal file
20
src/components/Collidable.js
Normal 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
50
src/components/Health.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
19
src/components/MeshComponent.js
Normal file
19
src/components/MeshComponent.js
Normal 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
66
src/components/Tags.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
39
src/components/Transform.js
Normal file
39
src/components/Transform.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
67
src/components/Velocity.js
Normal file
67
src/components/Velocity.js
Normal 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
53
src/ecs/System.js
Normal 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
170
src/ecs/World.js
Normal 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
157
src/game/EntityFactory.js
Normal 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
486
src/game/Game.js
Normal 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
18
src/main.js
Normal 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);
|
||||
|
||||
43
src/systems/BoundarySystem.js
Normal file
43
src/systems/BoundarySystem.js
Normal 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
33
src/systems/CoinSystem.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
src/systems/CollisionSystem.js
Normal file
80
src/systems/CollisionSystem.js
Normal 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
105
src/systems/InputSystem.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
23
src/systems/MovementSystem.js
Normal file
23
src/systems/MovementSystem.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
src/systems/ObstacleSystem.js
Normal file
34
src/systems/ObstacleSystem.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
src/systems/PlayerControlSystem.js
Normal file
76
src/systems/PlayerControlSystem.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
src/systems/RenderSystem.js
Normal file
28
src/systems/RenderSystem.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue