import { HOCKEY_ARENA_MAX_X, HOCKEY_ARENA_MAX_Z, HOCKEY_ARENA_MIN_X, HOCKEY_ARENA_MIN_Z, } from "./hockeyHealing"; import type { WorldPosition } from "./types"; export type AetherAssaultStatus = "inactive" | "live"; export type AetherShipKind = "standard" | "armored"; export type AetherShipPhase = "entering" | "formation" | "diving" | "returning"; export interface AetherShip { id: string; kind: AetherShipKind; hp: number; maxHp: number; position: WorldPosition; formationPosition: WorldPosition; phase: AetherShipPhase; phaseStartedAt: number; phaseEndsAt: number; startPosition: WorldPosition; targetPosition: WorldPosition; contactResolved: boolean; } export interface AetherProjectile { id: number; position: WorldPosition; velocity: WorldPosition; } export interface AetherAssaultState { status: AetherAssaultStatus; seed: number; randomState: number; wave: number; score: number; kills: number; killStreak: number; multiplier: number; ships: AetherShip[]; playerShots: AetherProjectile[]; enemyShots: AetherProjectile[]; nextProjectileId: number; nextPlayerShotAt: number; nextEnemyShotAt: number; nextDiveAt: number; nextWaveAt: number | null; lastPlayerHitAt: number; lastKillAt: number; lastKillScore: number; } export interface AetherAssaultStep { delta: number; time: number; playerPosition: WorldPosition; canAutoFire: boolean; } export interface AetherAssaultAdvance { state: AetherAssaultState; playerDamage: number; } export const AETHER_MAX_SHIPS = 20; export const AETHER_MAX_PLAYER_SHOTS = 32; export const AETHER_MAX_ENEMY_SHOTS = 64; export const AETHER_PLAYER_SHOT_DAMAGE = 1; export const AETHER_ENEMY_SHOT_DAMAGE = 10; export const AETHER_DIVE_DAMAGE = 24; export const AETHER_HIT_GRACE_SECONDS = 0.6; export const AETHER_PLAYER_SHOTS_PER_SECOND = 5; export const AETHER_WAVE_CLEAR_DELAY = 1.5; export const AETHER_STANDARD_SCORE = 100; export const AETHER_ARMORED_SCORE = 250; const PLAYER_SHOT_SPEED = 18; const PLAYER_SHOT_RADIUS = 0.24; const ENEMY_SHOT_RADIUS = 0.3; const SHIP_RADIUS = 0.72; const PLAYER_HIT_RADIUS = 0.62; const DIVE_HIT_RADIUS = 1.05; const ENTRY_DURATION = 1.45; const RETURN_DURATION = 1.25; const DIVE_DURATION = 2.15; function normalizeSeed(seed: number) { const normalized = Math.floor(Number(seed)) >>> 0; return normalized || 0x9e3779b9; } function nextRandom(state: number) { let next = normalizeSeed(state); next ^= next << 13; next ^= next >>> 17; next ^= next << 5; return next >>> 0; } function randomUnit(state: number) { const next = nextRandom(state); return { state: next, value: next / 0x100000000 }; } export function createAetherAssaultSeed(random: () => number = Math.random) { const sample = Number(random()); const normalized = Number.isFinite(sample) ? Math.max(0, Math.min(0.999999999999, sample)) : 0; return normalizeSeed(Math.floor(normalized * 0x100000000)); } export function aetherShipCount(wave: number) { return Math.min(AETHER_MAX_SHIPS, 8 + Math.max(0, Math.floor(wave) - 1) * 2); } export function aetherMultiplier(killStreak: number) { return Math.min(5, 1 + Math.floor(Math.max(0, killStreak) / 5) * 0.25); } export function aetherWaveClearBonus(wave: number) { return Math.max(1, Math.floor(wave)) * 750; } function formationPosition(index: number, count: number): WorldPosition { const columns = 5; const rows = Math.ceil(count / columns); const row = Math.floor(index / columns); const column = index % columns; const itemsInRow = row === rows - 1 && count % columns !== 0 ? count % columns : columns; const centeredColumn = column - (itemsInRow - 1) * 0.5; return [centeredColumn * 3.35, -10.7 + row * 2.25]; } function armoredCount(wave: number, count: number) { if (wave % 3 !== 0) return 0; return Math.min(count, 1 + Math.floor(wave / 6)); } function createWave(wave: number, startsAt: number, randomState: number) { const count = aetherShipCount(wave); const armored = armoredCount(wave, count); const ships: AetherShip[] = []; let nextState = randomState; for (let index = 0; index < count; index += 1) { const random = randomUnit(nextState); nextState = random.state; const formation = formationPosition(index, count); const entersFromLeft = index % 2 === 0; const start: WorldPosition = [ entersFromLeft ? HOCKEY_ARENA_MIN_X - 4 - random.value * 3 : HOCKEY_ARENA_MAX_X + 4 + random.value * 3, HOCKEY_ARENA_MIN_Z - 2.5 - (index % 4) * 0.65, ]; const kind: AetherShipKind = index >= count - armored ? "armored" : "standard"; const spawnAt = startsAt + index * 0.11; ships.push({ id: `${wave}:${index}`, kind, hp: kind === "armored" ? 3 : 1, maxHp: kind === "armored" ? 3 : 1, position: [...start], formationPosition: formation, phase: "entering", phaseStartedAt: spawnAt, phaseEndsAt: spawnAt + ENTRY_DURATION, startPosition: [...start], targetPosition: [...formation], contactResolved: false, }); } return { ships, randomState: nextState }; } export function createAetherAssaultState(active = false, requestedSeed = 1): AetherAssaultState { const seed = normalizeSeed(requestedSeed); const wave = createWave(1, 0, seed); return { status: active ? "live" : "inactive", seed, randomState: wave.randomState, wave: 1, score: 0, kills: 0, killStreak: 0, multiplier: 1, ships: active ? wave.ships : [], playerShots: [], enemyShots: [], nextProjectileId: 1, nextPlayerShotAt: 0, nextEnemyShotAt: 1.2, nextDiveAt: 4, nextWaveAt: null, lastPlayerHitAt: Number.NEGATIVE_INFINITY, lastKillAt: Number.NEGATIVE_INFINITY, lastKillScore: 0, }; } function cloneShip(ship: AetherShip): AetherShip { return { ...ship, position: [...ship.position], formationPosition: [...ship.formationPosition], startPosition: [...ship.startPosition], targetPosition: [...ship.targetPosition], }; } function easeOutCubic(value: number) { return 1 - (1 - value) ** 3; } function lerpPosition(start: WorldPosition, end: WorldPosition, progress: number): WorldPosition { return [start[0] + (end[0] - start[0]) * progress, start[1] + (end[1] - start[1]) * progress]; } function divePosition(ship: AetherShip, progress: number): WorldPosition { const controlX = ship.targetPosition[0] + Math.sign(ship.targetPosition[0] - ship.startPosition[0] || 1) * 3.2; const controlZ = (ship.startPosition[1] + ship.targetPosition[1]) * 0.5 - 1.5; const inverse = 1 - progress; return [ inverse * inverse * ship.startPosition[0] + 2 * inverse * progress * controlX + progress * progress * ship.targetPosition[0], inverse * inverse * ship.startPosition[1] + 2 * inverse * progress * controlZ + progress * progress * ship.targetPosition[1], ]; } function segmentDistanceSquared(start: WorldPosition, end: WorldPosition, point: WorldPosition) { const dx = end[0] - start[0]; const dz = end[1] - start[1]; const lengthSquared = dx * dx + dz * dz; const projection = lengthSquared < 0.000001 ? 0 : Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared)); const nearestX = start[0] + dx * projection; const nearestZ = start[1] + dz * projection; return (point[0] - nearestX) ** 2 + (point[1] - nearestZ) ** 2; } function canDamagePlayer(state: AetherAssaultState, time: number) { return time - state.lastPlayerHitAt >= AETHER_HIT_GRACE_SECONDS; } function registerPlayerHit(state: AetherAssaultState, time: number, damage: number) { if (!canDamagePlayer(state, time)) return 0; state.lastPlayerHitAt = time; state.killStreak = 0; state.multiplier = 1; return damage; } function awardKill(state: AetherAssaultState, ship: AetherShip, time: number) { state.kills += 1; state.killStreak += 1; state.multiplier = aetherMultiplier(state.killStreak); const base = ship.kind === "armored" ? AETHER_ARMORED_SCORE : AETHER_STANDARD_SCORE; const award = Math.round(base * state.multiplier); state.score += award; state.lastKillAt = time; state.lastKillScore = award; } function chooseShip(state: AetherAssaultState, candidates: readonly AetherShip[]) { if (!candidates.length) return undefined; const random = randomUnit(state.randomState); state.randomState = random.state; return candidates[Math.min(candidates.length - 1, Math.floor(random.value * candidates.length))]; } function enemyFireInterval(wave: number) { return Math.max(0.52, 1.85 - Math.max(0, wave - 1) * 0.055); } function diveInterval(wave: number) { return Math.max(1.7, 4.4 - Math.max(0, wave - 1) * 0.11); } export function advanceAetherAssault(source: AetherAssaultState, step: AetherAssaultStep): AetherAssaultAdvance { if (source.status !== "live" || step.delta <= 0) return { state: source, playerDamage: 0 }; const state: AetherAssaultState = { ...source, ships: source.ships.map(cloneShip), playerShots: source.playerShots.map((shot) => ({ ...shot, position: [...shot.position], velocity: [...shot.velocity] })), enemyShots: source.enemyShots.map((shot) => ({ ...shot, position: [...shot.position], velocity: [...shot.velocity] })), }; let playerDamage = 0; if (step.canAutoFire) { while (state.nextPlayerShotAt <= step.time && state.playerShots.length < AETHER_MAX_PLAYER_SHOTS) { state.playerShots.push({ id: state.nextProjectileId, position: [step.playerPosition[0], step.playerPosition[1] - 0.7], velocity: [0, -PLAYER_SHOT_SPEED], }); state.nextProjectileId += 1; state.nextPlayerShotAt += 1 / AETHER_PLAYER_SHOTS_PER_SECOND; } } else if (state.nextPlayerShotAt < step.time) { state.nextPlayerShotAt = step.time; } for (const ship of state.ships) { if (step.time < ship.phaseStartedAt) continue; const previous = [...ship.position] as WorldPosition; if (ship.phase === "entering") { const progress = Math.max(0, Math.min(1, (step.time - ship.phaseStartedAt) / Math.max(0.001, ship.phaseEndsAt - ship.phaseStartedAt))); ship.position = lerpPosition(ship.startPosition, ship.formationPosition, easeOutCubic(progress)); if (progress >= 1) { ship.phase = "formation"; ship.position = [...ship.formationPosition]; } } else if (ship.phase === "diving") { const progress = Math.max(0, Math.min(1, (step.time - ship.phaseStartedAt) / DIVE_DURATION)); ship.position = divePosition(ship, progress); if (!ship.contactResolved && segmentDistanceSquared(previous, ship.position, step.playerPosition) <= DIVE_HIT_RADIUS ** 2) { ship.contactResolved = true; playerDamage += registerPlayerHit(state, step.time, AETHER_DIVE_DAMAGE); } if (progress >= 1) { ship.phase = "returning"; ship.phaseStartedAt = step.time; ship.phaseEndsAt = step.time + RETURN_DURATION; ship.startPosition = [...ship.position]; ship.targetPosition = [...ship.formationPosition]; } } else if (ship.phase === "returning") { const progress = Math.max(0, Math.min(1, (step.time - ship.phaseStartedAt) / RETURN_DURATION)); ship.position = lerpPosition(ship.startPosition, ship.formationPosition, easeOutCubic(progress)); if (progress >= 1) { ship.phase = "formation"; ship.position = [...ship.formationPosition]; ship.contactResolved = false; } } } if (step.time >= state.nextDiveAt) { const diver = chooseShip(state, state.ships.filter((ship) => ship.phase === "formation")); if (diver) { diver.phase = "diving"; diver.phaseStartedAt = step.time; diver.phaseEndsAt = step.time + DIVE_DURATION; diver.startPosition = [...diver.position]; diver.targetPosition = [step.playerPosition[0], HOCKEY_ARENA_MAX_Z + 1.8]; diver.contactResolved = false; state.nextDiveAt = step.time + diveInterval(state.wave); } else { state.nextDiveAt = step.time + 0.25; } } if (step.time >= state.nextEnemyShotAt && state.enemyShots.length < AETHER_MAX_ENEMY_SHOTS) { const shooter = chooseShip(state, state.ships.filter((ship) => ship.phase === "formation" || ship.phase === "diving")); if (shooter) { const dx = step.playerPosition[0] - shooter.position[0]; const dz = step.playerPosition[1] - shooter.position[1]; const length = Math.max(0.001, Math.hypot(dx, dz)); const speed = Math.min(8.5, 5.6 + state.wave * 0.08); state.enemyShots.push({ id: state.nextProjectileId, position: [...shooter.position], velocity: [dx / length * speed, dz / length * speed], }); state.nextProjectileId += 1; state.nextEnemyShotAt = step.time + enemyFireInterval(state.wave); } else { state.nextEnemyShotAt = step.time + 0.25; } } const survivingPlayerShots: AetherProjectile[] = []; for (const shot of state.playerShots) { const start = [...shot.position] as WorldPosition; const end: WorldPosition = [start[0] + shot.velocity[0] * step.delta, start[1] + shot.velocity[1] * step.delta]; let hit: AetherShip | undefined; let hitDistance = Number.POSITIVE_INFINITY; for (const ship of state.ships) { if (step.time < ship.phaseStartedAt) continue; if (segmentDistanceSquared(start, end, ship.position) > (SHIP_RADIUS + PLAYER_SHOT_RADIUS) ** 2) continue; const distance = Math.hypot(ship.position[0] - start[0], ship.position[1] - start[1]); if (distance < hitDistance) { hit = ship; hitDistance = distance; } } if (hit) { hit.hp -= AETHER_PLAYER_SHOT_DAMAGE; if (hit.hp <= 0) awardKill(state, hit, step.time); continue; } shot.position = end; if (end[1] >= HOCKEY_ARENA_MIN_Z - 3 && Math.abs(end[0]) <= HOCKEY_ARENA_MAX_X + 4) survivingPlayerShots.push(shot); } state.playerShots = survivingPlayerShots; state.ships = state.ships.filter((ship) => ship.hp > 0); const survivingEnemyShots: AetherProjectile[] = []; for (const shot of state.enemyShots) { const start = [...shot.position] as WorldPosition; const end: WorldPosition = [start[0] + shot.velocity[0] * step.delta, start[1] + shot.velocity[1] * step.delta]; if (segmentDistanceSquared(start, end, step.playerPosition) <= (PLAYER_HIT_RADIUS + ENEMY_SHOT_RADIUS) ** 2) { playerDamage += registerPlayerHit(state, step.time, AETHER_ENEMY_SHOT_DAMAGE); continue; } shot.position = end; if (end[1] <= HOCKEY_ARENA_MAX_Z + 3 && end[1] >= HOCKEY_ARENA_MIN_Z - 3 && Math.abs(end[0]) <= HOCKEY_ARENA_MAX_X + 4) { survivingEnemyShots.push(shot); } } state.enemyShots = survivingEnemyShots; if (state.ships.length === 0 && state.nextWaveAt === null) { state.score += aetherWaveClearBonus(state.wave); state.nextWaveAt = step.time + AETHER_WAVE_CLEAR_DELAY; } if (state.nextWaveAt !== null && step.time >= state.nextWaveAt) { state.wave += 1; const wave = createWave(state.wave, step.time, state.randomState); state.ships = wave.ships; state.randomState = wave.randomState; state.nextWaveAt = null; state.nextEnemyShotAt = step.time + Math.min(1.2, enemyFireInterval(state.wave)); state.nextDiveAt = step.time + Math.min(3, diveInterval(state.wave)); } return { state, playerDamage }; }