commit 4170748438a16da8f5693c746eac939ba213c9b7 Author: shancheas Date: Thu Aug 27 17:36:00 2026 +0700 feat: implement Pixel Slide puzzle game with procedural art generation and Web Audio engine diff --git a/README.md b/README.md new file mode 100644 index 0000000..e95c3cd --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# 🧩 PixelSlide — 15-Puzzle Image Game with PixiJS + +> A premium sliding puzzle game built with **PixiJS v8**, Vanilla HTML5/CSS3/JavaScript, procedural canvas artwork, soundtrack integration, and a persistent local leaderboard. + +![PixiJS](https://img.shields.io/badge/PixiJS-v8.20.0-E72264?style=flat-square&logo=pixiv&logoColor=white) +![JavaScript](https://img.shields.io/badge/JavaScript-Vanilla%20ES6+-F7DF1E?style=flat-square&logo=javascript&logoColor=black) +![CSS3](https://img.shields.io/badge/CSS3-Glassmorphism%20Dark-1572B6?style=flat-square&logo=css3&logoColor=white) +![License](https://img.shields.io/badge/License-MIT-green?style=flat-square) + +--- + +## ✨ Features + +- **🖼️ Custom Image Slicing**: + - Upload any image (PNG, JPG, WebP, GIF) via file picker or drag-and-drop. + - Automatically center-crops and slices into grid tiles. + - Comes with 4 built-in procedural gallery presets (*Neon City*, *Deep Galaxy*, *Hex Prism*, *Aurora Peak*) for instant offline play. +- **🎮 5 Difficulty Levels**: + - 🐛 **Debug Mode (3×3)**: Starts from solved state and performs 5 non-reversing valid moves (guaranteed solvable in **≤ 5 moves**). + - 🟢 **Easy (3×3)**: 8 tiles + 1 blank, 30 valid shuffle moves. + - 🔵 **Medium (4×4)**: Classic 15-puzzle (15 tiles + 1 blank), 100 valid shuffle moves. + - 🟣 **Hard (5×5)**: 24 tiles + 1 blank, 200 valid shuffle moves. + - 🔴 **Insane (6×6)**: 35 tiles + 1 blank, 400 valid shuffle moves. +- **⚡ 100% Solvability Guarantee**: + - Shuffling simulates valid legal moves from the solved board state to ensure the puzzle is always solvable without parity issues. +- **🎨 Ultra-Smooth PixiJS v8 Rendering**: + - High-DPI canvas rendering with hardware acceleration. + - 60FPS smooth lerp/easing tile sliding animations. +- **🎵 Sound System & Soundtracks**: + - Background music ([`assets/sounds/backsound.mp3`](assets/sounds/backsound.mp3)) with auto-looping and browser autoplay-policy handling. + - Victory fanfare ([`assets/sounds/winner.mp3`](assets/sounds/winner.mp3)) upon solving. + - Native Web Audio synthesizer for tile slides, clicks, and invalid move bumps. + - Persistent sound toggle (`🔊` / `🔇`). +- **🏆 Persistent Leaderboard**: + - High scores saved in browser `localStorage`. + - Filterable by difficulty (All, Debug, Easy, Medium, Hard, Insane). + - Sorted by fastest completion time and least moves made. +- **💡 Helper Tools & Accessibility**: + - `#️⃣` **Tile Numbers Toggle**: Overlay numbering (1 to N-1) for challenging abstract images. + - `👁️` **Ghost Image Hint**: Semi-transparent reference overlay of the full image. + - `⏱️` **Millisecond-Precision Timer**: Starts on the very first move. + - `🔄` **Quick Restart**: Re-shuffle the same image and reset stats with one click. + - `⏸️` **Pause Modal**: Stops the clock when taking a break. + - `🎉` **Confetti Burst**: Physics-based canvas confetti explosion on victory. + +--- + +## 📁 Project Structure + +``` +eigen_vibe_code_competition/ +├── index.html # Main HTML markup, HUD, modals, and screen containers +├── style.css # Cyber dark theme, glassmorphism, responsive grid, animations +├── game.js # PixiJS v8 setup, puzzle engine, Web Audio, leaderboard logic +├── assets/ +│ └── sounds/ +│ ├── backsound.mp3 # Looping background soundtrack +│ └── winner.mp3 # Victory celebration audio track +└── README.md # Project documentation +``` + +--- + +## 🚀 How to Run the Project + +Since the project uses ES6 features and fetches audio assets / PixiJS canvas textures, it should be run through a local HTTP server. + +### Option 1: Python (Recommended, no installation required) + +If you have Python installed, run in your terminal: + +```bash +# Python 3 +python3 -m http.server 8080 +``` + +Then open your browser at: +👉 **[http://localhost:8080](http://localhost:8080)** + +--- + +### Option 2: Node.js (`npx serve` or `npx http-server`) + +If you have Node.js installed: + +```bash +npx serve . -p 8080 +``` +or +```bash +npx http-server -p 8080 +``` + +Then open **[http://localhost:8080](http://localhost:8080)**. + +--- + +### Option 3: VS Code Live Server + +1. Open the project folder in **Visual Studio Code**. +2. Install the **Live Server** extension (by Ritwick Dey). +3. Right-click [`index.html`](index.html) and select **"Open with Live Server"**. + +--- + +## 🎮 Controls & Shortcuts + +| Input / Key | Action | +| :--- | :--- | +| **Mouse Click / Tap** | Slide adjacent tile into the blank slot | +| / W | Move tile below blank upwards | +| / S | Move tile above blank downwards | +| / A | Move tile on right to the left | +| / D | Move tile on left to the right | +| N | Toggle tile number overlays (`#️⃣`) | +| H | Toggle ghost image hint (`👁️`) | +| R | Quick restart level (`🔄`) | +| M | Toggle audio mute (`🔊`/`🔇`) | +| Esc | Pause / Resume game (`⏸️`) | + +--- + +## 🛠️ Technology Stack + +- **Rendering Engine**: [PixiJS v8.20.0](https://pixijs.com/) via CDN +- **Structure & Logic**: Vanilla HTML5, Vanilla JavaScript (ES6+ Classes) +- **Styling**: Vanilla CSS3 (Custom Properties, Glassmorphism `backdrop-filter`, Flexbox, CSS Grid) +- **Typography**: Google Fonts (*Outfit*, *Inter*) +- **Audio**: HTML5 Audio API & Web Audio API Synthesizer +- **Storage**: Browser `localStorage` API + +--- + +## 📝 License + +Distributed under the MIT License. Feel free to use and customize for your own projects! diff --git a/assets/sounds/backsound.mp3 b/assets/sounds/backsound.mp3 new file mode 100644 index 0000000..d9e8a04 Binary files /dev/null and b/assets/sounds/backsound.mp3 differ diff --git a/assets/sounds/winner.mp3 b/assets/sounds/winner.mp3 new file mode 100644 index 0000000..b5a300c Binary files /dev/null and b/assets/sounds/winner.mp3 differ diff --git a/game.js b/game.js new file mode 100644 index 0000000..8891f37 --- /dev/null +++ b/game.js @@ -0,0 +1,1572 @@ +/** + * 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); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..55b457a --- /dev/null +++ b/index.html @@ -0,0 +1,328 @@ + + + + + + Pixel Slide — Premium 15-Puzzle Game + + + + + + + + + + + + + + + +
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + +
+ PIXELSLIDE +
+ +
+ + +
+
+ + +
+
+

