feat(puzzle): enhance snap detection glow and UI selection feedback

- Extend SnapDetector to include distance and neighbor IDs in glow segment data
- Add CSS animations for spinning border shimmer effects on puzzle selection cards
- Implement dynamic hover/selection states with scale transform and glow effects on cards
- Add puzzle view mode guard to prevent interaction during completed state display
- Refactor piece glow system: replace static multi-pass lines with dynamic intensity-based fading, add per-piece preFX glow, and include proper cleanup of timers/glow FX
This commit is contained in:
Brian Fertig 2026-04-06 19:16:48 -06:00
parent 4d07fcb1c4
commit eeebed10c0
3 changed files with 198 additions and 21 deletions

View File

@ -27,10 +27,11 @@ class SnapDetector {
heldPieceObj, allPieceObjects, groupManager, cols, pieceW, pieceH, glowRadius
);
return candidates.map(({ heldId, neighborId }) => {
return candidates.map(({ heldId, neighborId, dist }) => {
const hp = allPieceObjects.get(heldId).data;
const np = allPieceObjects.get(neighborId).data;
return SnapDetector._sharedEdgeSegment(hp, np, pieceW, pieceH);
const seg = SnapDetector._sharedEdgeSegment(hp, np, pieceW, pieceH);
return { ...seg, dist, neighborId, heldId, glowRadius };
});
}

View File

@ -164,6 +164,7 @@ class NewPuzzleScene extends Phaser.Scene {
this.scale.on('resize', this._onResizeScale);
window.addEventListener('resize', this._onResizeScale);
this._injectStyles();
this._buildPuzzleGrid(this._uiLayer);
this._buildControlsBar(this._uiLayer);
@ -177,6 +178,22 @@ class NewPuzzleScene extends Phaser.Scene {
});
}
_injectStyles() {
this._styleEl = document.createElement('style');
this._styleEl.textContent = `
@keyframes iPuzzleSpinBorder {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes iPuzzleShimmer {
0% { transform: translateX(-100%); }
25% { transform: translateX(200%); }
100% { transform: translateX(200%); }
}
`;
document.head.appendChild(this._styleEl);
}
_buildPuzzleGrid(uiLayer) {
// Scrollable area: sits below the Phaser title (top 10%) and above the controls bar (bottom 28%)
const scrollArea = document.createElement('div');
@ -203,13 +220,41 @@ class NewPuzzleScene extends Phaser.Scene {
const card = document.createElement('div');
Object.assign(card.style, {
cursor: 'pointer',
border: '2px solid transparent',
borderRadius: '6px',
borderRadius: '8px',
overflow: 'hidden',
transition: 'border-color 0.15s, box-shadow 0.15s',
transition: 'transform 0.2s ease',
background: 'rgba(10, 10, 30, 0.8)',
position: 'relative',
});
// Spinning gradient element for selection emitter (hidden by default)
const spinner = document.createElement('div');
Object.assign(spinner.style, {
position: 'absolute',
top: '50%',
left: '50%',
width: '200%',
height: '200%',
background: 'conic-gradient(from 0deg, transparent 0%, #f57c00 5%, #ffb74d 10%, #f57c00 15%, transparent 20%, transparent 50%, #f57c00 55%, #ffb74d 60%, #f57c00 65%, transparent 70%)',
animation: 'iPuzzleSpinBorder 2.5s linear infinite',
display: 'none',
zIndex: '0',
});
card._spinner = spinner;
// Content wrapper sits above the spinner
const contentWrap = document.createElement('div');
Object.assign(contentWrap.style, {
position: 'relative',
zIndex: '1',
background: 'rgba(10, 10, 30, 0.8)',
borderRadius: '5px',
overflow: 'hidden',
margin: '3px',
transition: 'box-shadow 0.15s',
});
card._contentWrap = contentWrap;
const thumb = document.createElement('img');
const thumbEntry = this._thumbByPuzzleKey[img.key];
thumb.src = thumbEntry ? thumbEntry.path : img.path;
@ -230,18 +275,38 @@ class NewPuzzleScene extends Phaser.Scene {
});
label.textContent = img.label;
card.appendChild(thumb);
card.appendChild(label);
// Shimmer overlay (hidden by default)
const shimmer = document.createElement('div');
Object.assign(shimmer.style, {
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
background: 'linear-gradient(110deg, transparent 20%, rgba(255,255,255,0.12) 35%, rgba(255,255,255,0.25) 50%, rgba(255,255,255,0.12) 65%, transparent 80%)',
animation: 'iPuzzleShimmer 4s ease-in-out infinite',
pointerEvents: 'none',
zIndex: '2',
display: 'none',
});
card._shimmer = shimmer;
contentWrap.appendChild(thumb);
contentWrap.appendChild(label);
contentWrap.appendChild(shimmer);
card.appendChild(spinner);
card.appendChild(contentWrap);
card.addEventListener('mouseenter', () => {
card.style.transform = 'scale(1.05)';
if (this.selectedImageIdx !== i) {
card.style.borderColor = '#64b5f6';
contentWrap.style.boxShadow = '0 0 0 2px #64b5f6';
}
});
card.addEventListener('mouseleave', () => {
card.style.transform = 'scale(1)';
if (this.selectedImageIdx !== i) {
card.style.borderColor = 'transparent';
card.style.boxShadow = '';
contentWrap.style.boxShadow = '';
}
});
card.addEventListener('click', () => this._selectImage(i));
@ -448,8 +513,9 @@ class NewPuzzleScene extends Phaser.Scene {
this.selectedImageIdx = idx;
this._cardEls.forEach((card, i) => {
const sel = i === idx;
card.style.borderColor = sel ? '#f57c00' : 'transparent';
card.style.boxShadow = sel ? '0 0 12px rgba(245, 124, 0, 0.5)' : '';
card._spinner.style.display = sel ? 'block' : 'none';
card._shimmer.style.display = sel ? 'block' : 'none';
card._contentWrap.style.boxShadow = sel ? '0 0 8px rgba(245, 124, 0, 0.4)' : '';
});
this._refreshStartButton();
}
@ -517,6 +583,10 @@ class NewPuzzleScene extends Phaser.Scene {
this._uiLayer.parentNode.removeChild(this._uiLayer);
}
this._uiLayer = null;
if (this._styleEl && this._styleEl.parentNode) {
this._styleEl.parentNode.removeChild(this._styleEl);
}
this._styleEl = null;
}
// ─── Start puzzle ────────────────────────────────────────────────────

