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 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 hp = allPieceObjects.get(heldId).data;
const np = allPieceObjects.get(neighborId).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); this.scale.on('resize', this._onResizeScale);
window.addEventListener('resize', this._onResizeScale); window.addEventListener('resize', this._onResizeScale);
this._injectStyles();
this._buildPuzzleGrid(this._uiLayer); this._buildPuzzleGrid(this._uiLayer);
this._buildControlsBar(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) { _buildPuzzleGrid(uiLayer) {
// Scrollable area: sits below the Phaser title (top 10%) and above the controls bar (bottom 28%) // Scrollable area: sits below the Phaser title (top 10%) and above the controls bar (bottom 28%)
const scrollArea = document.createElement('div'); const scrollArea = document.createElement('div');
@ -203,13 +220,41 @@ class NewPuzzleScene extends Phaser.Scene {
const card = document.createElement('div'); const card = document.createElement('div');
Object.assign(card.style, { Object.assign(card.style, {
cursor: 'pointer', cursor: 'pointer',
border: '2px solid transparent', borderRadius: '8px',
borderRadius: '6px',
overflow: 'hidden', overflow: 'hidden',
transition: 'border-color 0.15s, box-shadow 0.15s', transition: 'transform 0.2s ease',
background: 'rgba(10, 10, 30, 0.8)', 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 thumb = document.createElement('img');
const thumbEntry = this._thumbByPuzzleKey[img.key]; const thumbEntry = this._thumbByPuzzleKey[img.key];
thumb.src = thumbEntry ? thumbEntry.path : img.path; thumb.src = thumbEntry ? thumbEntry.path : img.path;
@ -230,18 +275,38 @@ class NewPuzzleScene extends Phaser.Scene {
}); });
label.textContent = img.label; label.textContent = img.label;
card.appendChild(thumb); // Shimmer overlay (hidden by default)
card.appendChild(label); 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.addEventListener('mouseenter', () => {
card.style.transform = 'scale(1.05)';
if (this.selectedImageIdx !== i) { if (this.selectedImageIdx !== i) {
card.style.borderColor = '#64b5f6'; contentWrap.style.boxShadow = '0 0 0 2px #64b5f6';
} }
}); });
card.addEventListener('mouseleave', () => { card.addEventListener('mouseleave', () => {
card.style.transform = 'scale(1)';
if (this.selectedImageIdx !== i) { if (this.selectedImageIdx !== i) {
card.style.borderColor = 'transparent'; contentWrap.style.boxShadow = '';
card.style.boxShadow = '';
} }
}); });
card.addEventListener('click', () => this._selectImage(i)); card.addEventListener('click', () => this._selectImage(i));
@ -448,8 +513,9 @@ class NewPuzzleScene extends Phaser.Scene {
this.selectedImageIdx = idx; this.selectedImageIdx = idx;
this._cardEls.forEach((card, i) => { this._cardEls.forEach((card, i) => {
const sel = i === idx; const sel = i === idx;
card.style.borderColor = sel ? '#f57c00' : 'transparent'; card._spinner.style.display = sel ? 'block' : 'none';
card.style.boxShadow = sel ? '0 0 12px rgba(245, 124, 0, 0.5)' : ''; card._shimmer.style.display = sel ? 'block' : 'none';
card._contentWrap.style.boxShadow = sel ? '0 0 8px rgba(245, 124, 0, 0.4)' : '';
}); });
this._refreshStartButton(); this._refreshStartButton();
} }
@ -517,6 +583,10 @@ class NewPuzzleScene extends Phaser.Scene {
this._uiLayer.parentNode.removeChild(this._uiLayer); this._uiLayer.parentNode.removeChild(this._uiLayer);
} }
this._uiLayer = null; this._uiLayer = null;
if (this._styleEl && this._styleEl.parentNode) {
this._styleEl.parentNode.removeChild(this._styleEl);
}
this._styleEl = null;
} }
// ─── Start puzzle ──────────────────────────────────────────────────── // ─── Start puzzle ────────────────────────────────────────────────────

View File

