156 lines
6.9 KiB
JavaScript
156 lines
6.9 KiB
JavaScript
/**
|
|
* JumpTravel — the pure geometry of a gate jump (no Phaser):
|
|
*
|
|
* returnGateFor(content, fromId)
|
|
* The destination system's RETURN gate — the gate in `content`
|
|
* whose destination is the system the ship just left (the first
|
|
* `content.jumps` entry with `to === fromId`, or null).
|
|
*
|
|
* arrivalPoint(gate, cfg)
|
|
* Where the ship materialises when it exits a gate: offset BACK
|
|
* along the gate's facing (a gate's `rotation` is the bearing to
|
|
* its DESTINATION star — the system we came from), just clear of
|
|
* the keepout circle (gate radius + ship clearance + ship radius
|
|
* + the config gap). The ship keeps its nose along the way it was
|
|
* travelling (heading = facing + π).
|
|
*
|
|
* jumpArrival(content, fromId, cfg)
|
|
* Both, together: { x, y, heading, gateId } — or null when the
|
|
* destination holds no return gate (a one-way SHORTCUT jump —
|
|
* JumpNetwork: tree edges run both ways, shortcuts don't). With
|
|
* shortcuts OFF (the current config) the network is a pure spanning
|
|
* tree, so a return gate always exists and this never returns null;
|
|
* the null path is defensive (re-enabled shortcuts, or a malformed
|
|
* record). The scene falls back to the destination's origin (its
|
|
* home-tether zone) when this is null.
|
|
*
|
|
* factionGateLocked(o)
|
|
* The FACTION GATE LOCK (data/gates.json → jump.factionLock): a
|
|
* gate FROM a neutral system INTO a faction-controlled one, locked
|
|
* while the player's level is below minLevel.
|
|
*
|
|
* GameScene.jumpThroughGate supplies the live numbers (the gate's
|
|
* radius/clearance, the ship's radius, data/gates.json → jump) and
|
|
* does the actual transport (re-stage the run for the destination
|
|
* system via the save pipeline, then scene.restart); this module only
|
|
* decides WHICH gate and WHERE — pure enough for Node tests
|
|
* (dev/jump-travel.test.mjs).
|
|
*/
|
|
|
|
/**
|
|
* @param {object|null} content the destination system's content record
|
|
* (galaxy.ensureContent(id): { jumps: [{ id, name, to, x, y, rotation }] })
|
|
* @param {string} fromId the id of the system the ship is leaving
|
|
* @returns {object|null} the return gate's content record, or null
|
|
*/
|
|
export function returnGateFor(content, fromId) {
|
|
if (!content || fromId == null) return null;
|
|
const jumps = Array.isArray(content.jumps) ? content.jumps : [];
|
|
return jumps.find((j) => j && j.to === fromId) ?? null;
|
|
}
|
|
|
|
/**
|
|
* The spawn point just past a gate's keepout, on the side the ship
|
|
* lands on (the far side of its facing).
|
|
*
|
|
* @param {object} gate the gate's content record: { x, y, rotation }
|
|
* @param {object} [cfg] { radius (gate keepout, px), clearance (ship
|
|
* keepout from the gate, px), shipRadius (px), gap (extra px, from
|
|
* data/gates.json → jump.arrivalGap) }
|
|
* @returns {{x:number, y:number, heading:number}}
|
|
*/
|
|
export function arrivalPoint(gate, cfg = {}) {
|
|
const radius = Number(cfg.radius) || 0;
|
|
const clearance = Number(cfg.clearance) || 0;
|
|
const shipRadius = Number(cfg.shipRadius) || 0;
|
|
const gap = Number(cfg.gap) || 0;
|
|
const a = Number(gate?.rotation) || 0;
|
|
const offset = radius + clearance + shipRadius + gap;
|
|
return {
|
|
x: (Number(gate?.x) || 0) - Math.cos(a) * offset,
|
|
y: (Number(gate?.y) || 0) - Math.sin(a) * offset,
|
|
heading: a + Math.PI,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The jump's arrival for a destination system: at its return gate (if
|
|
* it holds one — tree-edge jumps do; one-way shortcut jumps don't;
|
|
* shortcuts are OFF in the current config, so the return gate always
|
|
* exists and the null path below is defensive only).
|
|
*
|
|
* @returns {{x:number, y:number, heading:number, gateId:string}|null}
|
|
* null when there is no return gate — the caller falls back.
|
|
*/
|
|
export function jumpArrival(content, fromId, cfg = {}) {
|
|
const gate = returnGateFor(content, fromId);
|
|
if (!gate) return null;
|
|
return { ...arrivalPoint(gate, cfg), gateId: gate.id };
|
|
}
|
|
|
|
/**
|
|
* The 'Pirate Problems' ambush's TRIGGER DISTANCE — half the
|
|
* gate→capital-world stretch (GameScene._maybeStartPirateAmbush): the
|
|
* raiders materialise once the player is that far from the capital
|
|
* world, i.e. at the MIDPOINT of the entry-jumpgate → world line, so
|
|
* the conflict starts halfway to the capital from the jumpgate.
|
|
*
|
|
* @param {{x:number,y:number}|null} gate the entry gate (a content
|
|
* record, or any {x, y} anchor — the scene's spawn fallback)
|
|
* @param {{x:number,y:number}|null} world the capital world
|
|
* @returns {number|null} dist(gate, world) / 2, or null when either
|
|
* point is missing or the stretch is degenerate (zero)
|
|
*/
|
|
export function ambushTriggerDistance(gate, world) {
|
|
const gx = Number(gate?.x);
|
|
const gy = Number(gate?.y);
|
|
const wx = Number(world?.x);
|
|
const wy = Number(world?.y);
|
|
if (![gx, gy, wx, wy].every(Number.isFinite)) return null;
|
|
const d = Math.hypot(gx - wx, gy - wy);
|
|
return d > 0 ? d / 2 : null;
|
|
}
|
|
|
|
/**
|
|
* The FACTION GATE LOCK (data/gates.json → jump.factionLock): a jump
|
|
* gate FROM a NEUTRAL system INTO a FACTION-CONTROLLED one is locked
|
|
* while the player's level is below `minLevel`.
|
|
*
|
|
* - A system is NEUTRAL when its record's `faction` is null, '' or the
|
|
* string 'neutral' (the roster defaults to null — neutral wild space);
|
|
* any other `faction` (apex/mindweave/zenith, data/factions.json) is
|
|
* FACTION-CONTROLLED.
|
|
* - Gates FROM a faction system are never locked (the run is already
|
|
* inside faction space — leaving it, or moving within it, stays open),
|
|
* and neutral→neutral hops are never locked.
|
|
* - A missing level (null — no XP bank yet) counts as level 0, so a
|
|
* fresh run finds the gates locked.
|
|
* - A missing destination (unknown id) is not faction-controlled, so it
|
|
* is never locked by this rule (the scene's own "unknown destination"
|
|
* denial stands on its own).
|
|
*
|
|
* Pure (no Phaser, no config import) — the caller resolves the config
|
|
* section and the system records and passes them in (Node-testable in
|
|
* dev/jump-travel.test.mjs).
|
|
*
|
|
* @param {object} o { enabled (the feature switch, default true),
|
|
* minLevel (the level that OPENS the lock, default 5), level (the
|
|
* player's combat level — null/undefined = 0), from (the current
|
|
* system's record), dest (the gate's DESTINATION system's record —
|
|
* null when unknown) } — a system record only contributes `faction`
|
|
* @returns {boolean} true while the gate's jump is blocked
|
|
*/
|
|
export function factionGateLocked(o = {}) {
|
|
if (o.enabled === false) return false; // the feature switch
|
|
const minLevel = Number.isFinite(Number(o.minLevel)) ? Number(o.minLevel) : 5;
|
|
const level = Number.isFinite(Number(o.level)) ? Number(o.level) : 0;
|
|
if (level >= minLevel) return false; // the lock is open at/above minLevel
|
|
const neutral = (rec) => {
|
|
const f = rec?.faction;
|
|
return f == null || f === '' || f === 'neutral';
|
|
};
|
|
if (!neutral(o.from)) return false; // already inside faction space
|
|
if (o.dest == null) return false; // an unknown destination is not faction-controlled
|
|
return !neutral(o.dest);
|
|
}
|