new rpg mode

This commit is contained in:
Warren H
2026-07-17 12:47:42 -04:00
parent de7b59ce77
commit 10ff124466
180 changed files with 20863 additions and 1014 deletions
+432
View File
@@ -0,0 +1,432 @@
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 };
}