Compare commits

..

3 Commits

Author SHA1 Message Date
Brian Fertig b325a19c82 Updated Take-Off Video 2026-09-04 09:12:20 -06:00
Brian Fertig adc0389c1e Simplify action bar slots to label-only buttons
- Remove the drawn slot icons (research/build/ship/menu/reserved) and their RGB ghost copies; keep the GlitchText split on labels and panel outline only.
- Center button text vertically and shrink the bar height from 92 to 56 px now that there is no icon row.
- Drop iconSize/iconY config and the drawIcon helper, and update README/comments to match the label-only design.
2026-09-04 09:12:03 -06:00
Brian Fertig 2b37be7494 Fix asteroid rock overlap and add distinct compass color for clusters
- Replace the old per-rock placement retry loop with a deterministic pairwise relaxation pass (relaxRockGaps) that enforces a gapFactor x (r1+r2) separation between every pair, so rocks keep a subtle visible gap instead of interpenetrating
- Add cluster.gapFactor (default 1.12) and asteroids.compassColor (#c8d2e0) config keys, with updated comments in data/asteroids.json and SystemGenerator.js
- Tint the DiscoveryCompass arrow per target: bake the arrow texture white and tint it to the target's color (worlds keep theme neon cyan, clusters use their light gray)
- Extend dev/asteroids.test.mjs to assert every rock pair respects the gapFactor separation
2026-09-04 08:45:28 -06:00
9 changed files with 89 additions and 147 deletions

View File

@ -146,7 +146,7 @@ layers now, with the rules and panels to come next:
Six evenly spaced slots: **Research, Build, Ship, ·, ·, Menu**. The slots Six evenly spaced slots: **Research, Build, Ship, ·, ·, Menu**. The slots
fire `onAction` and are otherwise inert; two slots are reserved. Dressed fire `onAction` and are otherwise inert; two slots are reserved. Dressed
with the menu's CRT language — scanlines over the strip and the GlitchText with the menu's CRT language — scanlines over the strip and the GlitchText
RGB pull-apart on icons, labels, and the panel outline during bursts. All RGB pull-apart on labels and the panel outline during bursts. All
layout, palette, and motion (boot flicker, rail comet, glitch bursts, RGB layout, palette, and motion (boot flicker, rail comet, glitch bursts, RGB
split, scanlines) live in the config. split, scanlines) live in the config.

Binary file not shown.

View File

@ -1,14 +1,13 @@
{ {
"_comment": "COMMAND DECK — the cyberpunk bar across the bottom of the screen (rendered by js/ui/ActionBar.js). Six evenly spaced slots: `buttons` is the ORDERED list — give a slot an `id` (the action it will fire) + `label` + `accent`, or leave both `null` for a reserved slot. Buttons currently fire `onAction` (see GameScene); the behavior behind them comes next. Everything visual is procedural (no image assets) and tunable here: layout in `height`/`margin`/`padding`/`button`, palette in `colors`, CRT scanlines in `scanline`, motion in `animation` (boot flicker, the rail comet, the glitch bursts, the RGB pull-apart).", "_comment": "COMMAND DECK — the cyberpunk bar across the bottom of the screen (rendered by js/ui/ActionBar.js). Six evenly spaced slots: `buttons` is the ORDERED list — give a slot an `id` (the action it will fire) + `label` + `accent`, or leave both `null` for a reserved slot. Buttons currently fire `onAction` (see GameScene); the behavior behind them comes next. Everything visual is procedural (no image assets) and tunable here: layout in `height`/`margin`/`padding`/`button`, palette in `colors`, CRT scanlines in `scanline`, motion in `animation` (boot flicker, the rail comet, the glitch bursts, the RGB pull-apart).",
"enabled": true, "enabled": true,
"height": 92, "height": 56,
"margin": { "left": 20, "right": 20, "bottom": 12 }, "margin": { "left": 20, "right": 20, "bottom": 12 },
"padding": 16, "padding": 16,
"button": { "button": {
"widthFactor": 0.72, "widthFactor": 0.72,
"maxWidth": 190, "maxWidth": 190,
"notch": 10, "notch": 10,
"iconSize": 26,
"fontSize": 14, "fontSize": 14,
"letterSpacing": 2.5 "letterSpacing": 2.5
}, },
@ -39,7 +38,7 @@
"animation": { "animation": {
"bootStagger": 70, "bootStagger": 70,
"rgb": { "rgb": {
"_comment": "RGB pull-apart on icons/labels/panel (the menu title's GlitchText technique): idleOffset px of constant chromatic fringe, burstOffset extra px at full burst, idleAlpha/burstAlpha ghost strength.", "_comment": "RGB pull-apart on labels/panel (the menu title's GlitchText technique): idleOffset px of constant chromatic fringe, burstOffset extra px at full burst, idleAlpha/burstAlpha ghost strength.",
"idleOffset": 1.8, "idleOffset": 1.8,
"burstOffset": 6, "burstOffset": 6,
"idleAlpha": 0.4, "idleAlpha": 0.4,

View File

@ -1,17 +1,19 @@
{ {
"_comment": "ASTEROID CLUSTERS — loose groups of slowly tumbling rocks drifting in the system's void. texture = spritesheet of frameWidth×frameHeight asteroid frames (frameCount frames). cluster = how a group looks & moves: groupSize = rocks per cluster (minFullSize guarantees at least one full-size rock), sizes = per-rock diameter px, spread = how far a rock may sit from the cluster center, spin = each rock's OWN very slow tumble (deg/s, direction per-rock random), groupSpin = the whole loose group drifts around its center (deg/s), tint = subtle warm/cool starlight per cluster (anchors blended toward white by strength), halo = soft glow behind the group, debris = a halo of fine dust motes orbiting just outside the rocks. distribution = how many clusters a system gets: targetObjects planetCount, ±jitter, clamped to [minClusters, maxClusters] (more planets ⇒ fewer clusters); the starting system always gets at least startingSystemMinClusters, and those first ones are placed INSIDE the player's initial tether so they're reachable from the spawn. placement = where clusters may sit: minObjectSpacing = no cluster may be closer (center-to-center) than this to ANY other object (home world, planets, free-space stations, other clusters); minRadius/maxRadius = the annulus around the origin random clusters live in; tetherMargin = how far inside the tether rim a starting-system cluster must sit (whole group stays reachable). shipClearance = how close (edge-to-edge) the ship may get to a rock — clusters are solid, like worlds.", "_comment": "ASTEROID CLUSTERS — loose groups of slowly tumbling rocks drifting in the system's void. texture = spritesheet of frameWidth×frameHeight asteroid frames (frameCount frames). cluster = how a group looks & moves: groupSize = rocks per cluster (minFullSize guarantees at least one full-size rock), sizes = per-rock diameter px, spread = how far a rock may sit from the cluster center, gapFactor = the small edge-gap between ANY two rocks, as a multiple of their combined radii (1.0 = touching, 1.12 = a subtle gap — the generator pushes apart just the overlapping pairs, each by half, so groups stay compact), spin = each rock's OWN very slow tumble (deg/s, direction per-rock random), groupSpin = the whole loose group drifts around its center (deg/s), tint = subtle warm/cool starlight per cluster (anchors blended toward white by strength), halo = soft glow behind the group, debris = a halo of fine dust motes orbiting just outside the rocks. distribution = how many clusters a system gets: targetObjects planetCount, ±jitter, clamped to [minClusters, maxClusters] (more planets ⇒ fewer clusters); the starting system always gets at least startingSystemMinClusters, and those first ones are placed INSIDE the player's initial tether so they're reachable from the spawn. placement = where clusters may sit: minObjectSpacing = no cluster may be closer (center-to-center) than this to ANY other object (home world, planets, free-space stations, other clusters); minRadius/maxRadius = the annulus around the origin random clusters live in; tetherMargin = how far inside the tether rim a starting-system cluster must sit (whole group stays reachable). shipClearance = how close (edge-to-edge) the ship may get to a rock — clusters are solid, like worlds.",
"enabled": true, "enabled": true,
"texture": "assets/images/asteroids.png", "texture": "assets/images/asteroids.png",
"frameWidth": 128, "frameWidth": 128,
"frameHeight": 128, "frameHeight": 128,
"frameCount": 10, "frameCount": 10,
"typeLabel": "Asteroid Cluster", "typeLabel": "Asteroid Cluster",
"compassColor": "#c8d2e0",
"shipClearance": 50, "shipClearance": 50,
"cluster": { "cluster": {
"groupSize": { "min": 4, "max": 8 }, "groupSize": { "min": 4, "max": 8 },
"sizes": { "min": 64, "max": 128 }, "sizes": { "min": 64, "max": 128 },
"minFullSize": 1, "minFullSize": 1,
"spread": 140, "spread": 140,
"gapFactor": 1.12,
"spin": { "minDegPerSec": 0.3, "maxDegPerSec": 1.6 }, "spin": { "minDegPerSec": 0.3, "maxDegPerSec": 1.6 },
"groupSpin": { "minDegPerSec": 0.12, "maxDegPerSec": 0.4 }, "groupSpin": { "minDegPerSec": 0.12, "maxDegPerSec": 0.4 },
"tint": { "tint": {

View File

@ -14,6 +14,7 @@
* minus placement.tetherMargin); * minus placement.tetherMargin);
* - GROUP SHAPE: 48 rocks, sizes in [64, 128], at least one full-size * - GROUP SHAPE: 48 rocks, sizes in [64, 128], at least one full-size
* rock, frames in [0, frameCount) with NO frame twice in a cluster, * rock, frames in [0, frameCount) with NO frame twice in a cluster,
* every rock pair keeps a gapFactor × (r1+r2) gap (no interpenetration),
* bound = max(offset + size/2); * bound = max(offset + size/2);
* - SPACING: no cluster within 1024 px (center-to-center) of ANY other * - SPACING: no cluster within 1024 px (center-to-center) of ANY other
* object home world (origin), planets, free-space stations, other * object home world (origin), planets, free-space stations, other
@ -163,6 +164,18 @@ for (const rec of sample) {
if (Math.abs(bound - cl.bound) > 1e-9) { if (Math.abs(bound - cl.bound) > 1e-9) {
shapeOk = false; shapeWhy = `${rec.id}#${i}: bound ${cl.bound} ≠ max(offset + size/2) ${bound}`; break; shapeOk = false; shapeWhy = `${rec.id}#${i}: bound ${cl.bound} ≠ max(offset + size/2) ${bound}`; break;
} }
// Rocks keep a small gap from each other: no pair closer than
// gapFactor × (r1 + r2) centers (the generator's relaxation contract).
const gap = Math.max(1, CL.gapFactor ?? 1.12);
outer: for (let ai = 0; ai < rocks.length; ai++) {
for (let bi = ai + 1; bi < rocks.length; bi++) {
const d = Math.hypot(rocks[ai].x - rocks[bi].x, rocks[ai].y - rocks[bi].y);
const need = gap * (rocks[ai].size / 2 + rocks[bi].size / 2);
if (d < need - 1e-6) {
shapeOk = false; shapeWhy = `${rec.id}#${i}: rocks ${ai}/${bi} ${d.toFixed(1)} px apart (< ${need.toFixed(1)} gap)`; break outer;
}
}
}
// --- Motion ---------------------------------------------------------- // --- Motion ----------------------------------------------------------
if (!rocks.every((r) => Math.abs(r.spin) >= spinMin - 1e-12 && Math.abs(r.spin) <= spinMax + 1e-12)) { if (!rocks.every((r) => Math.abs(r.spin) >= spinMin - 1e-12 && Math.abs(r.spin) <= spinMax + 1e-12)) {
@ -212,7 +225,7 @@ for (const rec of sample) {
} }
check(`count: ${sample.length} systems obey targetObjectsplanets (±jitter), clamped ${D.minClusters}${D.maxClusters}${countOk ? '' : ' — ' + countWhy}`, countOk); check(`count: ${sample.length} systems obey targetObjectsplanets (±jitter), clamped ${D.minClusters}${D.maxClusters}${countOk ? '' : ' — ' + countWhy}`, countOk);
check(`shape: groups of ${CL.groupSize.min}${CL.groupSize.max}, sizes ${CL.sizes.min}${CL.sizes.max}, ≥1 full-size, frames unique per cluster, bound correct${shapeOk ? '' : ' — ' + shapeWhy}`, shapeOk); check(`shape: groups of ${CL.groupSize.min}${CL.groupSize.max}, sizes ${CL.sizes.min}${CL.sizes.max}, ≥1 full-size, frames unique per cluster, rocks keep a ${CL.gapFactor ?? 1.12}× gap, bound correct${shapeOk ? '' : ' — ' + shapeWhy}`, shapeOk);
check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY object (home, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk); check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY object (home, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk);
check(`scatter: every cluster inside the ${P.minRadius}${P.maxRadius} annulus around the origin${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk); check(`scatter: every cluster inside the ${P.minRadius}${P.maxRadius} annulus around the origin${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk);
check(`motion: per-rock spins & group drifts within the slow-spin ranges${spinOk ? '' : ' — ' + spinWhy}`, spinOk); check(`motion: per-rock spins & group drifts within the slow-spin ranges${spinOk ? '' : ' — ' + spinWhy}`, spinOk);

View File

@ -280,7 +280,9 @@ function layoutSystem(seed, systemId, planets, freeSpace) {
* *
* A cluster is a GROUP of 48 rocks (data cluster.groupSize), each 64128 * A cluster is a GROUP of 48 rocks (data cluster.groupSize), each 64128
* px (cluster.sizes) with at least one full-size rock, sitting within * px (cluster.sizes) with at least one full-size rock, sitting within
* `cluster.spread` of the cluster center. Every rock tumbles on its own * `cluster.spread` of the cluster center, and kept a small gap apart from
* each other (cluster.gapFactor the generator relaxes any overlap so no
* two rocks interpenetrate). Every rock tumbles on its own
* slow spin (its own speed and direction cluster.spin), and the whole * slow spin (its own speed and direction cluster.spin), and the whole
* group drifts slowly around its center (cluster.groupSpin) the render * group drifts slowly around its center (cluster.groupSpin) the render
* (js/entities/AsteroidCluster.js) reads these straight off the record. * (js/entities/AsteroidCluster.js) reads these straight off the record.
@ -397,22 +399,19 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace) {
const fullRocks = Math.min(Math.max(0, Math.floor(clusterCfg.minFullSize ?? 1)), memberCount); const fullRocks = Math.min(Math.max(0, Math.floor(clusterCfg.minFullSize ?? 1)), memberCount);
for (let k = 0; k < fullRocks; k++) sizes[rng.int(0, memberCount - 1)] = sizeMax; for (let k = 0; k < fullRocks; k++) sizes[rng.int(0, memberCount - 1)] = sizeMax;
// Offsets: a dense random blob around the center (slight overlap is // Offsets: a dense random blob around the center (uniform in area),
// allowed — collision clusters look organic; solids never fully // then a relaxation pass enforces a small gap between EVERY pair of
// interpenetrate, see the 0.75 factor). // rocks (cluster.gapFactor — 1.12 ≈ a subtle gap): only pairs that
// would overlap move, each by half the shortfall, so the group keeps
// its random look and stays compact while no rocks interpenetrate.
const gapFactor = Math.max(1, clusterCfg.gapFactor ?? 1.12);
const members = []; const members = [];
for (let k = 0; k < memberCount; k++) { for (let k = 0; k < memberCount; k++) {
let ox = 0; const a = rng.range(0, TAU);
let oy = 0; const r = spread * Math.sqrt(rng.next()); // uniform in area
for (let attempt = 0; attempt < 24; attempt++) { members.push({ x: Math.cos(a) * r, y: Math.sin(a) * r, size: sizes[k] });
const a = rng.range(0, TAU);
const r = spread * Math.sqrt(rng.next()); // uniform in area
ox = Math.cos(a) * r;
oy = Math.sin(a) * r;
if (members.every((m) => Math.hypot(ox - m.x, oy - m.y) >= 0.75 * (sizes[k] / 2 + m.size / 2))) break;
}
members.push({ x: ox, y: oy, size: sizes[k] });
} }
relaxRockGaps(members, gapFactor);
const bound = Math.max(...members.map((m) => Math.hypot(m.x, m.y) + m.size / 2)); const bound = Math.max(...members.map((m) => Math.hypot(m.x, m.y) + m.size / 2));
// Spins: each rock tumbles on its own — its own slow speed, its own // Spins: each rock tumbles on its own — its own slow speed, its own
@ -512,6 +511,39 @@ function placeInAnnulus(rng, rMin, rMax, placed, minSep, maxAttempts) {
return { x: best.x, y: best.y }; return { x: best.x, y: best.y };
} }
/**
* Enforce the generator's gap rule: after the pass, every pair of rocks is
* gapFactor × (r1 + r2) apart (centers) a small visible edge-gap
* (1.0 = touching). Iterative pairwise separation: each violator moves half
* the shortfall, only violating pairs move (minimal displacement), and a
* few dozen sweeps settle any rock count. Fully deterministic (fixed pair
* order, fixed iteration cap).
*/
function relaxRockGaps(members, gapFactor, maxIter = 96) {
for (let it = 0; it < maxIter; it++) {
let worst = 0;
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
const a = members[i];
const b = members[j];
let dx = b.x - a.x;
let dy = b.y - a.y;
let d = Math.hypot(dx, dy);
const need = gapFactor * (a.size / 2 + b.size / 2);
if (d >= need) continue;
if (d < 1e-9) { dx = 1; dy = 0; d = 1; } // coincident: deterministic axis
const push = (need - d) / 2;
a.x -= (dx / d) * push;
a.y -= (dy / d) * push;
b.x += (dx / d) * push;
b.y += (dy / d) * push;
worst = Math.max(worst, need - d);
}
}
if (worst <= 1e-6) break;
}
}
/** The starting system's initial tether radius (home world's level). */ /** The starting system's initial tether radius (home world's level). */
function homeTetherRadius() { function homeTetherRadius() {
const level = Math.max(1, Math.floor(config.get('tether.homeLevel', 1))); const level = Math.max(1, Math.floor(config.get('tether.homeLevel', 1)));

View File

@ -544,6 +544,9 @@ export class GameScene extends Phaser.Scene {
y: c.y, y: c.y,
radius: c.bound, radius: c.bound,
typeLabel: config.get('asteroids.typeLabel', 'Asteroid Cluster'), typeLabel: config.get('asteroids.typeLabel', 'Asteroid Cluster'),
// The compass shows clusters in their own light gray (not the
// worlds' neon cyan) — the arrow + name tag pick it up.
color: config.get('asteroids.compassColor', '#c8d2e0'),
name: c.discoveryName, name: c.discoveryName,
}); });
} }

View File

@ -6,17 +6,16 @@ import { canvasTexture } from '../utils/Textures.js';
import { CyberShape } from './CyberShape.js'; import { CyberShape } from './CyberShape.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const DEG = Math.PI / 180;
/** /**
* COMMAND DECK the cyberpunk action bar across the bottom of the screen: * COMMAND DECK the cyberpunk action bar across the bottom of the screen:
* a cut-corner console panel with an energy rail, six evenly spaced slot * a cut-corner console panel with an energy rail, six evenly spaced slot
* buttons (drawn icons, RGB-split labels, per-slot accent colors), reserved * buttons (label-only, per-slot accent rail on top, RGB-split labels),
* "standby" slots, a rail comet that streaks past on a loop, periodic glitch * reserved "standby" slots, a rail comet that streaks past on a loop, periodic glitch
* bursts (slice bars + label jitter), and a boot flicker-in. * bursts (slice bars + label jitter), and a boot flicker-in.
* Dressed with the menu's CRT language: scanlines over the whole bar (the * Dressed with the menu's CRT language: scanlines over the whole bar (the
* same 1px-dark-row tile as CyberOverlay) and the GlitchText RGB pull-apart * same 1px-dark-row tile as CyberOverlay) and the GlitchText RGB pull-apart
* on everything icons, labels, and the panel outline itself on bursts. * on labels and the panel outline itself on bursts.
* *
* Everything is procedural no image assets and fully config-driven * Everything is procedural no image assets and fully config-driven
* (data/actionbar.json): slots, labels, accents, palette, and every * (data/actionbar.json): slots, labels, accents, palette, and every
@ -69,9 +68,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
slotBorder: toColor(ab.colors?.slotBorder, 0x22405f), slotBorder: toColor(ab.colors?.slotBorder, 0x22405f),
reserved: toColor(ab.colors?.reserved, 0x3d4c74), reserved: toColor(ab.colors?.reserved, 0x3d4c74),
inkCss: toCss(ab.colors?.text ?? '#eaf6ff'), inkCss: toCss(ab.colors?.text ?? '#eaf6ff'),
iconY: -12, labelY: 0, // label-only buttons: the text is centred vertically
labelY: 16,
iconSize: bc.iconSize ?? 26,
}; };
// The menu's CRT scanline tuning (data/theme.json) is the default, so // The menu's CRT scanline tuning (data/theme.json) is the default, so
// the bar matches the menu unless actionbar.json overrides it. // the bar matches the menu unless actionbar.json overrides it.
@ -212,8 +209,6 @@ export class ActionBar extends Phaser.GameObjects.Container {
const letterSpacing = bc.letterSpacing ?? 2.5; const letterSpacing = bc.letterSpacing ?? 2.5;
const cyan = toCss('#00e5ff'); const cyan = toCss('#00e5ff');
const magenta = toCss('#ff2d6f'); const magenta = toCss('#ff2d6f');
const iconSize = this.style.iconSize;
const iconY = this.style.iconY;
this.slots = buttons.map((b, i) => { this.slots = buttons.map((b, i) => {
const live = typeof b.id === 'string' && b.id.length > 0; const live = typeof b.id === 'string' && b.id.length > 0;
@ -244,31 +239,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
_scaleTw: null, _scaleTw: null,
}; };
s.panel = scene.add.graphics().setScrollFactor(0); s.panel = scene.add.graphics().setScrollFactor(0);
// RGB-split icon ghosts (the GlitchText technique on the menu title): slot.add(s.panel);
// additive magenta/cyan copies of the drawn icon, moved per frame.
s.iconGhostM = scene
.add.graphics()
.setPosition(0, iconY)
.setScrollFactor(0)
.setBlendMode(Phaser.BlendModes.ADD)
.setAlpha(0);
s.iconGhostC = scene
.add.graphics()
.setPosition(0, iconY)
.setScrollFactor(0)
.setBlendMode(Phaser.BlendModes.ADD)
.setAlpha(0);
s.icon = scene.add.graphics().setPosition(0, iconY).setScrollFactor(0);
slot.add([s.panel, s.iconGhostM, s.iconGhostC, s.icon]);
// Drawn icon (accent for live slots, dim for reserved sockets) —
// live slots also get their ghost copies in the split colors.
const iconKind = live ? b.id : 'reserved';
drawIcon(s.icon, iconKind, iconSize, live ? s.accent : this.style.reserved);
if (live) {
drawIcon(s.iconGhostM, iconKind, iconSize, 0xff2d6f);
drawIcon(s.iconGhostC, iconKind, iconSize, 0x00e5ff);
}
if (live) { if (live) {
const labelText = String(b.label ?? '').toUpperCase(); const labelText = String(b.label ?? '').toUpperCase();
@ -312,8 +283,8 @@ export class ActionBar extends Phaser.GameObjects.Container {
s.panel.on('pointerout', () => this.setHover(s, false)); s.panel.on('pointerout', () => this.setHover(s, false));
s.panel.on('pointerdown', () => this.press(s)); s.panel.on('pointerdown', () => this.press(s));
} else { } else {
// Reserved socket: a standby dot that breathes in update(). // Reserved socket: a standby dot (centred) that breathes in update().
s.resDot = scene.add.circle(0, iconY, 1.6, this.style.reserved, 0.35).setScrollFactor(0); s.resDot = scene.add.circle(0, 0, 1.6, this.style.reserved, 0.35).setScrollFactor(0);
slot.add(s.resDot); slot.add(s.resDot);
} }
@ -509,7 +480,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
/** /**
* Fire a glitch burst right now: slice bars across the deck, and the RGB * Fire a glitch burst right now: slice bars across the deck, and the RGB
* split (level 1, decays in update) on icons, labels, and the panel. * split (level 1, decays in update) on labels and the panel.
*/ */
triggerBurst(duration = 300, intensity = 1) { triggerBurst(duration = 300, intensity = 1) {
const now = this.lastTime ?? this.scene.time.now; const now = this.lastTime ?? this.scene.time.now;
@ -620,7 +591,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
const flick = 0.6 + 0.4 * Math.sin(t * 3.3); const flick = 0.6 + 0.4 * Math.sin(t * 3.3);
const baseA = Math.min(1, rgb.idleAlpha * flick + L * rgb.burstAlpha); const baseA = Math.min(1, rgb.idleAlpha * flick + L * rgb.burstAlpha);
// Slots: the menu's RGB pull-apart on icon + label — magenta left, // Slots: the menu's RGB pull-apart on the label — magenta left,
// cyan right (GlitchText's exact offsets), always a faint fringe at // cyan right (GlitchText's exact offsets), always a faint fringe at
// idle, pulled apart + jittered during bursts; per-slot phase so the // idle, pulled apart + jittered during bursts; per-slot phase so the
// six buttons don't shimmer in lockstep. // six buttons don't shimmer in lockstep.
@ -636,8 +607,6 @@ export class ActionBar extends Phaser.GameObjects.Container {
const a = s.hoverOn ? Math.min(1, baseA + 0.25) : baseA; const a = s.hoverOn ? Math.min(1, baseA + 0.25) : baseA;
s.ghostMagenta.setAlpha(a).setPosition(-off + jx, this.style.labelY + jy * 0.5); s.ghostMagenta.setAlpha(a).setPosition(-off + jx, this.style.labelY + jy * 0.5);
s.ghostCyan.setAlpha(a * 0.95).setPosition(off * 0.85, this.style.labelY - off * 0.3 + jy); s.ghostCyan.setAlpha(a * 0.95).setPosition(off * 0.85, this.style.labelY - off * 0.3 + jy);
s.iconGhostM.setAlpha(a * 0.8).setPosition(-off + jx, this.style.iconY + jy * 0.5);
s.iconGhostC.setAlpha(a * 0.75).setPosition(off * 0.85, this.style.iconY - off * 0.3 + jy);
} }
// The bar itself: the panel outline pulled apart in magenta/cyan while // The bar itself: the panel outline pulled apart in magenta/cyan while
@ -835,85 +804,3 @@ function drawBarBody(ctx, W, H, P, c) {
corner(x - 3, y + h + 3, 1, -1); corner(x - 3, y + h + 3, 1, -1);
corner(x + w + 3, y + h + 3, -1, -1); corner(x + w + 3, y + h + 3, -1, -1);
} }
/**
* The drawn slot icons small, geometric, in the console's language.
* Each icon is drawn centered on the icon Graphics' origin.
*/
function drawIcon(g, id, size, color) {
const r = size * 0.44;
const glow = (lw) => g.lineStyle(lw, color, 0.25);
switch (id) {
case 'research': {
// Orbit: a ring with a lit trail, a satellite riding it, a core.
glow(4);
g.strokeCircle(0, 0, r);
g.lineStyle(1.5, color, 0.95);
g.strokeCircle(0, 0, r);
g.lineStyle(2, color, 0.5); // trail behind the satellite
g.beginPath();
g.arc(0, 0, r, -110 * DEG, -25 * DEG);
g.stroke();
const sa = -35 * DEG;
g.fillStyle(color, 1);
g.fillCircle(Math.cos(sa) * r, Math.sin(sa) * r, 2.6);
g.fillStyle(color, 0.9);
g.fillCircle(0, 0, 1.7);
break;
}
case 'build': {
// Isometric cube — construction.
const v = [0, -30, 30, 90, 150, 210].map((deg) => ({
x: Math.cos(deg * DEG) * r,
y: Math.sin(deg * DEG) * r,
}));
glow(4);
g.strokePoints(v, true);
g.lineStyle(1.5, color, 0.95);
g.strokePoints(v, true);
g.lineStyle(1.5, color, 0.8); // inner edges of the cube
g.lineBetween(0, 0, v[0].x, v[0].y);
g.lineBetween(0, 0, v[2].x, v[2].y);
g.lineBetween(0, 0, v[4].x, v[4].y);
break;
}
case 'ship': {
// Chevron arrow, nose up.
const pts = [
{ x: 0, y: -r },
{ x: r * 0.78, y: r * 0.62 },
{ x: 0, y: r * 0.18 },
{ x: -r * 0.78, y: r * 0.62 },
];
glow(4);
g.strokePoints(pts, true);
g.lineStyle(1.5, color, 0.95);
g.strokePoints(pts, true);
break;
}
case 'menu': {
// Signal bars — slightly ragged widths, left-aligned.
g.fillStyle(color, 0.9);
g.fillRect(-r, -r * 0.75, r * 2, 2.4);
g.fillRect(-r, -1.2, r * 1.5, 2.4);
g.fillRect(-r, r * 0.75 - 2.4, r * 1.85, 2.4);
break;
}
default: {
// Reserved socket: a dim diamond — a slot waiting for a command.
const d = [
{ x: 0, y: -7 },
{ x: 7, y: 0 },
{ x: 0, y: 7 },
{ x: -7, y: 0 },
];
g.lineStyle(1.2, color, 0.45);
g.strokePoints(d, true);
}
}
}

View File

@ -131,7 +131,10 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
makeEntry(t) { makeEntry(t) {
const scene = this.scene; const scene = this.scene;
const fam = fontStack('body', FONT_FALLBACK); const fam = fontStack('body', FONT_FALLBACK);
const neon = themeColor('neon', 0x00e5ff); // Per-target accent: a target may carry its own color (asteroid
// clusters — data/asteroids.json → compassColor, light gray) and the
// arrow + chip use it; worlds keep the theme's neon cyan.
const neon = t.color ? toColor(t.color) : themeColor('neon', 0x00e5ff);
const ink = themeColor('ink', 0xeaf6ff); const ink = themeColor('ink', 0xeaf6ff);
const fill = toColor(config.get('theme.colors.panel', '#0a1120')); const fill = toColor(config.get('theme.colors.panel', '#0a1120'));
const typeLabel = (t.typeLabel ?? 'OBJECT').toUpperCase(); const typeLabel = (t.typeLabel ?? 'OBJECT').toUpperCase();
@ -186,6 +189,7 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
// the __MISSING texture forever, even after the key is generated. // the __MISSING texture forever, even after the key is generated.
ensureArrowTexture(scene); ensureArrowTexture(scene);
const arrow = scene.add.image(0, 0, ARROW_KEY); const arrow = scene.add.image(0, 0, ARROW_KEY);
arrow.setTint(neon); // the texture is baked white; tint per target
arrow.setScrollFactor(0); // UI — pinned to the screen arrow.setScrollFactor(0); // UI — pinned to the screen
arrow.setDepth(40); arrow.setDepth(40);
@ -341,13 +345,15 @@ function separateAngles(entries, w, h, inset, minSep, cx, cy) {
/** /**
* The arrow glyph, generated once per game (procedural, no assets * The arrow glyph, generated once per game (procedural, no assets
* same pattern as Ship.ensureTexture). A neon chevron head with a soft * same pattern as Ship.ensureTexture). A chevron head with a soft glow
* glow pass over a dark fill, plus speed ticks streaming behind it. * pass over a dark fill, plus speed ticks streaming behind it. Points +x
* Points +x (angle 0); the tip sits 25 px right of the texture center. * (angle 0); the tip sits 25 px right of the texture center. Baked WHITE
* each arrow image is tinted per target (worlds = theme neon, asteroid
* clusters = their light gray).
*/ */
function ensureArrowTexture(scene) { function ensureArrowTexture(scene) {
if (scene.textures.exists(ARROW_KEY)) return; if (scene.textures.exists(ARROW_KEY)) return;
const neon = toColor(config.get('theme.colors.neon', '#00e5ff')); const neon = 0xffffff; // white source; tinted per arrow (see makeEntry)
const fill = toColor(config.get('theme.colors.panel', '#0a1120')); const fill = toColor(config.get('theme.colors.panel', '#0a1120'));
const W = 56; const W = 56;
const H = 36; const H = 36;