433 lines
15 KiB
TypeScript
433 lines
15 KiB
TypeScript
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 };
|
|
}
|