import type { Rng } from "../rng"; import type { ArcadeGameDefinition, ArcadeOutcome } from "./types"; /** * ORBIT — objects circle a core on concentric orbits. The player fires an * impulse at an angle; on each orbit the impulse can hit an object (angular * proximity), collecting its value, deflecting, or spawning a new orbit. * Supernova collapses every orbit and turns the remaining objects into * multipliers of the collected total. */ export interface OrbitConfig { orbits: number; objectsPerOrbit: [number, number]; /** Angular half-width of the impulse in degrees (hit tolerance). */ beamHalfWidth: number; objectTypes: { id: string; label: string; value: number; weight: number; deflect?: number; spawn?: boolean }[]; supernovaChance: number; maxOrbits: number; } export interface OrbitObject { id: string; type: string; angle: number; value: number; } export interface OrbitStep { orbit: number; angle: number; hit: OrbitObject | null; deflectedTo?: number; spawned?: boolean; collected: number; } function angleDiff(a: number, b: number): number { const d = Math.abs(((a - b) % 360 + 540) % 360 - 180); return d; } export function resolveOrbit(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: { angle?: number }): ArcadeOutcome { const cfg = def.config as unknown as OrbitConfig; let angle = ((inputRaw.angle ?? rng.int(360)) % 360 + 360) % 360; const orbits: OrbitObject[][] = []; let seq = 0; const makeOrbit = (): OrbitObject[] => { const n = cfg.objectsPerOrbit[0] + rng.int(cfg.objectsPerOrbit[1] - cfg.objectsPerOrbit[0] + 1); const objs: OrbitObject[] = []; for (let i = 0; i < n; i++) { const t = cfg.objectTypes[rng.weighted(cfg.objectTypes.map((o) => o.weight))]; objs.push({ id: `o${seq++}`, type: t.id, angle: rng.float() * 360, value: t.value }); } return objs; }; for (let i = 0; i < cfg.orbits; i++) orbits.push(makeOrbit()); const steps: OrbitStep[] = []; const features: string[] = []; let collected = 0; let i = 0; while (i < orbits.length && i < cfg.maxOrbits) { const objs = orbits[i]; let hit: OrbitObject | null = null; let best = Infinity; for (const o of objs) { const d = angleDiff(o.angle, angle); if (d <= cfg.beamHalfWidth && d < best) { best = d; hit = o; } } const step: OrbitStep = { orbit: i, angle, hit, collected }; if (hit) { const t = cfg.objectTypes.find((o) => o.id === hit!.type)!; collected += hit.value; step.collected = collected; if (t.deflect) { angle = (angle + (rng.float() - 0.5) * 2 * t.deflect + 360) % 360; step.deflectedTo = angle; features.push("Deflection"); } if (t.spawn && orbits.length < cfg.maxOrbits) { orbits.push(makeOrbit()); step.spawned = true; features.push("New orbit"); } features.push(t.label); } steps.push(step); i++; } let supernova: { remaining: number; multiplier: number } | null = null; const hitIds = new Set(steps.filter((s) => s.hit).map((s) => s.hit!.id)); if (collected > 0 && rng.chance(cfg.supernovaChance)) { const remaining = orbits.flat().filter((o) => !hitIds.has(o.id)).length; const mult = 1 + remaining * 0.5; supernova = { remaining, multiplier: mult }; collected *= mult; features.push("Supernova"); } const scaled = collected * def.payScale; const capped = scaled > def.maxMultiplier; const multiplier = Math.min(def.maxMultiplier, scaled); const totalWin = Math.round(bet * multiplier); return { game: def.slug, version: def.version, bet, totalWin, multiplier: bet ? totalWin / bet : 0, capped, features: [...new Set(features)], steps, summary: { angle: inputRaw.angle ?? null, orbits: orbits.map((o) => o.map((x) => ({ ...x, value: Math.round(x.value * def.payScale * 100) / 100 }))), supernova, hits: hitIds.size }, }; } export function randomOrbitInput(_def: ArcadeGameDefinition, rng: Rng): { angle: number } { return { angle: rng.int(360) }; }