Fix gate facing so gates sit on the destination side of system center

- Sort gate candidates by destination-side before tier, so an on-ray point behind the center loses to any destination-side aimed/scan point
- Add a new soft "facing from the center" check in tests: when some anchor's tether circle reaches the destination side (maxProj + tether > 0), the gate must lie there; a single far planet may still take the best aimed point
- Prefer a destination-side aimed candidate in the clearance fallback
This commit is contained in:
Brian Fertig 2026-09-07 14:37:21 -06:00
parent 963b2b5bc2
commit a846562782
2 changed files with 49 additions and 16 deletions

View File

@ -21,9 +21,14 @@
* - ANCHORED systems (a planet or the home world in the starting
* system): every gate is within level-1 tether (tether.level1Radius)
* of a planet anchor and FACES its destination star on the 2-D map
* (soft rule within 90° of the systemstar bearing from the
* anchoring object); stations are NOT anchors (the player must be
* able to build out from a world), but they DO get clearance;
* (soft rule the gate sits on the DESTINATION SIDE of the system
* center whenever some anchor can reach that side (maxProj + tether
* > 0), and from the anchoring object it is within 90° of the
* systemstar bearing; when the only anchor sits farther than the
* tether opposite the destination a single far planet the gate
* takes the best aimed point instead); stations are NOT anchors (the
* player must be able to build out from a world), but they DO get
* clearance;
* - every non-barren system holds at least ONE planet (the gate's
* anchor is guaranteed to exist);
* - BARREN systems (objectCount 0 the network's dead-end leaves):
@ -243,9 +248,21 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
tether++;
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (> ${TETHER})`;
}
// FACING (soft): from the anchoring object, the gate is on the
// target side — within 90° of the system→star bearing (some anchor
// must satisfy BOTH the tether and the facing).
// FACING (soft, from the CENTER): the gate sits on the DESTINATION
// side of the system center — dot(gate, destDir) > 0 — whenever
// geometry allows it: some anchor's tether circle reaches that side
// (maxProj + tether > 0). When every anchor sits farther than the
// tether opposite the destination (a single far planet), the gate
// takes the best aimed point — the tether rule (hard) outranks the
// facing rule (soft).
const maxProj = Math.max(...anchors.map((a) => a.x * Math.cos(th) + a.y * Math.sin(th)));
if (maxProj + TETHER > 0 && j.x * Math.cos(th) + j.y * Math.sin(th) <= 1e-6) {
facing++;
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} faces AWAY from its destination (wrong side of the center)`;
}
// FACING (soft, from the anchor): from the anchoring object, the
// gate is on the target side — within 90° of the system→star
// bearing (some anchor must satisfy BOTH the tether and the facing).
let onSide = false;
for (const a of anchors) {
const d = Math.hypot(j.x - a.x, j.y - a.y);

View File

@ -458,7 +458,13 @@ function bestRotation(place, targetAngles) {
* target star, (3) a forward-hemisphere scan of the circle (±75°).
* Every candidate is exactly `range` from its anchor, so
* tether-reachability holds by construction and the facing deviation
* never exceeds 90° (in practice a few degrees). Anchors are PLANETS
* never exceeds 90° (in practice a few degrees). Candidates on the
* DESTINATION SIDE of the system center sort first before every
* tier so the gate faces its destination FROM THE CENTER (the rule
* the map reads) and is never placed on the far side; an on-ray
* intersection behind the center loses to any destination-side
* aimed/scan point.
* Anchors are PLANETS
* only (plus the home world at home) free-space stations are
* deliberately excluded: the build console lives on a world, so every
* gate must be tether-reachable from a planet the player can build
@ -575,25 +581,32 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
// (32 points, ± up to 75° from the target direction) — the
// clearance search for tight systems.
// Every candidate is exactly `range` from an anchor, so the level-N
// tether rule (hard) is met by construction; the direction rule (soft)
// is honored by the tier order: on-ray → aimed → near-aimed.
// tether rule (hard) is met by construction. The facing rule (soft —
// the gate faces its destination FROM THE SYSTEM CENTER) is honored
// by the sort: destination-side candidates (side 0) come FIRST —
// before every tier — so a gate is never placed on the far side of
// the center (an on-ray intersection behind the center loses to any
// destination-side aimed/scan point); then on-ray → aimed →
// near-aimed.
const cands = [];
const add = (tier, order, px, py) =>
cands.push({ tier, order, side: px * ux + py * uy > 0 ? 0 : 1, px, py });
anchors.forEach((a, ai) => {
const proj = a.x * ux + a.y * uy; // signed distance along the ray
const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance
if (h <= range) {
const off = Math.sqrt(Math.max(0, range * range - h * h));
cands.push({ tier: 1, order: h * 1e6 + ai * 1000, px: (proj + off) * ux, py: (proj + off) * uy });
add(1, h * 1e6 + ai * 1000, (proj + off) * ux, (proj + off) * uy);
}
cands.push({ tier: 2, order: h * 1e6 + ai * 1000, px: a.x + range * ux, py: a.y + range * uy });
add(2, h * 1e6 + ai * 1000, a.x + range * ux, a.y + range * uy);
for (let k = 0; k < 32; k++) {
const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction
const dx = ux * Math.cos(phi) - uy * Math.sin(phi);
const dy = ux * Math.sin(phi) + uy * Math.cos(phi);
cands.push({ tier: 3, order: Math.abs(phi) * 1e6 + h + ai * 1e-3, px: a.x + range * dx, py: a.y + range * dy });
add(3, Math.abs(phi) * 1e6 + h + ai * 1e-3, a.x + range * dx, a.y + range * dy);
}
});
cands.sort((p, q) => p.tier - q.tier || p.order - q.order);
cands.sort((p, q) => p.side - q.side || p.tier - q.tier || p.order - q.order);
const ok = (c) => {
const d2c = c.px * c.px + c.py * c.py;
@ -622,9 +635,12 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
if (!chosen) {
// Every candidate failed clearance (nearly impossible — an anchor's
// tether circle is 5120 px across, the discs under a thousand): take
// the first anchor's aimed point anyway — the tether and facing rules
// outrank cosmetics.
chosen = cands.find((c) => c.tier === 2) ?? cands[0];
// the first DESTINATION-SIDE aimed point anyway — the tether and
// facing rules outrank cosmetics.
chosen =
cands.find((c) => c.tier === 2 && c.side === 0) ??
cands.find((c) => c.tier === 2) ??
cands[0];
console.warn(
`[orbit] ${record.id}: gate ${i + 1} fell back to its first aimed candidate (clearance)`,
);