+ Master the Art of Sliding +

+

+ Upload any picture or choose from gallery presets. Sliced, shuffled, and rendered with ultra-smooth 60FPS physics. +

+ + +
+
+ +
+ + + + + +
+
+ Click to upload or drag and drop an image +
+
Supports PNG, JPG, WebP, GIF (Max 15MB)
+
+ +
+ OR CHOOSE A PRESET IMAGE +
+ + +
+
+
+ + +
+
+ +
+

Select Difficulty

+

Pick your grid complexity and test your spatial reasoning.

+
+
+ +
+
+
+ Selected Image + +
+
+ +
+
+
+ +
+ +
+
+
+ + +
+ +
+
+ +
Medium 4×4
+
+ +
+
+ TIME + 00:00.00 +
+
+ MOVES + 0 +
+
+ +
+ + + +
+
+ + +
+
+
+ +
+ +
+
+ Shuffling tiles... +
+
+
+ + + +
+ + +
+
+ +
+

Hall of Fame

+

Global high scores sorted by fastest completion time and move count.

+
+
+ +
+
+ + + + + + +
+ +
+ + + + + + + + + + + + + + +
RankPlayerDifficultyTimeMovesDate
+
+
🏆
+

No records found for this category.

+ Complete a puzzle to claim the first spot! +
+
+ + +
+
+ + + + + + +
+ + + + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..57ed3fa --- /dev/null +++ b/style.css @@ -0,0 +1,1430 @@ +/* ========================================================================== + PIXEL SLIDE — DESIGN SYSTEM & STYLESHEET + Aesthetics: Deep Cyber Slate, Electric Cyan / Purple / Emerald, Glassmorphism + ========================================================================== */ + +:root { + /* Color Palette */ + --bg-main: #070a12; + --bg-surface: #0f1523; + --bg-surface-elevated: #161e33; + --bg-glass: rgba(18, 26, 44, 0.65); + --bg-glass-strong: rgba(15, 22, 38, 0.85); + --bg-glass-subtle: rgba(255, 255, 255, 0.04); + + --border-subtle: rgba(255, 255, 255, 0.08); + --border-glow: rgba(0, 229, 255, 0.25); + --border-focus: #00e5ff; + + --accent-cyan: #00e5ff; + --accent-purple: #9d4edd; + --accent-blue: #3a86ff; + --accent-emerald: #10b981; + --accent-amber: #f59e0b; + --accent-rose: #f43f5e; + + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --gradient-primary: linear-gradient(135deg, #00e5ff 0%, #7928ca 50%, #ff0080 100%); + --gradient-accent: linear-gradient(135deg, #00e5ff 0%, #3a86ff 100%); + --gradient-card: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.01) 100%); + --gradient-glow: radial-gradient(circle, rgba(0, 229, 255, 0.15) 0%, rgba(0, 0, 0, 0) 70%); + + /* Typography */ + --font-heading: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + + /* Shadows & Elevations */ + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6); + --shadow-neon-cyan: 0 0 20px rgba(0, 229, 255, 0.35); + --shadow-neon-purple: 0 0 20px rgba(157, 78, 221, 0.35); + + /* Dimensions & Radius */ + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-xl: 28px; + --radius-pill: 9999px; + + --transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1); + --transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1); + --transition-bounce: 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* ========================================================================== + Base & Reset + ========================================================================== */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +html, body { + width: 100%; + height: 100%; + overflow-x: hidden; + background-color: var(--bg-main); + color: var(--text-primary); + font-family: var(--font-body); + line-height: 1.5; + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +button, input { + font-family: inherit; + color: inherit; + border: none; + background: none; + outline: none; +} + +button { + cursor: pointer; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +/* ========================================================================== + Ambient Background Lighting & Grid + ========================================================================== */ +.ambient-bg { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.glow-orb { + position: absolute; + border-radius: 50%; + filter: blur(90px); + opacity: 0.4; + animation: floatOrb 18s ease-in-out infinite alternate; +} + +.orb-1 { + width: 500px; + height: 500px; + background: #00e5ff; + top: -150px; + left: 15%; +} + +.orb-2 { + width: 600px; + height: 600px; + background: #7928ca; + bottom: -200px; + right: 10%; + animation-duration: 24s; + animation-delay: -6s; +} + +.orb-3 { + width: 400px; + height: 400px; + background: #3a86ff; + top: 40%; + left: 60%; + opacity: 0.25; + animation-duration: 20s; + animation-delay: -12s; +} + +@keyframes floatOrb { + 0% { transform: translate(0, 0) scale(1); } + 50% { transform: translate(40px, 30px) scale(1.1); } + 100% { transform: translate(-30px, 60px) scale(0.95); } +} + +.grid-overlay { + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.02) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(circle at center, black 40%, transparent 85%); + -webkit-mask-image: radial-gradient(circle at center, black 40%, transparent 85%); +} + +/* ========================================================================== + Root Layout & Navigation + ========================================================================== */ +#app-root { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 28px; + background: var(--bg-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border-subtle); + position: sticky; + top: 0; + z-index: 50; +} + +.logo-group { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + user-select: none; + transition: transform var(--transition-fast); +} + +.logo-group:hover { + transform: scale(1.02); +} + +.logo-icon { + width: 36px; + height: 36px; + background: var(--gradient-accent); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + color: #050811; + box-shadow: var(--shadow-neon-cyan); +} + +.logo-text { + font-family: var(--font-heading); + font-weight: 800; + font-size: 1.35rem; + letter-spacing: 1px; +} + +.logo-highlight { + color: var(--accent-cyan); +} + +.header-controls { + display: flex; + align-items: center; + gap: 12px; +} + +.icon-btn { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--bg-glass-subtle); + border: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + color: var(--text-primary); + transition: all var(--transition-fast); +} + +.icon-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: var(--border-glow); + transform: translateY(-1px); +} + +.glass-pill-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: var(--radius-pill); + background: var(--bg-glass-subtle); + border: 1px solid var(--border-subtle); + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + transition: all var(--transition-fast); +} + +.glass-pill-btn:hover { + background: rgba(255, 255, 255, 0.12); + border-color: var(--border-glow); + box-shadow: var(--shadow-neon-cyan); + transform: translateY(-1px); +} + +/* ========================================================================== + Screen Management + ========================================================================== */ +.screen { + display: none; + flex-direction: column; + flex: 1; + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 24px 20px 48px; + animation: fadeInScreen 0.3s ease-out forwards; +} + +.screen.active { + display: flex; +} + +@keyframes fadeInScreen { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.screen-header { + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 28px; +} + +.back-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: var(--radius-sm); + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + color: var(--text-secondary); + font-weight: 600; + font-size: 0.9rem; + transition: all var(--transition-fast); +} + +.back-btn:hover { + color: var(--text-primary); + border-color: var(--accent-cyan); + transform: translateX(-2px); +} + +.screen-title-group h2 { + font-family: var(--font-heading); + font-size: 1.75rem; + font-weight: 700; +} + +.screen-title-group p { + color: var(--text-secondary); + font-size: 0.95rem; +} + +/* ========================================================================== + HOME SCREEN + ========================================================================== */ +.hero-section { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin: 20px auto 0; + max-width: 800px; +} + +.badge-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-radius: var(--radius-pill); + background: rgba(0, 229, 255, 0.1); + border: 1px solid rgba(0, 229, 255, 0.3); + color: var(--accent-cyan); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 1px; + margin-bottom: 20px; +} + +.badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-cyan); + box-shadow: 0 0 8px var(--accent-cyan); + animation: pulseDot 2s infinite; +} + +@keyframes pulseDot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.8); } +} + +.hero-title { + font-family: var(--font-heading); + font-size: clamp(2.4rem, 6vw, 3.8rem); + font-weight: 800; + line-height: 1.15; + letter-spacing: -0.5px; + margin-bottom: 16px; +} + +.gradient-text { + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + display: inline-block; +} + +.hero-subtitle { + color: var(--text-secondary); + font-size: clamp(1rem, 2vw, 1.15rem); + max-width: 580px; + margin-bottom: 36px; +} + +/* Upload Container */ +.upload-container { + width: 100%; + max-width: 680px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.upload-dropzone { + border: 2px dashed rgba(0, 229, 255, 0.35); + background: var(--bg-glass); + backdrop-filter: blur(12px); + border-radius: var(--radius-lg); + padding: 40px 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + cursor: pointer; + transition: all var(--transition-normal); + position: relative; + overflow: hidden; +} + +.upload-dropzone:hover, .upload-dropzone.drag-over { + border-color: var(--accent-cyan); + background: rgba(0, 229, 255, 0.05); + box-shadow: var(--shadow-neon-cyan); + transform: translateY(-2px); +} + +.dropzone-icon { + width: 54px; + height: 54px; + border-radius: 50%; + background: rgba(0, 229, 255, 0.12); + color: var(--accent-cyan); + display: flex; + align-items: center; + justify-content: center; + transition: transform var(--transition-bounce); +} + +.dropzone-icon svg { + width: 26px; + height: 26px; +} + +.upload-dropzone:hover .dropzone-icon { + transform: scale(1.15) rotate(-5deg); +} + +.dropzone-text { + font-size: 1.1rem; + color: var(--text-primary); +} + +.dropzone-text strong { + color: var(--accent-cyan); +} + +.dropzone-hint { + font-size: 0.85rem; + color: var(--text-muted); +} + +.preset-divider { + display: flex; + align-items: center; + text-align: center; + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 1.5px; +} + +.preset-divider::before, +.preset-divider::after { + content: ''; + flex: 1; + border-bottom: 1px solid var(--border-subtle); +} + +.preset-divider span { + padding: 0 16px; +} + +/* Preset Gallery */ +.preset-gallery { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + width: 100%; +} + +.preset-thumb-card { + position: relative; + aspect-ratio: 1 / 1; + border-radius: var(--radius-md); + overflow: hidden; + border: 2px solid var(--border-subtle); + background: var(--bg-surface); + cursor: pointer; + transition: all var(--transition-normal); +} + +.preset-thumb-card img, +.preset-thumb-card canvas { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform var(--transition-normal); +} + +.preset-thumb-card:hover { + border-color: var(--accent-cyan); + box-shadow: var(--shadow-neon-cyan); + transform: translateY(-4px); +} + +.preset-thumb-card:hover img, +.preset-thumb-card:hover canvas { + transform: scale(1.08); +} + +.preset-badge { + position: absolute; + bottom: 0; + inset-inline: 0; + padding: 6px 8px; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0%, transparent 100%); + font-size: 0.75rem; + font-weight: 600; + text-align: center; + color: var(--text-primary); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8); +} + +/* ========================================================================== + LEVEL SELECT SCREEN + ========================================================================== */ +.level-selection-layout { + display: grid; + grid-template-columns: 340px 1fr; + gap: 32px; + align-items: start; +} + +.image-preview-card { + background: var(--bg-glass); + backdrop-filter: blur(14px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + box-shadow: var(--shadow-md); +} + +.preview-header { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 600; + font-size: 0.95rem; +} + +.text-link-btn { + color: var(--accent-cyan); + font-size: 0.85rem; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; + transition: opacity var(--transition-fast); +} + +.text-link-btn:hover { + opacity: 0.8; +} + +.preview-canvas-wrapper { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-surface-elevated); + border: 1px solid var(--border-subtle); +} + +#preview-canvas { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.preview-grid-overlay { + position: absolute; + inset: 0; + pointer-events: none; + transition: all var(--transition-fast); +} + +/* Level Cards Grid */ +.level-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} + +.level-card { + background: var(--bg-glass); + backdrop-filter: blur(14px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 20px; + display: flex; + flex-direction: column; + gap: 14px; + cursor: pointer; + transition: all var(--transition-normal); + position: relative; + overflow: hidden; +} + +.level-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: var(--card-color, var(--accent-cyan)); + opacity: 0.7; + transition: opacity var(--transition-fast); +} + +.level-card:hover { + border-color: var(--card-color, var(--accent-cyan)); + transform: translateY(-4px); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4), 0 0 20px var(--card-glow, rgba(0, 229, 255, 0.2)); +} + +.level-card:hover::before { + opacity: 1; +} + +.level-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.level-title-block h3 { + font-family: var(--font-heading); + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 2px; +} + +.level-grid-dim { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--text-secondary); +} + +.level-badge-tag { + font-size: 0.72rem; + font-weight: 700; + padding: 4px 10px; + border-radius: var(--radius-pill); + background: rgba(255, 255, 255, 0.08); + color: var(--card-color, var(--accent-cyan)); + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.level-card-desc { + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.4; +} + +.level-card-stats { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 10px; + border-top: 1px solid var(--border-subtle); + font-size: 0.8rem; + color: var(--text-muted); +} + +.level-btn-action { + width: 100%; + padding: 10px; + border-radius: var(--radius-md); + background: var(--bg-surface-elevated); + border: 1px solid var(--border-subtle); + color: var(--text-primary); + font-weight: 600; + font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all var(--transition-fast); +} + +.level-card:hover .level-btn-action { + background: var(--card-color, var(--accent-cyan)); + color: #050811; + font-weight: 700; +} + +/* ========================================================================== + GAMEPLAY SCREEN & HUD + ========================================================================== */ +#screen-game { + align-items: center; + padding-top: 12px; + max-width: 720px; +} + +.game-hud { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + background: var(--bg-glass); + backdrop-filter: blur(16px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 10px 16px; + box-shadow: var(--shadow-md); + margin-bottom: 20px; +} + +.hud-left, .hud-right { + display: flex; + align-items: center; + gap: 8px; +} + +.hud-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: var(--radius-md); + background: var(--bg-surface-elevated); + border: 1px solid var(--border-subtle); + color: var(--text-primary); + font-weight: 600; + font-size: 0.88rem; + transition: all var(--transition-fast); +} + +.hud-btn:hover { + border-color: var(--accent-cyan); + background: rgba(0, 229, 255, 0.1); + transform: translateY(-1px); +} + +.hud-btn.icon-only { + padding: 8px 10px; + font-size: 1.05rem; +} + +.hud-btn.active { + background: rgba(0, 229, 255, 0.2); + border-color: var(--accent-cyan); + box-shadow: 0 0 10px rgba(0, 229, 255, 0.4); +} + +.level-badge { + font-family: var(--font-mono); + font-size: 0.78rem; + padding: 5px 10px; + border-radius: var(--radius-pill); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border-subtle); + color: var(--accent-cyan); + font-weight: 700; +} + +.hud-center { + display: flex; + align-items: center; + gap: 16px; +} + +.stat-box { + display: flex; + flex-direction: column; + align-items: center; +} + +.stat-label { + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 1px; + color: var(--text-muted); +} + +.stat-value { + font-family: var(--font-mono); + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); +} + +.timer-box .stat-value { + color: var(--accent-cyan); +} + +.moves-box .stat-value { + color: var(--accent-purple); +} + +/* Main Puzzle Frame */ +.puzzle-viewport-container { + width: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.puzzle-board-frame { + position: relative; + width: min(92vw, 540px); + height: min(92vw, 540px); + background: #090e1a; + border-radius: var(--radius-lg); + border: 3px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7), 0 0 30px rgba(0, 229, 255, 0.15); + overflow: hidden; + user-select: none; + touch-action: none; +} + +#pixi-canvas-mount { + width: 100%; + height: 100%; +} + +#pixi-canvas-mount canvas { + width: 100% !important; + height: 100% !important; + display: block; +} + +/* Ghost Overlay (Hint) */ +.ghost-overlay { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + z-index: 10; +} + +.ghost-overlay.visible { + opacity: 0.45; +} + +/* Shuffling overlay */ +.shuffle-overlay { + position: absolute; + inset: 0; + background: rgba(7, 10, 18, 0.85); + backdrop-filter: blur(8px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + z-index: 20; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; +} + +.shuffle-overlay.active { + opacity: 1; + pointer-events: auto; +} + +.shuffle-spinner { + width: 44px; + height: 44px; + border: 3px solid rgba(0, 229, 255, 0.2); + border-top-color: var(--accent-cyan); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.game-footer-info { + margin-top: 18px; + text-align: center; +} + +.tip-text { + font-size: 0.85rem; + color: var(--text-secondary); +} + +kbd { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + background: var(--bg-surface-elevated); + border: 1px solid var(--border-subtle); + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--accent-cyan); +} + +/* ========================================================================== + LEADERBOARD SCREEN + ========================================================================== */ +.leaderboard-container { + background: var(--bg-glass); + backdrop-filter: blur(16px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 24px; + box-shadow: var(--shadow-lg); +} + +.leaderboard-tabs { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 14px; + border-bottom: 1px solid var(--border-subtle); + margin-bottom: 20px; +} + +.tab-btn { + padding: 8px 16px; + border-radius: var(--radius-pill); + background: var(--bg-glass-subtle); + border: 1px solid var(--border-subtle); + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 600; + white-space: nowrap; + transition: all var(--transition-fast); +} + +.tab-btn:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.08); +} + +.tab-btn.active { + background: var(--accent-cyan); + color: #050811; + border-color: var(--accent-cyan); + font-weight: 700; + box-shadow: 0 0 14px rgba(0, 229, 255, 0.4); +} + +.leaderboard-table-wrapper { + overflow-x: auto; + min-height: 280px; +} + +.leaderboard-table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +.leaderboard-table th { + padding: 12px 14px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border-subtle); +} + +.leaderboard-table td { + padding: 14px; + font-size: 0.9rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.leaderboard-table tr:hover td { + background: rgba(255, 255, 255, 0.02); +} + +.rank-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + font-weight: 700; + font-size: 0.85rem; +} + +.rank-1 { background: #ffd700; color: #000; box-shadow: 0 0 10px rgba(255, 215, 0, 0.5); } +.rank-2 { background: #c0c0c0; color: #000; } +.rank-3 { background: #cd7f32; color: #000; } +.rank-other { background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); } + +.leaderboard-empty { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 16px; + text-align: center; + color: var(--text-secondary); +} + +.leaderboard-empty.visible { + display: flex; +} + +.empty-icon { + font-size: 3rem; + margin-bottom: 12px; + opacity: 0.6; +} + +.empty-sub { + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 4px; +} + +.leaderboard-footer { + display: flex; + justify-content: flex-end; + margin-top: 20px; + padding-top: 14px; + border-top: 1px solid var(--border-subtle); +} + +.danger-link-btn { + color: var(--accent-rose); + font-size: 0.85rem; + font-weight: 600; + padding: 6px 12px; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.danger-link-btn:hover { + background: rgba(244, 63, 94, 0.1); +} + +/* ========================================================================== + MODALS & OVERLAYS + ========================================================================== */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(5, 8, 15, 0.75); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + z-index: 100; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + opacity: 0; + transition: opacity 0.25s ease; +} + +.modal-overlay.active { + display: flex; + opacity: 1; +} + +.modal-card { + width: 100%; + max-width: 440px; + background: var(--bg-glass-strong); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-xl); + padding: 32px 28px; + text-align: center; + box-shadow: var(--shadow-lg), 0 0 30px rgba(0, 229, 255, 0.15); + transform: scale(0.95); + transition: transform 0.25s var(--transition-bounce); + position: relative; + z-index: 10; +} + +.modal-overlay.active .modal-card { + transform: scale(1); +} + +.modal-icon-badge { + width: 56px; + height: 56px; + border-radius: 50%; + background: rgba(0, 229, 255, 0.12); + border: 1px solid rgba(0, 229, 255, 0.3); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: var(--accent-cyan); + margin: 0 auto 16px; +} + +.modal-card h2 { + font-family: var(--font-heading); + font-size: 1.7rem; + font-weight: 800; + margin-bottom: 6px; +} + +.modal-card p { + color: var(--text-secondary); + font-size: 0.92rem; + margin-bottom: 24px; +} + +.modal-stats-row { + display: flex; + justify-content: center; + gap: 20px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 14px; + margin-bottom: 24px; +} + +.modal-stat { + display: flex; + flex-direction: column; +} + +.m-label { + font-size: 0.7rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; +} + +.m-val { + font-family: var(--font-mono); + font-size: 1.15rem; + font-weight: 700; + color: var(--accent-cyan); +} + +.modal-actions-stacked { + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 20px; + border-radius: var(--radius-md); + font-weight: 700; + font-size: 0.95rem; + transition: all var(--transition-fast); + width: 100%; +} + +.btn-primary { + background: var(--accent-cyan); + color: #050811; +} + +.btn-primary:hover { + background: #22eeff; + box-shadow: var(--shadow-neon-cyan); + transform: translateY(-2px); +} + +.btn-glow { + box-shadow: 0 0 15px rgba(0, 229, 255, 0.4); +} + +.btn-secondary { + background: var(--bg-surface-elevated); + border: 1px solid var(--border-subtle); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); + transform: translateY(-1px); +} + +.btn-outline { + background: transparent; + border: 1px solid var(--border-subtle); + color: var(--text-secondary); +} + +.btn-outline:hover { + color: var(--text-primary); + border-color: var(--accent-cyan); + background: rgba(0, 229, 255, 0.05); +} + +.btn-danger-outline { + background: transparent; + border: 1px solid rgba(244, 63, 94, 0.3); + color: var(--accent-rose); +} + +.btn-danger-outline:hover { + background: rgba(244, 63, 94, 0.15); +} + +/* ========================================================================== + VICTORY MODAL & CONFETTI + ========================================================================== */ +.confetti-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 5; +} + +.victory-card { + max-width: 480px; +} + +.crown-icon { + font-size: 3rem; + margin-bottom: 8px; + animation: bounceCrown 1.5s infinite; +} + +@keyframes bounceCrown { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px) rotate(5deg); } +} + +.victory-stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 24px; +} + +.v-stat-card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 12px 8px; + display: flex; + flex-direction: column; +} + +.v-stat-card.highlight { + border-color: var(--accent-cyan); + background: rgba(0, 229, 255, 0.06); +} + +.v-label { + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 1px; + color: var(--text-muted); +} + +.v-value { + font-family: var(--font-mono); + font-size: 1.1rem; + font-weight: 700; + color: var(--text-primary); + margin-top: 4px; +} + +.v-stat-card.highlight .v-value { + color: var(--accent-cyan); +} + +.victory-form { + margin-bottom: 24px; + text-align: left; +} + +.victory-form label { + display: block; + font-size: 0.8rem; + font-weight: 700; + color: var(--text-secondary); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.input-with-btn { + display: flex; + gap: 8px; +} + +.input-with-btn input { + flex: 1; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 10px 14px; + font-size: 0.95rem; + color: var(--text-primary); + transition: border-color var(--transition-fast); +} + +.input-with-btn input:focus { + border-color: var(--accent-cyan); + box-shadow: 0 0 10px rgba(0, 229, 255, 0.2); +} + +.input-with-btn .btn { + width: auto; + padding: 10px 18px; + white-space: nowrap; +} + +.save-feedback { + font-size: 0.82rem; + margin-top: 6px; + min-height: 18px; +} + +.save-feedback.success { + color: var(--accent-emerald); +} + +.save-feedback.error { + color: var(--accent-rose); +} + +.victory-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.victory-actions button:last-child { + grid-column: span 2; +} + +/* ========================================================================== + Responsive Adaptations + ========================================================================== */ +@media (max-width: 860px) { + .level-selection-layout { + grid-template-columns: 1fr; + } + + .image-preview-card { + max-width: 380px; + margin: 0 auto; + } + + .preset-gallery { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 580px) { + .app-header { + padding: 12px 16px; + } + + .logo-text { + font-size: 1.15rem; + } + + .hero-title { + font-size: 2.1rem; + } + + .game-hud { + padding: 8px 12px; + } + + .hud-btn span:not(.btn-icon) { + display: none; + } + + .stat-value { + font-size: 1rem; + } + + .victory-actions { + grid-template-columns: 1fr; + } + + .victory-actions button:last-child { + grid-column: span 1; + } +}