430 lines
14 KiB
JavaScript
430 lines
14 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
// ===== Puzzle state =====
|
|
let boardWidth = 0;
|
|
let boardHeight = 0;
|
|
let colConstraints = [];
|
|
let rowConstraints = [];
|
|
let blocked = []; // blocked[r][c] = true
|
|
let boardState = []; // boardState[r][c] = pieceIndex or -1
|
|
// Each piece in inventory: { shape: 3x3 bool, placed: bool, boardRow: int, boardCol: int, rotation: int }
|
|
let pieces = [];
|
|
|
|
// ===== Drag state =====
|
|
let dragging = false;
|
|
let dragPieceIndex = -1;
|
|
let dragShape = null; // current (possibly rotated) 3x3 bool grid
|
|
let dragGhostEl = null; // the floating DOM element
|
|
let dragOffsetX = 0; // offset from mouse to ghost top-left
|
|
let dragOffsetY = 0;
|
|
|
|
// ===== DOM refs =====
|
|
const importInput = document.getElementById("importInput");
|
|
const importBtn = document.getElementById("importBtn");
|
|
const gameArea = document.getElementById("gameArea");
|
|
const playBoardArea = document.getElementById("playBoardArea");
|
|
const playInventory = document.getElementById("playInventory");
|
|
|
|
// ===== Import =====
|
|
function importPuzzle() {
|
|
const encoded = importInput.value.trim();
|
|
if (!encoded) return;
|
|
let data;
|
|
try {
|
|
data = JSON.parse(atob(encoded));
|
|
} catch (e) {
|
|
alert("Invalid puzzle code.");
|
|
return;
|
|
}
|
|
boardWidth = data.width;
|
|
boardHeight = data.height;
|
|
colConstraints = data.colConstraints;
|
|
rowConstraints = data.rowConstraints;
|
|
blocked = data.blocked;
|
|
|
|
// Init board state
|
|
boardState = [];
|
|
for (let r = 0; r < boardHeight; r++) {
|
|
boardState.push(new Array(boardWidth).fill(-1));
|
|
}
|
|
|
|
// Init pieces
|
|
pieces = data.pieces.map(shape => ({
|
|
shape: shape, // original 3x3
|
|
currentShape: shape.map(row => row.slice()), // working copy (rotated when placed)
|
|
placed: false,
|
|
boardRow: -1,
|
|
boardCol: -1
|
|
}));
|
|
|
|
gameArea.style.display = "flex";
|
|
renderBoard();
|
|
renderInventory();
|
|
}
|
|
|
|
// ===== Rotation =====
|
|
// Rotate a 3x3 grid 90 degrees clockwise
|
|
function rotateShape(shape) {
|
|
const result = [
|
|
[false, false, false],
|
|
[false, false, false],
|
|
[false, false, false]
|
|
];
|
|
for (let r = 0; r < 3; r++) {
|
|
for (let c = 0; c < 3; c++) {
|
|
result[c][2 - r] = shape[r][c];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ===== Board rendering =====
|
|
function renderBoard() {
|
|
let html = '<table class="board-table">';
|
|
|
|
// Header row: empty corner + column constraints
|
|
html += "<tr>";
|
|
html += '<td></td>';
|
|
for (let c = 0; c < boardWidth; c++) {
|
|
const filled = countColFilled(c);
|
|
const target = colConstraints[c];
|
|
const overClass = filled > target ? " over" : "";
|
|
const matchClass = filled === target && target > 0 ? ' style="color:#4f4;"' : "";
|
|
html += `<td class="constraint-label${overClass}"${matchClass}>${filled}/${target}</td>`;
|
|
}
|
|
html += "</tr>";
|
|
|
|
// Board rows
|
|
for (let r = 0; r < boardHeight; r++) {
|
|
html += "<tr>";
|
|
// Row constraint
|
|
const filled = countRowFilled(r);
|
|
const target = rowConstraints[r];
|
|
const overClass = filled > target ? " over" : "";
|
|
const matchClass = filled === target && target > 0 ? ' style="color:#4f4;"' : "";
|
|
html += `<td class="constraint-label${overClass}"${matchClass}>${filled}/${target}</td>`;
|
|
|
|
for (let c = 0; c < boardWidth; c++) {
|
|
let cls = "board-cell";
|
|
if (blocked[r][c]) {
|
|
cls += " blocked";
|
|
} else if (boardState[r][c] >= 0) {
|
|
cls += " filled";
|
|
}
|
|
html += `<td class="${cls}" data-row="${r}" data-col="${c}"></td>`;
|
|
}
|
|
html += "</tr>";
|
|
}
|
|
|
|
html += "</table>";
|
|
playBoardArea.innerHTML = html;
|
|
}
|
|
|
|
function countRowFilled(r) {
|
|
let count = 0;
|
|
for (let c = 0; c < boardWidth; c++) {
|
|
if (boardState[r][c] >= 0) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function countColFilled(c) {
|
|
let count = 0;
|
|
for (let r = 0; r < boardHeight; r++) {
|
|
if (boardState[r][c] >= 0) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
// ===== Inventory rendering =====
|
|
function renderInventory() {
|
|
playInventory.innerHTML = "";
|
|
pieces.forEach((piece, index) => {
|
|
if (piece.placed) return; // don't show placed pieces in inventory
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "inventory-piece";
|
|
wrapper.dataset.pieceIndex = index;
|
|
|
|
const shape = piece.shape; // always show original orientation in inventory
|
|
for (let r = 0; r < 3; r++) {
|
|
for (let c = 0; c < 3; c++) {
|
|
const cell = document.createElement("div");
|
|
cell.className = "inventory-piece-cell" + (shape[r][c] ? " active" : "");
|
|
wrapper.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
// Start drag on mousedown
|
|
wrapper.addEventListener("mousedown", function (e) {
|
|
e.preventDefault();
|
|
startDrag(index, piece.shape.map(row => row.slice()), e.clientX, e.clientY, wrapper);
|
|
});
|
|
|
|
playInventory.appendChild(wrapper);
|
|
});
|
|
}
|
|
|
|
// ===== Drag & Drop =====
|
|
function startDrag(pieceIndex, shape, mouseX, mouseY, sourceEl) {
|
|
dragging = true;
|
|
dragPieceIndex = pieceIndex;
|
|
dragShape = shape.map(row => row.slice());
|
|
|
|
// If the piece was on the board, remove it
|
|
if (pieces[pieceIndex].placed) {
|
|
removePieceFromBoard(pieceIndex);
|
|
pieces[pieceIndex].placed = false;
|
|
renderBoard();
|
|
renderInventory();
|
|
}
|
|
|
|
// Create ghost element
|
|
createDragGhost(mouseX, mouseY);
|
|
}
|
|
|
|
function createDragGhost(mouseX, mouseY) {
|
|
// Remove existing ghost
|
|
if (dragGhostEl) {
|
|
dragGhostEl.remove();
|
|
dragGhostEl = null;
|
|
}
|
|
|
|
const ghost = document.createElement("div");
|
|
ghost.className = "drag-ghost";
|
|
|
|
for (let r = 0; r < 3; r++) {
|
|
for (let c = 0; c < 3; c++) {
|
|
const cell = document.createElement("div");
|
|
cell.className = "drag-ghost-cell" + (dragShape[r][c] ? " active" : "");
|
|
ghost.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
document.body.appendChild(ghost);
|
|
dragGhostEl = ghost;
|
|
|
|
// Compute offset: center the ghost grid on the cursor at the center cell (1,1)
|
|
// Each cell is 36px + 1px gap; total grid ~ 3*36 + 2*1 = 110px
|
|
const ghostSize = 3 * 36 + 2 * 1;
|
|
dragOffsetX = ghostSize / 2;
|
|
dragOffsetY = ghostSize / 2;
|
|
|
|
positionGhost(mouseX, mouseY);
|
|
}
|
|
|
|
function positionGhost(mouseX, mouseY) {
|
|
if (!dragGhostEl) return;
|
|
dragGhostEl.style.left = (mouseX - dragOffsetX) + "px";
|
|
dragGhostEl.style.top = (mouseY - dragOffsetY) + "px";
|
|
}
|
|
|
|
function endDrag(mouseX, mouseY) {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
|
|
// Try to find which board cell is under the center of the ghost
|
|
const boardCell = getCellUnderPoint(mouseX, mouseY);
|
|
|
|
let placed = false;
|
|
if (boardCell) {
|
|
// The ghost center corresponds to the center cell (1,1) of the 3x3 shape.
|
|
// So the top-left of the shape maps to (boardCell.row - 1, boardCell.col - 1).
|
|
const topRow = boardCell.row - 1;
|
|
const topCol = boardCell.col - 1;
|
|
|
|
if (canPlace(dragShape, topRow, topCol, dragPieceIndex)) {
|
|
placePiece(dragPieceIndex, dragShape, topRow, topCol);
|
|
placed = true;
|
|
}
|
|
}
|
|
|
|
if (!placed) {
|
|
// Return to inventory
|
|
pieces[dragPieceIndex].placed = false;
|
|
}
|
|
|
|
// Clean up ghost
|
|
if (dragGhostEl) {
|
|
dragGhostEl.remove();
|
|
dragGhostEl = null;
|
|
}
|
|
|
|
renderBoard();
|
|
renderInventory();
|
|
|
|
// Check win
|
|
if (placed) {
|
|
checkWin();
|
|
}
|
|
}
|
|
|
|
function getCellUnderPoint(x, y) {
|
|
// Use document.elementFromPoint - temporarily hide ghost
|
|
if (dragGhostEl) dragGhostEl.style.display = "none";
|
|
const el = document.elementFromPoint(x, y);
|
|
if (dragGhostEl) dragGhostEl.style.display = "";
|
|
|
|
if (el && el.classList.contains("board-cell")) {
|
|
return {
|
|
row: parseInt(el.dataset.row),
|
|
col: parseInt(el.dataset.col)
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function canPlace(shape, topRow, topCol, pieceIndex) {
|
|
for (let r = 0; r < 3; r++) {
|
|
for (let c = 0; c < 3; c++) {
|
|
if (!shape[r][c]) continue;
|
|
const br = topRow + r;
|
|
const bc = topCol + c;
|
|
// Out of bounds
|
|
if (br < 0 || br >= boardHeight || bc < 0 || bc >= boardWidth) return false;
|
|
// Blocked
|
|
if (blocked[br][bc]) return false;
|
|
// Occupied by another piece
|
|
if (boardState[br][bc] >= 0 && boardState[br][bc] !== pieceIndex) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function placePiece(pieceIndex, shape, topRow, topCol) {
|
|
pieces[pieceIndex].placed = true;
|
|
pieces[pieceIndex].boardRow = topRow;
|
|
pieces[pieceIndex].boardCol = topCol;
|
|
pieces[pieceIndex].currentShape = shape.map(row => row.slice());
|
|
|
|
for (let r = 0; r < 3; r++) {
|
|
for (let c = 0; c < 3; c++) {
|
|
if (!shape[r][c]) continue;
|
|
boardState[topRow + r][topCol + c] = pieceIndex;
|
|
}
|
|
}
|
|
}
|
|
|
|
function removePieceFromBoard(pieceIndex) {
|
|
for (let r = 0; r < boardHeight; r++) {
|
|
for (let c = 0; c < boardWidth; c++) {
|
|
if (boardState[r][c] === pieceIndex) {
|
|
boardState[r][c] = -1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Allow picking up pieces from the board
|
|
function handleBoardMouseDown(e) {
|
|
if (dragging) return;
|
|
const target = e.target;
|
|
if (!target.classList.contains("board-cell")) return;
|
|
if (!target.classList.contains("filled")) return;
|
|
|
|
const r = parseInt(target.dataset.row);
|
|
const c = parseInt(target.dataset.col);
|
|
const pieceIndex = boardState[r][c];
|
|
if (pieceIndex < 0) return;
|
|
|
|
e.preventDefault();
|
|
const piece = pieces[pieceIndex];
|
|
const shape = piece.currentShape.map(row => row.slice());
|
|
|
|
removePieceFromBoard(pieceIndex);
|
|
piece.placed = false;
|
|
|
|
startDrag(pieceIndex, shape, e.clientX, e.clientY, target);
|
|
renderBoard();
|
|
}
|
|
|
|
// ===== Highlight preview while dragging =====
|
|
function updateHighlight(mouseX, mouseY) {
|
|
// Remove old highlights
|
|
playBoardArea.querySelectorAll(".highlight-valid, .highlight-invalid").forEach(el => {
|
|
el.classList.remove("highlight-valid", "highlight-invalid");
|
|
});
|
|
|
|
if (!dragging) return;
|
|
|
|
const boardCell = getCellUnderPoint(mouseX, mouseY);
|
|
if (!boardCell) return;
|
|
|
|
const topRow = boardCell.row - 1;
|
|
const topCol = boardCell.col - 1;
|
|
const valid = canPlace(dragShape, topRow, topCol, dragPieceIndex);
|
|
|
|
for (let r = 0; r < 3; r++) {
|
|
for (let c = 0; c < 3; c++) {
|
|
if (!dragShape[r][c]) continue;
|
|
const br = topRow + r;
|
|
const bc = topCol + c;
|
|
if (br < 0 || br >= boardHeight || bc < 0 || bc >= boardWidth) continue;
|
|
const cellEl = playBoardArea.querySelector(`[data-row="${br}"][data-col="${bc}"]`);
|
|
if (cellEl) {
|
|
cellEl.classList.add(valid ? "highlight-valid" : "highlight-invalid");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Win check =====
|
|
function checkWin() {
|
|
// All pieces must be placed
|
|
const allPlaced = pieces.every(p => p.placed);
|
|
if (!allPlaced) return;
|
|
|
|
// All row constraints must match
|
|
for (let r = 0; r < boardHeight; r++) {
|
|
if (countRowFilled(r) !== rowConstraints[r]) return;
|
|
}
|
|
|
|
// All column constraints must match
|
|
for (let c = 0; c < boardWidth; c++) {
|
|
if (countColFilled(c) !== colConstraints[c]) return;
|
|
}
|
|
|
|
// WIN!
|
|
setTimeout(function () {
|
|
alert("Puzzle complete!");
|
|
}, 100);
|
|
}
|
|
|
|
// ===== Global event listeners =====
|
|
document.addEventListener("mousemove", function (e) {
|
|
if (!dragging) return;
|
|
positionGhost(e.clientX, e.clientY);
|
|
updateHighlight(e.clientX, e.clientY);
|
|
});
|
|
|
|
document.addEventListener("mouseup", function (e) {
|
|
if (!dragging) return;
|
|
endDrag(e.clientX, e.clientY);
|
|
});
|
|
|
|
document.addEventListener("keydown", function (e) {
|
|
if (!dragging) return;
|
|
if (e.key === "r" || e.key === "R") {
|
|
dragShape = rotateShape(dragShape);
|
|
// Rebuild ghost
|
|
if (dragGhostEl) {
|
|
const rect = dragGhostEl.getBoundingClientRect();
|
|
const cx = rect.left + rect.width / 2;
|
|
const cy = rect.top + rect.height / 2;
|
|
createDragGhost(cx, cy);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Board click to pick up pieces
|
|
playBoardArea.addEventListener("mousedown", handleBoardMouseDown);
|
|
|
|
// Import
|
|
importBtn.addEventListener("click", importPuzzle);
|
|
importInput.addEventListener("keydown", function (e) {
|
|
if (e.key === "Enter") importPuzzle();
|
|
});
|
|
|
|
})();
|