Add CRT scanlines and RGB pull-apart to the command deck

- Port the menu's GlitchText chromatic fringe to ActionBar: additive
  magenta/cyan icon ghosts, label ghosts with per-slot phase, and a panel
  outline ghost that activates during glitch bursts.
- Add a tiled-scanline overlay (1 dark row per pitch) matching CyberOverlay,
  configurable via actionbar.scanline with theme.overlay.scanline defaults.
- Replace idleGhost with animation.rgb (idleOffset/burstOffset/idleAlpha/
  burstAlpha); level now decays ~90 ms after triggerBurst for the fast-snap
  GlitchText feel while slice bars linger.
- Update README, PROJECT_NOTES, and research-builds tests to cover the new
  scanline and rgb config keys.
This commit is contained in:
Brian Fertig 2026-09-03 20:42:33 -06:00
parent 2253251768
commit 9299859003
5 changed files with 141 additions and 32 deletions

View File

@ -114,9 +114,11 @@ layers now, with the rules and panels to come next:
- **The command deck** (`data/actionbar.json`, rendered by
`js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the screen.
Six evenly spaced slots: **Research, Build, Ship, ·, ·, Menu**. The slots
fire `onAction` and are otherwise inert; two slots are reserved. All layout,
palette, and motion (boot flicker, rail comet, glitch bursts, RGB shimmer)
live in the config.
fire `onAction` and are otherwise inert; two slots are reserved. Dressed
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
layout, palette, and motion (boot flicker, rail comet, glitch bursts, RGB
split, scanlines) live in the config.
`_`-prefixed keys (`_comment`, `_template`, …) are documentation, not
runtime data — loaders and tests ignore them.

View File

@ -1,5 +1,5 @@
{
"_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`, motion in `animation` (boot flicker, the rail comet, the glitch bursts, the idle RGB shimmer).",
"_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,
"height": 92,
"margin": { "left": 20, "right": 20, "bottom": 12 },
@ -31,8 +31,20 @@
"reserved": "#3d4c74",
"hatch": "#78b4ff"
},
"scanline": {
"_comment": "CRT scanlines over the bar + buttons — the menu's recipe (one dark row per pitch rows). Defaults follow theme.overlay.scanline; override here for the deck only.",
"pitch": 3,
"alpha": 0.14
},
"animation": {
"bootStagger": 70,
"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.",
"idleOffset": 1.8,
"burstOffset": 6,
"idleAlpha": 0.4,
"burstAlpha": 0.55
},
"comet": {
"enabled": true,
"everyMs": [3600, 7800],
@ -43,7 +55,6 @@
"intervalMs": [3500, 9000],
"durationMs": [240, 480],
"slices": [3, 6]
},
"idleGhost": 0.09
}
}
}

View File

@ -82,6 +82,8 @@ check('actionbar: labels (Research / Build / Ship / · / · / Menu)', JSON.strin
const hex = /^#[0-9a-fA-F]{6}$/;
check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? '')));
check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null));
check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.scanline?.pitch === 'number' && typeof actionbar.scanline?.alpha === 'number');
check('actionbar: RGB pull-apart configured (offsets + alphas)', typeof actionbar.animation?.rgb?.idleOffset === 'number' && typeof actionbar.animation?.rgb?.burstOffset === 'number' && typeof actionbar.animation?.rgb?.idleAlpha === 'number' && typeof actionbar.animation?.rgb?.burstAlpha === 'number');
check('actionbar: sane geometry (height 40200 px)', typeof actionbar.height === 'number' && actionbar.height > 40 && actionbar.height < 200);
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);

View File

@ -244,7 +244,10 @@ world** — solid, rendered, flyable-to. Rules and seams:
ship/planet/station) data layers (`data/research.json`,
`data/builds.json`) with templates, and the command deck
(`js/ui/ActionBar.js` + `data/actionbar.json`) — Research, Build,
Ship, ·, ·, Menu
Ship, ·, ·, Menu. The deck reuses the menu's CRT language: the same
scanline tile recipe as `CyberOverlay` (clipped to the bar) and the
`GlitchText` RGB pull-apart (icons + labels at all times, panel outline
during bursts) — see `actionbar.animation.rgb` / `actionbar.scanline`
- [ ] Research rules + panel: start a project (one at a time), tick its
duration, award `unlocks`; the Research slot on the deck opens it
- [ ] Build panel: pay credits/minerals, apply `effects`, respect

