Files
15puzzle/game.js
T

1573 lines
50 KiB
JavaScript
Raw Blame History

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