View File

@ -419,6 +419,7 @@ class PuzzleScene extends Phaser.Scene {
// Right-click also starts pan (only when no piece is held)
this.input.on('pointerdown', (ptr) => {
if (this._completed || this._puzzleViewMode) return;
if (ptr.rightButtonDown() && !this._heldPiece) {
this._isPanning = true;
this._panLastX = ptr.x;
@ -846,6 +847,7 @@ class PuzzleScene extends Phaser.Scene {
// click (pickup) so we don't immediately release again.
_onPointerDown(ptr) {
if (this._completed || this._puzzleViewMode) return;
if (ptr.rightButtonDown()) return; // handled by pan logic
this._lastPtrX = ptr.x;
this._lastPtrY = ptr.y;
@ -867,7 +869,7 @@ class PuzzleScene extends Phaser.Scene {
}
_onPieceDown(ptr, gameObject) {
if (!this._ready) return;
if (!this._ready || this._completed || this._puzzleViewMode) return;
const clickedPO = this._getPieceObjectForImage(gameObject);
if (!clickedPO) return;
@ -1086,6 +1088,7 @@ class PuzzleScene extends Phaser.Scene {
}
this._glowGraphics.clear();
this._clearPieceGlowFX();
this._heldPiece = null;
// Refresh shadows after piece positions changed
@ -1175,9 +1178,35 @@ class PuzzleScene extends Phaser.Scene {
// ─── Glow ────────────────────────────────────────────────────────────
_lerpColor(c1, c2, t) {
const r1 = (c1 >> 16) & 0xff, g1 = (c1 >> 8) & 0xff, b1 = c1 & 0xff;
const r2 = (c2 >> 16) & 0xff, g2 = (c2 >> 8) & 0xff, b2 = c2 & 0xff;
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return (r << 16) | (g << 8) | b;
}
_clearPieceGlowFX() {
if (this._glowingPieceIds) {
this._glowingPieceIds.forEach(id => {
const po = this._pieceObjects.get(id);
if (po && po.image.preFX && po._glowFX) {
po.image.preFX.remove(po._glowFX);
po._glowFX = null;
}
});
this._glowingPieceIds.clear();
}
if (this._glowTimers) this._glowTimers.clear();
}
_drawGlow() {
this._glowGraphics.clear();
if (!this._heldPiece || this._boxSelectedIds) return;
if (!this._heldPiece || this._boxSelectedIds) {
this._clearPieceGlowFX();
return;
}
const glowRadius = this._pieceW * GLOW_RADIUS_FACTOR;
const segments = SnapDetector.getGlowSegments(
@ -1186,20 +1215,95 @@ class PuzzleScene extends Phaser.Scene {
this._pieceW, this._pieceH, glowRadius
);
segments.forEach(({ x0, y0, x1, y1 }) => {
// Four passes: wide outer, outer, mid, inner glow
if (!this._glowingPieceIds) this._glowingPieceIds = new Set();
if (!this._glowTimers) this._glowTimers = new Map();
const GLOW_DELAY = 500; // ms a neighbor must stay in range before glow appears
const now = Date.now();
// Track which neighbors are in proximity this frame
const activeIds = new Set();
// Color endpoints: far = cyan, close = warm gold-white
const COLOR_FAR = 0x00ccff;
const COLOR_CLOSE = 0xffffaa;
// Collect the best (highest intensity) glow per held piece
const heldGlowMap = new Map(); // heldId → { intensity, fadeIn, color }
segments.forEach(({ x0, y0, x1, y1, dist, neighborId, heldId, glowRadius: gr }) => {
activeIds.add(neighborId);
// Record when this neighbor first entered proximity
if (!this._glowTimers.has(neighborId)) {
this._glowTimers.set(neighborId, now);
}
// Skip rendering until the piece has been in proximity for GLOW_DELAY ms
const elapsed = now - this._glowTimers.get(neighborId);
if (elapsed < GLOW_DELAY) return;
// Fade in over 300ms after the delay
const fadeIn = Math.min(1, (elapsed - GLOW_DELAY) / 300);
// Intensity: 0 at edge of glow radius, 1 when pieces are perfectly aligned
const intensity = Math.max(0, Math.min(1, 1 - dist / gr)) * fadeIn;
const color = this._lerpColor(COLOR_FAR, COLOR_CLOSE, intensity);
// Edge-line glow — scale width and alpha by intensity
[
[20, 0x00ccff, 0.06],
[14, 0x00ccff, 0.12],
[8, 0x00eeff, 0.30],
[4, 0xaaffff, 0.70],
].forEach(([lw, color, alpha]) => {
[20, 0.06],
[14, 0.12],
[8, 0.30],
[4, 0.70],
].forEach(([baseLW, baseAlpha]) => {
const lw = Math.max(1, baseLW * intensity);
const alpha = baseAlpha * intensity;
this._glowGraphics.lineStyle(lw, color, alpha);
this._glowGraphics.beginPath();
this._glowGraphics.moveTo(x0, y0);
this._glowGraphics.lineTo(x1, y1);
this._glowGraphics.strokePath();
});
// Track the strongest glow for each held piece
const prev = heldGlowMap.get(heldId);
if (!prev || intensity > prev.intensity) {
heldGlowMap.set(heldId, { intensity, color });
}
});
// Apply preFX glow to held pieces (not neighbors)
heldGlowMap.forEach(({ intensity, color }, heldId) => {
const po = this._pieceObjects.get(heldId);
if (po && po.image.preFX) {
activeIds.add(heldId);
const outerStrength = 2 + intensity * 8;
const innerStrength = intensity * 2;
if (po._glowFX) {
po._glowFX.outerStrength = outerStrength;
po._glowFX.innerStrength = innerStrength;
po._glowFX.color = color;
} else {
po._glowFX = po.image.preFX.addGlow(color, outerStrength, innerStrength, false, 0.1, 12);
this._glowingPieceIds.add(heldId);
}
}
});
// Remove FX and timers from pieces no longer in range
this._glowTimers.forEach((_, id) => {
if (!activeIds.has(id)) this._glowTimers.delete(id);
});
this._glowingPieceIds.forEach(id => {
if (!activeIds.has(id)) {
const po = this._pieceObjects.get(id);
if (po && po.image.preFX && po._glowFX) {
po.image.preFX.remove(po._glowFX);
po._glowFX = null;
}
this._glowingPieceIds.delete(id);
}
});
}
@ -2747,6 +2851,7 @@ class PuzzleScene extends Phaser.Scene {
_showPuzzleView() {
if (!this._completionOverlay) return;
this._puzzleViewMode = true;
this._completionOverlay.style.display = 'none';
// Hide other UI elements
@ -2772,6 +2877,7 @@ class PuzzleScene extends Phaser.Scene {
}
_hidePuzzleView() {
this._puzzleViewMode = false;
// Remove back button
if (this._showPuzzleBackBtn && this._showPuzzleBackBtn.parentNode) {
this._showPuzzleBackBtn.parentNode.removeChild(this._showPuzzleBackBtn);