feat: implement ECS architecture with game entity management
- Introduced a new Entity-Component-System (ECS) architecture for the game. - Created foundational components such as Transform, Velocity, Health, and Collidable. - Developed systems for handling input, movement, collision detection, and rendering. - Added game logic for player control, coin collection, and obstacle interactions. - Implemented a performance monitor for real-time metrics display. - Enhanced game initialization and entity creation processes. This update significantly refactors the game structure, improving maintainability and scalability.
This commit is contained in:
parent
50544989ca
commit
7dd7477a3b
20 changed files with 1610 additions and 616 deletions
657
index.html
657
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);
|
||||
}
|
||||
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js';
|
||||
|
||||
// Override in child classes
|
||||
createMesh() {
|
||||
throw new Error('createMesh() must be implemented by child class');
|
||||
}
|
||||
// Make THREE globally available for our modules
|
||||
window.THREE = THREE;
|
||||
|
||||
// Override in child classes for initialization
|
||||
initialize() {
|
||||
// Default implementation - can be overridden
|
||||
}
|
||||
|
||||
// Override in child classes for update logic
|
||||
update(...args) {
|
||||
// Default implementation - can be overridden
|
||||
}
|
||||
|
||||
// Common collision detection
|
||||
checkCollision(otherPosition, collisionRadius) {
|
||||
const distance = this.mesh.position.distanceTo(otherPosition);
|
||||
return distance < collisionRadius;
|
||||
}
|
||||
|
||||
// Common position getter
|
||||
getPosition() {
|
||||
return this.mesh.position;
|
||||
}
|
||||
|
||||
// Remove from scene
|
||||
remove() {
|
||||
this.scene.remove(this.mesh);
|
||||
}
|
||||
|
||||
// Set position
|
||||
setPosition(x, y, z) {
|
||||
this.mesh.position.set(x, y, z);
|
||||
}
|
||||
|
||||
// Get position vector
|
||||
getPositionVector() {
|
||||
return this.mesh.position.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Player class extends GameObject
|
||||
class Player extends GameObject {
|
||||
constructor(scene, groundSize) {
|
||||
super(scene, groundSize);
|
||||
this.maxSpeed = 0.15;
|
||||
this.acceleration = 0.08;
|
||||
this.deceleration = 0.12;
|
||||
this.velocity = new THREE.Vector3(0, 0, 0);
|
||||
}
|
||||
|
||||
createMesh() {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: 0x4169E1,
|
||||
metalness: 0.3,
|
||||
roughness: 0.4
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Initialize velocity (needed because initialize is called from parent constructor)
|
||||
if (!this.velocity) {
|
||||
this.velocity = new THREE.Vector3(0, 0, 0);
|
||||
}
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.setPosition(0, 0.5, 0);
|
||||
this.mesh.rotation.y = 0;
|
||||
if (this.velocity) {
|
||||
this.velocity.set(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
update(keys) {
|
||||
// Calculate target velocity based on input
|
||||
const targetVelocity = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
if (keys['w'] || keys['arrowup']) targetVelocity.z -= this.maxSpeed;
|
||||
if (keys['s'] || keys['arrowdown']) targetVelocity.z += this.maxSpeed;
|
||||
if (keys['a'] || keys['arrowleft']) targetVelocity.x -= this.maxSpeed;
|
||||
if (keys['d'] || keys['arrowright']) targetVelocity.x += this.maxSpeed;
|
||||
|
||||
// Smoothly interpolate velocity towards target
|
||||
const isMoving = targetVelocity.length() > 0;
|
||||
const accelRate = isMoving ? this.acceleration : this.deceleration;
|
||||
|
||||
// Calculate velocity difference for each axis
|
||||
const velDiffX = targetVelocity.x - this.velocity.x;
|
||||
const velDiffZ = targetVelocity.z - this.velocity.z;
|
||||
|
||||
// Apply easing to each axis independently for smoother diagonal movement
|
||||
if (Math.abs(velDiffX) > 0.001) {
|
||||
this.velocity.x += velDiffX * accelRate;
|
||||
} else {
|
||||
this.velocity.x = targetVelocity.x;
|
||||
}
|
||||
|
||||
if (Math.abs(velDiffZ) > 0.001) {
|
||||
this.velocity.z += velDiffZ * accelRate;
|
||||
} else {
|
||||
this.velocity.z = targetVelocity.z;
|
||||
}
|
||||
|
||||
// Apply velocity to position
|
||||
this.mesh.position.add(this.velocity);
|
||||
|
||||
// Boundary checks
|
||||
const boundary = this.groundSize / 2 - 0.5;
|
||||
this.mesh.position.x = Math.max(-boundary, Math.min(boundary, this.mesh.position.x));
|
||||
this.mesh.position.z = Math.max(-boundary, Math.min(boundary, this.mesh.position.z));
|
||||
|
||||
// Stop velocity if hitting boundary
|
||||
if (Math.abs(this.mesh.position.x) >= boundary) {
|
||||
this.velocity.x = 0;
|
||||
}
|
||||
if (Math.abs(this.mesh.position.z) >= boundary) {
|
||||
this.velocity.z = 0;
|
||||
}
|
||||
|
||||
// Rotate player based on movement
|
||||
if (this.velocity.length() > 0.01) {
|
||||
this.mesh.rotation.y += 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
handleCollision(obstaclePosition) {
|
||||
const pushDirection = this.mesh.position.clone().sub(obstaclePosition);
|
||||
pushDirection.y = 0;
|
||||
pushDirection.normalize();
|
||||
this.mesh.position.add(pushDirection.multiplyScalar(0.3));
|
||||
this.mesh.position.y = 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Coin class extends GameObject
|
||||
class Coin extends GameObject {
|
||||
constructor(scene, groundSize, index = 0) {
|
||||
super(scene, groundSize);
|
||||
this.index = index;
|
||||
this.rotationSpeed = 0.02;
|
||||
this.collisionRadius = 0.8;
|
||||
}
|
||||
|
||||
createMesh() {
|
||||
const geometry = new THREE.SphereGeometry(0.3, 16, 16);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: 0xFFD700,
|
||||
metalness: 0.8,
|
||||
roughness: 0.2,
|
||||
emissive: 0xFFD700,
|
||||
emissiveIntensity: 0.3
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.setRandomPosition();
|
||||
}
|
||||
|
||||
setRandomPosition() {
|
||||
const x = (Math.random() - 0.5) * (this.groundSize - 4);
|
||||
const z = (Math.random() - 0.5) * (this.groundSize - 4);
|
||||
this.setPosition(x, 0.5, z);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.mesh.rotation.y += this.rotationSpeed;
|
||||
this.mesh.position.y = 0.5 + Math.sin(Date.now() * 0.003 + this.index) * 0.2;
|
||||
}
|
||||
|
||||
checkCollisionWithPlayer(playerPosition) {
|
||||
return this.checkCollision(playerPosition, this.collisionRadius);
|
||||
}
|
||||
}
|
||||
|
||||
// Obstacle class extends GameObject
|
||||
class Obstacle extends GameObject {
|
||||
constructor(scene, groundSize) {
|
||||
super(scene, groundSize);
|
||||
this.direction = this.createRandomDirection();
|
||||
this.collisionRadius = 1.5;
|
||||
}
|
||||
|
||||
createMesh() {
|
||||
const geometry = new THREE.BoxGeometry(1.5, 2, 1.5);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color: 0xFF4500,
|
||||
metalness: 0.3,
|
||||
roughness: 0.7
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.setRandomPosition();
|
||||
this.mesh.position.y = 1;
|
||||
}
|
||||
|
||||
createRandomDirection() {
|
||||
return new THREE.Vector3(
|
||||
(Math.random() - 0.5) * 0.05,
|
||||
0,
|
||||
(Math.random() - 0.5) * 0.05
|
||||
);
|
||||
}
|
||||
|
||||
setRandomPosition() {
|
||||
let posX, posZ;
|
||||
do {
|
||||
posX = (Math.random() - 0.5) * (this.groundSize - 4);
|
||||
posZ = (Math.random() - 0.5) * (this.groundSize - 4);
|
||||
} while (Math.abs(posX) < 3 && Math.abs(posZ) < 3);
|
||||
|
||||
this.setPosition(posX, 1, posZ);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.mesh.position.add(this.direction);
|
||||
|
||||
// Bounce off boundaries
|
||||
const boundary = this.groundSize / 2 - 1;
|
||||
if (Math.abs(this.mesh.position.x) > boundary) {
|
||||
this.direction.x *= -1;
|
||||
}
|
||||
if (Math.abs(this.mesh.position.z) > boundary) {
|
||||
this.direction.z *= -1;
|
||||
}
|
||||
}
|
||||
|
||||
checkCollisionWithPlayer(playerPosition) {
|
||||
return this.checkCollision(playerPosition, this.collisionRadius);
|
||||
}
|
||||
}
|
||||
|
||||
// Game class
|
||||
class Game {
|
||||
constructor() {
|
||||
this.groundSize = 30;
|
||||
this.score = 0;
|
||||
this.health = 100;
|
||||
this.gameActive = true;
|
||||
this.keys = {};
|
||||
this.coins = [];
|
||||
this.obstacles = [];
|
||||
this.touchActive = false;
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
this.touchCurrentX = 0;
|
||||
this.touchCurrentY = 0;
|
||||
this.touchId = null;
|
||||
|
||||
this.init();
|
||||
this.setupEventListeners();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupScene();
|
||||
this.setupCamera();
|
||||
this.setupRenderer();
|
||||
this.setupLights();
|
||||
this.setupGround();
|
||||
this.setupPlayer();
|
||||
this.createGameObjects();
|
||||
}
|
||||
|
||||
setupScene() {
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x87CEEB);
|
||||
this.scene.fog = new THREE.Fog(0x87CEEB, 0, 50);
|
||||
}
|
||||
|
||||
setupCamera() {
|
||||
this.camera = new THREE.PerspectiveCamera(
|
||||
75,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
1000
|
||||
);
|
||||
this.camera.position.set(0, 10, 15);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
}
|
||||
|
||||
setupRenderer() {
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
document.body.appendChild(this.renderer.domElement);
|
||||
}
|
||||
|
||||
setupLights() {
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
this.scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
directionalLight.position.set(10, 20, 10);
|
||||
directionalLight.castShadow = true;
|
||||
directionalLight.shadow.mapSize.width = 2048;
|
||||
directionalLight.shadow.mapSize.height = 2048;
|
||||
directionalLight.shadow.camera.left = -20;
|
||||
directionalLight.shadow.camera.right = 20;
|
||||
directionalLight.shadow.camera.top = 20;
|
||||
directionalLight.shadow.camera.bottom = -20;
|
||||
directionalLight.shadow.camera.near = 0.5;
|
||||
directionalLight.shadow.camera.far = 50;
|
||||
this.scene.add(directionalLight);
|
||||
}
|
||||
|
||||
setupGround() {
|
||||
const groundGeometry = new THREE.PlaneGeometry(this.groundSize, this.groundSize);
|
||||
const groundMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x90EE90,
|
||||
roughness: 0.8
|
||||
});
|
||||
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.receiveShadow = true;
|
||||
this.scene.add(ground);
|
||||
|
||||
const gridHelper = new THREE.GridHelper(this.groundSize, 20, 0x000000, 0x000000);
|
||||
gridHelper.material.opacity = 0.2;
|
||||
gridHelper.material.transparent = true;
|
||||
this.scene.add(gridHelper);
|
||||
}
|
||||
|
||||
setupPlayer() {
|
||||
this.player = new Player(this.scene, this.groundSize);
|
||||
}
|
||||
|
||||
createGameObjects() {
|
||||
this.createCoins(10);
|
||||
this.createObstacles(8);
|
||||
}
|
||||
|
||||
createCoins(count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const coin = new Coin(this.scene, this.groundSize, this.coins.length);
|
||||
this.coins.push(coin);
|
||||
}
|
||||
}
|
||||
|
||||
createObstacles(count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const obstacle = new Obstacle(this.scene, this.groundSize);
|
||||
this.obstacles.push(obstacle);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
this.keys[e.key.toLowerCase()] = true;
|
||||
});
|
||||
window.addEventListener('keyup', (e) => {
|
||||
this.keys[e.key.toLowerCase()] = false;
|
||||
});
|
||||
window.addEventListener('resize', () => this.onWindowResize());
|
||||
document.getElementById('restartBtn').addEventListener('click', () => this.restart());
|
||||
|
||||
// Touch event listeners
|
||||
this.setupTouchControls();
|
||||
}
|
||||
|
||||
setupTouchControls() {
|
||||
const canvas = this.renderer.domElement;
|
||||
|
||||
// Prevent default touch behaviors
|
||||
canvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleTouchStart(e);
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleTouchMove(e);
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleTouchEnd(e);
|
||||
}, { passive: false });
|
||||
|
||||
canvas.addEventListener('touchcancel', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleTouchEnd(e);
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
handleTouchStart(e) {
|
||||
if (this.touchActive) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||
|
||||
// Use center of screen as reference point
|
||||
this.touchStartX = rect.left + rect.width / 2;
|
||||
this.touchStartY = rect.top + rect.height / 2;
|
||||
|
||||
this.touchId = touch.identifier;
|
||||
this.touchCurrentX = touch.clientX;
|
||||
this.touchCurrentY = touch.clientY;
|
||||
this.touchActive = true;
|
||||
}
|
||||
|
||||
handleTouchMove(e) {
|
||||
if (!this.touchActive) return;
|
||||
|
||||
const touch = Array.from(e.touches).find(t => t.identifier === this.touchId);
|
||||
if (!touch) return;
|
||||
|
||||
this.touchCurrentX = touch.clientX;
|
||||
this.touchCurrentY = touch.clientY;
|
||||
|
||||
this.updateTouchMovement();
|
||||
}
|
||||
|
||||
handleTouchEnd(e) {
|
||||
if (!this.touchActive) return;
|
||||
|
||||
// Check if the touch that ended is our tracked touch
|
||||
const touch = Array.from(e.changedTouches).find(t => t.identifier === this.touchId);
|
||||
if (!touch) return;
|
||||
|
||||
this.touchActive = false;
|
||||
this.touchId = null;
|
||||
this.touchCurrentX = this.touchStartX;
|
||||
this.touchCurrentY = this.touchStartY;
|
||||
this.updateTouchMovement();
|
||||
}
|
||||
|
||||
updateTouchMovement() {
|
||||
if (!this.touchActive) {
|
||||
// Reset all movement keys
|
||||
this.keys['w'] = false;
|
||||
this.keys['s'] = false;
|
||||
this.keys['a'] = false;
|
||||
this.keys['d'] = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = this.touchCurrentX - this.touchStartX;
|
||||
const deltaY = this.touchCurrentY - this.touchStartY;
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// Normalize movement direction
|
||||
const threshold = 10; // Minimum movement to register
|
||||
if (distance < threshold) {
|
||||
this.keys['w'] = false;
|
||||
this.keys['s'] = false;
|
||||
this.keys['a'] = false;
|
||||
this.keys['d'] = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate direction
|
||||
const normalizedX = deltaX / distance;
|
||||
const normalizedY = deltaY / distance;
|
||||
|
||||
// Update movement keys based on direction
|
||||
this.keys['w'] = normalizedY < -0.3;
|
||||
this.keys['s'] = normalizedY > 0.3;
|
||||
this.keys['a'] = normalizedX < -0.3;
|
||||
this.keys['d'] = normalizedX > 0.3;
|
||||
}
|
||||
|
||||
updatePlayer() {
|
||||
if (!this.gameActive) return;
|
||||
this.player.update(this.keys);
|
||||
}
|
||||
|
||||
updateCoins() {
|
||||
this.coins.forEach((coin, index) => {
|
||||
coin.update();
|
||||
|
||||
if (coin.checkCollisionWithPlayer(this.player.getPosition())) {
|
||||
coin.remove();
|
||||
this.coins.splice(index, 1);
|
||||
this.score += 10;
|
||||
this.updateUI();
|
||||
this.createCoins(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateObstacles() {
|
||||
if (!this.gameActive) return;
|
||||
|
||||
this.obstacles.forEach(obstacle => {
|
||||
obstacle.update();
|
||||
|
||||
if (obstacle.checkCollisionWithPlayer(this.player.getPosition())) {
|
||||
this.health -= 1;
|
||||
this.updateUI();
|
||||
this.player.handleCollision(obstacle.getPosition());
|
||||
|
||||
if (this.health <= 0) {
|
||||
this.gameOver();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateCamera() {
|
||||
const playerPos = this.player.getPosition();
|
||||
this.camera.position.x = playerPos.x;
|
||||
this.camera.position.z = playerPos.z + 15;
|
||||
this.camera.lookAt(playerPos);
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
document.getElementById('score').textContent = this.score;
|
||||
document.getElementById('health').textContent = Math.max(0, this.health);
|
||||
}
|
||||
|
||||
gameOver() {
|
||||
this.gameActive = false;
|
||||
document.getElementById('finalScore').textContent = this.score;
|
||||
document.getElementById('gameOver').style.display = 'block';
|
||||
}
|
||||
|
||||
restart() {
|
||||
// Remove all coins and obstacles
|
||||
this.coins.forEach(coin => coin.remove());
|
||||
this.obstacles.forEach(obstacle => obstacle.remove());
|
||||
this.coins = [];
|
||||
this.obstacles = [];
|
||||
|
||||
// Reset player
|
||||
this.player.reset();
|
||||
|
||||
// Reset game state
|
||||
this.score = 0;
|
||||
this.health = 100;
|
||||
this.gameActive = true;
|
||||
|
||||
// Recreate game objects
|
||||
this.createGameObjects();
|
||||
|
||||
// Hide game over screen
|
||||
document.getElementById('gameOver').style.display = 'none';
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
this.camera.aspect = window.innerWidth / window.innerHeight;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
|
||||
animate() {
|
||||
requestAnimationFrame(() => this.animate());
|
||||
|
||||
// Update touch movement if active
|
||||
if (this.touchActive) {
|
||||
this.updateTouchMovement();
|
||||
}
|
||||
|
||||
this.updatePlayer();
|
||||
this.updateCoins();
|
||||
this.updateObstacles();
|
||||
this.updateCamera();
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the game
|
||||
const game = new Game();
|
||||
// Load the game after THREE is loaded
|
||||
import('./src/main.js').catch(err => {
|
||||
console.error('Failed to load game:', err);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</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