Use server authority for multiplayer movement and PvP

This commit is contained in:
Admin 2026-09-21 01:54:15 +00:00
parent 909abb1487
commit 711d9cd884

View File

@ -145,6 +145,11 @@ class GameScene extends Phaser.Scene {
this.tradeLosses = 0; this.tradeLosses = 0;
this.playerTradeLosses = 0; this.playerTradeLosses = 0;
this.remotePlayers = new Map(); this.remotePlayers = new Map();
this.multiplayerProjectiles = new Map();
this.multiplayerAuthoritativeSelf = null;
this.multiplayerHasAuthoritativeState = false;
this.multiplayerDead = false;
this.multiplayerLastHull = null;
this.multiplayerSendAccumulatorMs = 0; this.multiplayerSendAccumulatorMs = 0;
airshipLog('scene:init', { airshipLog('scene:init', {
@ -185,9 +190,22 @@ class GameScene extends Phaser.Scene {
this.createPlayerSmoke(); this.createPlayerSmoke();
this.handleMultiplayerSnapshot = (event) => { this.handleMultiplayerSnapshot = (event) => {
this.syncRemotePlayers(event.detail?.players || []); this.syncMultiplayerSnapshot(event.detail || {});
};
this.handleMultiplayerState = (event) => {
if (event.detail?.state === 'online') return;
this.clearMultiplayerEntities();
this.multiplayerAuthoritativeSelf = null;
this.multiplayerHasAuthoritativeState = false;
this.multiplayerDead = false;
this.multiplayerLastHull = null;
if (this.player && this.gameState === 'playing') {
this.player.setVisible(true);
this.playerHull = Math.max(1, this.playerHull || this.playerStats?.maxHull || PLAYER_MAX_HULL);
}
}; };
window.addEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot); window.addEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot);
window.addEventListener('airship:multiplayer-state', this.handleMultiplayerState);
this.pirates = this.physics.add.group(); this.pirates = this.physics.add.group();
this.merchants = this.physics.add.group(); this.merchants = this.physics.add.group();
@ -284,11 +302,8 @@ class GameScene extends Phaser.Scene {
window.removeEventListener('airship:helm-vector', this.handleHelmVector); window.removeEventListener('airship:helm-vector', this.handleHelmVector);
window.removeEventListener('airship:action', this.handleMobileAction); window.removeEventListener('airship:action', this.handleMobileAction);
window.removeEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot); window.removeEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot);
for (const remote of this.remotePlayers.values()) { window.removeEventListener('airship:multiplayer-state', this.handleMultiplayerState);
remote.sprite?.destroy(); this.clearMultiplayerEntities();
remote.label?.destroy();
}
this.remotePlayers.clear();
airshipLog('scene:shutdown', { airshipLog('scene:shutdown', {
gameState: this.gameState, gameState: this.gameState,
hull: this.playerHull, hull: this.playerHull,
@ -831,6 +846,7 @@ class GameScene extends Phaser.Scene {
// Airships can cross islands, but lose momentum while flying through the terrain envelope. // Airships can cross islands, but lose momentum while flying through the terrain envelope.
this.physics.add.overlap(this.player, obstacle, () => { this.physics.add.overlap(this.player, obstacle, () => {
if (window.airshipMultiplayer?.isConnected?.()) return;
this.playerSpeed *= 0.985; this.playerSpeed *= 0.985;
}); });
this.physics.add.overlap( this.physics.add.overlap(
@ -1451,6 +1467,71 @@ class GameScene extends Phaser.Scene {
} }
} }
clearMultiplayerEntities() {
for (const remote of this.remotePlayers.values()) {
remote.sprite?.destroy();
remote.label?.destroy();
}
this.remotePlayers.clear();
for (const projectile of this.multiplayerProjectiles.values()) {
projectile.sprite?.destroy();
}
this.multiplayerProjectiles.clear();
}
syncMultiplayerSnapshot(snapshot = {}) {
this.syncAuthoritativeSelf(snapshot.self || null);
this.syncRemotePlayers(snapshot.players || []);
this.syncMultiplayerProjectiles(snapshot.projectiles || []);
}
syncAuthoritativeSelf(state) {
if (!state || !this.player) return;
const wasDead = this.multiplayerDead;
const firstState = !this.multiplayerHasAuthoritativeState;
const previousHull = this.multiplayerLastHull;
this.multiplayerAuthoritativeSelf = { ...state };
this.multiplayerHasAuthoritativeState = true;
this.multiplayerDead = state.alive === false;
this.multiplayerLastHull = Math.max(0, Number(state.hull) || 0);
if (this.multiplayerDead) {
if (!wasDead) {
this.spawnExplosion(this.player.x, this.player.y, 1.2);
this.status.setText(`SHOT DOWN${state.lastKiller ? ` BY ${state.lastKiller}` : ''}\nRESPAWNING...`);
}
this.playerHull = 0;
this.playerSpeed = 0;
this.player.setVelocity(0, 0);
this.player.setVisible(false);
return;
}
if (firstState || wasDead) {
this.player.setVisible(true);
this.player.setPosition(Number(state.x) || this.world.spawn.x, Number(state.y) || this.world.spawn.y);
this.player.angle = Number(state.angle) || 0;
this.playerSpeed = Number(state.speed) || 0;
this.playerHull = Math.max(0, Number(state.hull) || PLAYER_MAX_HULL);
if (wasDead) {
this.status.setText('REDEPLOYED');
this.time.delayedCall(900, () => {
if (this.gameState === 'playing' && !this.multiplayerDead) this.status.setText('');
});
}
return;
}
if (previousHull != null && state.hull < previousHull) {
const damage = previousHull - state.hull;
this.playerHitSequence += 1;
this.spawnExplosion(this.player.x, this.player.y, 0.32);
this.playPlayerHitReaction(damage);
}
this.playerHull = Math.max(0, Number(state.hull) || 0);
}
syncRemotePlayers(players = []) { syncRemotePlayers(players = []) {
const seen = new Set(); const seen = new Set();
@ -1488,16 +1569,23 @@ class GameScene extends Phaser.Scene {
targetX: sprite.x, targetX: sprite.x,
targetY: sprite.y, targetY: sprite.y,
targetAngle: Number(state.angle) || 0, targetAngle: Number(state.angle) || 0,
hull: Number(state.hull) || 100, hull: Number(state.hull) || PLAYER_MAX_HULL,
alive: state.alive !== false,
}; };
this.remotePlayers.set(state.id, remote); this.remotePlayers.set(state.id, remote);
} }
const wasAlive = remote.alive;
remote.targetX = clamp(Number(state.x) || 0, 0, this.world.width); remote.targetX = clamp(Number(state.x) || 0, 0, this.world.width);
remote.targetY = clamp(Number(state.y) || 0, 0, this.world.height); remote.targetY = clamp(Number(state.y) || 0, 0, this.world.height);
remote.targetAngle = normalizeDegrees(Number(state.angle) || 0); remote.targetAngle = normalizeDegrees(Number(state.angle) || 0);
remote.hull = Math.max(0, Number(state.hull) || 0); remote.hull = Math.max(0, Number(state.hull) || 0);
remote.label.setText(`${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}`); remote.alive = state.alive !== false;
if (wasAlive && !remote.alive) this.spawnExplosion(remote.sprite.x, remote.sprite.y, 1.05);
remote.sprite.setVisible(remote.alive);
remote.label.setText(remote.alive
? `${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}HP K${state.kills || 0}/D${state.deaths || 0}`
: `${state.name || 'CAPTAIN'} • DOWN`);
} }
for (const [id, remote] of this.remotePlayers) { for (const [id, remote] of this.remotePlayers) {
@ -1508,16 +1596,89 @@ class GameScene extends Phaser.Scene {
} }
} }
syncMultiplayerProjectiles(projectiles = []) {
const seen = new Set();
const selfId = window.airshipMultiplayer?.getSelfId?.();
for (const state of projectiles) {
if (!state?.id) continue;
seen.add(state.id);
let projectile = this.multiplayerProjectiles.get(state.id);
if (!projectile) {
const sprite = this.add.image(Number(state.x) || 0, Number(state.y) || 0, 'cannonball');
sprite
.setDepth(8)
.setDisplaySize(14, 14)
.setTint(state.ownerId === selfId ? 0xffdf91 : 0x8cd3ff);
projectile = { sprite, vx: 0, vy: 0 };
this.multiplayerProjectiles.set(state.id, projectile);
}
const targetX = Number(state.x) || 0;
const targetY = Number(state.y) || 0;
const error = Math.hypot(projectile.sprite.x - targetX, projectile.sprite.y - targetY);
if (error > 90) {
projectile.sprite.setPosition(targetX, targetY);
} else {
projectile.sprite.x = Phaser.Math.Linear(projectile.sprite.x, targetX, 0.48);
projectile.sprite.y = Phaser.Math.Linear(projectile.sprite.y, targetY, 0.48);
}
projectile.vx = Number(state.vx) || 0;
projectile.vy = Number(state.vy) || 0;
}
for (const [id, projectile] of this.multiplayerProjectiles) {
if (seen.has(id)) continue;
projectile.sprite?.destroy();
this.multiplayerProjectiles.delete(id);
}
}
multiplayerControlInput() {
if (!this.cursors || !this.keys) return { throttle: 0, steer: 0, stop: false, docked: true };
const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0;
const helmX = Math.sign(rawHelmX) * Math.pow(Math.abs(rawHelmX), HELM_STEER_CURVE);
const helmY = Math.abs(this.helmVector.y) > HELM_DEAD_ZONE ? this.helmVector.y : 0;
const throttleForward = this.cursors.up.isDown ? 1 : Math.max(0, -helmY);
const throttleReverse = this.cursors.down.isDown ? 1 : Math.max(0, helmY);
const keyboardTurn = (this.cursors.right.isDown ? 1 : 0) - (this.cursors.left.isDown ? 1 : 0);
return {
throttle: clamp(throttleForward - throttleReverse, -1, 1),
steer: keyboardTurn || helmX,
stop: this.keys.stop.isDown,
docked: this.dockOpen || this.shipyardOpen || this.multiplayerDead,
};
}
updateMultiplayer(deltaMs) { updateMultiplayer(deltaMs) {
const online = Boolean(window.airshipMultiplayer?.isConnected?.());
if (!online) return;
this.multiplayerSendAccumulatorMs += deltaMs; this.multiplayerSendAccumulatorMs += deltaMs;
if (this.multiplayerSendAccumulatorMs >= 100) { if (this.multiplayerSendAccumulatorMs >= 50) {
this.multiplayerSendAccumulatorMs %= 100; this.multiplayerSendAccumulatorMs %= 50;
window.airshipMultiplayer?.sendState?.({ window.airshipMultiplayer?.sendInput?.(this.multiplayerControlInput());
x: this.player?.x || 0, }
y: this.player?.y || 0,
angle: this.player?.angle || 0, const authoritative = this.multiplayerAuthoritativeSelf;
hull: this.playerHull, if (authoritative && !this.multiplayerDead && this.player?.visible) {
}); const targetX = Number(authoritative.x) || 0;
const targetY = Number(authoritative.y) || 0;
const positionError = Math.hypot(this.player.x - targetX, this.player.y - targetY);
if (positionError > 180) {
this.player.setPosition(targetX, targetY);
} else {
this.player.x = Phaser.Math.Linear(this.player.x, targetX, 0.14);
this.player.y = Phaser.Math.Linear(this.player.y, targetY, 0.14);
}
this.player.angle += signedAngleDelta(Number(authoritative.angle) || 0, this.player.angle) * 0.16;
this.playerSpeed = Phaser.Math.Linear(this.playerSpeed, Number(authoritative.speed) || 0, 0.16);
}
const dt = Math.min(deltaMs / 1000, 0.05);
for (const projectile of this.multiplayerProjectiles.values()) {
projectile.sprite.x += projectile.vx * dt;
projectile.sprite.y += projectile.vy * dt;
} }
for (const remote of this.remotePlayers.values()) { for (const remote of this.remotePlayers.values()) {
@ -1530,8 +1691,52 @@ class GameScene extends Phaser.Scene {
} }
} }
updateMultiplayerPredictedPlayer(dt, deltaMs) {
if (!this.player?.body || this.multiplayerDead) return;
const input = this.multiplayerControlInput();
if (input.throttle > 0) this.playerSpeed += PLAYER_ACCELERATION * input.throttle * dt;
if (input.throttle < 0) this.playerSpeed += PLAYER_BRAKE * input.throttle * dt;
if (input.stop) this.playerSpeed *= Math.pow(0.025, dt);
if (input.throttle === 0 && !input.stop) {
this.playerSpeed *= Math.pow(PLAYER_COAST_DRAG, deltaMs / 16.667);
}
this.playerSpeed = clamp(this.playerSpeed, PLAYER_REVERSE_SPEED, PLAYER_MAX_SPEED);
const turnStrength = clamp(Math.abs(this.playerSpeed) / 100, 0.28, 1);
const directionSign = this.playerSpeed < 0 ? -1 : 1;
this.player.angle += PLAYER_TURN_RATE * turnStrength * directionSign * input.steer * dt;
const effectiveForwardThrust = clamp(this.playerSpeed / PLAYER_MAX_SPEED, 0, 1);
if (effectiveForwardThrust > 0.06) {
if (!this.player.anims.isPlaying || this.player.anims.currentAnim?.key !== 'player-forward') {
this.player.play('player-forward');
}
this.player.anims.timeScale = 0.65 + effectiveForwardThrust * 1.35;
} else {
if (this.player.anims.isPlaying) this.player.anims.stop();
this.player.setFrame(0);
this.player.anims.timeScale = 1;
}
this.environmentWind = environmentWindAt({
x: this.player.x,
y: this.player.y,
timeMs: this.time.now,
islands: this.world?.islands,
});
const h = headingRad(this.player);
this.player.setVelocity(Math.cos(h) * this.playerSpeed, Math.sin(h) * this.playerSpeed);
if (Phaser.Input.Keyboard.JustDown(this.keys.firePort)) this.playerBroadside(-1);
if (Phaser.Input.Keyboard.JustDown(this.keys.fireStarboard)) this.playerBroadside(1);
}
updatePlayer(dt, deltaMs) { updatePlayer(dt, deltaMs) {
if (!this.player?.body) return; if (!this.player?.body) return;
if (window.airshipMultiplayer?.isConnected?.()) {
this.updateMultiplayerPredictedPlayer(dt, deltaMs);
return;
}
const stats = this.playerStats || deriveShipStats(this.progression?.loadout, this.progression?.installedConditions); const stats = this.playerStats || deriveShipStats(this.progression?.loadout, this.progression?.installedConditions);
const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0; const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0;
@ -1673,7 +1878,10 @@ class GameScene extends Phaser.Scene {
playerBroadside(side) { playerBroadside(side) {
const broadside = side < 0 ? 'port' : 'starboard'; const broadside = side < 0 ? 'port' : 'starboard';
if (this.time.now - this.lastPlayerShot[broadside] < this.playerBroadsideReloadMs) return; const multiplayerOnline = Boolean(window.airshipMultiplayer?.isConnected?.());
const reloadMs = multiplayerOnline ? PLAYER_BROADSIDE_RELOAD_MS : this.playerBroadsideReloadMs;
if (this.time.now - this.lastPlayerShot[broadside] < reloadMs) return;
if (multiplayerOnline) window.airshipMultiplayer?.fire?.(broadside);
this.fireBroadside( this.fireBroadside(
this.player, this.player,
side, side,
@ -1706,6 +1914,9 @@ class GameScene extends Phaser.Scene {
const y = ship.y + Math.sin(base) * 34; const y = ship.y + Math.sin(base) * 34;
const shot = group.create(x, y, texture); const shot = group.create(x, y, texture);
shot.setDepth(8).setDisplaySize(14, 14); shot.setDepth(8).setDisplaySize(14, 14);
if (actor === 'player' && window.airshipMultiplayer?.isConnected?.()) {
shot.setVisible(false);
}
shot.body.setAllowGravity(false); shot.body.setAllowGravity(false);
shot.setData('bornAt', this.time.now); shot.setData('bornAt', this.time.now);
shot.setData('damage', projectileDamage); shot.setData('damage', projectileDamage);
@ -1785,6 +1996,8 @@ class GameScene extends Phaser.Scene {
hitPlayer(first, second) { hitPlayer(first, second) {
if (this.gameState !== 'playing') return; if (this.gameState !== 'playing') return;
// Online player damage is owned by the Go server; local pirates remain client-side until AI migrates.
if (window.airshipMultiplayer?.isConnected?.()) return;
const shot = resolveTaggedProjectile(first, second, 'pirate'); const shot = resolveTaggedProjectile(first, second, 'pirate');
if (!shot?.active || shot === this.player) { if (!shot?.active || shot === this.player) {
@ -1923,6 +2136,7 @@ class GameScene extends Phaser.Scene {
} }
validateLivePlayerState() { validateLivePlayerState() {
if (this.multiplayerDead) return;
if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return; if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return;
const issues = []; const issues = [];
@ -2037,7 +2251,9 @@ class GameScene extends Phaser.Scene {
kills: this.defeated, kills: this.defeated,
player: { player: {
hull: this.playerHull, hull: this.playerHull,
maxHull: this.playerStats?.maxHull || PLAYER_MAX_HULL, maxHull: window.airshipMultiplayer?.isConnected?.()
? this.multiplayerAuthoritativeSelf?.maxHull || PLAYER_MAX_HULL
: this.playerStats?.maxHull || PLAYER_MAX_HULL,
hitSequence: this.playerHitSequence, hitSequence: this.playerHitSequence,
}, },
target: nearest ? { target: nearest ? {
@ -2251,6 +2467,7 @@ class GameScene extends Phaser.Scene {
} }
checkGameState() { checkGameState() {
if (window.airshipMultiplayer?.isConnected?.()) return;
if (this.playerHull <= 0) this.finishGame(); if (this.playerHull <= 0) this.finishGame();
} }