View File

@ -14,6 +14,9 @@ const DEG = Math.PI / 180;
* buttons (drawn icons, RGB-split labels, per-slot accent colors), reserved
* "standby" slots, a rail comet that streaks past on a loop, periodic glitch
* bursts (slice bars + label jitter), and a boot flicker-in.
* 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
* on everything icons, labels, and the panel outline itself on bursts.
*
* Everything is procedural no image assets and fully config-driven
* (data/actionbar.json): slots, labels, accents, palette, and every
@ -70,7 +73,20 @@ export class ActionBar extends Phaser.GameObjects.Container {
labelY: 16,
iconSize: bc.iconSize ?? 26,
};
this.idleGhost = anim.idleGhost ?? 0.09;
// The menu's CRT scanline tuning (data/theme.json) is the default, so
// the bar matches the menu unless actionbar.json overrides it.
const themeScan = config.section('theme.overlay', {}).scanline ?? {};
this.cfgScan = { pitch: 3, alpha: 0.14, ...themeScan, ...(ab.scanline ?? {}) };
// RGB pull-apart (the GlitchText motion model, scaled to button size):
// idleOffset px of constant chromatic fringe, burstOffset extra px at
// level 1, plus the ghost alphas.
this.cfgRgb = {
idleOffset: 1.8,
burstOffset: 6,
idleAlpha: 0.4,
burstAlpha: 0.55,
...(anim.rgb ?? {}),
};
this.cfgComet = { enabled: true, everyMs: [3600, 7800], durationMs: 950, ...(anim.comet ?? {}) };
this.cfgGlitch = {
enabled: true,
@ -87,6 +103,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
this.cometT0 = null;
this.cometDur = 1;
this.nextCometAt = null;
this.level = 0; // RGB-split level 0..1 (GlitchText model, decays ~90 ms)
this.burstT0 = null;
this.burstDur = 1;
this.nextBurstAt = null;
@ -96,6 +113,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
this.buildBody(barW, barH);
this.buildRails();
this.buildSlots(ab, bc, x0, y0, barW, barH);
this.buildFx(barW, barH); // scanlines + panel RGB ghost (topmost of the bar)
this.boot();
}
@ -226,16 +244,31 @@ export class ActionBar extends Phaser.GameObjects.Container {
_scaleTw: null,
};
s.panel = scene.add.graphics().setScrollFactor(0);
// RGB-split icon ghosts (the GlitchText technique on the menu title):
// 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.icon]);
slot.add([s.panel, s.iconGhostM, s.iconGhostC, s.icon]);
// Drawn icon (accent for live slots, dim for reserved sockets).
drawIcon(
s.icon,
live ? b.id : 'reserved',
iconSize,
live ? s.accent : this.style.reserved,
);
// 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) {
const labelText = String(b.label ?? '').toUpperCase();
@ -289,6 +322,42 @@ export class ActionBar extends Phaser.GameObjects.Container {
});
}
/**
* The menu's CRT dressing, clipped to the bar: scanlines over the whole
* strip (body + buttons) and the panel RGB ghost the cut-corner outline
* pulled apart in magenta/cyan while a glitch burst is live (the
* GlitchText technique applied to the bar itself).
*/
buildFx(barW, barH) {
const { scene } = this;
const { x, y, w, h, cx, cy } = this.rect;
// Scanlines — the exact recipe CyberOverlay uses on the menu (one dark
// row per `pitch` rows, tiled), sized to the bar strip.
const sl = this.cfgScan;
const pitch = Math.max(2, Math.round(sl.pitch));
const scanKey = `__ab_scan_${pitch}`;
canvasTexture(scene, scanKey, 1, pitch, (ctx) => {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, pitch - 1, 1, 1); // one dark line every `pitch` rows
});
this.scanlines = scene
.add.tileSprite(cx, cy, w, h, scanKey)
.setScrollFactor(0)
.setAlpha(sl.alpha);
this.add(this.scanlines);
// Panel RGB ghost — same cut corners as the body (drawBarBody uses
// min(14, h*0.16)), stroke-only, additive; driven in update().
const cut = Math.max(4, Math.min(14, Math.round(barH * 0.16)));
this.ghostPanelM = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0);
CyberShape.draw(this.ghostPanelM, barW, barH, { notch: cut, stroke: 0xff2d6f, strokeAlpha: 1, lineWidth: 1.5 });
this.ghostPanelC = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0);
CyberShape.draw(this.ghostPanelC, barW, barH, { notch: cut, stroke: 0x00e5ff, strokeAlpha: 1, lineWidth: 1.5 });
this.add(this.ghostPanelM);
this.add(this.ghostPanelC);
}
// ------------------------------------------------------------------
// Slot states
// ------------------------------------------------------------------
@ -438,11 +507,15 @@ export class ActionBar extends Phaser.GameObjects.Container {
// Glitch + comet
// ------------------------------------------------------------------
/** Fire a glitch burst right now (slice bars across the deck + jitter). */
triggerBurst(duration = 300, _intensity = 1) {
/**
* 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.
*/
triggerBurst(duration = 300, intensity = 1) {
const now = this.lastTime ?? this.scene.time.now;
this.burstT0 = now;
this.burstDur = Math.max(1, duration);
this.level = Math.max(this.level, intensity);
this.spawnSlices();
}
@ -536,10 +609,21 @@ export class ActionBar extends Phaser.GameObjects.Container {
}
if (this.burstT0 !== null && time - this.burstT0 >= this.burstDur) this.burstT0 = null;
}
const level =
this.burstT0 === null ? 0 : Math.max(0, 1 - (time - this.burstT0) / this.burstDur);
// RGB-split level: pushed to 1 on burst trigger, decays ~90 ms — the
// GlitchText model (fast snap, while the slice bars linger longer).
this.level *= Math.exp(-(delta / 90));
if (this.level < 0.002) this.level = 0;
const L = this.level;
const rgb = this.cfgRgb;
const idle = 0.65 + 0.35 * Math.sin(t * 1.9);
const off = rgb.idleOffset * idle + L * rgb.burstOffset;
const flick = 0.6 + 0.4 * Math.sin(t * 3.3);
const baseA = Math.min(1, rgb.idleAlpha * flick + L * rgb.burstAlpha);
// Slots: idle RGB shimmer, hover ghosts, and burst jitter.
// Slots: the menu's RGB pull-apart on icon + label — magenta left,
// cyan right (GlitchText's exact offsets), always a faint fringe at
// idle, pulled apart + jittered during bursts; per-slot phase so the
// six buttons don't shimmer in lockstep.
for (const s of this.slots) {
if (!s.live) {
if (s.resDot) {
@ -547,17 +631,24 @@ export class ActionBar extends Phaser.GameObjects.Container {
}
continue;
}
const idle = s.hoverOn
? 0.85
: this.idleGhost * (0.55 + 0.45 * Math.sin(t * 2.2 + s.phase));
const ghostA = Math.max(idle, level * 0.8);
s.ghostCyan.setAlpha(ghostA);
s.ghostMagenta.setAlpha(ghostA * 0.9);
const jx = level * 2.6 * Math.sin(time * 0.061 + s.phase * 7);
const jy = level * 1.6 * Math.sin(time * 0.043 + s.phase * 5);
s.ghostCyan.setPosition(-1.8 + jx, this.style.labelY + jy * 0.4);
s.ghostMagenta.setPosition(1.8 - jx * 0.6, this.style.labelY - jy * 0.5);
s.label.setPosition(jx * 0.5, this.style.labelY + jy * 0.5);
const jx = L * 6 * Math.sin(time * 0.053 + s.phase * 3.1) * Math.sin(time * 0.021 + 1.7 + s.phase);
const jy = L * 4 * Math.sin(time * 0.083 + 0.4 + s.phase * 2.3);
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.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 burst is live — the RGB separation on the bar, not just the text.
if (L > 0.02) {
const po = 2 + L * 5;
this.ghostPanelM.setAlpha(L * 0.5).setPosition(-po, L * 1.5);
this.ghostPanelC.setAlpha(L * 0.45).setPosition(po * 0.85, -po * 0.3);
} else {
this.ghostPanelM.setAlpha(0);
this.ghostPanelC.setAlpha(0);
}
// Reap expired slice bars.