feat: enhance puzzle rendering with clean textures and improved UI/UX

- Add clean texture variants (`piece_clean_N`) for merged pieces to remove outlines after grouping
- Improve piece renderer progress tracking to distinguish main vs. clean textures
- Support 140-piece puzzles (14×10 grid) across generator, menu, and selection scenes
- Auto-open join dialog when `?room=` URL parameter present in main menu
- Add shareable room link with copy-to-clipboard button in puzzle scene UI
- Introduce `_updatePieceEffects()` to manage visual states: clean textures for merged groups, drop shadows for singletons/groups
- Enhance music playback with fallback completion detection and robust event handling
This commit is contained in:
Brian Fertig 2026-04-05 11:58:01 -06:00
parent 8b30269b56
commit 5b1c8214a6
5 changed files with 207 additions and 26 deletions

View File

@ -26,17 +26,27 @@ class PieceRenderer {
const dims = { pieceW, pieceH, tabSize, canvasW, canvasH };
// Build a set of expected keys so the listener ignores unrelated textures
const pending = new Set(pieceDataArray.map(p => `piece_${p.id}`));
// Track both outlined (piece_N) and clean (piece_clean_N) textures
const pending = new Set();
pieceDataArray.forEach(p => {
pending.add(`piece_${p.id}`);
pending.add(`piece_clean_${p.id}`);
});
const totalTextures = pending.size;
let texDone = 0;
let done = 0;
return new Promise(resolve => {
// Single listener for all pieces — Phaser 3.9 emits 'addtexture' with key arg
const onAdded = (key) => {
if (!pending.has(key)) return;
pending.delete(key);
done++;
if (onProgress) onProgress(done, total);
if (done === total) {
texDone++;
// Only count progress for main piece textures (not clean variants)
if (key.startsWith('piece_') && !key.startsWith('piece_clean_')) {
done++;
if (onProgress) onProgress(done, total);
}
if (texDone === totalTextures) {
scene.textures.off('addtexture', onAdded);
resolve(dims);
}
@ -78,6 +88,33 @@ class PieceRenderer {
ctx.stroke();
ctx.restore();
// Save clean version (used after pieces merge)
const cleanKey = `piece_clean_${piece.id}`;
if (scene.textures.exists(cleanKey)) scene.textures.remove(cleanKey);
scene.textures.addBase64(cleanKey, canvas.toDataURL('image/png'));
// Dark outer glow (drawn behind the stroke, outside the clip)
ctx.save();
buildPiecePath(ctx, piece.gridCol, piece.gridRow, cols, rows,
pieceW, pieceH, piece.edges, tabSize);
ctx.shadowColor = 'rgba(0, 0, 0, 0.7)';
ctx.shadowBlur = 8;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
// Bright off-white stroke along the piece outline
ctx.save();
buildPiecePath(ctx, piece.gridCol, piece.gridRow, cols, rows,
pieceW, pieceH, piece.edges, tabSize);
ctx.strokeStyle = 'rgba(240, 240, 255, 0.6)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
const key = `piece_${piece.id}`;
if (scene.textures.exists(key)) scene.textures.remove(key);
scene.textures.addBase64(key, canvas.toDataURL('image/png'));

View File

@ -3,7 +3,8 @@ const GRID_SIZES = {
20: { cols: 5, rows: 4 },
40: { cols: 8, rows: 5 },
60: { cols: 10, rows: 6 },
100: { cols: 10, rows: 10 }
100: { cols: 10, rows: 10 },
140: { cols: 14, rows: 10 }
};
class PuzzleGenerator {

View File

@ -1,4 +1,4 @@
/* global Phaser, NetworkManager, StorageManager */
/* global Phaser, NetworkManager, StorageManager, getRoomCodeFromURL */
class MainMenuScene extends Phaser.Scene {
constructor() {
@ -36,6 +36,12 @@ class MainMenuScene extends Phaser.Scene {
// Run animated intro sequence
this._animateIntro();
// Auto-open join dialog if URL has ?room= parameter
const urlRoom = getRoomCodeFromURL();
if (urlRoom) {
this.time.delayedCall(1600, () => this._showJoinDialog(urlRoom));
}
}
// ─── Animated Intro ─────────────────────────────────────────────────
@ -240,7 +246,7 @@ class MainMenuScene extends Phaser.Scene {
});
}
_showJoinDialog() {
_showJoinDialog(prefillCode) {
if (this._joinDialogEl) return;
const overlay = document.createElement('div');
@ -319,6 +325,9 @@ class MainMenuScene extends Phaser.Scene {
input.maxLength = 4;
input.placeholder = 'ABCD';
input.autocomplete = 'off';
if (prefillCode) {
input.value = prefillCode.toUpperCase().replace(/[^A-Z0-9]/g, '').substring(0, 4);
}
input.addEventListener('input', () => {
input.value = input.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
updateJoinBtn();
@ -382,10 +391,15 @@ class MainMenuScene extends Phaser.Scene {
this._uiLayer.appendChild(overlay);
this._joinDialogEl = overlay;
// Focus the name input (or code input if name already filled)
// Focus the name input (or code input if name already filled and no prefill)
setTimeout(() => {
if (nameInput.value.trim().length > 0) input.focus();
else nameInput.focus();
if (prefillCode) {
nameInput.focus();
} else if (nameInput.value.trim().length > 0) {
input.focus();
} else {
nameInput.focus();
}
}, 50);
// Initial button state

View File

@ -4,7 +4,8 @@ const DIFFICULTIES = [
{ pieces: 20, label: '20 Pieces' },
{ pieces: 40, label: '40 Pieces' },
{ pieces: 60, label: '60 Pieces' },
{ pieces: 100, label: '100 Pieces' }
{ pieces: 100, label: '100 Pieces' },
{ pieces: 140, label: '140 Pieces' }
];
const BACKGROUNDS = [
@ -25,7 +26,7 @@ class NewPuzzleScene extends Phaser.Scene {
create() {
this.selectedImageIdx = null;
this.selectedPieces = null;
this.selectedPieces = 60;
this.selectedBg = BACKGROUNDS[0]; // default to dark wood
const W = this.sys.game.config.width;
@ -273,11 +274,12 @@ class NewPuzzleScene extends Phaser.Scene {
this._diffBtnEls = DIFFICULTIES.map((diff, i) => {
const btn = document.createElement('button');
btn.textContent = diff.label;
const isDefault = diff.pieces === this.selectedPieces;
Object.assign(btn.style, {
padding: '0.7vmin 2.5vmin',
background: 'rgba(10, 10, 30, 0.8)',
background: isDefault ? '#1565c0' : 'rgba(10, 10, 30, 0.8)',
color: '#e0e0e0',
border: '1px solid rgba(100, 181, 246, 0.3)',
border: '1px solid ' + (isDefault ? '#64b5f6' : 'rgba(100, 181, 246, 0.3)'),
borderRadius: '4px',
fontSize: '1.6vmin',
fontFamily: 'Arial, sans-serif',

View File

@ -167,6 +167,11 @@ class PuzzleScene extends Phaser.Scene {
update() {
if (!this._ready) return;
// Fallback: detect when music track has finished (Phaser 3.9 'complete' event can be unreliable)
if (this._currentMusic && this._musicStarted && !this._currentMusic.isPlaying && !this._currentMusic.isPaused) {
this._playNextTrack();
}
// Camera pan (right-click drag, or left-click drag on empty space)
const ptr = this.input.activePointer;
if (this._isPanning && ptr.isDown && !this._heldPiece) {
@ -330,6 +335,9 @@ class PuzzleScene extends Phaser.Scene {
// DOM UI (immune to camera zoom)
this._buildDomUI();
// Apply piece visual effects (outlines on singletons, shadows on groups)
this._updatePieceEffects();
// Check already complete on restore
if (saved && saved.completed) {
this._showCompletion(false);
@ -469,6 +477,7 @@ class PuzzleScene extends Phaser.Scene {
if (this._groupManager.groupCount < prevCount) {
this.sound.play('sfx_click');
}
this._updatePieceEffects();
}
// Remove floating label
@ -796,6 +805,9 @@ class PuzzleScene extends Phaser.Scene {
this._glowGraphics.clear();
this._heldPiece = null;
// Refresh shadows after piece positions changed
this._updatePieceEffects();
this._saveState();
this._checkCompletion();
}
@ -843,6 +855,29 @@ class PuzzleScene extends Phaser.Scene {
// Update heldPiece reference so getPeersOf returns the enlarged group
// (heldPiece pointer stays the same object, groupManager now returns merged group)
}
this._updatePieceEffects();
}
// ─── Piece visual effects ─────────────────────────────────────────────
/**
* Swap merged pieces to clean textures (no outline) and
* redraw drop shadows behind merged groups.
*/
_updatePieceEffects() {
this._groupManager.groups.forEach((members) => {
const isMerged = members.size > 1;
members.forEach(id => {
const po = this._pieceObjects.get(id);
if (!po) return;
const expectedKey = isMerged ? `piece_clean_${id}` : `piece_${id}`;
if (po.image.texture.key !== expectedKey && this.textures.exists(expectedKey)) {
po.image.setTexture(expectedKey);
}
});
});
}
// ─── Glow ────────────────────────────────────────────────────────────
@ -859,8 +894,13 @@ class PuzzleScene extends Phaser.Scene {
);
segments.forEach(({ x0, y0, x1, y1 }) => {
// Three passes: outer, mid, inner glow
[[10, 0x00ccff, 0.10], [6, 0x00eeff, 0.28], [3, 0xaaffff, 0.65]].forEach(([lw, color, alpha]) => {
// Four passes: wide outer, outer, mid, inner glow
[
[20, 0x00ccff, 0.06],
[14, 0x00ccff, 0.12],
[8, 0x00eeff, 0.30],
[4, 0xaaffff, 0.70],
].forEach(([lw, color, alpha]) => {
this._glowGraphics.lineStyle(lw, color, alpha);
this._glowGraphics.beginPath();
this._glowGraphics.moveTo(x0, y0);
@ -1000,9 +1040,11 @@ class PuzzleScene extends Phaser.Scene {
const idx = this._musicQueue.shift();
const key = `music_${idx}`;
this._musicStarted = false;
this._currentMusic = this.sound.add(key, { volume: 0.3 });
this._currentMusic.setMute(this._musicMuted);
this._currentMusic.play();
this._musicStarted = true;
// Update track info display
const track = this._musicTracks[idx];
@ -1010,8 +1052,12 @@ class PuzzleScene extends Phaser.Scene {
this._trackInfoEl.textContent = `${track.title}${track.artist}`;
}
// When track ends, play next
this._currentMusic.once('complete', () => this._playNextTrack());
// When track ends, play next (both event and polling fallback)
this._currentMusic.once('complete', () => {
if (this._currentMusic && this._currentMusic.key === key) {
this._playNextTrack();
}
});
}
_toggleMute() {
@ -1048,24 +1094,105 @@ class PuzzleScene extends Phaser.Scene {
});
document.body.appendChild(this._uiLayer);
// Room code — bottom-right
// Room code + share link — bottom-right
const roomCode = this.cfg.roomCode;
const shareUrl = `${window.location.origin}${window.location.pathname}?room=${roomCode}`;
const roomEl = document.createElement('div');
Object.assign(roomEl.style, {
position: 'absolute',
bottom: '1.5%',
right: '1%',
bottom: '48px',
right: '20px',
color: '#ddeeff',
fontSize: '2.4vmin',
fontSize: '26px',
fontFamily: 'monospace',
fontWeight: 'bold',
letterSpacing: '0.15em',
background: 'rgba(0, 0, 0, 0.55)',
padding: '0.6vmin 1.2vmin',
borderRadius: '4px',
padding: '6px 14px',
borderRadius: '4px 4px 0 0',
});
roomEl.textContent = `Room: ${this.cfg.roomCode}`;
roomEl.textContent = `Room: ${roomCode}`;
this._uiLayer.appendChild(roomEl);
const linkRow = document.createElement('div');
Object.assign(linkRow.style, {
position: 'absolute',
bottom: '16px',
right: '20px',
display: 'flex',
alignItems: 'center',
gap: '8px',
background: 'rgba(0, 0, 0, 0.55)',
padding: '5px 14px',
borderRadius: '0 0 4px 4px',
pointerEvents: 'auto',
});
const linkText = document.createElement('span');
Object.assign(linkText.style, {
color: '#8899bb',
fontSize: '14px',
fontFamily: 'monospace',
userSelect: 'all',
});
linkText.textContent = shareUrl;
const copyBtn = document.createElement('button');
Object.assign(copyBtn.style, {
background: 'none',
border: 'none',
color: '#8899bb',
fontSize: '16px',
cursor: 'pointer',
padding: '2px 4px',
lineHeight: '1',
});
copyBtn.textContent = '\uD83D\uDCCB';
copyBtn.title = 'Copy link';
copyBtn.addEventListener('mouseenter', () => { copyBtn.style.color = '#ddeeff'; });
copyBtn.addEventListener('mouseleave', () => { copyBtn.style.color = '#8899bb'; });
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
const showCopied = () => {
copyBtn.textContent = '\u2713 Copied!';
copyBtn.style.color = '#44cc66';
setTimeout(() => {
copyBtn.textContent = '\uD83D\uDCCB';
copyBtn.style.color = '#8899bb';
}, 2000);
};
// Try modern clipboard API first, fall back to execCommand
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(shareUrl).then(showCopied).catch(() => {
// Fallback for non-secure contexts
const temp = document.createElement('textarea');
temp.value = shareUrl;
temp.style.position = 'fixed';
temp.style.opacity = '0';
document.body.appendChild(temp);
temp.select();
document.execCommand('copy');
document.body.removeChild(temp);
showCopied();
});
} else {
const temp = document.createElement('textarea');
temp.value = shareUrl;
temp.style.position = 'fixed';
temp.style.opacity = '0';
document.body.appendChild(temp);
temp.select();
document.execCommand('copy');
document.body.removeChild(temp);
showCopied();
}
});
linkRow.appendChild(linkText);
linkRow.appendChild(copyBtn);
this._uiLayer.appendChild(linkRow);
// Connection status indicator (only for networked games)
if (this._isNetworked) {
this._connStatusEl = document.createElement('div');