Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/data/abilities/abilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,140 @@ export const ABILITY_DEFINITIONS: Record<string, AbilityDataDefinition> = {
requiresResearch: ['supply_drop'],
interruptsMovement: false,
},

// === NAVAL ABILITIES ===

boost: {
id: 'boost',
name: 'Boost',
description: 'Engage emergency thrusters for a burst of speed.',
targetType: 'instant',
cooldown: 15,
duration: 4,
effects: [
{ type: 'buff', value: 1.75, duration: 4 }, // 75% speed boost
],
interruptsMovement: false,
soundEffect: 'ability_boost',
},

flak_barrage: {
id: 'flak_barrage',
name: 'Flak Barrage',
description: 'Release a barrage of flak rounds, dealing damage to all air units in the area.',
targetType: 'instant',
energyCost: 50,
cooldown: 10,
radius: 6,
effects: [
{
type: 'damage',
value: 40,
radius: 6,
targetFilter: { requiresAir: true, includeEnemies: true },
},
],
interruptsMovement: false,
visualEffect: 'flak_explosion',
soundEffect: 'ability_flak',
},

submerge: {
id: 'submerge',
name: 'Submerge',
description: 'Dive beneath the surface, becoming invisible to units without detection.',
targetType: 'toggle',
cooldown: 3, // Brief cooldown to prevent rapid toggle abuse
effects: [
{ type: 'cloak' },
{ type: 'custom', customHandler: 'toggleSubmerge' },
],
interruptsMovement: false,
soundEffect: 'submarine_dive',
},

depth_charge_defense: {
id: 'depth_charge_defense',
name: 'Depth Charge',
description: 'Launch depth charges that deal heavy damage to naval units.',
targetType: 'targeted',
range: 5,
energyCost: 25,
cooldown: 8,
effects: [
{
type: 'damage',
value: 75,
targetFilter: { includeEnemies: true },
},
],
interruptsMovement: false,
visualEffect: 'depth_charge_explosion',
soundEffect: 'ability_depth_charge',
},

beach_assault: {
id: 'beach_assault',
name: 'Beach Assault',
description: 'Prepare for amphibious landing, granting loaded units a combat bonus upon disembark.',
targetType: 'instant',
cooldown: 30,
duration: 15,
effects: [
{ type: 'buff', value: 1.25, duration: 15 }, // 25% damage boost to unloaded units
],
interruptsMovement: false,
soundEffect: 'ability_beach_assault',
},

amphibious_mode: {
id: 'amphibious_mode',
name: 'Amphibious Mode',
description: 'Toggle between water and land movement. Slower on land but can traverse both terrains.',
targetType: 'toggle',
cooldown: 2,
effects: [
{ type: 'custom', customHandler: 'toggleAmphibious' },
],
interruptsMovement: true,
soundEffect: 'vehicle_transform',
},

shore_bombardment: {
id: 'shore_bombardment',
name: 'Shore Bombardment',
description: 'Fire a salvo of heavy shells at a ground target location.',
targetType: 'ground',
range: 14,
radius: 3,
energyCost: 75,
cooldown: 12,
castTime: 1.5, // Brief aiming delay
effects: [
{ type: 'damage', value: 100, radius: 3 },
],
interruptsMovement: false,
visualEffect: 'artillery_strike',
soundEffect: 'ability_bombardment',
},

yamato_cannon: {
id: 'yamato_cannon',
name: 'Yamato Cannon',
description: 'Fire the main battery, dealing devastating damage to a single target.',
targetType: 'targeted',
range: 10,
energyCost: 150,
cooldown: 60,
castTime: 2, // Charge-up time
effects: [
{ type: 'damage', value: 300 },
],
channeled: true,
interruptsMovement: true,
visualEffect: 'yamato_beam',
soundEffect: 'ability_yamato',
},
};

// ==================== ABILITY REGISTRY ====================
Expand Down
141 changes: 141 additions & 0 deletions src/engine/core/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,13 +389,56 @@ export class Game extends GameCore {
return;
}

// LOCKSTEP BARRIER: In multiplayer, wait for all players before advancing tick
if (this.config.isMultiplayer) {
const nextTick = this.currentTick + 1;
if (!this.hasAllCommandsForTick(nextTick)) {
// Start tracking wait time if not already
if (!this.tickWaitStart.has(nextTick)) {
this.tickWaitStart.set(nextTick, this.currentTick);
debugNetworking.log(`[Game] LOCKSTEP: Waiting for commands for tick ${nextTick}`);
}

// Check for timeout
const waitStartTick = this.tickWaitStart.get(nextTick)!;
const ticksWaited = this.currentTick - waitStartTick;

if (ticksWaited >= this.LOCKSTEP_TIMEOUT_TICKS) {
// Timeout - report desync and proceed anyway to avoid infinite hang
console.error(
`[Game] LOCKSTEP TIMEOUT: No commands from all players for tick ${nextTick} ` +
`after waiting ${ticksWaited} ticks. Expected: ${this.getExpectedPlayerIds().join(', ')}, ` +
`Received: ${Array.from(this.tickCommandReceipts.get(nextTick) || []).join(', ')}`
);
reportDesync(nextTick);
this.eventBus.emit('desync:detected', {
tick: nextTick,
localChecksum: 0,
remoteChecksum: 0,
remotePeerId: useMultiplayerStore.getState().remotePeerId || 'unknown',
reason: 'lockstep_timeout',
});
// Clear wait tracking and proceed - game may desync but won't freeze
this.tickWaitStart.delete(nextTick);
} else {
// Still waiting - skip this frame
return;
}
} else {
// Got all commands - clear wait tracking
this.tickWaitStart.delete(nextTick);
}
}