@ -419,6 +419,7 @@ class PuzzleScene extends Phaser.Scene {
// Right-click also starts pan (only when no piece is held) // Right-click also starts pan (only when no piece is held)
this.input.on('pointerdown', (ptr) => { this.input.on('pointerdown', (ptr) => {
if (this._completed || this._puzzleViewMode) return;
if (ptr.rightButtonDown() && !this._heldPiece) { if (ptr.rightButtonDown() && !this._heldPiece) {
this._isPanning = true; this._isPanning = true;
this._panLastX = ptr.x; this._panLastX = ptr.x;
@ -846,6 +847,7 @@ class PuzzleScene extends Phaser.Scene {
// click (pickup) so we don't immediately release again. // click (pickup) so we don't immediately release again.
_onPointerDown(ptr) { _onPointerDown(ptr) {
if (this._completed || this._puzzleViewMode) return;
if (ptr.rightButtonDown()) return; // handled by pan logic if (ptr.rightButtonDown()) return; // handled by pan logic
this._lastPtrX = ptr.x; this._lastPtrX = ptr.x;
this._lastPtrY = ptr.y; this._lastPtrY = ptr.y;
@ -867,7 +869,7 @@ class PuzzleScene extends Phaser.Scene {
} }
_onPieceDown(ptr, gameObject) { _onPieceDown(ptr, gameObject) {
if (!this._ready) return; if (!this._ready || this._completed || this._puzzleViewMode) return;
const clickedPO = this._getPieceObjectForImage(gameObject); const clickedPO = this._getPieceObjectForImage(gameObject);
if (!clickedPO) return; if (!clickedPO) return;
@ -1086,6 +1088,7 @@ class PuzzleScene extends Phaser.Scene {
} }
this._glowGraphics.clear(); this._glowGraphics.clear();
this._clearPieceGlowFX();
this._heldPiece = null; this._heldPiece = null;
// Refresh shadows after piece positions changed // Refresh shadows after piece positions changed
@ -1175,9 +1178,35 @@ class PuzzleScene extends Phaser.Scene {
// ─── Glow ──────────────────────────────────────────────────────────── // ─── 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() { _drawGlow() {
this._glowGraphics.clear(); 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 glowRadius = this._pieceW * GLOW_RADIUS_FACTOR;
const segments = SnapDetector.getGlowSegments( const segments = SnapDetector.getGlowSegments(
@ -1186,20 +1215,95 @@ class PuzzleScene extends Phaser.Scene {
this._pieceW, this._pieceH, glowRadius this._pieceW, this._pieceH, glowRadius
); );
segments.forEach(({ x0, y0, x1, y1 }) => { if (!this._glowingPieceIds) this._glowingPieceIds = new Set();
// Four passes: wide outer, outer, mid, inner glow 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], [20, 0.06],
[14, 0x00ccff, 0.12], [14, 0.12],
[8, 0x00eeff, 0.30], [8, 0.30],
[4, 0xaaffff, 0.70], [4, 0.70],
].forEach(([lw, color, alpha]) => { ].forEach(([baseLW, baseAlpha]) => {
const lw = Math.max(1, baseLW * intensity);
const alpha = baseAlpha * intensity;
this._glowGraphics.lineStyle(lw, color, alpha); this._glowGraphics.lineStyle(lw, color, alpha);
this._glowGraphics.beginPath(); this._glowGraphics.beginPath();
this._glowGraphics.moveTo(x0, y0); this._glowGraphics.moveTo(x0, y0);
this._glowGraphics.lineTo(x1, y1); this._glowGraphics.lineTo(x1, y1);
this._glowGraphics.strokePath(); 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() { _showPuzzleView() {
if (!this._completionOverlay) return; if (!this._completionOverlay) return;
this._puzzleViewMode = true;
this._completionOverlay.style.display = 'none'; this._completionOverlay.style.display = 'none';
// Hide other UI elements // Hide other UI elements
@ -2772,6 +2877,7 @@ class PuzzleScene extends Phaser.Scene {
} }
_hidePuzzleView() { _hidePuzzleView() {
this._puzzleViewMode = false;
// Remove back button // Remove back button
if (this._showPuzzleBackBtn && this._showPuzzleBackBtn.parentNode) { if (this._showPuzzleBackBtn && this._showPuzzleBackBtn.parentNode) {
this._showPuzzleBackBtn.parentNode.removeChild(this._showPuzzleBackBtn); this._showPuzzleBackBtn.parentNode.removeChild(this._showPuzzleBackBtn);