/** * 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 = `

${level.label}

${level.gridText} Grid
${level.badge}

${level.desc}

${level.tilesText} ${level.key === "debug" ? "≤ 5 Moves" : `~${level.shuffleMoves} Shuffles`}
`; 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 = ` ${rank} ${this.escapeHtml(entry.name)} ${entry.levelLabel || entry.level} ${entry.timeFormatted || this.formatTime(entry.time)} ${entry.moves} ${entry.date} `; 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); });