Files
restitution/src/entities/Unit.js
kaykayyali 3fc29f728e feat: Colyseus authoritative server + invite-code lobby
- Replace socket.io relay with Colyseus 0.15 authoritative server
- GameRoom with GameState schema (players, units, resources)
- Pure TS services: CombatResolver, EconomyService, PathfindingService, UnitManager
- POST /api/create-room → 4-char invite code
- React/MUI LobbyScreen: Create (shows code + START GAME) / Join by code
- ColyseusClient: joinOrCreate/join by room type = invite code
- Nginx: static assets direct, all else proxied to Colyseus (WS upgrade)
- Content-hashed JS bundles for Cloudflare cache-busting
- 1-player lobbies: START GAME button bypasses 2-player wait
2026-05-30 02:49:20 +00:00

384 lines
9.8 KiB
JavaScript

import Phaser from 'phaser';
import EntityStateMachine from 'Systems/EntityStateMachine';
/**
* Unit Entity - Component-based architecture
* Extends Phaser.Physics.Arcade.Sprite with modular components
*/
export default class Unit extends Phaser.Physics.Arcade.Sprite {
constructor(scene, texture, startingTile, config = {}) {
if (!scene) {
throw new Error('Unit requires scene reference');
}
const worldPointer = scene.interface?.generateWorldXY?.(startingTile) ||
{ x: startingTile.x * 64, y: startingTile.y * 64 };
super(scene, worldPointer.x, worldPointer.y, texture);
// Add to scene and enable physics
scene.add.existing(this);
scene.physics.world.enableBody(this, Phaser.Physics.Arcade.DYNAMIC_BODY);
// Initialize components
this.components = {
health: {
maxHp: config.maxHp || 100,
current: config.maxHp || 100,
armor: config.armor || 1,
damageModifiers: config.damageModifiers || {}
},
owner: {
playerId: config.playerId || null,
team: config.team || 'neutral',
teamColor: config.teamColor || 0xffffff
},
inventory: {
fuel: config.fuel || 0,
ammo: config.ammo || 0,
consumptionRates: config.consumptionRates || { fuel: 0, ammo: 0 }
},
movement: {
speed: config.speed || 100,
acceleration: config.acceleration || 200,
rotationSpeed: config.rotationSpeed || 180,
maxPathLength: config.maxPathLength || 50
},
combat: {
weaponRange: config.weaponRange || 200,
damage: config.damage || 25,
fireRate: config.fireRate || 1000,
projectileType: config.projectileType || 'rifle',
lastFireTime: 0
}
};
// Physics setup
this.body.allowGravity = false;
this.setScale(1);
this.updatePhysicsSize();
this.dead = false;
this.setInteractive({ pixelPerfect: true });
// Initialize state machine
this.stateMachine = null;
this._initStateMachine();
// Pointer events
this.on('pointerover', () => this.select());
this.on('pointerout', () => this.unSelect());
this.on('pointerdown', () => {
scene.orchestrator?.systems?.selection?.add(this);
});
// Animation wrapper - provides anims.play() interface for state configs
this.anims = {
create: (config) => this._createAnimation(config),
play: (key) => this._playAnimation(key),
generateFrameNumbers: (texture, config) => {
const frameCount = (config.end || 0) - (config.start || 0) + 1;
return Array.from({ length: frameCount }, (_, i) => i + (config.start || 0));
}
};
// Store current animation state
this._currentAnim = null;
this._anims = new Map();
}
/**
* Create an animation for this sprite
*/
_createAnimation(config) {
const key = config.key;
const frames = config.frames || [];
if (frames.length === 0) {
// Auto-generate frames from texture
const texture = this.scene.textures.get(this.texture.key);
if (texture) {
const frameCount = texture.frameTotal;
for (let i = 0; i < frameCount; i++) {
frames.push({ key: this.texture.key, frame: i });
}
}
}
this._anims.set(key, {
frames,
frameRate: config.frameRate || 10,
repeat: config.repeat || 0
});
// Start the animation if not already playing
if (!this._currentAnim) {
this._playAnimation(key);
}
}
/**
* Play an animation by key
*/
_playAnimation(key) {
const anim = this._anims.get(key);
if (!anim) {
console.warn(`Animation "${key}" not found for ${this.texture.key}`);
return;
}
this._currentAnim = {
key,
frames: anim.frames,
frameRate: anim.frameRate,
repeat: anim.repeat,
currentFrame: 0,
frameTime: 0,
loopCount: 0
};
}
/**
* Initialize the XState state machine
*/
_initStateMachine() {
if (this.scene.orchestrator?.systems?.EntityStateMachine) {
this.stateMachine = EntityStateMachine.forEntity(this, {
scene: this.scene,
combatSystem: this.scene.orchestrator.systems.combat,
pathfindingSystem: this.scene.orchestrator.systems.pathfinding
});
}
}
/**
* Component accessors
*/
getComponent(name) {
return this.components[name];
}
setComponent(name, value) {
this.components[name] = { ...this.components[name], ...value };
}
/**
* Health methods
*/
damage(amount, damageType = 'default') {
if (this.dead) return 0;
const combat = this.getComponent('combat');
const health = this.getComponent('health');
if (!health) return 0;
// Apply armor reduction
const damageModifiers = combat?.damageModifiers || {};
const armorPiercing = damageModifiers[damageType]?.armorPiercing || 0;
const effectiveArmor = health.armor * (1 - armorPiercing);
const finalDamage = Math.max(1, amount - effectiveArmor);
health.current = Math.max(0, health.current - finalDamage);
this.setData('health', health.current);
// Emit damage event
this.scene.events.emit('unit:damaged', {
unit: this,
amount: finalDamage,
damageType,
remaining: health.current
});
// Check death
if (health.current <= 0) {
this.die();
}
return finalDamage;
}
heal(amount) {
const health = this.getComponent('health');
if (!health) return 0;
health.current = Math.min(health.maxHp, health.current + amount);
this.setData('health', health.current);
return amount;
}
isDead() {
return this.dead || this.getComponent('health').current <= 0;
}
die() {
this.dead = true;
this.stateMachine?.send('DIE');
this.scene.events.emit('unit:dying', { unit: this });
}
/**
* Movement methods
*/
moveToTile(tile) {
const positionVector = this.scene.interface?.generateWorldXY?.(tile) ||
{ x: tile.x * 64, y: tile.y * 64 };
this.setData('lastTile', tile);
this.setData('targetTile', tile);
return !!this.setPosition(positionVector.x, positionVector.y);
}
getDirection(pointA, pointB) {
const radians = Phaser.Math.Angle.BetweenPoints(pointA, pointB);
const degrees = Phaser.Math.RadToDeg(radians);
if (degrees >= 0 && degrees < 90) return 'NORTH';
if (degrees >= 90 && degrees < 180) return 'EAST';
if (degrees >= 180 && degrees < 270) return 'SOUTH';
return 'WEST';
}
orientToTarget(target) {
if (!target) return;
const direction = this.getDirection(this, target);
const shouldFlip = direction === 'EAST' || direction === 'SOUTH';
this.setFlipX(shouldFlip);
}
/**
* Combat methods
*/
canHitBody(target) {
if (!target || target.isDead?.()) return false;
const combat = this.getComponent('combat');
const distance = Phaser.Math.Distance.BetweenPoints(this, target);
return distance <= (combat?.weaponRange || 200);
}
attackTarget(target) {
if (!target) return false;
const combat = this.getComponent('combat');
const now = Date.now();
if (!this.canHitBody(target)) return false;
if (now - combat.lastFireTime < combat.fireRate) return false;
combat.lastFireTime = now;
this.stateMachine?.send('ATTACK', { target });
// Fire projectile via combat system
this.scene.orchestrator?.systems?.combat?.fireProjectile?.(this, target, {
damage: combat.damage,
speed: 400,
type: combat.projectileType
});
return true;
}
/**
* Selection methods
*/
select() {
this.pulse?.stop();
const team = this.getComponent('owner')?.team;
const isEnemy = team === 'enemy';
this.pulse = this.scene.tweens.addCounter({
from: 175,
to: 255,
duration: 750,
ease: 'Power2',
loop: -1,
yoyo: true,
onUpdate: (tween) => {
const value = Math.floor(tween.getValue());
if (isEnemy) {
this.setTint(Phaser.Display.Color.GetColor32(value, 0, 0, 255));
} else {
this.setTint(Phaser.Display.Color.GetColor32(0, value, 0, 255));
}
}
});
this.setData('selected', true);
}
unSelect() {
if (!this.getData('selected')) return;
this.pulse?.stop();
this.clearTint();
this.setData('selected', false);
}
/**
* Physics
*/
updatePhysicsSize() {
// Override in subclasses to define sprite-specific physics
this.body.setSize(32, 32);
this.body.setOffset(16, 16);
}
/**
* Update loop
*/
preUpdate(time, delta) {
// Phaser.Sprite.preUpdate may not exist in mock
if (super.preUpdate) {
super.preUpdate(time, delta);
}
// Update animation frames
this._updateAnimation(delta);
// Tick state machine
this.stateMachine?.tick(time, delta);
}
/**
* Update animation frame based on frame rate
*/
_updateAnimation(delta) {
if (!this._currentAnim) return;
const anim = this._currentAnim;
anim.frameTime += delta;
const frameInterval = 1000 / anim.frameRate;
if (anim.frameTime >= frameInterval) {
anim.currentFrame++;
anim.frameTime -= frameInterval;
if (anim.currentFrame >= anim.frames.length) {
if (anim.repeat === -1 || (anim.repeat > 0 && anim.loopCount < anim.repeat)) {
anim.currentFrame = 0;
if (anim.repeat > 0) anim.loopCount++;
} else {
// Animation complete, stay on last frame
anim.currentFrame = anim.frames.length - 1;
}
}
// Set the texture frame
const frame = anim.frames[anim.currentFrame];
if (frame && this.texture) {
this.setFrame(frame.frame !== undefined ? frame.frame : frame);
}
}
}
/**
* Cleanup
*/
destroy(fromScene) {
this.pulse?.stop();
this.stateMachine?.destroy();
super.destroy(fromScene);
}
}