const tickStart = performance.now();

this.currentTick++;
this.world.setCurrentTick(this.currentTick);

if (this.config.isMultiplayer) {
this.processQueuedCommandsWithCleanup();
// Send heartbeats for upcoming ticks to keep lockstep flowing
this.ensureHeartbeatsForUpcomingTicks();
}

this.world.update(deltaTime);
Expand All @@ -421,6 +464,101 @@ export class Game extends GameCore {
}
}

// ============================================================================
// LOCKSTEP BARRIER HELPERS
// ============================================================================

/**
* Get the set of player IDs that should send commands for lockstep.
* In 2-player multiplayer, this is local player + remote peer.
*/
private getExpectedPlayerIds(): string[] {
const players: string[] = [this.config.playerId];
const remotePeerId = useMultiplayerStore.getState().remotePeerId;
if (remotePeerId) {
players.push(remotePeerId);
}
return players;
}

/**
* Check if we have received command acknowledgments from all expected players for a tick.
* For lockstep, every player must send at least one command or heartbeat for each tick.
* We track receipts when commands are queued via queueCommandWithReceipt().
*/
private hasAllCommandsForTick(tick: number): boolean {
const expectedPlayers = this.getExpectedPlayerIds();
const receipts = this.tickCommandReceipts.get(tick);

if (!receipts) {
// No commands received yet for this tick
return false;
}

// Check if all expected players have sent commands
for (const playerId of expectedPlayers) {
if (!receipts.has(playerId)) {
return false;
}
}

return true;
}

// Track which ticks we've sent commands for (to avoid duplicate heartbeats)
private sentCommandForTick: Set<number> = new Set();

/**
* Send a heartbeat command to acknowledge a tick when no other commands were sent.
* This ensures the lockstep barrier can proceed even when a player has no actions.
*/
private sendHeartbeatForTick(tick: number): void {
if (this.sentCommandForTick.has(tick)) {
return; // Already sent a command for this tick
}

const heartbeat: GameCommand = {
tick,
playerId: this.config.playerId,
type: 'HEARTBEAT',
entityIds: [],
};

sendMultiplayerMessage({
type: 'command',
payload: heartbeat,
});

this.queueCommandWithReceipt(heartbeat);
this.sentCommandForTick.add(tick);

// Cleanup old entries
const oldestRelevant = this.currentTick - this.COMMAND_HISTORY_SIZE;
for (const t of this.sentCommandForTick) {
if (t < oldestRelevant) {
this.sentCommandForTick.delete(t);
}
}

debugNetworking.log(`[Game] Sent heartbeat for tick ${tick}`);
}

/**
* Ensure heartbeats are sent for upcoming ticks that need acknowledgment.
* Called periodically to keep lockstep in sync.
*/
private ensureHeartbeatsForUpcomingTicks(): void {
// Send heartbeats for ticks within the command delay window
const startTick = this.currentTick + 1;
const endTick = this.currentTick + this.currentCommandDelay + 1;

for (let tick = startTick; tick <= endTick; tick++) {
if (!this.sentCommandForTick.has(tick)) {
this.sendHeartbeatForTick(tick);
}
}
}

private updateEntityCounts(): void {
const units = this.world.getEntitiesWith('Unit', 'Transform');
const buildings = this.world.getEntitiesWith('Building', 'Transform');
Expand Down Expand Up @@ -472,6 +610,9 @@ export class Game extends GameCore {
debugNetworking.log('[Game] Sent command for tick', executionTick);

this.queueCommandWithReceipt(command);

// Track that we sent a real command for this tick (not just a heartbeat)
this.sentCommandForTick.add(executionTick);
} else {
this.processCommand(command);
}
Expand Down
9 changes: 8 additions & 1 deletion src/engine/core/GameCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export type GameCommandType =
| 'BUILD_WALL'
| 'ADDON_LIFT'
| 'ADDON_LAND'
| 'SUBMERGE';
| 'SUBMERGE'
| 'HEARTBEAT'; // Lockstep sync - no-op command to acknowledge tick

/**
* Game command structure for all player actions
Expand Down Expand Up @@ -150,6 +151,7 @@ const COMMAND_EVENTS: Record<GameCommandType, string> = {
ADDON_LIFT: 'addon:lift',
ADDON_LAND: 'addon:land',
SUBMERGE: 'command:submerge',
HEARTBEAT: 'command:heartbeat', // No-op for lockstep sync
};

/**
Expand Down Expand Up @@ -390,6 +392,11 @@ export function dispatchCommand(eventBus: EventBus, command: GameCommand): void
playerId: command.playerId,
});
break;

case 'HEARTBEAT':
// No-op command for lockstep sync - just acknowledges player is alive for this tick
// The command:received event already fired at the start of dispatchCommand
break;
}
}

Expand Down
Loading
Loading