1573 lines
50 KiB
JavaScript
1573 lines
50 KiB
JavaScript
/**
|
||
* PIXEL SLIDE — 15-Puzzle Image Game with PixiJS v8
|
||
* Complete game logic: Image Slicing, Puzzle Engine, PixiJS Rendering,
|
||
* Web Audio FX, Leaderboard, and UI Controllers.
|
||
*/
|
||
|
||
// ============================================================================
|
||
// 1. CONFIGURATION & CONSTANTS
|
||
// ============================================================================
|
||
const LEVELS = {
|
||
debug: {
|
||
key: "debug",
|
||
grid: 3,
|
||
shuffleMoves: 5,
|
||
label: "Debug",
|
||
gridText: "3×3",
|
||
tilesText: "8 Tiles + 1 Blank",
|
||
desc: "Trivial dev mode guaranteed solvable in ≤5 moves.",
|
||
badge: "🐛 Dev Mode",
|
||
color: "#f59e0b",
|
||
glow: "rgba(245, 158, 11, 0.4)"
|
||
},
|
||
easy: {
|
||
key: "easy",
|
||
grid: 3,
|
||
shuffleMoves: 30,
|
||
label: "Easy",
|
||
gridText: "3×3",
|
||
tilesText: "8 Tiles + 1 Blank",
|
||
desc: "Quick 3×3 warm-up puzzle for beginners.",
|
||
badge: "Beginner",
|
||
color: "#10b981",
|
||
glow: "rgba(16, 185, 129, 0.4)"
|
||
},
|
||
medium: {
|
||
key: "medium",
|
||
grid: 4,
|
||
shuffleMoves: 100,
|
||
label: "Medium",
|
||
gridText: "4×4",
|
||
tilesText: "15 Tiles + 1 Blank",
|
||
desc: "The classic 15-puzzle standard challenge.",
|
||
badge: "Standard",
|
||
color: "#00e5ff",
|
||
glow: "rgba(0, 229, 255, 0.4)"
|
||
},
|
||
hard: {
|
||
key: "hard",
|
||
grid: 5,
|
||
shuffleMoves: 200,
|
||
label: "Hard",
|
||
gridText: "5×5",
|
||
tilesText: "24 Tiles + 1 Blank",
|
||
desc: "24 sliding tiles for advanced puzzle solvers.",
|
||
badge: "Expert",
|
||
color: "#9d4edd",
|
||
glow: "rgba(157, 78, 221, 0.4)"
|
||
},
|
||
insane: {
|
||
key: "insane",
|
||
grid: 6,
|
||
shuffleMoves: 400,
|
||
label: "Insane",
|
||
gridText: "6×6",
|
||
tilesText: "35 Tiles + 1 Blank",
|
||
desc: "Extreme 35-tile challenge for true masters.",
|
||
badge: "Extreme",
|
||
color: "#f43f5e",
|
||
glow: "rgba(244, 63, 94, 0.4)"
|
||
}
|
||
};
|
||
|
||
const LEADERBOARD_KEY = "puzzle15_leaderboard_v1";
|
||
const SOUND_MUTE_KEY = "puzzle15_sound_muted";
|
||
const CANVAS_BOARD_SIZE = 600; // Internal Pixi resolution
|
||
const TILE_GAP = 3; // Pixel gap between tiles
|
||
|
||
// ============================================================================
|
||
// 2. AUDIO SYNTHESIZER (Web Audio API)
|
||
// ============================================================================
|
||
// 2. AUDIO SYNTHESIZER & SOUNDTRACK (Web Audio API & Audio Tracks)
|
||
// ============================================================================
|
||
class SoundController {
|
||
constructor() {
|
||
this.ctx = null;
|
||
this.muted = localStorage.getItem(SOUND_MUTE_KEY) === "true";
|
||
this.userInteracted = false;
|
||
|
||
// Background soundtrack
|
||
this.bgMusic = new Audio("assets/sounds/backsound.mp3");
|
||
this.bgMusic.loop = true;
|
||
this.bgMusic.volume = 0.35;
|
||
|
||
// Winner fanfare track
|
||
this.winAudio = new Audio("assets/sounds/winner.mp3");
|
||
this.winAudio.volume = 0.75;
|
||
}
|
||
|
||
init() {
|
||
this.userInteracted = true;
|
||
if (!this.ctx && (window.AudioContext || window.webkitAudioContext)) {
|
||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||
this.ctx = new AudioCtx();
|
||
}
|
||
if (this.ctx && this.ctx.state === "suspended") {
|
||
this.ctx.resume();
|
||
}
|
||
if (!this.muted && this.bgMusic.paused) {
|
||
this.playBGM();
|
||
}
|
||
}
|
||
|
||
playBGM() {
|
||
if (this.muted) return;
|
||
this.bgMusic.play().catch(() => {
|
||
// Handled silently if autoplay restricted before interaction
|
||
});
|
||
}
|
||
|
||
pauseBGM() {
|
||
this.bgMusic.pause();
|
||
}
|
||
|
||
resumeBGM() {
|
||
if (!this.muted && this.userInteracted) {
|
||
this.playBGM();
|
||
}
|
||
}
|
||
|
||
toggleMute() {
|
||
this.muted = !this.muted;
|
||
localStorage.setItem(SOUND_MUTE_KEY, this.muted);
|
||
if (this.muted) {
|
||
this.pauseBGM();
|
||
this.winAudio.pause();
|
||
} else {
|
||
if (this.userInteracted) {
|
||
this.playBGM();
|
||
}
|
||
}
|
||
return this.muted;
|
||
}
|
||
|
||
playSlide() {
|
||
if (this.muted) return;
|
||
this.init();
|
||
if (!this.ctx) return;
|
||
const now = this.ctx.currentTime;
|
||
const osc = this.ctx.createOscillator();
|
||
const gain = this.ctx.createGain();
|
||
|
||
osc.type = "sine";
|
||
osc.frequency.setValueAtTime(180, now);
|
||
osc.frequency.exponentialRampToValueAtTime(320, now + 0.08);
|
||
|
||
gain.gain.setValueAtTime(0.08, now);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.09);
|
||
|
||
osc.connect(gain);
|
||
gain.connect(this.ctx.destination);
|
||
|
||
osc.start(now);
|
||
osc.stop(now + 0.1);
|
||
}
|
||
|
||
playInvalid() {
|
||
if (this.muted) return;
|
||
this.init();
|
||
if (!this.ctx) return;
|
||
const now = this.ctx.currentTime;
|
||
const osc = this.ctx.createOscillator();
|
||
const gain = this.ctx.createGain();
|
||
|
||
osc.type = "sawtooth";
|
||
osc.frequency.setValueAtTime(120, now);
|
||
osc.frequency.linearRampToValueAtTime(90, now + 0.1);
|
||
|
||
gain.gain.setValueAtTime(0.06, now);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||
|
||
osc.connect(gain);
|
||
gain.connect(this.ctx.destination);
|
||
|
||
osc.start(now);
|
||
osc.stop(now + 0.12);
|
||
}
|
||
|
||
playClick() {
|
||
if (this.muted) return;
|
||
this.init();
|
||
if (!this.ctx) return;
|
||
const now = this.ctx.currentTime;
|
||
const osc = this.ctx.createOscillator();
|
||
const gain = this.ctx.createGain();
|
||
|
||
osc.type = "triangle";
|
||
osc.frequency.setValueAtTime(440, now);
|
||
gain.gain.setValueAtTime(0.05, now);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
|
||
|
||
osc.connect(gain);
|
||
gain.connect(this.ctx.destination);
|
||
|
||
osc.start(now);
|
||
osc.stop(now + 0.04);
|
||
}
|
||
|
||
playVictory() {
|
||
if (this.muted) return;
|
||
this.init();
|
||
|
||
// Pause backsound so winner fanfare is crystal clear
|
||
this.pauseBGM();
|
||
|
||
// Play winner mp3 audio
|
||
this.winAudio.currentTime = 0;
|
||
const playPromise = this.winAudio.play();
|
||
if (playPromise !== undefined) {
|
||
playPromise.catch((e) => {
|
||
console.warn("winner.mp3 playback failed, playing synth fallback:", e);
|
||
this.playSynthVictoryFallback();
|
||
});
|
||
}
|
||
}
|
||
|
||
playSynthVictoryFallback() {
|
||
if (!this.ctx) return;
|
||
const notes = [523.25, 659.25, 783.99, 1046.50, 1318.51];
|
||
const now = this.ctx.currentTime;
|
||
|
||
notes.forEach((freq, i) => {
|
||
const osc = this.ctx.createOscillator();
|
||
const gain = this.ctx.createGain();
|
||
const start = now + i * 0.1;
|
||
|
||
osc.type = "triangle";
|
||
osc.frequency.setValueAtTime(freq, start);
|
||
|
||
gain.gain.setValueAtTime(0, start);
|
||
gain.gain.linearRampToValueAtTime(0.12, start + 0.03);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.4);
|
||
|
||
osc.connect(gain);
|
||
gain.connect(this.ctx.destination);
|
||
|
||
osc.start(start);
|
||
osc.stop(start + 0.45);
|
||
});
|
||
}
|
||
}
|
||
|
||
const sounds = new SoundController();
|
||
|
||
// ============================================================================
|
||
// 3. PRESET PROCEDURAL ART GENERATORS
|
||
// ============================================================================
|
||
function createPresetArtworks() {
|
||
const presets = [];
|
||
|
||
// Preset 1: Cyberpunk Neon Skyline
|
||
const canvas1 = document.createElement("canvas");
|
||
canvas1.width = 600;
|
||
canvas1.height = 600;
|
||
const ctx1 = canvas1.getContext("2d");
|
||
const grad1 = ctx1.createLinearGradient(0, 0, 0, 600);
|
||
grad1.addColorStop(0, "#08071a");
|
||
grad1.addColorStop(0.5, "#2b0938");
|
||
grad1.addColorStop(1, "#03001e");
|
||
ctx1.fillStyle = grad1;
|
||
ctx1.fillRect(0, 0, 600, 600);
|
||
|
||
// Cyber grid
|
||
ctx1.strokeStyle = "rgba(0, 229, 255, 0.35)";
|
||
ctx1.lineWidth = 1.5;
|
||
for (let y = 350; y <= 600; y += 25) {
|
||
ctx1.beginPath();
|
||
ctx1.moveTo(0, y);
|
||
ctx1.lineTo(600, y);
|
||
ctx1.stroke();
|
||
}
|
||
for (let x = -200; x <= 800; x += 50) {
|
||
ctx1.beginPath();
|
||
ctx1.moveTo(300, 320);
|
||
ctx1.lineTo(x, 600);
|
||
ctx1.stroke();
|
||
}
|
||
// Neon Sun
|
||
const sunGrad = ctx1.createRadialGradient(300, 300, 20, 300, 300, 140);
|
||
sunGrad.addColorStop(0, "#ff007f");
|
||
sunGrad.addColorStop(0.7, "#ffaa00");
|
||
sunGrad.addColorStop(1, "rgba(255, 170, 0, 0)");
|
||
ctx1.fillStyle = sunGrad;
|
||
ctx1.beginPath();
|
||
ctx1.arc(300, 300, 140, 0, Math.PI * 2);
|
||
ctx1.fill();
|
||
|
||
// Skyline buildings
|
||
ctx1.fillStyle = "#0c0824";
|
||
const bldg = [
|
||
[40, 280, 50, 200], [100, 220, 60, 260], [170, 260, 50, 220],
|
||
[230, 180, 70, 300], [310, 200, 65, 280], [390, 250, 55, 230],
|
||
[460, 210, 60, 270], [530, 290, 45, 190]
|
||
];
|
||
bldg.forEach(([x, y, w, h]) => {
|
||
ctx1.fillRect(x, y, w, h);
|
||
ctx1.fillStyle = "rgba(0, 229, 255, 0.7)";
|
||
for (let wy = y + 15; wy < 400; wy += 20) {
|
||
for (let wx = x + 8; wx < x + w - 8; wx += 14) {
|
||
if (Math.sin(wx * wy) > 0) ctx1.fillRect(wx, wy, 6, 8);
|
||
}
|
||
}
|
||
ctx1.fillStyle = "#0c0824";
|
||
});
|
||
presets.push({ id: "cyberpunk", name: "Neon City", canvas: canvas1 });
|
||
|
||
// Preset 2: Cosmic Nebula Galaxy
|
||
const canvas2 = document.createElement("canvas");
|
||
canvas2.width = 600;
|
||
canvas2.height = 600;
|
||
const ctx2 = canvas2.getContext("2d");
|
||
ctx2.fillStyle = "#020208";
|
||
ctx2.fillRect(0, 0, 600, 600);
|
||
|
||
// Stars
|
||
for (let i = 0; i < 200; i++) {
|
||
const x = Math.random() * 600;
|
||
const y = Math.random() * 600;
|
||
const r = Math.random() * 1.8;
|
||
ctx2.fillStyle = Math.random() > 0.3 ? "#ffffff" : "#00e5ff";
|
||
ctx2.beginPath();
|
||
ctx2.arc(x, y, r, 0, Math.PI * 2);
|
||
ctx2.fill();
|
||
}
|
||
|
||
// Nebula clouds
|
||
const nebula1 = ctx2.createRadialGradient(240, 260, 10, 240, 260, 220);
|
||
nebula1.addColorStop(0, "rgba(157, 78, 221, 0.75)");
|
||
nebula1.addColorStop(0.5, "rgba(58, 134, 255, 0.4)");
|
||
nebula1.addColorStop(1, "rgba(0,0,0,0)");
|
||
ctx2.fillStyle = nebula1;
|
||
ctx2.fillRect(0, 0, 600, 600);
|
||
|
||
const nebula2 = ctx2.createRadialGradient(380, 360, 20, 380, 360, 180);
|
||
nebula2.addColorStop(0, "rgba(0, 229, 255, 0.65)");
|
||
nebula2.addColorStop(0.6, "rgba(255, 0, 128, 0.3)");
|
||
nebula2.addColorStop(1, "rgba(0,0,0,0)");
|
||
ctx2.fillStyle = nebula2;
|
||
ctx2.fillRect(0, 0, 600, 600);
|
||
|
||
// Glowing core
|
||
const core = ctx2.createRadialGradient(300, 300, 0, 300, 300, 60);
|
||
core.addColorStop(0, "rgba(255, 255, 255, 0.95)");
|
||
core.addColorStop(0.3, "rgba(0, 229, 255, 0.6)");
|
||
core.addColorStop(1, "rgba(0, 229, 255, 0)");
|
||
ctx2.fillStyle = core;
|
||
ctx2.beginPath();
|
||
ctx2.arc(300, 300, 60, 0, Math.PI * 2);
|
||
ctx2.fill();
|
||
presets.push({ id: "nebula", name: "Deep Galaxy", canvas: canvas2 });
|
||
|
||
// Preset 3: Geometric Synth Crystals
|
||
const canvas3 = document.createElement("canvas");
|
||
canvas3.width = 600;
|
||
canvas3.height = 600;
|
||
const ctx3 = canvas3.getContext("2d");
|
||
const bg3 = ctx3.createLinearGradient(0, 0, 600, 600);
|
||
bg3.addColorStop(0, "#0f172a");
|
||
bg3.addColorStop(1, "#020617");
|
||
ctx3.fillStyle = bg3;
|
||
ctx3.fillRect(0, 0, 600, 600);
|
||
|
||
const colors = ["#00e5ff", "#3a86ff", "#7928ca", "#ff007f", "#10b981", "#f59e0b"];
|
||
for (let r = 240; r >= 30; r -= 35) {
|
||
ctx3.strokeStyle = colors[(r / 35) % colors.length];
|
||
ctx3.lineWidth = 4;
|
||
ctx3.beginPath();
|
||
for (let a = 0; a < Math.PI * 2; a += Math.PI / 3) {
|
||
const px = 300 + Math.cos(a + r * 0.02) * r;
|
||
const py = 300 + Math.sin(a + r * 0.02) * r;
|
||
if (a === 0) ctx3.moveTo(px, py);
|
||
else ctx3.lineTo(px, py);
|
||
}
|
||
ctx3.closePath();
|
||
ctx3.stroke();
|
||
}
|
||
presets.push({ id: "synth", name: "Hex Prism", canvas: canvas3 });
|
||
|
||
// Preset 4: Emerald Forest Nature
|
||
const canvas4 = document.createElement("canvas");
|
||
canvas4.width = 600;
|
||
canvas4.height = 600;
|
||
const ctx4 = canvas4.getContext("2d");
|
||
const bg4 = ctx4.createLinearGradient(0, 0, 0, 600);
|
||
bg4.addColorStop(0, "#06281e");
|
||
bg4.addColorStop(0.6, "#0a4433");
|
||
bg4.addColorStop(1, "#03140f");
|
||
ctx4.fillStyle = bg4;
|
||
ctx4.fillRect(0, 0, 600, 600);
|
||
|
||
// Emerald mountain layers & golden moon
|
||
const moon = ctx4.createRadialGradient(420, 150, 10, 420, 150, 70);
|
||
moon.addColorStop(0, "#fff5d0");
|
||
moon.addColorStop(0.5, "#ffd166");
|
||
moon.addColorStop(1, "rgba(255, 209, 102, 0)");
|
||
ctx4.fillStyle = moon;
|
||
ctx4.beginPath();
|
||
ctx4.arc(420, 150, 70, 0, Math.PI * 2);
|
||
ctx4.fill();
|
||
|
||
const drawMountain = (color, pts) => {
|
||
ctx4.fillStyle = color;
|
||
ctx4.beginPath();
|
||
ctx4.moveTo(pts[0][0], pts[0][1]);
|
||
for (let i = 1; i < pts.length; i++) ctx4.lineTo(pts[i][0], pts[i][1]);
|
||
ctx4.lineTo(600, 600);
|
||
ctx4.lineTo(0, 600);
|
||
ctx4.closePath();
|
||
ctx4.fill();
|
||
};
|
||
|
||
drawMountain("rgba(16, 185, 129, 0.35)", [[0, 360], [150, 240], [300, 340], [450, 200], [600, 330]]);
|
||
drawMountain("rgba(5, 150, 105, 0.65)", [[0, 420], [180, 310], [340, 400], [500, 280], [600, 390]]);
|
||
drawMountain("#032e22", [[0, 480], [120, 380], [260, 470], [420, 360], [600, 450]]);
|
||
|
||
presets.push({ id: "emerald", name: "Aurora Peak", canvas: canvas4 });
|
||
|
||
return presets;
|
||
}
|
||
|
||
// ============================================================================
|
||
// 4. MAIN GAME STATE & APP
|
||
// ============================================================================
|
||
class PixelSlideGame {
|
||
constructor() {
|
||
this.presets = createPresetArtworks();
|
||
this.selectedImageCanvas = this.presets[0].canvas; // default image
|
||
this.currentLevel = LEVELS.medium;
|
||
this.board = []; // 1D array of tile indices (null for blank)
|
||
this.sprites = []; // array of { id, sprite, textContainer, targetX, targetY, currX, currY }
|
||
this.blankTileSprite = null; // Sprite for the solved state blank tile
|
||
|
||
// Game stats
|
||
this.moves = 0;
|
||
this.startTime = null;
|
||
this.elapsedTimeMs = 0;
|
||
this.timerInterval = null;
|
||
this.isGameStarted = false;
|
||
this.isGameActive = false; // true when tiles are interactive & timer can run
|
||
this.isPaused = false;
|
||
this.isVictory = false;
|
||
this.isAnimatingMove = false;
|
||
|
||
// View toggles
|
||
this.showNumbers = false;
|
||
this.showGhostHint = false;
|
||
|
||
// Pixi App
|
||
this.pixiApp = null;
|
||
this.boardContainer = null;
|
||
this.tilesContainer = null;
|
||
this.numbersContainer = null;
|
||
|
||
// DOM cache
|
||
this.dom = {
|
||
// Screens
|
||
screenHome: document.getElementById("screen-home"),
|
||
screenLevelSelect: document.getElementById("screen-level-select"),
|
||
screenGame: document.getElementById("screen-game"),
|
||
screenLeaderboard: document.getElementById("screen-leaderboard"),
|
||
|
||
// Overlays
|
||
overlayPause: document.getElementById("overlay-pause"),
|
||
overlayVictory: document.getElementById("overlay-victory"),
|
||
shuffleOverlay: document.getElementById("shuffle-overlay"),
|
||
ghostOverlay: document.getElementById("ghost-overlay"),
|
||
|
||
// Home elements
|
||
fileInput: document.getElementById("file-input"),
|
||
uploadDropzone: document.getElementById("upload-dropzone"),
|
||
presetGallery: document.getElementById("preset-gallery"),
|
||
|
||
// Level select elements
|
||
previewCanvas: document.getElementById("preview-canvas"),
|
||
previewGridOverlay: document.getElementById("preview-grid-overlay"),
|
||
levelCardsGrid: document.getElementById("level-cards-grid"),
|
||
btnChangeImage: document.getElementById("btn-change-image"),
|
||
btnBackToHome: document.getElementById("btn-back-to-home"),
|
||
|
||
// Game HUD elements
|
||
hudTimer: document.getElementById("hud-timer"),
|
||
hudMoves: document.getElementById("hud-moves"),
|
||
hudLevelBadge: document.getElementById("hud-level-badge"),
|
||
pixiMount: document.getElementById("pixi-canvas-mount"),
|
||
btnGamePause: document.getElementById("btn-game-pause"),
|
||
btnToggleNumbers: document.getElementById("btn-toggle-numbers"),
|
||
btnToggleHint: document.getElementById("btn-toggle-hint"),
|
||
btnGameRestartQuick: document.getElementById("btn-game-restart-quick"),
|
||
|
||
// Pause Modal elements
|
||
pauseModalTime: document.getElementById("pause-modal-time"),
|
||
pauseModalMoves: document.getElementById("pause-modal-moves"),
|
||
btnPauseResume: document.getElementById("btn-pause-resume"),
|
||
btnPauseRestart: document.getElementById("btn-pause-restart"),
|
||
btnPauseChangeLevel: document.getElementById("btn-pause-change-level"),
|
||
btnPauseQuit: document.getElementById("btn-pause-quit"),
|
||
|
||
// Victory Modal elements
|
||
victoryTime: document.getElementById("victory-time"),
|
||
victoryMoves: document.getElementById("victory-moves"),
|
||
victoryDifficulty: document.getElementById("victory-difficulty"),
|
||
playerNameInput: document.getElementById("player-name-input"),
|
||
victoryForm: document.getElementById("victory-form"),
|
||
btnSaveScore: document.getElementById("btn-save-score"),
|
||
saveFeedback: document.getElementById("save-feedback"),
|
||
btnVictoryReplay: document.getElementById("btn-victory-replay"),
|
||
btnVictoryLevels: document.getElementById("btn-victory-levels"),
|
||
btnVictoryLeaderboard: document.getElementById("btn-victory-leaderboard"),
|
||
confettiCanvas: document.getElementById("confetti-canvas"),
|
||
|
||
// Leaderboard elements
|
||
leaderboardTabs: document.getElementById("leaderboard-tabs"),
|
||
leaderboardTbody: document.getElementById("leaderboard-tbody"),
|
||
leaderboardEmpty: document.getElementById("leaderboard-empty"),
|
||
btnClearLeaderboard: document.getElementById("btn-clear-leaderboard"),
|
||
btnBackFromLeaderboard: document.getElementById("btn-back-from-leaderboard"),
|
||
|
||
// Header controls
|
||
btnNavHome: document.getElementById("btn-nav-home"),
|
||
btnSoundToggle: document.getElementById("btn-sound-toggle"),
|
||
soundIcon: document.getElementById("sound-icon"),
|
||
btnHeaderLeaderboard: document.getElementById("btn-header-leaderboard")
|
||
};
|
||
}
|
||
|
||
// ==========================================================================
|
||
// INITIALIZATION
|
||
// ==========================================================================
|
||
async init() {
|
||
this.setupSoundUI();
|
||
this.renderPresetGallery();
|
||
this.renderLevelCards();
|
||
this.bindEvents();
|
||
this.updatePreviewCanvas();
|
||
this.renderLeaderboardTable("all");
|
||
|
||
// Auto-focus home screen
|
||
this.showScreen("home");
|
||
}
|
||
|
||
setupSoundUI() {
|
||
this.dom.soundIcon.textContent = sounds.muted ? "🔇" : "🔊";
|
||
}
|
||
|
||
// ==========================================================================
|
||
// EVENT BINDINGS
|
||
// ==========================================================================
|
||
bindEvents() {
|
||
// Nav Home
|
||
this.dom.btnNavHome.addEventListener("click", () => {
|
||
sounds.playClick();
|
||
this.pauseTimer();
|
||
this.showScreen("home");
|
||
});
|
||
|
||
// Sound toggle
|
||
this.dom.btnSoundToggle.addEventListener("click", () => {
|
||
const isMuted = sounds.toggleMute();
|
||
this.dom.soundIcon.textContent = isMuted ? "🔇" : "🔊";
|
||
if (!isMuted) sounds.playClick();
|
||
});
|
||
|
||
// Header Leaderboard
|
||
this.dom.btnHeaderLeaderboard.addEventListener("click", () => {
|
||
sounds.playClick();
|
||
this.pauseTimer();
|
||
this.renderLeaderboardTable("all");
|
||
this.showScreen("leaderboard");
|
||
});
|
||
|
||
// File Upload & Dropzone
|
||
this.dom.uploadDropzone.addEventListener("click", () => {
|
||
this.dom.fileInput.click();
|
||
});
|
||
|
||
this.dom.fileInput.addEventListener("change", (e) => {
|
||
const file = e.target.files[0];
|
||
if (file) this.handleImageUpload(file);
|
||
});
|
||
|
||
["dragenter", "dragover"].forEach((eventName) => {
|
||
this.dom.uploadDropzone.addEventListener(eventName, (e) => {
|
||
e.preventDefault();
|
||
this.dom.uploadDropzone.classList.add("drag-over");
|
||
});
|
||
});
|
||
|
||
["dragleave", "drop"].forEach((eventName) => {
|
||
this.dom.uploadDropzone.addEventListener(eventName, (e) => {
|
||
e.preventDefault();
|
||
this.dom.uploadDropzone.classList.remove("drag-over");
|
||
});
|
||
});
|
||
|
||
this.dom.uploadDropzone.addEventListener("drop", (e) => {
|
||
const files = e.dataTransfer.files;
|
||
if (files && files.length > 0) {
|
||
this.handleImageUpload(files[0]);
|
||
}
|
||
});
|
||
|
||
// Level Select Back & Change Image
|
||
this.dom.btnBackToHome.addEventListener("click", () => {
|
||
sounds.playClick();
|
||
this.showScreen("home");
|
||
});
|
||
|
||
this.dom.btnChangeImage.addEventListener("click", () => {
|
||
sounds.playClick();
|
||
this.showScreen("home");
|
||
});
|
||
|
||
// Game HUD Controls
|
||
this.dom.btnGamePause.addEventListener("click", () => {
|
||
this.openPauseModal();
|
||
});
|
||
|
||
this.dom.btnToggleNumbers.addEventListener("click", () => {
|
||
sounds.playClick();
|
||
this.toggleNumbers();
|
||
});
|
||
|
||
this.dom.btnToggleHint.addEventListener("click", () => {
|
||
sounds.playClick();
|
||
this.toggleHint();
|
||
});
|
||
|
||
this.dom.btnGameRestartQuick.addEventListener("click", () => {
|
||
sounds.playClick();
|
||
this.restartCurrentGame();
|
||
});
|
||
|
||
// Pause Modal actions
|
||
this.dom.btnPauseResume.addEventListener("click", () => {
|
||
this.closePauseModal();
|
||
});
|
||
|
||
this.dom.btnPauseRestart.addEventListener("click", () => {
|
||
this.closePauseModal();
|
||
this.restartCurrentGame();
|
||
});
|
||
|
||
this.dom.btnPauseChangeLevel.addEventListener("click", () => {
|
||
this.closePauseModal();
|
||
this.showScreen("level-select");
|
||
});
|
||
|
||
this.dom.btnPauseQuit.addEventListener("click", () => {
|
||
this.closePauseModal();
|
||
this.showScreen("home");
|
||
});
|
||
|
||
// Victory Form & Actions
|
||
this.dom.victoryForm.addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
this.saveVictoryScore();
|
||
});
|
||
|
||
this.dom.btnVictoryReplay.addEventListener("click", () => {
|
||
this.closeVictoryModal();
|
||
this.restartCurrentGame();
|
||
});
|
||
|
||
this.dom.btnVictoryLevels.addEventListener("click", () => {
|
||
this.closeVictoryModal();
|
||
this.showScreen("level-select");
|
||
});
|
||
|
||
this.dom.btnVictoryLeaderboard.addEventListener("click", () => {
|
||
this.closeVictoryModal();
|
||
this.renderLeaderboardTable(this.currentLevel.key);
|
||
this.showScreen("leaderboard");
|
||
});
|
||
|
||
// Leaderboard actions
|
||
this.dom.btnBackFromLeaderboard.addEventListener("click", () => {
|
||
sounds.playClick();
|
||
this.showScreen("home");
|
||
});
|
||
|
||
this.dom.leaderboardTabs.addEventListener("click", (e) => {
|
||
const btn = e.target.closest(".tab-btn");
|
||
if (!btn) return;
|
||
sounds.playClick();
|
||
this.dom.leaderboardTabs.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
const level = btn.getAttribute("data-level");
|
||
this.renderLeaderboardTable(level);
|
||
});
|
||
|
||
this.dom.btnClearLeaderboard.addEventListener("click", () => {
|
||
if (confirm("Are you sure you want to clear all high scores?")) {
|
||
sounds.playClick();
|
||
localStorage.removeItem(LEADERBOARD_KEY);
|
||
const activeTab = this.dom.leaderboardTabs.querySelector(".tab-btn.active");
|
||
const level = activeTab ? activeTab.getAttribute("data-level") : "all";
|
||
this.renderLeaderboardTable(level);
|
||
}
|
||
});
|
||
|
||
// Keyboard controls (Arrow keys / WASD, Esc, M, N, H, R)
|
||
window.addEventListener("keydown", (e) => {
|
||
if (this.dom.screenGame.classList.contains("active") && !this.isPaused && !this.isVictory) {
|
||
if (e.key === "ArrowUp" || e.key === "w" || e.key === "W") {
|
||
e.preventDefault();
|
||
this.moveBlankDirection("down"); // Move tile down into blank = slide tile below blank upwards
|
||
} else if (e.key === "ArrowDown" || e.key === "s" || e.key === "S") {
|
||
e.preventDefault();
|
||
this.moveBlankDirection("up");
|
||
} else if (e.key === "ArrowLeft" || e.key === "a" || e.key === "A") {
|
||
e.preventDefault();
|
||
this.moveBlankDirection("right");
|
||
} else if (e.key === "ArrowRight" || e.key === "d" || e.key === "D") {
|
||
e.preventDefault();
|
||
this.moveBlankDirection("left");
|
||
} else if (e.key === "Escape") {
|
||
this.openPauseModal();
|
||
} else if (e.key === "h" || e.key === "H") {
|
||
this.toggleHint();
|
||
} else if (e.key === "n" || e.key === "N") {
|
||
this.toggleNumbers();
|
||
} else if (e.key === "r" || e.key === "R") {
|
||
this.restartCurrentGame();
|
||
}
|
||
} else if (this.isPaused && e.key === "Escape") {
|
||
this.closePauseModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ==========================================================================
|
||
// IMAGE PROCESSING
|
||
// ==========================================================================
|
||
handleImageUpload(file) {
|
||
if (!file.type.startsWith("image/")) {
|
||
alert("Please upload a valid image file.");
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
this.selectedImageCanvas = this.cropImageToSquare(img);
|
||
this.updatePreviewCanvas();
|
||
sounds.playClick();
|
||
this.showScreen("level-select");
|
||
};
|
||
img.src = e.target.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
cropImageToSquare(img) {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = CANVAS_BOARD_SIZE;
|
||
canvas.height = CANVAS_BOARD_SIZE;
|
||
const ctx = canvas.getContext("2d");
|
||
|
||
const minDim = Math.min(img.width, img.height);
|
||
const sx = (img.width - minDim) / 2;
|
||
const sy = (img.height - minDim) / 2;
|
||
|
||
ctx.drawImage(img, sx, sy, minDim, minDim, 0, 0, CANVAS_BOARD_SIZE, CANVAS_BOARD_SIZE);
|
||
return canvas;
|
||
}
|
||
|
||
selectPreset(preset) {
|
||
this.selectedImageCanvas = preset.canvas;
|
||
this.updatePreviewCanvas();
|
||
sounds.playClick();
|
||
this.showScreen("level-select");
|
||
}
|
||
|
||
renderPresetGallery() {
|
||
this.dom.presetGallery.innerHTML = "";
|
||
this.presets.forEach((preset) => {
|
||
const card = document.createElement("div");
|
||
card.className = "preset-thumb-card";
|
||
card.title = `Choose ${preset.name}`;
|
||
|
||
const img = document.createElement("img");
|
||
img.src = preset.canvas.toDataURL();
|
||
img.alt = preset.name;
|
||
|
||
const badge = document.createElement("div");
|
||
badge.className = "preset-badge";
|
||
badge.textContent = preset.name;
|
||
|
||
card.appendChild(img);
|
||
card.appendChild(badge);
|
||
card.addEventListener("click", () => this.selectPreset(preset));
|
||
|
||
this.dom.presetGallery.appendChild(card);
|
||
});
|
||
}
|
||
|
||
updatePreviewCanvas() {
|
||
const pCanvas = this.dom.previewCanvas;
|
||
pCanvas.width = CANVAS_BOARD_SIZE;
|
||
pCanvas.height = CANVAS_BOARD_SIZE;
|
||
const ctx = pCanvas.getContext("2d");
|
||
ctx.drawImage(this.selectedImageCanvas, 0, 0);
|
||
|
||
// Also update ghost overlay
|
||
this.dom.ghostOverlay.style.backgroundImage = `url(${this.selectedImageCanvas.toDataURL()})`;
|
||
}
|
||
|
||
renderLevelCards() {
|
||
this.dom.levelCardsGrid.innerHTML = "";
|
||
Object.values(LEVELS).forEach((level) => {
|
||
const card = document.createElement("div");
|
||
card.className = "level-card";
|
||
card.style.setProperty("--card-color", level.color);
|
||
card.style.setProperty("--card-glow", level.glow);
|
||
|
||
card.innerHTML = `
|
||
<div class="level-card-top">
|
||
<div class="level-title-block">
|
||
<h3>${level.label}</h3>
|
||
<span class="level-grid-dim">${level.gridText} Grid</span>
|
||
</div>
|
||
<span class="level-badge-tag">${level.badge}</span>
|
||
</div>
|
||
<p class="level-card-desc">${level.desc}</p>
|
||
<div class="level-card-stats">
|
||
<span>${level.tilesText}</span>
|
||
<span>${level.key === "debug" ? "≤ 5 Moves" : `~${level.shuffleMoves} Shuffles`}</span>
|
||
</div>
|
||
<button class="level-btn-action">
|
||
<span>Play ${level.label}</span>
|
||
<span>→</span>
|
||
</button>
|
||
`;
|
||
|
||
card.addEventListener("click", () => {
|
||
sounds.playClick();
|
||
this.startLevel(level);
|
||
});
|
||
|
||
this.dom.levelCardsGrid.appendChild(card);
|
||
});
|
||
}
|
||
|
||
// ==========================================================================
|
||
// SCREEN NAVIGATION
|
||
// ==========================================================================
|
||
showScreen(screenId) {
|
||
const screens = [
|
||
this.dom.screenHome,
|
||
this.dom.screenLevelSelect,
|
||
this.dom.screenGame,
|
||
this.dom.screenLeaderboard
|
||
];
|
||
|
||
screens.forEach((s) => s.classList.remove("active"));
|
||
|
||
if (screenId === "home") this.dom.screenHome.classList.add("active");
|
||
else if (screenId === "level-select") this.dom.screenLevelSelect.classList.add("active");
|
||
else if (screenId === "game") this.dom.screenGame.classList.add("active");
|
||
else if (screenId === "leaderboard") this.dom.screenLeaderboard.classList.add("active");
|
||
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
}
|
||
|
||
// ==========================================================================
|
||
// PIXIJS APP & BOARD RENDERING
|
||
// ==========================================================================
|
||
async startLevel(level) {
|
||
this.currentLevel = level;
|
||
this.dom.hudLevelBadge.textContent = `${level.label} ${level.gridText}`;
|
||
this.showScreen("game");
|
||
|
||
// Reset stats
|
||
this.resetStats();
|
||
|
||
// Initialize Pixi application if not yet created
|
||
await this.initPixiApp();
|
||
|
||
// Build the sliced puzzle board
|
||
await this.buildPuzzleBoard();
|
||
|
||
// Shuffle tiles
|
||
await this.shuffleBoard();
|
||
}
|
||
|
||
async initPixiApp() {
|
||
if (this.pixiApp) {
|
||
return;
|
||
}
|
||
|
||
this.pixiApp = new PIXI.Application();
|
||
await this.pixiApp.init({
|
||
width: CANVAS_BOARD_SIZE,
|
||
height: CANVAS_BOARD_SIZE,
|
||
resolution: window.devicePixelRatio || 1,
|
||
autoDensity: true,
|
||
backgroundColor: 0x090e1a,
|
||
antialias: true
|
||
});
|
||
|
||
this.dom.pixiMount.innerHTML = "";
|
||
this.dom.pixiMount.appendChild(this.pixiApp.canvas);
|
||
|
||
// Root containers
|
||
this.boardContainer = new PIXI.Container();
|
||
this.tilesContainer = new PIXI.Container();
|
||
this.numbersContainer = new PIXI.Container();
|
||
|
||
this.boardContainer.addChild(this.tilesContainer);
|
||
this.boardContainer.addChild(this.numbersContainer);
|
||
this.pixiApp.stage.addChild(this.boardContainer);
|
||
|
||
// Animation ticker for smooth tile sliding
|
||
this.pixiApp.ticker.add((time) => {
|
||
this.updateTilePositions(time.deltaTime);
|
||
});
|
||
}
|
||
|
||
async buildPuzzleBoard() {
|
||
// Clear existing tiles
|
||
this.tilesContainer.removeChildren();
|
||
this.numbersContainer.removeChildren();
|
||
this.sprites = [];
|
||
this.board = [];
|
||
|
||
const grid = this.currentLevel.grid;
|
||
const totalTiles = grid * grid;
|
||
const tileSize = (CANVAS_BOARD_SIZE - (grid + 1) * TILE_GAP) / grid;
|
||
|
||
// Create Base Texture from current selected square image
|
||
const baseTexture = PIXI.Texture.from(this.selectedImageCanvas);
|
||
|
||
for (let i = 0; i < totalTiles; i++) {
|
||
const row = Math.floor(i / grid);
|
||
const col = i % grid;
|
||
|
||
const frameX = (col * CANVAS_BOARD_SIZE) / grid;
|
||
const frameY = (row * CANVAS_BOARD_SIZE) / grid;
|
||
const frameW = CANVAS_BOARD_SIZE / grid;
|
||
const frameH = CANVAS_BOARD_SIZE / grid;
|
||
|
||
const tileTexture = new PIXI.Texture({
|
||
source: baseTexture.source,
|
||
frame: new PIXI.Rectangle(frameX, frameY, frameW, frameH)
|
||
});
|
||
|
||
const tileSprite = new PIXI.Sprite(tileTexture);
|
||
tileSprite.width = tileSize;
|
||
tileSprite.height = tileSize;
|
||
|
||
// Position in grid
|
||
const posX = TILE_GAP + col * (tileSize + TILE_GAP);
|
||
const posY = TILE_GAP + row * (tileSize + TILE_GAP);
|
||
tileSprite.position.set(posX, posY);
|
||
|
||
// Create Number Label
|
||
const numContainer = new PIXI.Container();
|
||
numContainer.position.set(posX, posY);
|
||
|
||
const numBg = new PIXI.Graphics();
|
||
numBg.roundRect(6, 6, 28, 24, 6);
|
||
numBg.fill({ color: 0x070a12, alpha: 0.75 });
|
||
numBg.stroke({ color: 0x00e5ff, width: 1 });
|
||
|
||
const numText = new PIXI.Text({
|
||
text: `${i + 1}`,
|
||
style: {
|
||
fontFamily: "Outfit, Inter, sans-serif",
|
||
fontSize: 13,
|
||
fontWeight: "bold",
|
||
fill: 0xffffff,
|
||
align: "center"
|
||
}
|
||
});
|
||
numText.anchor.set(0.5);
|
||
numText.position.set(20, 18);
|
||
|
||
numContainer.addChild(numBg);
|
||
numContainer.addChild(numText);
|
||
numContainer.visible = this.showNumbers;
|
||
|
||
// Check if this is the blank tile (last tile)
|
||
if (i === totalTiles - 1) {
|
||
tileSprite.visible = false;
|
||
numContainer.visible = false;
|
||
this.blankTileSprite = tileSprite;
|
||
this.board.push(null); // Blank slot in board
|
||
} else {
|
||
tileSprite.eventMode = "static";
|
||
tileSprite.cursor = "pointer";
|
||
tileSprite.on("pointerdown", () => this.onTileClicked(i));
|
||
|
||
this.tilesContainer.addChild(tileSprite);
|
||
this.numbersContainer.addChild(numContainer);
|
||
this.board.push(i);
|
||
}
|
||
|
||
this.sprites[i] = {
|
||
id: i,
|
||
sprite: tileSprite,
|
||
numContainer: numContainer,
|
||
currX: posX,
|
||
currY: posY,
|
||
targetX: posX,
|
||
targetY: posY
|
||
};
|
||
}
|
||
}
|
||
|
||
// Smooth lerp tile sliding in Pixi ticker
|
||
updateTilePositions(deltaTime) {
|
||
const lerpSpeed = 0.35;
|
||
for (let i = 0; i < this.sprites.length; i++) {
|
||
const item = this.sprites[i];
|
||
if (!item || item.id === this.sprites.length - 1) continue;
|
||
|
||
const dx = item.targetX - item.currX;
|
||
const dy = item.targetY - item.currY;
|
||
|
||
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
||
item.currX += dx * lerpSpeed;
|
||
item.currY += dy * lerpSpeed;
|
||
item.sprite.position.set(item.currX, item.currY);
|
||
item.numContainer.position.set(item.currX, item.currY);
|
||
} else {
|
||
item.currX = item.targetX;
|
||
item.currY = item.targetY;
|
||
item.sprite.position.set(item.currX, item.currY);
|
||
item.numContainer.position.set(item.currX, item.currY);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// PUZZLE ENGINE & SHUFFLING
|
||
// ==========================================================================
|
||
async shuffleBoard() {
|
||
this.isGameActive = false;
|
||
this.dom.shuffleOverlay.classList.add("active");
|
||
|
||
const grid = this.currentLevel.grid;
|
||
const shuffleCount = this.currentLevel.shuffleMoves;
|
||
let blankPos = grid * grid - 1;
|
||
let prevBlankPos = -1;
|
||
|
||
// Start with solved board
|
||
this.board = [];
|
||
for (let i = 0; i < grid * grid - 1; i++) this.board.push(i);
|
||
this.board.push(null);
|
||
|
||
// Perform N legal valid random moves
|
||
for (let step = 0; step < shuffleCount; step++) {
|
||
const neighbors = this.getValidMoveNeighbors(blankPos, grid);
|
||
// Filter out immediately returning to previous blank position
|
||
const validNeighbors = neighbors.filter((pos) => pos !== prevBlankPos);
|
||
const chosenNeighbor = validNeighbors.length > 0
|
||
? validNeighbors[Math.floor(Math.random() * validNeighbors.length)]
|
||
: neighbors[Math.floor(Math.random() * neighbors.length)];
|
||
|
||
// Swap in board array
|
||
this.board[blankPos] = this.board[chosenNeighbor];
|
||
this.board[chosenNeighbor] = null;
|
||
|
||
prevBlankPos = blankPos;
|
||
blankPos = chosenNeighbor;
|
||
}
|
||
|
||
// Set sprite positions to match shuffled board
|
||
const tileSize = (CANVAS_BOARD_SIZE - (grid + 1) * TILE_GAP) / grid;
|
||
for (let pos = 0; pos < this.board.length; pos++) {
|
||
const tileId = this.board[pos];
|
||
if (tileId !== null) {
|
||
const row = Math.floor(pos / grid);
|
||
const col = pos % grid;
|
||
const targetX = TILE_GAP + col * (tileSize + TILE_GAP);
|
||
const targetY = TILE_GAP + row * (tileSize + TILE_GAP);
|
||
|
||
const item = this.sprites[tileId];
|
||
item.currX = targetX;
|
||
item.currY = targetY;
|
||
item.targetX = targetX;
|
||
item.targetY = targetY;
|
||
item.sprite.position.set(targetX, targetY);
|
||
item.numContainer.position.set(targetX, targetY);
|
||
}
|
||
}
|
||
|
||
// If shuffled state accidentally equals solved state, make one more legal swap
|
||
if (this.isSolved()) {
|
||
const neighbors = this.getValidMoveNeighbors(blankPos, grid);
|
||
const neighbor = neighbors[0];
|
||
this.board[blankPos] = this.board[neighbor];
|
||
this.board[neighbor] = null;
|
||
this.syncSpritesWithBoard(false);
|
||
}
|
||
|
||
// Small delay to ensure smooth UX
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
this.dom.shuffleOverlay.classList.remove("active");
|
||
this.isGameActive = true;
|
||
}
|
||
|
||
getValidMoveNeighbors(blankPos, grid) {
|
||
const neighbors = [];
|
||
const row = Math.floor(blankPos / grid);
|
||
const col = blankPos % grid;
|
||
|
||
if (row > 0) neighbors.push(blankPos - grid); // Up
|
||
if (row < grid - 1) neighbors.push(blankPos + grid); // Down
|
||
if (col > 0) neighbors.push(blankPos - 1); // Left
|
||
if (col < grid - 1) neighbors.push(blankPos + 1); // Right
|
||
|
||
return neighbors;
|
||
}
|
||
|
||
// ==========================================================================
|
||
// MOVE EXECUTION & INTERACTION
|
||
// ==========================================================================
|
||
onTileClicked(tileId) {
|
||
if (!this.isGameActive || this.isPaused || this.isVictory) return;
|
||
|
||
const tilePos = this.board.indexOf(tileId);
|
||
if (tilePos === -1) return;
|
||
|
||
const blankPos = this.board.indexOf(null);
|
||
const grid = this.currentLevel.grid;
|
||
|
||
const tileRow = Math.floor(tilePos / grid);
|
||
const tileCol = tilePos % grid;
|
||
const blankRow = Math.floor(blankPos / grid);
|
||
const blankCol = blankPos % grid;
|
||
|
||
// Check if tile is adjacent to blank
|
||
const isAdjacent = Math.abs(tileRow - blankRow) + Math.abs(tileCol - blankCol) === 1;
|
||
|
||
if (isAdjacent) {
|
||
this.executeMove(tilePos, blankPos);
|
||
} else {
|
||
sounds.playInvalid();
|
||
}
|
||
}
|
||
|
||
moveBlankDirection(dir) {
|
||
if (!this.isGameActive || this.isPaused || this.isVictory) return;
|
||
|
||
const grid = this.currentLevel.grid;
|
||
const blankPos = this.board.indexOf(null);
|
||
const blankRow = Math.floor(blankPos / grid);
|
||
const blankCol = blankPos % grid;
|
||
|
||
let targetRow = blankRow;
|
||
let targetCol = blankCol;
|
||
|
||
if (dir === "up") targetRow += 1; // Tile below moves up
|
||
else if (dir === "down") targetRow -= 1; // Tile above moves down
|
||
else if (dir === "left") targetCol += 1; // Tile on right moves left
|
||
else if (dir === "right") targetCol -= 1; // Tile on left moves right
|
||
|
||
if (targetRow >= 0 && targetRow < grid && targetCol >= 0 && targetCol < grid) {
|
||
const tilePos = targetRow * grid + targetCol;
|
||
this.executeMove(tilePos, blankPos);
|
||
}
|
||
}
|
||
|
||
executeMove(tilePos, blankPos) {
|
||
// Start timer on first move
|
||
if (!this.isGameStarted) {
|
||
this.startTimer();
|
||
}
|
||
|
||
const tileId = this.board[tilePos];
|
||
const grid = this.currentLevel.grid;
|
||
const tileSize = (CANVAS_BOARD_SIZE - (grid + 1) * TILE_GAP) / grid;
|
||
|
||
// Swap in board array
|
||
this.board[blankPos] = tileId;
|
||
this.board[tilePos] = null;
|
||
|
||
// Update target coordinate for smooth lerp
|
||
const newRow = Math.floor(blankPos / grid);
|
||
const newCol = blankPos % grid;
|
||
const targetX = TILE_GAP + newCol * (tileSize + TILE_GAP);
|
||
const targetY = TILE_GAP + newRow * (tileSize + TILE_GAP);
|
||
|
||
const item = this.sprites[tileId];
|
||
item.targetX = targetX;
|
||
item.targetY = targetY;
|
||
|
||
// Stats
|
||
this.moves++;
|
||
this.dom.hudMoves.textContent = this.moves;
|
||
sounds.playSlide();
|
||
|
||
// Check Win
|
||
if (this.isSolved()) {
|
||
this.handleVictory();
|
||
}
|
||
}
|
||
|
||
syncSpritesWithBoard(animate = true) {
|
||
const grid = this.currentLevel.grid;
|
||
const tileSize = (CANVAS_BOARD_SIZE - (grid + 1) * TILE_GAP) / grid;
|
||
|
||
for (let pos = 0; pos < this.board.length; pos++) {
|
||
const tileId = this.board[pos];
|
||
if (tileId !== null) {
|
||
const row = Math.floor(pos / grid);
|
||
const col = pos % grid;
|
||
const targetX = TILE_GAP + col * (tileSize + TILE_GAP);
|
||
const targetY = TILE_GAP + row * (tileSize + TILE_GAP);
|
||
|
||
const item = this.sprites[tileId];
|
||
item.targetX = targetX;
|
||
item.targetY = targetY;
|
||
if (!animate) {
|
||
item.currX = targetX;
|
||
item.currY = targetY;
|
||
item.sprite.position.set(targetX, targetY);
|
||
item.numContainer.position.set(targetX, targetY);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
isSolved() {
|
||
const total = this.currentLevel.grid * this.currentLevel.grid;
|
||
for (let i = 0; i < total - 1; i++) {
|
||
if (this.board[i] !== i) return false;
|
||
}
|
||
return this.board[total - 1] === null;
|
||
}
|
||
|
||
// ==========================================================================
|
||
// TIMER & STATS
|
||
// ==========================================================================
|
||
startTimer() {
|
||
this.isGameStarted = true;
|
||
this.startTime = performance.now() - this.elapsedTimeMs;
|
||
this.timerInterval = setInterval(() => {
|
||
this.elapsedTimeMs = performance.now() - this.startTime;
|
||
this.dom.hudTimer.textContent = this.formatTime(this.elapsedTimeMs);
|
||
}, 25);
|
||
}
|
||
|
||
pauseTimer() {
|
||
if (this.timerInterval) {
|
||
clearInterval(this.timerInterval);
|
||
this.timerInterval = null;
|
||
}
|
||
}
|
||
|
||
resumeTimer() {
|
||
if (this.isGameStarted && !this.isVictory) {
|
||
this.startTimer();
|
||
}
|
||
}
|
||
|
||
resetStats() {
|
||
this.pauseTimer();
|
||
this.moves = 0;
|
||
this.elapsedTimeMs = 0;
|
||
this.startTime = null;
|
||
this.isGameStarted = false;
|
||
this.isVictory = false;
|
||
this.dom.hudTimer.textContent = "00:00.00";
|
||
this.dom.hudMoves.textContent = "0";
|
||
}
|
||
|
||
formatTime(ms) {
|
||
const totalSeconds = Math.floor(ms / 1000);
|
||
const minutes = Math.floor(totalSeconds / 60);
|
||
const seconds = totalSeconds % 60;
|
||
const centiseconds = Math.floor((ms % 1000) / 10);
|
||
|
||
const mStr = String(minutes).padStart(2, "0");
|
||
const sStr = String(seconds).padStart(2, "0");
|
||
const cStr = String(centiseconds).padStart(2, "0");
|
||
|
||
return `${mStr}:${sStr}.${cStr}`;
|
||
}
|
||
|
||
// ==========================================================================
|
||
// TOGGLES (Numbers & Hint)
|
||
// ==========================================================================
|
||
toggleNumbers() {
|
||
this.showNumbers = !this.showNumbers;
|
||
this.dom.btnToggleNumbers.classList.toggle("active", this.showNumbers);
|
||
this.sprites.forEach((item) => {
|
||
if (item && item.numContainer && item.id !== this.sprites.length - 1) {
|
||
item.numContainer.visible = this.showNumbers;
|
||
}
|
||
});
|
||
}
|
||
|
||
toggleHint() {
|
||
this.showGhostHint = !this.showGhostHint;
|
||
this.dom.btnToggleHint.classList.toggle("active", this.showGhostHint);
|
||
this.dom.ghostOverlay.classList.toggle("visible", this.showGhostHint);
|
||
}
|
||
|
||
// ==========================================================================
|
||
// MODALS & PAUSE / RESTART
|
||
// ==========================================================================
|
||
openPauseModal() {
|
||
if (this.isVictory) return;
|
||
sounds.playClick();
|
||
this.isPaused = true;
|
||
this.pauseTimer();
|
||
sounds.pauseBGM();
|
||
this.dom.pauseModalTime.textContent = this.formatTime(this.elapsedTimeMs);
|
||
this.dom.pauseModalMoves.textContent = this.moves;
|
||
this.dom.overlayPause.classList.add("active");
|
||
}
|
||
|
||
closePauseModal() {
|
||
sounds.playClick();
|
||
this.isPaused = false;
|
||
this.dom.overlayPause.classList.remove("active");
|
||
sounds.resumeBGM();
|
||
this.resumeTimer();
|
||
}
|
||
|
||
async restartCurrentGame() {
|
||
this.resetStats();
|
||
sounds.resumeBGM();
|
||
await this.buildPuzzleBoard();
|
||
await this.shuffleBoard();
|
||
}
|
||
|
||
// ==========================================================================
|
||
// VICTORY & CONFETTI
|
||
// ==========================================================================
|
||
async handleVictory() {
|
||
this.isVictory = true;
|
||
this.isGameActive = false;
|
||
this.pauseTimer();
|
||
|
||
// Reveal the missing blank tile
|
||
if (this.blankTileSprite) {
|
||
this.tilesContainer.addChild(this.blankTileSprite);
|
||
this.blankTileSprite.visible = true;
|
||
this.blankTileSprite.alpha = 0;
|
||
let alpha = 0;
|
||
const revealTicker = () => {
|
||
alpha += 0.08;
|
||
if (this.blankTileSprite) this.blankTileSprite.alpha = Math.min(1, alpha);
|
||
if (alpha < 1) requestAnimationFrame(revealTicker);
|
||
};
|
||
revealTicker();
|
||
}
|
||
|
||
// Play winner track
|
||
sounds.playVictory();
|
||
|
||
// Fill victory modal info
|
||
const finalFormattedTime = this.formatTime(this.elapsedTimeMs);
|
||
this.dom.victoryTime.textContent = finalFormattedTime;
|
||
this.dom.victoryMoves.textContent = this.moves;
|
||
this.dom.victoryDifficulty.textContent = this.currentLevel.label;
|
||
this.dom.saveFeedback.textContent = "";
|
||
this.dom.saveFeedback.className = "save-feedback";
|
||
|
||
// Reset save button and input state so user can save subsequent games
|
||
if (this.dom.btnSaveScore) {
|
||
this.dom.btnSaveScore.disabled = false;
|
||
this.dom.btnSaveScore.textContent = "Save Score";
|
||
}
|
||
if (this.dom.playerNameInput) {
|
||
this.dom.playerNameInput.disabled = false;
|
||
}
|
||
|
||
// Prefill name if stored
|
||
const savedName = localStorage.getItem("puzzle15_last_player_name") || "";
|
||
this.dom.playerNameInput.value = savedName;
|
||
|
||
// Launch Confetti animation
|
||
this.launchConfetti();
|
||
|
||
// Show modal after brief delay
|
||
setTimeout(() => {
|
||
this.dom.overlayVictory.classList.add("active");
|
||
this.dom.playerNameInput.focus();
|
||
}, 600);
|
||
}
|
||
|
||
closeVictoryModal() {
|
||
sounds.playClick();
|
||
this.dom.overlayVictory.classList.remove("active");
|
||
this.stopConfetti();
|
||
sounds.resumeBGM();
|
||
}
|
||
|
||
launchConfetti() {
|
||
const canvas = this.dom.confettiCanvas;
|
||
const ctx = canvas.getContext("2d");
|
||
canvas.width = window.innerWidth;
|
||
canvas.height = window.innerHeight;
|
||
|
||
const particles = [];
|
||
const colors = ["#00e5ff", "#3a86ff", "#7928ca", "#ff007f", "#ffd700", "#10b981"];
|
||
|
||
for (let i = 0; i < 140; i++) {
|
||
particles.push({
|
||
x: canvas.width * 0.5,
|
||
y: canvas.height * 0.5,
|
||
vx: (Math.random() - 0.5) * 18,
|
||
vy: (Math.random() - 0.5) * 18 - 4,
|
||
size: Math.random() * 8 + 4,
|
||
color: colors[Math.floor(Math.random() * colors.length)],
|
||
rotation: Math.random() * 360,
|
||
rotationSpeed: (Math.random() - 0.5) * 10,
|
||
gravity: 0.28,
|
||
opacity: 1
|
||
});
|
||
}
|
||
|
||
let animId = null;
|
||
const render = () => {
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
let aliveCount = 0;
|
||
|
||
particles.forEach((p) => {
|
||
p.x += p.vx;
|
||
p.y += p.vy;
|
||
p.vy += p.gravity;
|
||
p.rotation += p.rotationSpeed;
|
||
p.opacity -= 0.006;
|
||
|
||
if (p.opacity > 0) {
|
||
aliveCount++;
|
||
ctx.save();
|
||
ctx.translate(p.x, p.y);
|
||
ctx.rotate((p.rotation * Math.PI) / 180);
|
||
ctx.globalAlpha = Math.max(0, p.opacity);
|
||
ctx.fillStyle = p.color;
|
||
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
|
||
ctx.restore();
|
||
}
|
||
});
|
||
|
||
if (aliveCount > 0) {
|
||
animId = requestAnimationFrame(render);
|
||
}
|
||
};
|
||
|
||
render();
|
||
this.confettiAnimId = animId;
|
||
}
|
||
|
||
stopConfetti() {
|
||
if (this.confettiAnimId) {
|
||
cancelAnimationFrame(this.confettiAnimId);
|
||
this.confettiAnimId = null;
|
||
}
|
||
const canvas = this.dom.confettiCanvas;
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
}
|
||
|
||
// ==========================================================================
|
||
// LEADERBOARD STORAGE & RENDERING
|
||
// ==========================================================================
|
||
saveVictoryScore() {
|
||
const rawName = this.dom.playerNameInput.value.trim();
|
||
const name = rawName || "Anonymous Player";
|
||
localStorage.setItem("puzzle15_last_player_name", name);
|
||
|
||
const record = {
|
||
id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5),
|
||
name: name,
|
||
level: this.currentLevel.key,
|
||
levelLabel: this.currentLevel.label,
|
||
time: Math.round(this.elapsedTimeMs),
|
||
timeFormatted: this.formatTime(this.elapsedTimeMs),
|
||
moves: this.moves,
|
||
date: new Date().toISOString().split("T")[0]
|
||
};
|
||
|
||
const leaderboard = this.getLeaderboardData();
|
||
leaderboard.push(record);
|
||
|
||
// Sort by fastest time ascending, then moves ascending
|
||
leaderboard.sort((a, b) => a.time - b.time || a.moves - b.moves);
|
||
|
||
// Keep top 100 overall
|
||
const trimmed = leaderboard.slice(0, 100);
|
||
localStorage.setItem(LEADERBOARD_KEY, JSON.stringify(trimmed));
|
||
|
||
sounds.playClick();
|
||
this.dom.saveFeedback.className = "save-feedback success";
|
||
this.dom.saveFeedback.textContent = "✓ Record saved to Leaderboard!";
|
||
if (this.dom.btnSaveScore) {
|
||
this.dom.btnSaveScore.disabled = true;
|
||
this.dom.btnSaveScore.textContent = "Saved ✓";
|
||
}
|
||
}
|
||
|
||
getLeaderboardData() {
|
||
try {
|
||
const data = localStorage.getItem(LEADERBOARD_KEY);
|
||
return data ? JSON.parse(data) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
renderLeaderboardTable(filterLevel = "all") {
|
||
const data = this.getLeaderboardData();
|
||
const filtered = filterLevel === "all" ? data : data.filter((item) => item.level === filterLevel);
|
||
|
||
this.dom.leaderboardTbody.innerHTML = "";
|
||
|
||
if (filtered.length === 0) {
|
||
this.dom.leaderboardEmpty.classList.add("visible");
|
||
return;
|
||
}
|
||
|
||
this.dom.leaderboardEmpty.classList.remove("visible");
|
||
|
||
filtered.slice(0, 50).forEach((entry, idx) => {
|
||
const rank = idx + 1;
|
||
let rankClass = "rank-other";
|
||
if (rank === 1) rankClass = "rank-1";
|
||
else if (rank === 2) rankClass = "rank-2";
|
||
else if (rank === 3) rankClass = "rank-3";
|
||
|
||
const tr = document.createElement("tr");
|
||
tr.innerHTML = `
|
||
<td><span class="rank-badge ${rankClass}">${rank}</span></td>
|
||
<td><strong>${this.escapeHtml(entry.name)}</strong></td>
|
||
<td><span class="level-badge-tag" style="background: rgba(0,229,255,0.1); color: var(--accent-cyan);">${entry.levelLabel || entry.level}</span></td>
|
||
<td style="font-family: var(--font-mono); color: var(--accent-cyan); font-weight: 700;">${entry.timeFormatted || this.formatTime(entry.time)}</td>
|
||
<td style="font-family: var(--font-mono);">${entry.moves}</td>
|
||
<td style="color: var(--text-muted); font-size: 0.85rem;">${entry.date}</td>
|
||
`;
|
||
this.dom.leaderboardTbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
escapeHtml(str) {
|
||
const div = document.createElement("div");
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// BOOTSTRAP
|
||
// ============================================================================
|
||
window.addEventListener("DOMContentLoaded", () => {
|
||
const game = new PixelSlideGame();
|
||
game.init();
|
||
|
||
// Autoplay audio on first user gesture (mouse click, touch, or key)
|
||
const unlockAudioOnGesture = () => {
|
||
sounds.init();
|
||
window.removeEventListener("pointerdown", unlockAudioOnGesture);
|
||
window.removeEventListener("keydown", unlockAudioOnGesture);
|
||
};
|
||
window.addEventListener("pointerdown", unlockAudioOnGesture);
|
||
window.addEventListener("keydown", unlockAudioOnGesture);
|
||
});
|