From 167fdd6e3f8984e6eeb3dfaab221e7de7ff95046 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 10 Aug 2026 21:43:34 -0600 Subject: [PATCH] refactor: replace scanline with diagonal shimmer and add orbiting border lights to VegaButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace horizontal scanline sweep with an occasional diagonal shimmer effect that crosses the button face at a 22° angle with a 1.5s pause between passes - Add three white border lights that slowly orbit the button's stroke outline (~3.6s per full revolution), drawn above all other layers - Reduce corner brackets from all four corners to upper-right and lower-left only, matching the chamfered panel's 45° cuts - Extract reusable perimeter utilities (_computePerimeter, _perimeterPoint) cached once at construction to avoid per-frame arc-length recomputation - Wire shimmer and border lights into hover enter/exit and destroy lifecycle --- src/games/mastervega/VegaButton.js | 166 +++++++++++++++++++++++------ 1 file changed, 134 insertions(+), 32 deletions(-) diff --git a/src/games/mastervega/VegaButton.js b/src/games/mastervega/VegaButton.js index b119553..8e5798d 100644 --- a/src/games/mastervega/VegaButton.js +++ b/src/games/mastervega/VegaButton.js @@ -7,9 +7,11 @@ // games and stays untouched; this is a mastervega-local reskin only. // // Look: angular chamfered panel (not rounded), a static neon glow on the -// border (baseline look, not a hover animation), and two hover-only -// animations — a scanline sweep and four corner brackets snapping outward, -// like a targeting reticle acquiring a lock. +// border (baseline look, not a hover animation), and hover-only animations — +// two upper-right/lower-left corner brackets snapping outward like a +// targeting reticle acquiring a lock, an occasional diagonal shimmer +// crossing the face, and a few small white lights slowly orbiting the +// button's own stroke outline, drawn above everything else in the button. import * as Phaser from 'phaser'; @@ -50,19 +52,29 @@ export class Button extends Phaser.GameObjects.Container { const scm = SCHEMES[scheme] || SCHEMES.cyan; this.options = { width, height, bg, textColor, fontSize, variant, scheme: scm }; + // Cached once — _chamferPoints() only depends on width/height, which + // never change after construction — so the border-lights orbit + // (_perimeterPoint) doesn't recompute the polygon's arc-length table + // on every animation frame. + this._perimeter = this._computePerimeter(); const isGhost = variant === 'ghost'; this.bgRect = scene.add.graphics(); this.bgRect.postFX.addGlow(scm.glow, 3, 0, false, 0.1, 10); - // Scanline + corner-bracket overlays, chamfer-clipped so they never + // Shimmer + corner-bracket overlays, chamfer-clipped so they never // spill past the panel outline. Created before the first _drawBg() call // since that call populates fxMaskShape's geometry. this.fxMaskShape = scene.add.graphics().setVisible(false); - this.scanline = scene.add.graphics().setAlpha(0); - this.scanline.setMask(new Phaser.Display.Masks.GeometryMask(scene, this.fxMaskShape)); + this.shimmer = scene.add.graphics().setAlpha(0); + this.shimmer.setMask(new Phaser.Display.Masks.GeometryMask(scene, this.fxMaskShape)); this.brackets = scene.add.graphics().setAlpha(0); + // Unmasked (unlike shimmer/brackets, which stay inside or hug the + // panel) — its points are computed directly on the stroke's own path, + // so it never needs clipping, and it is added to the container LAST, + // below, so it renders above bgRect's stroke rather than under it. + this.borderLights = scene.add.graphics().setAlpha(0); this._drawBg(bg, isGhost ? GHOST_ALPHA : 1); @@ -84,7 +96,10 @@ export class Button extends Phaser.GameObjects.Container { this._drawBrackets(false); - this.add([this.bgRect, this.fxMaskShape, this.scanline, this.brackets, this.text]); + // borderLights is last — Phaser Containers render children in insertion + // order regardless of individual depth, so this is what actually puts + // it above bgRect's stroke (and everything else) rather than under it. + this.add([this.bgRect, this.fxMaskShape, this.shimmer, this.brackets, this.text, this.borderLights]); this.setSize(width, height); this.setInteractive({ @@ -103,7 +118,8 @@ export class Button extends Phaser.GameObjects.Container { this._drawBg(s.glow, 1); this.text.setColor(s.textDark); } - this._startScanline(); + this._startShimmer(); + this._startBorderLights(); this._drawBrackets(true); }; const onOut = () => { @@ -111,7 +127,8 @@ export class Button extends Phaser.GameObjects.Container { const { bg: b, textColor: tc, variant: v } = this.options; this._drawBg(b, v === 'ghost' ? GHOST_ALPHA : 1); this.text.setColor(tc); - this._stopScanline(); + this._stopShimmer(); + this._stopBorderLights(); this._drawBrackets(false); }; const onDown = () => this.bgRect.setScale(0.97); @@ -124,7 +141,10 @@ export class Button extends Phaser.GameObjects.Container { this.on('pointerupoutside', onUp); if (onClick) this.on('pointerup', onClick); - this.once(Phaser.GameObjects.Events.DESTROY, () => this._stopScanline()); + this.once(Phaser.GameObjects.Events.DESTROY, () => { + this._stopShimmer(); + this._stopBorderLights(); + }); scene.add.existing(this); } @@ -167,10 +187,12 @@ export class Button extends Phaser.GameObjects.Container { const hh = height / 2; const arm = 10; const pad = extended ? 4 : 0; + // Only the upper-right and lower-left corners (Brian's ask) — these are + // also the two the chamfered panel itself cuts at 45° (_chamferPoints), + // so the brackets echo the panel's own shape instead of framing all + // four corners generically. const corners = [ - { cx: -hw - pad, cy: -hh - pad, dx: 1, dy: 1 }, { cx: hw + pad, cy: -hh - pad, dx: -1, dy: 1 }, - { cx: hw + pad, cy: hh + pad, dx: -1, dy: -1 }, { cx: -hw - pad, cy: hh + pad, dx: 1, dy: -1 }, ]; this.brackets.clear(); @@ -184,36 +206,116 @@ export class Button extends Phaser.GameObjects.Container { } } - _startScanline() { - this._stopScanline(); - const { width, height, scheme } = this.options; + // A tall, narrow, tilted bright band tweened across in X — much taller + // than the button so the tilt never uncovers the top/bottom edge as it + // crosses — clipped to the chamfer shape by the shimmer/fxMaskShape mask + // set up in the constructor, then a repeatDelay pause before it crosses + // again: "occasional", not a continuous back-and-forth sweep. + _startShimmer() { + this._stopShimmer(); + const { width, height } = this.options; const hw = width / 2; - const hh = height / 2; - this.scanline.clear(); - this.scanline.fillStyle(scheme.glow, 0.85); - this.scanline.fillRect(-hw, -3, width, 3); - this.scanline.y = -hh; - this.scanline.setAlpha(1); + const margin = height; // clears the band fully off both sides at rest + this.shimmer.clear(); + this.shimmer.fillStyle(0xffffff, 0.5); + this.shimmer.fillRect(-12, -height * 2, 24, height * 4); + this.shimmer.rotation = Phaser.Math.DegToRad(22); + this.shimmer.x = -hw - margin; + this.shimmer.y = 0; + this.shimmer.setAlpha(1); this.brackets.setAlpha(1); - this._scanTween = this.scene.tweens.add({ - targets: this.scanline, - y: hh, - duration: 650, + this._shimmerTween = this.scene.tweens.add({ + targets: this.shimmer, + x: hw + margin, + duration: 550, repeat: -1, - ease: 'Sine.easeInOut', - yoyo: true, + repeatDelay: 1500, + ease: 'Cubic.easeInOut', }); } - _stopScanline() { - if (this._scanTween) { - this._scanTween.stop(); - this._scanTween = null; + _stopShimmer() { + if (this._shimmerTween) { + this._shimmerTween.stop(); + this._shimmerTween = null; } - this.scanline.setAlpha(0); + this.shimmer.setAlpha(0); this.brackets.setAlpha(0); } + // Arc-length table for _perimeterPoint: cumulative distance walked around + // the chamfered polygon, point by point, closing back to the start. + _computePerimeter() { + const pts = this._chamferPoints(); + const cum = [0]; + for (let i = 0; i < pts.length; i += 1) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + cum.push(cum[i] + Phaser.Math.Distance.Between(a.x, a.y, b.x, b.y)); + } + return { pts, cum, total: cum[cum.length - 1] }; + } + + /** Point at `frac` (0..1, wraps) of the way around the perimeter. */ + _perimeterPoint(frac) { + const { pts, cum, total } = this._perimeter; + const target = (((frac % 1) + 1) % 1) * total; + for (let i = 0; i < pts.length; i += 1) { + if (target <= cum[i + 1] || i === pts.length - 1) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + const segLen = cum[i + 1] - cum[i]; + const t = segLen > 0 ? (target - cum[i]) / segLen : 0; + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; + } + } + return pts[0]; + } + + // A few short white segments, evenly spaced around the perimeter, redrawn + // each tick as `phase` advances — the "orbiting lights" effect. Each + // segment is a straight line between two perimeter points rather than a + // true corner-following polyline, so a segment straddling a corner cuts + // it very slightly; negligible at this segment length, not worth a + // multi-point path for. + _drawBorderLights(phase) { + const LIGHT_COUNT = 3; + const LIGHT_FRAC = 0.05; + this.borderLights.clear(); + this.borderLights.lineStyle(3, 0xffffff, 0.9); + for (let i = 0; i < LIGHT_COUNT; i += 1) { + const start = phase + i / LIGHT_COUNT; + const a = this._perimeterPoint(start); + const b = this._perimeterPoint(start + LIGHT_FRAC); + this.borderLights.beginPath(); + this.borderLights.moveTo(a.x, a.y); + this.borderLights.lineTo(b.x, b.y); + this.borderLights.strokePath(); + } + } + + _startBorderLights() { + this._stopBorderLights(); + const counter = { phase: 0 }; + this.borderLights.setAlpha(1); + this._lightsTween = this.scene.tweens.add({ + targets: counter, + phase: 1, + duration: 3600, // slow, per Brian's ask + repeat: -1, + onUpdate: () => this._drawBorderLights(counter.phase), + }); + } + + _stopBorderLights() { + if (this._lightsTween) { + this._lightsTween.stop(); + this._lightsTween = null; + } + this.borderLights.clear(); + this.borderLights.setAlpha(0); + } + setActive(active) { this._active = active; const { bg, textColor, scheme, variant } = this.options;