- 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
384 lines
9.8 KiB
JavaScript
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);
|
|
}
|
|
}
|