Created
February 8, 2025 21:28
-
-
Save HamsterofDeath/ef2745e1cb4bc9aaf67bed4ec29e27cb to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
minigames.set('LaserHack', { | |
autorun: true, | |
html: ` | |
<div class="aurigan_ui_container laserhack_container"> | |
<h2 class="aurigan_ui_heading">Laser Hack</h2> | |
<div class="level-indicator-container" id="laserhack-levels"></div> | |
<div class="game-container"> | |
<canvas id="laserhack-canvas"></canvas> | |
</div> | |
</div> | |
<style> | |
.aurigan_ui_container.laserhack_container { | |
display: flex; | |
flex-direction: column; | |
height: calc(100% - 40px); | |
width: 100%; | |
position: relative; | |
padding: 20px; | |
background-color: #111; | |
} | |
.aurigan_ui_heading { | |
margin: 0 0 10px 0; | |
color: #fff; | |
font-family: Arial, sans-serif; | |
text-align: center; | |
font-size: 24px; | |
} | |
.level-indicator-container { | |
display: flex; | |
justify-content: center; | |
margin-bottom: 10px; | |
} | |
.level-block { | |
width: 10px; | |
height: 10px; | |
background-color: #444; | |
margin: 0 2px; | |
border-radius: 2px; | |
} | |
.level-block.active { | |
background-color: #0f0; | |
} | |
.game-container { | |
flex: 1; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
background-color: #222; | |
border-radius: 8px; | |
overflow: hidden; | |
} | |
#laserhack-canvas { | |
width: 100%; | |
height: 100%; | |
display: block; | |
} | |
.splitter-selecting { | |
border: 2px dashed yellow; | |
} | |
</style> | |
`, | |
init: (container, endCallback, config) => { | |
// ---------------- Constants & Utilities ---------------- | |
const DEBUG_MODE = true; | |
const COLS = 16, ROWS = 16; | |
const maxLevels = 30; | |
const levels = Array.from({ length: maxLevels }, (_, i) => i + 1); | |
// Tile types | |
const TILE_EMPTY = 'EMPTY'; | |
const TILE_LASER = 'LASER'; | |
const TILE_MIRROR = 'MIRROR'; | |
const TILE_SPLITTER = 'SPLITTER'; | |
const TILE_RECEPTOR = 'RECEPTOR'; | |
// Laser colors and direction helpers | |
const LASER_COLORS = ['red', 'green', 'blue']; | |
const directionNames = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; | |
const invSqrt2 = 1 / Math.sqrt(2); | |
const directionVectors = [ | |
{ x: 0, y: -1 }, // N (0) | |
{ x: invSqrt2, y: -invSqrt2 }, // NE (1) | |
{ x: 1, y: 0 }, // E (2) | |
{ x: invSqrt2, y: invSqrt2 }, // SE (3) | |
{ x: 0, y: 1 }, // S (4) | |
{ x: -invSqrt2, y: invSqrt2 }, // SW (5) | |
{ x: -1, y: 0 }, // W (6) | |
{ x: -invSqrt2, y: -invSqrt2 } // NW (7) | |
]; | |
const mirrorLineVectors = [ | |
{ x: 1, y: 0 }, | |
{ x: invSqrt2, y: invSqrt2 }, | |
{ x: 0, y: 1 }, | |
{ x: -invSqrt2, y: invSqrt2 }, | |
{ x: -1, y: 0 }, | |
{ x: -invSqrt2, y: -invSqrt2 }, | |
{ x: 0, y: -1 }, | |
{ x: invSqrt2, y: -invSqrt2 } | |
]; | |
// Simple random utilities | |
function randInt(n) { | |
return Math.floor(Math.random() * n); | |
} | |
function shuffleArray(a) { | |
for (let i = a.length - 1; i > 0; i--) { | |
const j = Math.floor(Math.random() * (i + 1)); | |
[a[i], a[j]] = [a[j], a[i]]; | |
} | |
} | |
// ---------------- Element Queue Factory (Modified to Flat Queue) ---------------- | |
let elementIdCounter = 0; // To generate unique IDs | |
function makeQueue(levelIndex) { | |
elementIdCounter = 0; | |
const laserId = `element-${++elementIdCounter}`; | |
let queue = [{ id: laserId, type: TILE_LASER, dependency: null }]; // Laser is now in queue | |
function makeSubQueue(n, dependencyId = laserId, previousElementId = laserId) { // Default dependency to laserId | |
let currentDependencyId = dependencyId; | |
let lastElementId = previousElementId; | |
if (n <= 0) { | |
return [{ id: `element-${++elementIdCounter}`, type: TILE_RECEPTOR, dependency: lastElementId || currentDependencyId }]; | |
} | |
if (n <= 3) { | |
let arr = []; | |
for (let i = 0; i < n; i++) { | |
const mirrorId = `element-${++elementIdCounter}`; | |
arr.push({ id: mirrorId, type: TILE_MIRROR, dependency: currentDependencyId }); | |
lastElementId = mirrorId; | |
currentDependencyId = mirrorId; | |
} | |
arr.push({ id: `element-${++elementIdCounter}`, type: TILE_RECEPTOR, dependency: lastElementId || dependencyId }); | |
return arr; | |
} else { | |
const splitterId = `element-${++elementIdCounter}`; | |
const splitterElement = { id: splitterId, type: TILE_SPLITTER, dependency: lastElementId || dependencyId }; | |
lastElementId = splitterId; | |
currentDependencyId = splitterId; | |
const subQueue1 = makeSubQueue(n - 3, currentDependencyId, lastElementId); | |
const subQueue2 = makeSubQueue(1, currentDependencyId, lastElementId); | |
return [ | |
{ id: `element-${++elementIdCounter}`, type: TILE_MIRROR, dependency: dependencyId }, | |
{ id: `element-${++elementIdCounter}`, type: TILE_MIRROR, dependency: dependencyId }, | |
{ id: `element-${++elementIdCounter}`, type: TILE_MIRROR, dependency: dependencyId }, | |
splitterElement, | |
...subQueue1, | |
...subQueue2 | |
]; | |
} | |
} | |
queue = [...queue, ...makeSubQueue(levelIndex + 1, laserId, laserId)]; // Chain from laser | |
return queue; | |
} | |
function elementQueueFactory(levelIndex) { | |
return makeQueue(levelIndex); | |
} | |
// ---------------- Puzzle Element & Logic ---------------- | |
// Defines individual puzzle cell objects (immutable). | |
class PuzzleElement { | |
constructor(type, x, y, rotation = 0, color = null, lit = false, splitDirs = []) { | |
this.type = type; | |
this.x = x; | |
this.y = y; | |
this.rotation = rotation; | |
this.color = color; | |
this.lit = lit; | |
this.splitDirs = [...splitDirs]; // Copy to preserve immutability | |
} | |
withType(type) { | |
return new PuzzleElement(type, this.x, this.y, this.rotation, this.color, this.lit, this.splitDirs); | |
} | |
withRotation(rotation) { | |
return new PuzzleElement(this.type, this.x, this.y, rotation, this.color, this.lit, this.splitDirs); | |
} | |
withColor(color) { | |
return new PuzzleElement(this.type, this.x, this.y, this.rotation, color, this.lit, this.splitDirs); | |
} | |
withLit(lit) { | |
return new PuzzleElement(this.type, this.x, this.y, this.rotation, this.color, lit, this.splitDirs); | |
} | |
withSplitDirs(splitDirs) { | |
return new PuzzleElement(this.type, this.x, this.y, this.rotation, this.color, this.lit, [...splitDirs]); | |
} | |
} | |
// ---------------- PuzzleRenderer ---------------- | |
// Draws the grid, beams, and tiles. | |
class PuzzleRenderer { | |
constructor(canvas, puzzle) { | |
this.canvas = canvas; | |
this.ctx = canvas.getContext('2d'); | |
this.puzzle = puzzle; | |
this.cellSize = 0; | |
} | |
// Adjust canvas size and compute cell dimensions. | |
resize() { | |
this.canvas.width = this.canvas.getBoundingClientRect().width; | |
this.canvas.height = this.canvas.getBoundingClientRect().height; | |
this.cellSize = Math.min(this.canvas.width / this.puzzle.cols, this.canvas.height / this.puzzle.rows); | |
} | |
// Draw the grid, cells, and beam paths. | |
draw(splitterState) { | |
const ctx = this.ctx; | |
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
const cs = this.cellSize; | |
for (let r = 0; r < this.puzzle.rows; r++) { | |
for (let c = 0; c < this.puzzle.cols; c++) { | |
const px = c * cs, py = r * cs; | |
ctx.fillStyle = '#333'; | |
ctx.fillRect(px, py, cs, cs); | |
ctx.strokeStyle = '#555'; | |
ctx.strokeRect(px, py, cs, cs); | |
// Debug: display cell coordinates. | |
if (DEBUG_MODE) { | |
ctx.fillStyle = '#888'; | |
ctx.font = '8px Arial'; | |
ctx.textAlign = 'right'; | |
ctx.textBaseline = 'bottom'; | |
ctx.fillText(c + ',' + r, px + cs - 2, py + cs - 2); | |
} | |
const tile = this.puzzle.grid[r][c]; | |
ctx.save(); | |
ctx.translate(px + cs / 2, py + cs / 2); | |
ctx.rotate(tile.rotation * (Math.PI / 4)); | |
if (tile.type === TILE_LASER) { | |
ctx.beginPath(); | |
ctx.moveTo(0, -cs * 0.35); | |
ctx.lineTo(cs * 0.4, cs * 0.3); | |
ctx.lineTo(0, cs * 0.15); | |
ctx.lineTo(-cs * 0.4, cs * 0.3); | |
ctx.closePath(); | |
ctx.fillStyle = tile.color || 'red'; | |
ctx.fill(); | |
if (DEBUG_MODE) { | |
ctx.fillStyle = 'white'; | |
ctx.font = '8px Arial'; | |
ctx.textAlign = 'center'; | |
ctx.textBaseline = 'top'; | |
ctx.fillText(`${directionNames[tile.rotation]} (${tile.rotation})`, 0, cs * 0.4); | |
ctx.strokeStyle = 'white'; | |
ctx.beginPath(); | |
ctx.moveTo(0, 0); | |
ctx.lineTo(directionVectors[tile.rotation].x * cs * 0.2, directionVectors[tile.rotation].y * cs * 0.2); | |
ctx.stroke(); | |
} | |
} else if (tile.type === TILE_MIRROR) { | |
ctx.fillStyle = '#bbb'; | |
ctx.fillRect(-cs / 2 + 2, -3, cs - 4, 6); | |
} else if (tile.type === TILE_RECEPTOR) { | |
ctx.beginPath(); | |
ctx.arc(0, 0, cs / 3, 0, 2 * Math.PI); | |
ctx.strokeStyle = tile.lit ? tile.color : '#999'; | |
ctx.lineWidth = tile.lit ? 3 : 1; | |
ctx.stroke(); | |
} else if (tile.type === TILE_SPLITTER) { | |
ctx.fillStyle = '#0cf'; | |
ctx.beginPath(); | |
ctx.moveTo(-cs * 0.3, 0); | |
ctx.lineTo(0, -cs * 0.3); | |
ctx.lineTo(cs * 0.3,0); | |
ctx.lineTo(0, cs * 0.3); | |
ctx.closePath(); | |
ctx.fill(); | |
} | |
ctx.restore(); | |
} | |
} | |
// Draw beams on top of the grid. | |
for (const b of this.puzzle.beams) { | |
ctx.beginPath(); | |
ctx.moveTo(b.x1 * cs, b.y1 * cs); | |
ctx.lineTo(b.x2 * cs, b.y2 * cs); | |
ctx.strokeStyle = b.color; | |
ctx.lineWidth = 2; | |
ctx.stroke(); | |
} | |
// If selecting splitter directions, draw helper lines. | |
if (splitterState.active) { | |
const px = splitterState.x * cs + cs / 2; | |
const py = splitterState.y * cs + cs / 2; | |
ctx.strokeStyle = 'yellow'; | |
ctx.lineWidth = 1; | |
for (let i = 0; i < 8; i++) { | |
const dv = directionVectors[i]; | |
ctx.beginPath(); | |
ctx.moveTo(px, py); | |
ctx.lineTo(px + dv.x * cs, py + dv.y * cs); | |
ctx.stroke(); | |
} | |
} | |
} | |
} | |
// Manages the grid, beam simulation, and cell locking (immutable operations). | |
class PuzzleLogic { | |
constructor(cols, rows, grid = null, beams = null, lockedCells = null, laserColor = "red", paths = null) { | |
this.cols = cols; | |
this.rows = rows; | |
this.grid = grid || (() => { | |
const initialGrid = []; | |
for (let r = 0; r < rows; r++) { | |
initialGrid[r] = []; | |
for (let c = 0; c < cols; c++) { | |
initialGrid[r][c] = new PuzzleElement(TILE_EMPTY, c, r); | |
} | |
} | |
return initialGrid; | |
})(); | |
this.beams = beams || []; | |
this.lockedCells = lockedCells ? new Set(lockedCells) : new Set(); | |
this.laserColor = laserColor; | |
this.paths = paths || []; | |
} | |
clone() { | |
const clonedGrid = this.grid.map(row => row.map(cell => | |
new PuzzleElement(cell.type, cell.x, cell.y, cell.rotation, cell.color, cell.lit, cell.splitDirs) | |
)); | |
return new PuzzleLogic(this.cols, this.rows, clonedGrid, [...this.beams], new Set(this.lockedCells), this.laserColor, [...this.paths]); | |
} | |
// --- Immutable operations --- | |
withGrid(newGrid) { | |
return new PuzzleLogic(this.cols, this.rows, newGrid, this.beams, this.lockedCells, this.laserColor, this.paths); | |
} | |
withUpdatedTile(r, c, tileUpdateFn) { | |
if (r < 0 || r >= this.rows || c < 0 || c >= this.cols) { | |
console.error(`withUpdatedTile: Invalid grid coordinates: r=${r}, c=${c}`); | |
return this; // Or throw an error, depending on desired behavior | |
} | |
const newGrid = this.grid.map(row => [...row]); | |
newGrid[r] = [...newGrid[r]]; | |
newGrid[r][c] = tileUpdateFn(this.grid[r][c]); | |
return this.withGrid(newGrid); | |
} | |
withLockedCells(newLockedCells) { | |
return new PuzzleLogic(this.cols, this.rows, this.grid, this.beams, new Set(newLockedCells), this.laserColor, this.paths); | |
} | |
withLaserColor(newLaserColor) { | |
return new PuzzleLogic(this.cols, this.rows, this.grid, this.beams, this.lockedCells, newLaserColor, this.paths); | |
} | |
withBeams(newBeams) { | |
return new PuzzleLogic(this.cols, this.rows, this.grid, newBeams, this.lockedCells, this.laserColor, this.paths); | |
} | |
withPaths(newPaths) { | |
return new PuzzleLogic(this.cols, this.rows, this.grid, this.beams, this.lockedCells, this.laserColor, newPaths); | |
} | |
// Given an incoming direction and a mirror rotation, compute the reflection direction. | |
mirrorReflect(dirIndex, mirrorRot) { | |
const d = directionVectors[dirIndex]; | |
const m = mirrorLineVectors[mirrorRot]; | |
const dotDM = d.x * m.x + d.y * m.y; | |
if (Math.abs(dotDM) > 0.9999) return -1; | |
const n = { x: -m.y, y: m.x }; | |
const dotDN = d.x * n.x + d.y * n.y; | |
const rx = d.x - 2 * dotDN * n.x; | |
const ry = d.y - 2 * dotDN * n.y; | |
const rLen = Math.sqrt(rx * rx + ry * ry); | |
if (rLen < 0.0001) return -1; | |
let bestDir = -1, bestDot = -999; | |
const rnx = rx / rLen, rny = ry / rLen; | |
for (let i = 0; i < 8; i++) { | |
const dv = directionVectors[i]; | |
const dot = dv.x * rnx + dv.y * rny; | |
if (dot > bestDot) { | |
bestDot = dot; | |
bestDir = i; | |
} | |
} | |
return bestDot > 0.99 ? bestDir : -1; | |
} | |
// Modified onBeamEnter: for splitters, check and mark them as used. | |
onBeamEnter(dirIndex, tile, color, usedSplitters) { | |
switch (tile.type) { | |
case TILE_EMPTY: | |
return dirIndex; | |
case TILE_LASER: // Modified: Laser blocks incoming lasers | |
return -1; | |
case TILE_MIRROR: | |
return this.mirrorReflect(dirIndex, tile.rotation); | |
case TILE_RECEPTOR: | |
if (tile.color === color) { | |
tile.lit = true; | |
} | |
return -1; | |
case TILE_SPLITTER: { | |
const key = `${tile.x},${tile.y}`; | |
if (usedSplitters.has(key)) return -1; | |
usedSplitters.add(key); | |
if (tile.splitDirs.length < 2) return -1; | |
if (tile.splitDirs.includes(dirIndex)) return -1; | |
return tile.splitDirs; | |
} | |
default: | |
return -1; | |
} | |
} | |
// Runs beam simulation from all laser sources. | |
simulateBeams() { | |
let nextPuzzle = this.withBeams([]); | |
let mutableGrid = nextPuzzle.grid.map(row => row.map(cell => ({ ...cell, lit: false }))); | |
nextPuzzle = nextPuzzle.withGrid(mutableGrid.map(row => row.map(cell => | |
new PuzzleElement(cell.type, cell.x, cell.y, cell.rotation, cell.color, cell.lit, cell.splitDirs) | |
))); | |
const beams = []; | |
const paths = []; | |
const visited = new Set(); | |
const usedSplitters = new Set(); // Track which splitters have been used | |
for (let r = 0; r < this.rows; r++) { | |
for (let c = 0; c < this.cols; c++) { | |
const tile = this.grid[r][c]; | |
if (tile.type === TILE_LASER) { | |
const startPath = [{ | |
x: c, | |
y: r, | |
dirIndex: tile.rotation | |
}]; | |
this.traceBeamAndPath(c, r, tile.rotation, tile.color, visited, startPath, beams, paths, nextPuzzle, usedSplitters); | |
} | |
} | |
} | |
return nextPuzzle.withBeams(beams).withPaths(paths); | |
} | |
// Recursive beam tracing. | |
traceBeamAndPath(x, y, dirIndex, color, visited, currentPath, beams, paths, puzzleState, usedSplitters) { | |
const key = `${x},${y},${dirIndex},${color}`; | |
if (visited.has(key)) return; | |
visited.add(key); | |
const dv = directionVectors[dirIndex]; | |
const nx = x + (dv.x > 0 ? Math.ceil(dv.x) : Math.floor(dv.x)); | |
const ny = y + (dv.y > 0 ? Math.ceil(dv.y) : Math.floor(dv.y)); | |
// Beam goes off grid. | |
if (nx < 0 || nx >= this.cols || ny < 0 || ny >= this.rows) { | |
paths.push([...currentPath]); | |
return; | |
} | |
beams.push({ | |
x1: x + 0.5, | |
y1: y + 0.5, | |
x2: nx + 0.5, | |
y2: ny + 0.5, | |
color | |
}); | |
const tile = puzzleState.grid[ny][nx]; | |
const nd = puzzleState.onBeamEnter(dirIndex, tile, color, usedSplitters); | |
currentPath.push({ | |
x: nx, | |
y: ny, | |
dirIndex: Array.isArray(nd) ? nd[0] : nd | |
}); | |
if (Array.isArray(nd)) { | |
for (const splitDir of nd) { | |
const splitPath = [...currentPath]; | |
splitPath[splitPath.length - 1].dirIndex = splitDir; | |
this.traceBeamAndPath(nx, ny, splitDir, color, visited, splitPath, beams, paths, puzzleState, usedSplitters); | |
} | |
} else if (nd >= 0) { | |
this.traceBeamAndPath(nx, ny, nd, color, visited, currentPath, beams, paths, puzzleState, usedSplitters); | |
} else { | |
paths.push([...currentPath]); | |
} | |
} | |
// Returns all beam paths. | |
simulateAndGetAllPaths() { | |
const simulationResult = this.simulateBeams(); | |
return simulationResult.paths; | |
} | |
// Lock cells along a beam path. | |
lockCellsUpToIndex(path, idx) { | |
let locked = new Set(this.lockedCells); | |
let lockedCoords = []; | |
for (let i = 0; i <= idx; i++) { | |
const { x, y } = path[i]; | |
const key = `${x},${y}`; | |
locked.add(key); | |
lockedCoords.push(`(${x},${y})`); | |
} | |
return { updatedPuzzle: this.withLockedCells(locked), lockedCells: lockedCoords }; | |
} | |
// Returns the sub-branch of a beam path starting after a given cell. | |
getCandidateBranch(fullPath, placedX, placedY) { | |
const idx = fullPath.findIndex(pt => pt.x === placedX && pt.y === placedY); | |
if (idx < 0) return []; | |
return fullPath.slice(idx + 1); | |
} | |
} | |
// ---------------- PuzzleGenerator with Backtracking (Modified for Flat Queue and Verbose Log) ---------------- | |
class PuzzleGenerator { | |
constructor(puzzle, elementQueue, verbose = false) { | |
this.puzzle = puzzle; | |
this.initialElementQueue = elementQueue; | |
this.strategies = { | |
[TILE_MIRROR]: this.placeMirror.bind(this), | |
[TILE_SPLITTER]: this.placeSplitter.bind(this), | |
[TILE_RECEPTOR]: this.placeReceptor.bind(this) | |
}; | |
this.placedElements = {}; | |
this.verbose = verbose; | |
this.backtrackCounter = 0; | |
} | |
placeLaser(elementSpec) { | |
let nextPuzzle = new PuzzleLogic(this.puzzle.cols, this.puzzle.rows); | |
nextPuzzle = nextPuzzle.withLockedCells(new Set()); | |
for (let r = 0; r < nextPuzzle.rows; r++) { | |
for (let c = 0; c < nextPuzzle.cols; c++) { | |
nextPuzzle = nextPuzzle.withUpdatedTile(r, c, (tile) => | |
tile.withType(TILE_EMPTY).withRotation(0).withColor(null).withSplitDirs([]) | |
); | |
} | |
} | |
let lx, ly, ldir; | |
while (true) { | |
lx = randInt(nextPuzzle.cols); | |
ly = randInt(nextPuzzle.rows); | |
ldir = randInt(8); | |
const dv = directionVectors[ldir]; | |
if (lx === 0 && dv.x < 0) continue; | |
if (lx === nextPuzzle.cols - 1 && dv.x > 0) continue; | |
if (ly === 0 && dv.y < 0) continue; | |
if (ly === nextPuzzle.rows - 1 && dv.y > 0) continue; | |
break; | |
} | |
let laserColor = LASER_COLORS[randInt(LASER_COLORS.length)]; | |
nextPuzzle = nextPuzzle.withLaserColor(laserColor); | |
nextPuzzle = nextPuzzle.withUpdatedTile(ly, lx, (tile) => | |
tile.withType(TILE_LASER).withRotation(ldir).withColor(laserColor) | |
); | |
this.puzzle = nextPuzzle; | |
if (elementSpec && elementSpec.rotation !== undefined && elementSpec.color !== undefined) { | |
nextPuzzle = nextPuzzle.withUpdatedTile(ly, lx, (tile) => | |
tile.withRotation(elementSpec.rotation).withColor(elementSpec.color) | |
); | |
if (this.verbose) console.log(`Laser placed at (${lx},${ly}) with rotation ${directionNames[elementSpec.rotation]} and color ${elementSpec.color} (from queue spec)`); | |
} else { | |
if (this.verbose) console.log(`Laser placed at (${lx},${ly}) with rotation ${directionNames[ldir]} and color ${laserColor} (random)`); | |
} | |
this.puzzle = nextPuzzle.simulateBeams(); | |
return { x: lx, y: ly, rotation: ldir, color: laserColor }; | |
} | |
generate() { | |
const elementQueue = this.initialElementQueue; | |
const laserSpec = elementQueue[0]; | |
const laserPlacement = this.placeLaser(laserSpec); | |
if (!laserPlacement) return false; | |
let paths = this.puzzle.simulateAndGetAllPaths(); | |
if (paths.length === 0) return false; | |
this.placedElements = {}; | |
this.placedElements[laserSpec.id] = { x: laserPlacement.x, y: laserPlacement.y, rotation: laserPlacement.rotation }; | |
this.backtrackCounter = 0; | |
if (!this.verbose) { | |
console.log("Generating puzzle..."); | |
const elementTree = this.buildElementQueueTree(this.initialElementQueue); | |
this.logElementQueueTree(elementTree); | |
} else { | |
const elementTree = this.buildElementQueueTree(this.initialElementQueue); | |
this.logElementQueueTree(elementTree); | |
} | |
const success = this.placeElementFromQueue(elementQueue.slice(1)); | |
this.renderGridAscii(this.puzzle.grid); // Always render grid at the end of generate | |
return success; | |
} | |
buildElementQueueTree(elementQueue) { | |
const elementMap = {}; | |
const rootNodes = []; | |
elementQueue.forEach(element => { | |
elementMap[element.id] = { element: element, children: [] }; | |
}); | |
elementQueue.forEach(element => { | |
if (element.dependency) { | |
const parentNode = elementMap[element.dependency]; | |
if (parentNode) { | |
parentNode.children.push(elementMap[element.id]); | |
} | |
} else { | |
rootNodes.push(elementMap[element.id]); | |
} | |
}); | |
return rootNodes; | |
} | |
logElementQueueTree(treeNodes, indent = '') { | |
if (this.verbose) { | |
if (!Array.isArray(treeNodes)) { | |
treeNodes = [treeNodes]; | |
} | |
treeNodes.forEach(treeNode => { | |
const element = treeNode.element; | |
const line = `${indent}- ${element.type} (${element.id}) ${element.dependency ? `[dep: ${element.dependency}]` : ''}`; | |
console.log(line); | |
if (treeNode.children.length > 0) { | |
this.logElementQueueTree(treeNode.children, indent + ' '); | |
} | |
}); | |
} else { | |
console.log("Element Queue: (see verbose log for details)"); | |
} | |
} | |
placeElementFromQueue(elementQueue) { | |
if (elementQueue.length === 0) return true; | |
const currentElementSpec = elementQueue[0]; | |
const remainingQueue = elementQueue.slice(1); | |
const elementType = currentElementSpec.type; | |
const elementId = currentElementSpec.id; | |
let validBranch = null; // To store a valid branch with free cells | |
const dependencyId = currentElementSpec.dependency; | |
if (dependencyId) { | |
const dependencyPlacement = this.placedElements[dependencyId]; | |
if (dependencyPlacement) { | |
const dependencyX = dependencyPlacement.x; | |
const dependencyY = dependencyPlacement.y; | |
const nextPaths = this.puzzle.simulateAndGetAllPaths(); | |
const pathsThroughDependency = nextPaths.filter(path => | |
path.some(p => p.x === dependencyX && p.y === dependencyY) | |
); | |
for (const path of pathsThroughDependency) { | |
const branch = this.puzzle.getCandidateBranch(path, dependencyX, dependencyY); | |
if (branch.length > 0) { | |
// Check if the branch has at least one valid cell | |
const hasValidCell = branch.some(pt => { | |
return !this.puzzle.lockedCells.has(`${pt.x},${pt.y}`) && this.puzzle.grid[pt.y][pt.x].type === TILE_EMPTY; | |
}); | |
if (hasValidCell) { | |
validBranch = branch; // Found a valid branch | |
break; // Exit loop after finding the first valid branch | |
} | |
} | |
} | |
if (!validBranch && this.verbose) console.log(`No valid branch found from dependency ${dependencyId} @(${dependencyX},${dependencyY}) with free cells!`); | |
} else { | |
console.error("Dependency placement not found!", dependencyId); | |
return false; | |
} | |
} else { | |
// For laser, any path is potentially valid at the start | |
const nextPaths = this.puzzle.simulateAndGetAllPaths(); | |
if (nextPaths.length > 0) { | |
validBranch = nextPaths[0]; // Just take the first path for laser's initial branch. | |
} else { | |
return false; // No paths at all, something is wrong. | |
} | |
} | |
if (this.verbose) { | |
console.log(`\n=== Placing Element: ${elementType} (ID: ${elementId}, Parent: ${dependencyId}) ===`); | |
console.log(`Remaining Queue IDs:`, remainingQueue.map(e => e.id)); | |
console.log("Current Branch (calculated and filtered for free cells):", validBranch ? validBranch.map(p => `(${p.x},${p.y}:${directionNames[p.dirIndex]})`).join(' → ') : 'No Valid Branch'); | |
this.renderGridAscii(this.puzzle.grid, validBranch || []); | |
} | |
if (dependencyId && !this.placedElements[dependencyId]) { | |
if (this.verbose) console.log(`Dependency ${dependencyId} not yet placed. Skipping ${elementId} for now.`); | |
return false; | |
} | |
if (!validBranch || validBranch.length === 0) { | |
if (this.verbose) console.log(`No valid branch with free cells to place ${elementType} (ID: ${elementId}) on, backtracking.`); | |
return false; | |
} | |
let validMoves = this.getValidMovesForElement({ type: elementType }, validBranch); | |
shuffleArray(validMoves); | |
for (const move of validMoves) { | |
if (this.verbose) console.log(`Trying move: ${elementType} (ID: ${elementId}, Parent: ${dependencyId}) @(${move.x},${move.y}) ${move.directionInfo || ''}`); | |
let puzzleStateBeforeMove = this.puzzle.clone(); | |
let nextPuzzle = this.applyMoveToPuzzle(puzzleStateBeforeMove, move, validBranch); | |
if (!nextPuzzle) continue; | |
if (!nextPuzzle.grid[move.y] || !nextPuzzle.grid[move.y][move.x]) { | |
console.error("Invalid grid access after applyMoveToPuzzle!", move.y, move.x, "Grid bounds:", ROWS, COLS, "Move:", move); | |
continue; | |
} | |
this.puzzle = nextPuzzle; | |
this.placedElements[elementId] = move; | |
if (remainingQueue.length === 0) { | |
if (this.verbose) console.log(`Queue fully placed! Success for ${elementType} (ID: ${elementId}, Parent: ${dependencyId}) @(${move.x},${move.y})`); | |
return true; | |
} | |
if (this.placeElementFromQueue(remainingQueue)) { | |
return true; | |
} else { | |
if (this.verbose) console.log(`Placing remaining queue failed after ${elementType} (ID: ${elementId}, Parent: ${dependencyId}) @(${move.x},${move.y}), backtracking.`); | |
this.puzzle = puzzleStateBeforeMove; | |
delete this.placedElements[elementId]; | |
continue; | |
} | |
} | |
this.backtrackCounter++; | |
if (!this.verbose && this.backtrackCounter % 1000 === 0) { | |
console.log("Working... Backtracks:", this.backtrackCounter); | |
generator.renderGridAscii(this.puzzle.grid); // Added grid debug log here | |
} | |
if (this.verbose) console.log(`No valid moves found for ${elementType} (ID: ${elementId}), backtracking.`); | |
return false; | |
} | |
getValidMovesForElement(elementSpec, branch, allowedSplitterBranches = null) { | |
const puzzle = this.puzzle; | |
const strat = this.strategies[elementSpec.type]; | |
if (!strat) throw new Error("No strategy for " + elementSpec.type); | |
return strat(branch, elementSpec, allowedSplitterBranches); | |
} | |
applyMoveToPuzzle(puzzle, move, branch) { | |
let nextPuzzle = puzzle.clone(); | |
if (move.y < 0 || move.y >= ROWS || move.x < 0 || move.x >= COLS) { | |
console.error(`applyMoveToPuzzle: Invalid move coordinates! r=${move.y}, c=${move.x}`); | |
return null; | |
} | |
nextPuzzle = nextPuzzle.withUpdatedTile(move.y, move.x, (tile) => { | |
let updatedTile = null; | |
if (move.newState.type === TILE_LASER) { | |
updatedTile = tile.withType(TILE_LASER).withRotation(move.newState.rotation).withColor(move.newState.color); | |
} else if (move.newState.type === TILE_MIRROR) { | |
updatedTile = tile.withType(TILE_MIRROR).withRotation(move.newState.rotation); | |
} else if (move.newState.type === TILE_SPLITTER) { | |
updatedTile = tile.withType(TILE_SPLITTER).withSplitDirs(move.newState.splitDirs || []); | |
} else if (move.newState.type === TILE_RECEPTOR) { | |
updatedTile = tile.withType(TILE_RECEPTOR).withColor(this.puzzle.laserColor); | |
} else { | |
return tile; | |
} | |
return updatedTile; | |
}); | |
if (!nextPuzzle.grid[move.y][move.x].type) | |
return null; | |
let idx = branch.findIndex(pt => pt.x === move.x && pt.y === move.y); | |
if (idx >= 0) { | |
let lockResult = nextPuzzle.lockCellsUpToIndex(branch, idx); | |
nextPuzzle = lockResult.updatedPuzzle; | |
} | |
return nextPuzzle; | |
} | |
// ---------------- Placement Strategies ---------------- | |
placeMirror(branch, elementSpec, allowedSplitterBranches = null) { | |
let moves = []; | |
let candidateCellCoords = new Set(); | |
for (const pathPoint of branch) { | |
const { x, y, dirIndex } = pathPoint; | |
if (y < 0 || y >= ROWS || x < 0 || x >= COLS) continue; | |
if (this.puzzle.lockedCells.has(`${x},${y}`)) continue; | |
if (this.puzzle.grid[y][x].type !== TILE_EMPTY) continue; | |
candidateCellCoords.add(`${x},${y}`); | |
} | |
let filteredCellCoords = new Set(); | |
for (const cellCoord of candidateCellCoords) { | |
const [x, y] = cellCoord.split(",").map(Number); | |
let isOldLaserCell = false; | |
const cellKey = `${x},${y}`; | |
let firstIndex = -1; | |
for (let i = 0; i < branch.length; i++) { | |
if (branch[i].x === x && branch[i].y === y) { | |
firstIndex = i; | |
break; | |
} | |
} | |
if (firstIndex !== -1) { | |
for (let j = 0; j < firstIndex; j++) { | |
if (branch[j].x === x && branch[j].y === y) { | |
isOldLaserCell = true; | |
break; | |
} | |
} | |
} | |
if (!isOldLaserCell) { | |
filteredCellCoords.add(cellCoord); | |
} | |
} | |
for (const cellCoord of filteredCellCoords) { | |
const [x, y] = cellCoord.split(",").map(Number); | |
let incomingDir = -1; | |
for (const pathPoint of branch) { | |
if (pathPoint.x === x && pathPoint.y === y) { | |
const ptIndex = branch.indexOf(pathPoint); | |
if (ptIndex > 0) { | |
incomingDir = branch[ptIndex - 1].dirIndex; | |
if (incomingDir !== -1) break; | |
} | |
} | |
} | |
for (let rot = 0; rot < 8; rot++) { | |
if (incomingDir !== -1 && this.puzzle.mirrorReflect(incomingDir, rot) < 0) continue; | |
moves.push({ | |
x, y, | |
newState: { type: TILE_MIRROR, rotation: rot }, | |
directionInfo: `rotation ${directionNames[rot]}` | |
}); | |
} | |
} | |
return moves; | |
} | |
placeSplitter(branch, elementSpec, allowedSplitterBranches = null) { | |
let moves = []; | |
let candidateCellCoords = new Set(); | |
for (const pathPoint of branch) { | |
const { x, y, dirIndex } = pathPoint; | |
if (y < 0 || y >= ROWS || x < 0 || x >= COLS) continue; | |
if (this.puzzle.lockedCells.has(`${x},${y}`)) continue; | |
if (this.puzzle.grid[y][x].type !== TILE_EMPTY) continue; | |
candidateCellCoords.add(`${x},${y}`); | |
} | |
let filteredCellCoords = new Set(); | |
for (const cellCoord of candidateCellCoords) { | |
const [x, y] = cellCoord.split(",").map(Number); | |
let isOldLaserCell = false; | |
const cellKey = `${x},${y}`; | |
let firstIndex = -1; | |
for (let i = 0; i < branch.length; i++) { | |
if (branch[i].x === x && branch[i].y === y) { | |
firstIndex = i; | |
break; | |
} | |
} | |
if (firstIndex !== -1) { | |
for (let j = 0; j < firstIndex; j++) { | |
if (branch[j].x === x && branch[j].y === y) { | |
isOldLaserCell = true; | |
break; | |
} | |
} | |
} | |
if (!isOldLaserCell) { | |
filteredCellCoords.add(cellCoord); | |
} | |
} | |
for (const cellCoord of filteredCellCoords) { | |
const [x, y] = cellCoord.split(",").map(Number); | |
let dirs = [...Array(8).keys()]; | |
let subMoves = []; | |
for (let a = 0; a < dirs.length; a++) { | |
for (let b = a + 1; b < dirs.length; b++) { | |
subMoves.push({ | |
x, y, | |
newState: { type: TILE_SPLITTER, splitDirs: [dirs[a], dirs[b]] }, | |
directionInfo: `splitDirs [${directionNames[dirs[a]]}, ${directionNames[dirs[b]]}]` | |
}); | |
} | |
} | |
return subMoves; | |
} | |
return moves; | |
} | |
placeReceptor(branch, elementSpec, allowedSplitterBranches = null) { | |
let moves = []; | |
let candidateCellCoords = new Set(); | |
for (const pathPoint of branch) { | |
const { x, y, dirIndex } = pathPoint; | |
if (y < 0 || y >= ROWS || x < 0 || x >= COLS) continue; | |
if (this.puzzle.lockedCells.has(`${x},${y}`)) continue; | |
if (this.puzzle.grid[y][x].type !== TILE_EMPTY) continue; | |
candidateCellCoords.add(`${x},${y}`); | |
} | |
let filteredCellCoords = new Set(); | |
for (const cellCoord of candidateCellCoords) { | |
const [x, y] = cellCoord.split(",").map(Number); | |
let isOldLaserCell = false; | |
const cellKey = `${x},${y}`; | |
let firstIndex = -1; | |
for (let i = 0; i < branch.length; i++) { | |
if (branch[i].x === x && branch[i].y === y) { | |
firstIndex = i; | |
break; | |
} | |
} | |
if (firstIndex !== -1) { | |
for (let j = 0; j < firstIndex; j++) { | |
if (branch[j].x === x && branch[j].y === y) { | |
isOldLaserCell = true; | |
break; | |
} | |
} | |
} | |
if (!isOldLaserCell) { | |
filteredCellCoords.add(cellCoord); | |
} | |
} | |
for (const cellCoord of filteredCellCoords) { | |
const [x, y] = cellCoord.split(",").map(Number); | |
moves.push({ | |
x, y, | |
newState: { type: TILE_RECEPTOR } | |
}); | |
} | |
return moves; | |
} | |
// ---------------- Debugging ---------------- | |
renderGridAscii(grid, currentBranch = []) { | |
if (!DEBUG_MODE && !this.verbose) return; // Keep debug mode and verbose control | |
let gridStr = ""; | |
const pathsCoords = new Set(); | |
const allPaths = this.puzzle.simulateAndGetAllPaths(); | |
for (const path of allPaths) { | |
for (const pt of path) { | |
pathsCoords.add(`${pt.x},${pt.y}`); | |
} | |
} | |
const currentBranchCoords = new Set(currentBranch.map(pt => `${pt.x},${pt.y}`)); | |
let receptorCount = 0; | |
for (let r = 0; r < grid.length; r++) { | |
for (let c = 0; c < grid[0].length; c++) { | |
const tile = grid[r][c]; | |
let char = '.'; | |
const isPathCell = pathsCoords.has(`${c},${r}`); | |
const isCurrentBranchCell = currentBranchCoords.has(`${c},${r}`); | |
if (tile.type === TILE_LASER) char = 'L'; | |
else if (tile.type === TILE_MIRROR) char = 'M'; | |
else if (tile.type === TILE_SPLITTER) char = 'S'; | |
else if (tile.type === TILE_RECEPTOR) { | |
char = 'R'; | |
receptorCount++; | |
} | |
else if (this.puzzle.lockedCells.has(`${c},${r}`)) char = 'x'; | |
else if (isCurrentBranchCell) char = '#'; | |
else if (isPathCell) char = '*'; | |
gridStr += char; | |
} | |
gridStr += "\n"; | |
} | |
console.log("\n--- Grid ASCII (Receptor Count: " + receptorCount + ") ---"); // Always log grid | |
console.log(gridStr); | |
console.log("--- End Grid ASCII ---"); | |
} | |
} | |
const levelsContainer = container.querySelector('#laserhack-levels'); | |
const canvasEl = container.querySelector('#laserhack-canvas'); | |
let rendererPuzzle = new PuzzleLogic(COLS, ROWS); | |
let renderer = new PuzzleRenderer(canvasEl, rendererPuzzle); | |
renderer.puzzle = rendererPuzzle; | |
let currentLevelIndex = 3; | |
let splitterState = { | |
active: false, | |
x: -1, | |
y: -1, | |
step: 0, | |
element: null | |
}; | |
let generator = null; | |
const verboseLogging = false; | |
function nextLevel() { | |
currentLevelIndex++; | |
if (currentLevelIndex >= levels.length) | |
endCallback('success'); | |
else | |
initLevel(); | |
} | |
function initLevel() { | |
console.log(`=== INIT LEVEL ${currentLevelIndex + 1} ===`); | |
let elementQueue = elementQueueFactory(currentLevelIndex); | |
generator = new PuzzleGenerator(renderer.puzzle, elementQueue, verboseLogging); | |
const generationSuccess = generator.generate(); // Capture return value | |
if (generationSuccess) { | |
console.log("Puzzle generated successfully with backtracking!"); | |
// generator.renderGridAscii(generator.puzzle.grid); // Moved to generate method | |
renderer.puzzle = generator.puzzle; | |
} else { | |
console.error("Puzzle generation failed after exhaustive backtracking."); | |
// generator.renderGridAscii(generator.puzzle.grid); // Moved to generate method, will run even on fail | |
} | |
updateLevelIndicator(); | |
} | |
function updateLevelIndicator() { | |
levelsContainer.innerHTML = ''; | |
for (let i = 0; i < maxLevels; i++) { | |
const block = document.createElement('div'); | |
block.classList.add('level-block'); | |
if (i <= currentLevelIndex) block.classList.add('active'); | |
levelsContainer.appendChild(block); | |
} | |
} | |
function onClick(e) { | |
const rect = canvasEl.getBoundingClientRect(); | |
const mx = e.clientX - rect.left, my = e.clientY - rect.top; | |
const cx = Math.floor(mx / renderer.cellSize), cy = Math.floor(my / renderer.cellSize); | |
if (cx < 0 || cx >= COLS || cy >= ROWS || cy < 0) { | |
if (splitterState.active) { | |
splitterState.active = false; | |
if (splitterState.element) { | |
splitterState.element.classList.remove('splitter-selecting'); | |
splitterState.element = null; | |
} | |
} | |
return; | |
} | |
let tile = renderer.puzzle.grid[cy][cx]; | |
if (splitterState.active) { | |
if (cx === splitterState.x && cy === splitterState.y) { | |
splitterState.active = false; | |
if (splitterState.element) { | |
splitterState.element.classList.remove('splitter-selecting'); | |
splitterState.element = null; | |
} | |
console.log("Splitter direction selection cancelled."); | |
return; | |
} | |
const dx = cx - splitterState.x, dy = cy - splitterState.y; | |
const len = Math.sqrt(dx * dx + dy * dy); | |
if (len > 0.01) { | |
let bestDir = -1, bestDot = -999; | |
const ndx = dx / len, ndy = dy / len; | |
for (let i = 0; i < 8; i++) { | |
const dv = directionVectors[i]; | |
ctx.beginPath(); | |
ctx.moveTo(px, py); | |
ctx.lineTo(px + dv.x * cs, py + dv.y * cs); | |
ctx.stroke(); | |
} | |
} | |
return; | |
} | |
if (tile.type === TILE_LASER || tile.type === TILE_MIRROR) { | |
const newRotation = (tile.rotation + 1) % 8; | |
renderer.puzzle = renderer.puzzle.withUpdatedTile(cy, cx, (oldTile) => oldTile.withRotation(newRotation)); | |
console.log(`${tile.type} @(${cx},${cy}) => rotation ${directionNames[newRotation]}`); | |
} else if (tile.type === TILE_RECEPTOR) { | |
if (DEBUG_MODE) { | |
console.log("DEBUG => nextLevel()"); | |
nextLevel(); | |
} else { | |
if (tile.lit) nextLevel(); | |
} | |
} else if (tile.type === TILE_SPLITTER) { | |
splitterState.active = true; | |
splitterState.x = cx; | |
splitterState.y = cy; | |
splitterState.step = 0; | |
splitterState.element = canvasEl; | |
splitterState.element.classList.add('splitter-selecting'); | |
tile.splitDirs = []; | |
console.log(`Clicked SPLITTER @(${cx},${cy}), pick 2 directions...`); | |
} | |
} | |
function onResize() { | |
renderer.resize(); | |
} | |
function animate() { | |
renderer.puzzle = renderer.puzzle.simulateBeams(); | |
renderer.draw(splitterState); | |
requestAnimationFrame(animate); | |
} | |
renderer.resize(); | |
window.addEventListener('resize', onResize); | |
canvasEl.addEventListener('click', onClick); | |
initLevel(); | |
animate(); | |
return { | |
destroy() { | |
window.removeEventListener('resize', onResize); | |
canvasEl.removeEventListener('click', onClick); | |
if (splitterState.element) { | |
splitterState.element.classList.remove('splitter-selecting'); | |
} | |
} | |
}; | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment