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:
parent
4d07fcb1c4
commit
eeebed10c0
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue