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:
parent
8b30269b56
commit
5b1c8214a6
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue