new rpg mode
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user