diff --git a/README.md b/README.md index b21bd72..effce0a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/data/actionbar.json b/data/actionbar.json index 18f5065..746738c 100644 --- a/data/actionbar.json +++ b/data/actionbar.json @@ -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 + } } } diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index 8aa5b20..f806925 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -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 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 ✘`); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 38412df..714f20d 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 diff --git a/js/ui/ActionBar.js b/js/ui/ActionBar.js index 3bcc315..f0f6d25 100644 --- a/js/ui/ActionBar.js +++ b/js/ui/ActionBar.js @@ -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.