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