From 8ade4734798bc4976b06651448deb3c1e7a60c1b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 21 Sep 2026 19:07:18 -0600 Subject: [PATCH] Add faction-tinted swirl to locked jump gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Locked gates (neutral → faction, below minLevel) now render their swirling center in the controlling faction's hue as an in-world "that gate belongs to someone" tell, complementing the comm window's grayed REQUEST JUMP and lock message. - JumpGate.setFactionTint tints the active swirl (setTint) or recolors the procedural fallback field discs; null clears back to the ordinary swirl. Falsy-but-valid hues (e.g. 0x000000) are not treated as clear. - GameScene.gateFactionTintFor resolves the destination faction's color; _refreshGateFactionTints re-resolves all gates at build, once the XP ledger exists (save may have already lifted the lock), and on each level-up event to clear tints as the lock lifts. - Test harness extended with setTint/setFillStyle mocks; new checks cover tint apply/clear/falsy-hue on the sprite path and field-disc recolor + restore on the fallback path. --- data/gates.json | 2 +- dev/jumpgate.test.mjs | 33 +++++++++++++++++++++++++++ js/entities/JumpGate.js | 42 +++++++++++++++++++++++++++++++++-- js/scenes/GameScene.js | 49 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/data/gates.json b/data/gates.json index d5a285a..27b3fa5 100644 --- a/data/gates.json +++ b/data/gates.json @@ -59,7 +59,7 @@ "miningToast": "CANNOT JUMP WHILE MINING", "combatToast": "CANNOT JUMP WHILE UNDER ATTACK", "factionLock": { - "_comment": "FACTION GATE LOCK (the pure rule is js/galaxy/JumpTravel.js → factionGateLocked; the gate comm window shows it — js/ui/CommsPanel.js's gate variant — and GameScene.jumpThroughGate keeps its own flat denial on top): a jump gate that leads FROM a NEUTRAL system (faction null/'neutral') INTO a FACTION-CONTROLLED one (faction = apex/mindweave/zenith, the roster's faction pass — data/factions.json) is LOCKED while the player's combat level (js/combat/XpManager.js) is below minLevel — the window's LINK line is replaced by `message` (rendered verbatim under the panel's uppercase display convention) and REQUEST JUMP is a grayed ghost. At level >= minLevel the lock lifts and the gate reads as ordinary again (LINK ACTIVE/DORMANT). Gates FROM a faction system (any destination — the run is already inside faction space) and neutral→neutral hops are never locked. `enabled` is the feature switch (false = the lock is off); `minLevel` is the level that OPENS the lock (a fresh run starts at 0).", + "_comment": "FACTION GATE LOCK (the pure rule is js/galaxy/JumpTravel.js → factionGateLocked; the gate comm window shows it — js/ui/CommsPanel.js's gate variant — and GameScene.jumpThroughGate keeps its own flat denial on top): a jump gate that leads FROM a NEUTRAL system (faction null/'neutral') INTO a FACTION-CONTROLLED one (faction = apex/mindweave/zenith, the roster's faction pass — data/factions.json) is LOCKED while the player's combat level (js/combat/XpManager.js) is below minLevel — the window's LINK line is replaced by `message` (rendered verbatim under the panel's uppercase display convention), REQUEST JUMP is a grayed ghost, and the gate's SWIRLING CENTER reads in the controlling faction's hue (data/factions.json → factions..color — GameScene.gateFactionTintFor → JumpGate.setFactionTint, the in-world 'that gate belongs to someone' tell; it clears on a level-up that lifts the lock). At level >= minLevel the lock lifts and the gate reads as ordinary again (LINK ACTIVE/DORMANT, the ordinary swirl). Gates FROM a faction system (any destination — the run is already inside faction space) and neutral→neutral hops are never locked. `enabled` is the feature switch (false = the lock is off); `minLevel` is the level that OPENS the lock (a fresh run starts at 0).", "enabled": true, "minLevel": 5, "message": "Jump Gate is under Faction Control. Status: Locked" diff --git a/dev/jumpgate.test.mjs b/dev/jumpgate.test.mjs index b905a7e..aae323f 100644 --- a/dev/jumpgate.test.mjs +++ b/dev/jumpgate.test.mjs @@ -22,6 +22,11 @@ * default; setRouteTarget(true) shows it, update() animates the * steady ring + two expanding/fading ripples outside the keepout * (even on a dormant gate), setRouteTarget(false) clears + hides it; + * - the FACTION GATE LOCK's swirl tint (data/gates.json → + * jump.factionLock; data/factions.json → factions.\u003cid\u003e.color): + * setFactionTint(hue) tints the swirling center (sprite: the swirl + * frame — fallback: the field discs), setFactionTint(null) clears it + * back to the ordinary swirl; * - the SOLID contract (same as Planet/Station): minCenterDistance, * edgePoint exactly `gap` past the surface, aimPoint clamping, and * constrainShip pushing the ship out of the keepout circle @@ -114,6 +119,7 @@ const scene = { setScale(s) { this.scale = s; return this; }, setAlpha(a) { this.alpha = a; return this; }, setVisible(v) { this.visible = v; return this; }, + setTint(c) { this.tint = c; return this; }, }; images.push(img); return img; @@ -130,6 +136,7 @@ const scene = { }), circle: (x, y, r, fill, alpha) => { const c = { x, y, r, fill, alpha, setAlpha(a) { this.alpha = a; return this; } }; + c.setFillStyle = (f, a) => { c.fill = f; if (a != null) c.alpha = a; return c; }; alphas.push(c); return c; }, @@ -209,6 +216,22 @@ gate.setRouteTarget(true); check('setRouteTarget(true) twice is a no-op (idempotent)', gate.routePulse.on === true && gate.routePulse.ring.visible === true); gate.setRouteTarget(false); +// --- The FACTION GATE LOCK's swirl tint (the in-world tell) ------------- +// A gate into FACTION-CONTROLLED space, below the player's level-5 lock +// (GameScene.gateFactionTintFor → the lock's pure rule in JumpTravel), +// swirls in the controlling faction's hue (data/factions.json); +// null clears it (the lock lifted on a level-up). +const APEX_HUE = hexToRgbInt(config.get('factions.factions.apex.color')); +check('fresh gate: no faction tint parked (the ordinary swirl)', gateB.factionTint === null); +gateB.setFactionTint(APEX_HUE); +check('setFactionTint(faction hue): the swirl carries the faction tint', gateB.factionTint === APEX_HUE && swirlB.tint === APEX_HUE); +gateB.update(1500); // the spin/breath keeps running tinted +gateB.setFactionTint(null); +check('setFactionTint(null): the tint clears (white = untinted)', gateB.factionTint === null && swirlB.tint === 0xffffff); +gateB.setFactionTint(0); // a falsy-but-valid hue (e.g. #000000) is NOT 'clear' +check('setFactionTint(0) parks 0x0 (falsy ≠ null — only null clears)', gateB.factionTint === 0 && swirlB.tint === 0); +gateB.setFactionTint(null); + // --- The solid contract (same rules as Planet / Station) ------------------ const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2; const minDist = gate.minCenterDistance(shipRadius); @@ -258,6 +281,16 @@ const d0 = circlesD.map((c) => c.alpha); gateD.update(873); // t ≈ π/2 of the pulse → maximum breathing check('fallback: an active gate breathes its field', circlesD.some((c, i) => Math.abs(c.alpha - d0[i]) > 1e-6)); +// --- FACTION LOCK tint, fallback path: the field discs recolor ----------- +const MIND_HUE = hexToRgbInt(config.get('factions.factions.mindweave.color')); +const baseFills = circlesD.map((c) => c.fill); +gateD.setFactionTint(MIND_HUE); +check('fallback: setFactionTint recolors the field discs (the faction hue)', + circlesD.every((c) => c.fill === MIND_HUE)); +gateD.setFactionTint(null); +check('fallback: clearing restores the base field hues', + circlesD.every((c, i) => c.fill === baseFills[i])); + // --- Teardown ------------------------------------------------------------- gate.update(1000); // must not throw (sprite path, dormant) check('sprite gate: update() runs without throwing', true); diff --git a/js/entities/JumpGate.js b/js/entities/JumpGate.js index 2161e25..e80f007 100644 --- a/js/entities/JumpGate.js +++ b/js/entities/JumpGate.js @@ -46,6 +46,16 @@ import { Planet } from './Planet.js'; * * The collision API is circular and rotation-blind. * + * FACTION GATE LOCK (data/gates.json → jump.factionLock): while the + * gate's jump is LOCKED (its destination system is FACTION-CONTROLLED, + * the player is below the lock's minLevel — GameScene.gateFactionTintFor), + * the SWIRLING CENTER reads in the controlling faction's hue + * (data/factions.json → factions..color — apex red / mindweave blue + * / zenith gold; setFactionTint, cleared to null when the lock lifts on + * a level-up). The in-world "that gate belongs to someone" tell; the + * gate comm window (its grayed REQUEST JUMP + the lock's message) is + * the primary read. + * * update(time) spins the swirl (active gates) / breathes the fallback * art — driven by GameScene.update, like the stations and asteroid * clusters. @@ -75,6 +85,7 @@ export class JumpGate extends Phaser.GameObjects.Container { this.ringBody = null; this.swirl = null; this.routePulse = null; // the route's next-stop mark (GameScene → setRouteTarget) + this.factionTint = null; // the FACTION LOCK's swirl hue (GameScene → setFactionTint) this.setDepth(o.depth ?? 5); this.rotation = gate.rotation ?? 0; // face the destination star @@ -205,8 +216,14 @@ export class JumpGate extends Phaser.GameObjects.Container { arc(FIELD * 0.82, -0.5, 0.5, S * 0.035, 1, rgb(1.4, 1.6, 2)); // The energy field (the breathing discs — alpha driven in update()). - this.field = scene.add.circle(0, 0, FIELD, rgb(0.5, 0.7, 1), 0.16); - this.fieldCore = scene.add.circle(0, 0, FIELD * 0.5, rgb(0.75, 0.9, 1), 0.22); + // The base hues are kept for setFactionTint (the FACTION LOCK's + // swirl tint — a faction hue while locked, these when it clears). + this._fieldBase = rgb(0.5, 0.7, 1); + this._fieldBaseAlpha = 0.16; + this._fieldCoreBase = rgb(0.75, 0.9, 1); + this._fieldCoreBaseAlpha = 0.22; + this.field = scene.add.circle(0, 0, FIELD, this._fieldBase, this._fieldBaseAlpha); + this.fieldCore = scene.add.circle(0, 0, FIELD * 0.5, this._fieldCoreBase, this._fieldCoreBaseAlpha); this.add([this.field, this.fieldCore]); // The chevrons — pointing at the destination star (+x), over the field. @@ -239,6 +256,27 @@ export class JumpGate extends Phaser.GameObjects.Container { if (this.swirl) this.swirl.setVisible(true); // update() breathes it from the next frame } + /** + * The FACTION GATE LOCK's swirl tint (GameScene.gateFactionTintFor — + * this gate's destination is FACTION-CONTROLLED and the player is + * below the lock's minLevel, data/gates.json → jump.factionLock): the + * swirling center (the active swirl — frame 1; the procedural + * fallback's field discs) reads in the controlling faction's hue + * (data/factions.json → factions..color). `null` clears it (the + * ordinary swirl — the lock lifted). On a DORMANT gate the swirl is + * hidden — the tint is simply parked, so a later activate() reveals + * the swirl already tinted. + * @param {number|null} color — the faction's 0xRRGGBB hue (null = clear) + */ + setFactionTint(color) { + this.factionTint = color == null ? null : color >>> 0; + if (this.swirl) this.swirl.setTint(this.factionTint ?? 0xffffff); // white = untinted + if (this.field && this.fieldCore) { + this.field.setFillStyle(this.factionTint ?? this._fieldBase, this._fieldBaseAlpha); + this.fieldCore.setFillStyle(this.factionTint ?? this._fieldCoreBase, this._fieldCoreBaseAlpha); + } + } + /** * ROUTE-TARGET state (GameScene.update, each frame): true while this * gate is the route's NEXT STOP in the current system (routeNextGate). diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index f69dc89..1f12332 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -762,6 +762,13 @@ export class GameScene extends Phaser.Scene { this.systemGates.push(new JumpGate(this, j, { depth: 5 })); } } + // FACTION GATE LOCK — the swirl tint (the in-world tell): a gate + // into faction-controlled space, below the player's level-5 lock, + // swirls in that faction's hue (gateFactionTintFor). With combat on + // the XP ledger isn't stood up yet (it lands below with the save's + // level), so _refreshGateFactionTints() re-resolves once the ledger + // exists; a level-up that lifts the lock clears it again. + this._refreshGateFactionTints(); // Every solid in the system — worlds first (their keep-out circles are // disjoint), then the clusters, then the stations, then the gates. The @@ -1079,6 +1086,12 @@ export class GameScene extends Phaser.Scene { // `active` — the same freeze contract as the comms' ack pause. this.levelMarquee = new LevelUpMarquee(this); this.levelMarquee.bind(this.combat.xp); + // FACTION GATE LOCK — the swirl tint follows the level: the ledger + // now stands (the save's level restored), so re-resolve the gates' + // tints (a level that already lifted the lock clears them), and + // every level-up after re-resolves too (the lock lifts at minLevel). + this._refreshGateFactionTints(); + this.combat.xp.on('levelup', () => this._refreshGateFactionTints()); // ?combat boot (the tuning sandbox): the SEVEN-TYPE DEMO around the // ship (data/combat.json → waves.scripts.combat_demo) — a sequence // of rolling waves, one per new hostile type in difficulty order @@ -3560,6 +3573,42 @@ export class GameScene extends Phaser.Scene { }); } + /** + * The FACTION GATE LOCK's SWIRL TINT (data/gates.json → + * jump.factionLock; the in-world half of the lock): while this gate's + * jump is LOCKED — a gate from a NEUTRAL system into a + * FACTION-CONTROLLED one, below the lock's minLevel — the gate's + * swirling center reads in the CONTROLLING FACTION's hue: the + * destination system's `faction` (data/factions.json → + * factions.\.color — apex red / mindweave blue / zenith gold). + * null when the lock doesn't hold (an ordinary swirl) — the gate comm + * window's grayed REQUEST JUMP + its message stay the primary read; + * this is the "that gate belongs to someone" tell on the gate itself. + * @param {object} gt the gate entity (JumpGate — .gate record) + * @returns {?number} the faction's 0xRRGGBB hue (ready for setTint), or null + */ + gateFactionTintFor(gt) { + if (!this.factionGateLockedFor(gt)) return null; // unlocked → the ordinary swirl + const destId = gt?.gate?.to; + const faction = + destId != null && this.galaxy ? (this.galaxy.byId.get(destId)?.faction ?? null) : null; + if (!faction || faction === 'neutral') return null; + const hex = config.get(`factions.factions.${faction}.color`, ''); + return typeof hex === 'string' && hex.length > 0 ? toColor(hex) : null; + } + + /** + * Re-resolve every gate's faction-lock tint (JumpGate.setFactionTint). + * Called at build (the tint follows the lock as it stands — level 0 + * while the XP ledger is still to come), once the combat ledger exists + * (the save's level may already have lifted the lock), and on each + * level-up (the ledger's 'levelup' event — the same hook the level-up + * marquee rides), when a locked gate's tint clears. + */ + _refreshGateFactionTints() { + for (const gt of this.systemGates ?? []) gt.setFactionTint(this.gateFactionTintFor(gt)); + } + /** * The panel's button presses (CommsPanel → onAction). The panel is * already closed by the time this runs: