diff --git a/src/game-v4.js b/src/game-v4.js index 24645f4..979317e 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -145,6 +145,11 @@ class GameScene extends Phaser.Scene { this.tradeLosses = 0; this.playerTradeLosses = 0; this.remotePlayers = new Map(); + this.multiplayerProjectiles = new Map(); + this.multiplayerAuthoritativeSelf = null; + this.multiplayerHasAuthoritativeState = false; + this.multiplayerDead = false; + this.multiplayerLastHull = null; this.multiplayerSendAccumulatorMs = 0; airshipLog('scene:init', { @@ -185,9 +190,22 @@ class GameScene extends Phaser.Scene { this.createPlayerSmoke(); 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-state', this.handleMultiplayerState); this.pirates = 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:action', this.handleMobileAction); window.removeEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot); - for (const remote of this.remotePlayers.values()) { - remote.sprite?.destroy(); - remote.label?.destroy(); - } - this.remotePlayers.clear(); + window.removeEventListener('airship:multiplayer-state', this.handleMultiplayerState); + this.clearMultiplayerEntities(); airshipLog('scene:shutdown', { gameState: this.gameState, 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. this.physics.add.overlap(this.player, obstacle, () => { + if (window.airshipMultiplayer?.isConnected?.()) return; this.playerSpeed *= 0.985; }); 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 = []) { const seen = new Set(); @@ -1488,16 +1569,23 @@ class GameScene extends Phaser.Scene { targetX: sprite.x, targetY: sprite.y, 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); } + const wasAlive = remote.alive; remote.targetX = clamp(Number(state.x) || 0, 0, this.world.width); remote.targetY = clamp(Number(state.y) || 0, 0, this.world.height); remote.targetAngle = normalizeDegrees(Number(state.angle) || 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) { @@ -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) { + const online = Boolean(window.airshipMultiplayer?.isConnected?.()); + if (!online) return; + this.multiplayerSendAccumulatorMs += deltaMs; - if (this.multiplayerSendAccumulatorMs >= 100) { - this.multiplayerSendAccumulatorMs %= 100; - window.airshipMultiplayer?.sendState?.({ - x: this.player?.x || 0, - y: this.player?.y || 0, - angle: this.player?.angle || 0, - hull: this.playerHull, - }); + if (this.multiplayerSendAccumulatorMs >= 50) { + this.multiplayerSendAccumulatorMs %= 50; + window.airshipMultiplayer?.sendInput?.(this.multiplayerControlInput()); + } + + const authoritative = this.multiplayerAuthoritativeSelf; + 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()) { @@ -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) { 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 rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0; @@ -1673,7 +1878,10 @@ class GameScene extends Phaser.Scene { playerBroadside(side) { 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.player, side, @@ -1706,6 +1914,9 @@ class GameScene extends Phaser.Scene { const y = ship.y + Math.sin(base) * 34; const shot = group.create(x, y, texture); shot.setDepth(8).setDisplaySize(14, 14); + if (actor === 'player' && window.airshipMultiplayer?.isConnected?.()) { + shot.setVisible(false); + } shot.body.setAllowGravity(false); shot.setData('bornAt', this.time.now); shot.setData('damage', projectileDamage); @@ -1785,6 +1996,8 @@ class GameScene extends Phaser.Scene { hitPlayer(first, second) { 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'); if (!shot?.active || shot === this.player) { @@ -1923,6 +2136,7 @@ class GameScene extends Phaser.Scene { } validateLivePlayerState() { + if (this.multiplayerDead) return; if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return; const issues = []; @@ -2037,7 +2251,9 @@ class GameScene extends Phaser.Scene { kills: this.defeated, player: { 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, }, target: nearest ? { @@ -2251,6 +2467,7 @@ class GameScene extends Phaser.Scene { } checkGameState() { + if (window.airshipMultiplayer?.isConnected?.()) return; if (this.playerHull <= 0) this.finishGame(); }