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:
parent
2253251768
commit
9299859003
|
|
@ -114,9 +114,11 @@ layers now, with the rules and panels to come next:
|
||||||
- **The command deck** (`data/actionbar.json`, rendered by
|
- **The command deck** (`data/actionbar.json`, rendered by
|
||||||
`js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the screen.
|
`js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the screen.
|
||||||
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. All layout,
|
fire `onAction` and are otherwise inert; two slots are reserved. Dressed
|
||||||
palette, and motion (boot flicker, rail comet, glitch bursts, RGB shimmer)
|
with the menu's CRT language — scanlines over the strip and the GlitchText
|
||||||
live in the config.
|
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
|
`_`-prefixed keys (`_comment`, `_template`, …) are documentation, not
|
||||||
runtime data — loaders and tests ignore them.
|
runtime data — loaders and tests ignore them.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
"enabled": true,
|
||||||
"height": 92,
|
"height": 92,
|
||||||
"margin": { "left": 20, "right": 20, "bottom": 12 },
|
"margin": { "left": 20, "right": 20, "bottom": 12 },
|
||||||
|
|
@ -31,8 +31,20 @@
|
||||||
"reserved": "#3d4c74",
|
"reserved": "#3d4c74",
|
||||||
"hatch": "#78b4ff"
|
"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": {
|
"animation": {
|
||||||
"bootStagger": 70,
|
"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": {
|
"comet": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"everyMs": [3600, 7800],
|
"everyMs": [3600, 7800],
|
||||||
|
|
@ -43,7 +55,6 @@
|
||||||
"intervalMs": [3500, 9000],
|
"intervalMs": [3500, 9000],
|
||||||
"durationMs": [240, 480],
|
"durationMs": [240, 480],
|
||||||
"slices": [3, 6]
|
"slices": [3, 6]
|
||||||
},
|
}
|
||||||
"idleGhost": 0.09
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ check('actionbar: labels (Research / Build / Ship / · / · / Menu)', JSON.strin
|
||||||
const hex = /^#[0-9a-fA-F]{6}$/;
|
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: 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: 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 40–200 px)', typeof actionbar.height === 'number' && actionbar.height > 40 && actionbar.height < 200);
|
check('actionbar: sane geometry (height 40–200 px)', typeof actionbar.height === 'number' && actionbar.height > 40 && actionbar.height < 200);
|
||||||
|
|
||||||
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
|
console.log(failures === 0 ? '\nall checks passed ✔' : `\n${failures} check(s) FAILED ✘`);
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,10 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
||||||
ship/planet/station) data layers (`data/research.json`,
|
ship/planet/station) data layers (`data/research.json`,
|
||||||
`data/builds.json`) with templates, and the command deck
|
`data/builds.json`) with templates, and the command deck
|
||||||
(`js/ui/ActionBar.js` + `data/actionbar.json`) — Research, Build,
|
(`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
|
- [ ] Research rules + panel: start a project (one at a time), tick its
|
||||||
duration, award `unlocks`; the Research slot on the deck opens it
|
duration, award `unlocks`; the Research slot on the deck opens it
|
||||||
- [ ] Build panel: pay credits/minerals, apply `effects`, respect
|
- [ ] Build panel: pay credits/minerals, apply `effects`, respect
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ const DEG = Math.PI / 180;
|
||||||
* buttons (drawn icons, RGB-split labels, per-slot accent colors), reserved
|
* buttons (drawn icons, RGB-split labels, per-slot accent colors), reserved
|
||||||
* "standby" slots, a rail comet that streaks past on a loop, periodic glitch
|
* "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
|
||||||
|
* 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
|
* 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
|
||||||
|
|
@ -70,7 +73,20 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
||||||
labelY: 16,
|
labelY: 16,
|
||||||
iconSize: bc.iconSize ?? 26,
|
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.cfgComet = { enabled: true, everyMs: [3600, 7800], durationMs: 950, ...(anim.comet ?? {}) };
|
||||||
this.cfgGlitch = {
|
this.cfgGlitch = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -87,6 +103,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
||||||
this.cometT0 = null;
|
this.cometT0 = null;
|
||||||
this.cometDur = 1;
|
this.cometDur = 1;
|
||||||
this.nextCometAt = null;
|
this.nextCometAt = null;
|
||||||
|
this.level = 0; // RGB-split level 0..1 (GlitchText model, decays ~90 ms)
|
||||||
this.burstT0 = null;
|
this.burstT0 = null;
|
||||||
this.burstDur = 1;
|
this.burstDur = 1;
|
||||||
this.nextBurstAt = null;
|
this.nextBurstAt = null;
|
||||||
|
|
@ -96,6 +113,7 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
||||||
this.buildBody(barW, barH);
|
this.buildBody(barW, barH);
|
||||||
this.buildRails();
|
this.buildRails();
|
||||||
this.buildSlots(ab, bc, x0, y0, barW, barH);
|
this.buildSlots(ab, bc, x0, y0, barW, barH);
|
||||||
|
this.buildFx(barW, barH); // scanlines + panel RGB ghost (topmost of the bar)
|
||||||
this.boot();
|
this.boot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,16 +244,31 @@ 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):
|
||||||
|
// 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);
|
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).
|
// Drawn icon (accent for live slots, dim for reserved sockets) —
|
||||||
drawIcon(
|
// live slots also get their ghost copies in the split colors.
|
||||||
s.icon,
|
const iconKind = live ? b.id : 'reserved';
|
||||||
live ? b.id : 'reserved',
|
drawIcon(s.icon, iconKind, iconSize, live ? s.accent : this.style.reserved);
|
||||||
iconSize,
|
if (live) {
|
||||||
live ? s.accent : this.style.reserved,
|
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();
|
||||||
|
|
@ -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
|
// Slot states
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -438,11 +507,15 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
||||||
// Glitch + comet
|
// 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;
|
const now = this.lastTime ?? this.scene.time.now;
|
||||||
this.burstT0 = now;
|
this.burstT0 = now;
|
||||||
this.burstDur = Math.max(1, duration);
|
this.burstDur = Math.max(1, duration);
|
||||||
|
this.level = Math.max(this.level, intensity);
|
||||||
this.spawnSlices();
|
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;
|
if (this.burstT0 !== null && time - this.burstT0 >= this.burstDur) this.burstT0 = null;
|
||||||
}
|
}
|
||||||
const level =
|
// RGB-split level: pushed to 1 on burst trigger, decays ~90 ms — the
|
||||||
this.burstT0 === null ? 0 : Math.max(0, 1 - (time - this.burstT0) / this.burstDur);
|
// 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) {
|
for (const s of this.slots) {
|
||||||
if (!s.live) {
|
if (!s.live) {
|
||||||
if (s.resDot) {
|
if (s.resDot) {
|
||||||
|
|
@ -547,17 +631,24 @@ export class ActionBar extends Phaser.GameObjects.Container {
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const idle = s.hoverOn
|
const jx = L * 6 * Math.sin(time * 0.053 + s.phase * 3.1) * Math.sin(time * 0.021 + 1.7 + s.phase);
|
||||||
? 0.85
|
const jy = L * 4 * Math.sin(time * 0.083 + 0.4 + s.phase * 2.3);
|
||||||
: this.idleGhost * (0.55 + 0.45 * Math.sin(t * 2.2 + s.phase));
|
const a = s.hoverOn ? Math.min(1, baseA + 0.25) : baseA;
|
||||||
const ghostA = Math.max(idle, level * 0.8);
|
s.ghostMagenta.setAlpha(a).setPosition(-off + jx, this.style.labelY + jy * 0.5);
|
||||||
s.ghostCyan.setAlpha(ghostA);
|
s.ghostCyan.setAlpha(a * 0.95).setPosition(off * 0.85, this.style.labelY - off * 0.3 + jy);
|
||||||
s.ghostMagenta.setAlpha(ghostA * 0.9);
|
s.iconGhostM.setAlpha(a * 0.8).setPosition(-off + jx, this.style.iconY + jy * 0.5);
|
||||||
const jx = level * 2.6 * Math.sin(time * 0.061 + s.phase * 7);
|
s.iconGhostC.setAlpha(a * 0.75).setPosition(off * 0.85, this.style.iconY - off * 0.3 + jy);
|
||||||
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);
|
// The bar itself: the panel outline pulled apart in magenta/cyan while
|
||||||
s.label.setPosition(jx * 0.5, this.style.labelY + jy * 0.5);
|
// 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.
|
// Reap expired slice bars.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue