Spaces:
Running
Running
| import { useState } from "react"; | |
| import type { PuzzleType } from "../lib/api"; | |
| type Props = { | |
| puzzleType: PuzzleType; | |
| problemAscii: string; | |
| boardAscii: string; | |
| onChange: (nextAscii: string) => void; | |
| }; | |
| type BridgesOpportunity = { | |
| id: string; | |
| kind: "horizontal" | "vertical"; | |
| cells: Array<[number, number]>; | |
| }; | |
| const FLOW_FREE_COLORS = [ | |
| "#f15b5d", | |
| "#f5a623", | |
| "#f6df5a", | |
| "#7ed957", | |
| "#36cfc9", | |
| "#4b8ef7", | |
| "#7a5cff", | |
| "#ff8bd1", | |
| "#7d5a50", | |
| "#5f6a6b", | |
| ]; | |
| function splitLines(board: string): string[] { | |
| return board.replace(/\r/g, "").split("\n"); | |
| } | |
| function cloneGrid(lines: string[]): string[][] { | |
| return lines.map((line) => [...line]); | |
| } | |
| function joinGrid(grid: string[][]): string { | |
| return grid.map((row) => row.join("")).join("\n"); | |
| } | |
| function isClueChar(char: string): boolean { | |
| return /[0-9A-G]/.test(char); | |
| } | |
| function parseBridges(problemAscii: string) { | |
| const lines = splitLines(problemAscii); | |
| const opportunities: BridgesOpportunity[] = []; | |
| const cellToOpportunity = new Map<string, BridgesOpportunity>(); | |
| for (let r = 0; r < lines.length; r += 1) { | |
| for (let c = 0; c < lines[r].length; c += 1) { | |
| if (!isClueChar(lines[r][c])) { | |
| continue; | |
| } | |
| let next = c + 1; | |
| while (next < lines[r].length && lines[r][next] === ".") { | |
| next += 1; | |
| } | |
| if (next < lines[r].length && isClueChar(lines[r][next]) && next > c + 1) { | |
| const opp: BridgesOpportunity = { | |
| id: `h-${r}-${c}-${next}`, | |
| kind: "horizontal", | |
| cells: [], | |
| }; | |
| for (let cell = c + 1; cell < next; cell += 1) { | |
| opp.cells.push([r, cell]); | |
| cellToOpportunity.set(`${r}:${cell}`, opp); | |
| } | |
| opportunities.push(opp); | |
| } | |
| let nextRow = r + 1; | |
| while (nextRow < lines.length && c < lines[nextRow].length && lines[nextRow][c] === ".") { | |
| nextRow += 1; | |
| } | |
| if ( | |
| nextRow < lines.length && | |
| c < lines[nextRow].length && | |
| isClueChar(lines[nextRow][c]) && | |
| nextRow > r + 1 | |
| ) { | |
| const opp: BridgesOpportunity = { | |
| id: `v-${r}-${c}-${nextRow}`, | |
| kind: "vertical", | |
| cells: [], | |
| }; | |
| for (let cell = r + 1; cell < nextRow; cell += 1) { | |
| opp.cells.push([cell, c]); | |
| cellToOpportunity.set(`${cell}:${c}`, opp); | |
| } | |
| opportunities.push(opp); | |
| } | |
| } | |
| } | |
| return { lines, opportunities, cellToOpportunity }; | |
| } | |
| function bridgesBoardFromStates( | |
| problemAscii: string, | |
| states: Record<string, 0 | 1 | 2>, | |
| ): string { | |
| const parsed = parseBridges(problemAscii); | |
| const grid = cloneGrid(parsed.lines); | |
| for (const opportunity of parsed.opportunities) { | |
| const level = states[opportunity.id] ?? 0; | |
| const symbol = | |
| opportunity.kind === "horizontal" | |
| ? level === 2 | |
| ? "=" | |
| : level === 1 | |
| ? "-" | |
| : "." | |
| : level === 2 | |
| ? '"' | |
| : level === 1 | |
| ? "|" | |
| : "."; | |
| for (const [r, c] of opportunity.cells) { | |
| grid[r][c] = symbol; | |
| } | |
| } | |
| return joinGrid(grid); | |
| } | |
| function bridgesStatesFromBoard( | |
| problemAscii: string, | |
| boardAscii: string, | |
| ): Record<string, 0 | 1 | 2> { | |
| const parsed = parseBridges(problemAscii); | |
| const boardLines = splitLines(boardAscii); | |
| const states: Record<string, 0 | 1 | 2> = {}; | |
| for (const opportunity of parsed.opportunities) { | |
| const [r, c] = opportunity.cells[0]; | |
| const char = boardLines[r]?.[c] ?? "."; | |
| if (opportunity.kind === "horizontal") { | |
| states[opportunity.id] = char === "=" ? 2 : char === "-" ? 1 : 0; | |
| } else { | |
| states[opportunity.id] = char === '"' ? 2 : char === "|" ? 1 : 0; | |
| } | |
| } | |
| return states; | |
| } | |
| function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) { | |
| const parsed = parseBridges(problemAscii); | |
| const states = bridgesStatesFromBoard(problemAscii, boardAscii); | |
| function cycle(opportunity: BridgesOpportunity) { | |
| const nextStates = { ...states }; | |
| const current = nextStates[opportunity.id] ?? 0; | |
| nextStates[opportunity.id] = (((current + 1) % 3) as 0 | 1 | 2); | |
| onChange(bridgesBoardFromStates(problemAscii, nextStates)); | |
| } | |
| return ( | |
| <div | |
| className="board-grid" | |
| style={{ gridTemplateColumns: `repeat(${parsed.lines[0]?.length ?? 0}, 38px)` }} | |
| > | |
| {parsed.lines.flatMap((line, r) => | |
| [...line].map((char, c) => { | |
| const opportunity = parsed.cellToOpportunity.get(`${r}:${c}`); | |
| const boardChar = splitLines(boardAscii)[r]?.[c] ?? char; | |
| const className = isClueChar(char) | |
| ? "board-cell fixed" | |
| : opportunity | |
| ? "board-cell" | |
| : "board-cell water"; | |
| return ( | |
| <button | |
| key={`${r}-${c}`} | |
| type="button" | |
| className={className} | |
| onClick={() => opportunity && cycle(opportunity)} | |
| disabled={!opportunity} | |
| > | |
| {isClueChar(char) ? char : boardChar === "." ? "" : boardChar} | |
| </button> | |
| ); | |
| }), | |
| )} | |
| </div> | |
| ); | |
| } | |
| function FlowFreeEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) { | |
| const problemLines = splitLines(problemAscii); | |
| const boardLines = splitLines(boardAscii); | |
| const letters = [...new Set(problemAscii.replace(/[^A-Z]/g, "").split(""))].sort(); | |
| const [activeColor, setActiveColor] = useState(letters[0] ?? "A"); | |
| function updateCell(r: number, c: number) { | |
| if (problemLines[r][c] !== ".") { | |
| setActiveColor(problemLines[r][c]); | |
| return; | |
| } | |
| const grid = cloneGrid(boardLines); | |
| grid[r][c] = grid[r][c] === activeColor ? "." : activeColor; | |
| onChange(joinGrid(grid)); | |
| } | |
| function swatchColor(letter: string) { | |
| return FLOW_FREE_COLORS[(letter.charCodeAt(0) - 65) % FLOW_FREE_COLORS.length]; | |
| } | |
| return ( | |
| <div className="pattern-layout"> | |
| <div className="palette"> | |
| {letters.map((letter) => ( | |
| <button | |
| key={letter} | |
| type="button" | |
| className={activeColor === letter ? "active" : ""} | |
| style={{ background: swatchColor(letter) }} | |
| onClick={() => setActiveColor(letter)} | |
| > | |
| {letter} | |
| </button> | |
| ))} | |
| </div> | |
| <div | |
| className="board-grid" | |
| style={{ gridTemplateColumns: `repeat(${problemLines[0]?.length ?? 0}, 38px)` }} | |
| > | |
| {boardLines.flatMap((line, r) => | |
| [...line].map((char, c) => { | |
| const fixed = problemLines[r][c] !== "."; | |
| const letter = fixed ? problemLines[r][c] : char; | |
| return ( | |
| <button | |
| key={`${r}-${c}`} | |
| type="button" | |
| className={`board-cell ${fixed ? "fixed" : "water"}`} | |
| style={{ | |
| background: | |
| letter === "." | |
| ? undefined | |
| : `${swatchColor(letter)}cc`, | |
| }} | |
| onClick={() => updateCell(r, c)} | |
| > | |
| {letter === "." ? "" : letter} | |
| </button> | |
| ); | |
| }), | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function CharGridEditor({ | |
| problemAscii, | |
| boardAscii, | |
| onChange, | |
| canEdit, | |
| nextChar, | |
| }: { | |
| problemAscii: string; | |
| boardAscii: string; | |
| onChange: (nextAscii: string) => void; | |
| canEdit: (r: number, c: number, char: string) => boolean; | |
| nextChar: (r: number, c: number, current: string, problemChar: string) => string; | |
| }) { | |
| const problemLines = splitLines(problemAscii); | |
| const boardLines = splitLines(boardAscii); | |
| const width = Math.max(...boardLines.map((line) => line.length)); | |
| function updateCell(r: number, c: number) { | |
| const current = boardLines[r]?.[c] ?? " "; | |
| const problemChar = problemLines[r]?.[c] ?? " "; | |
| const grid = boardLines.map((line) => line.padEnd(width, " ").split("")); | |
| grid[r][c] = nextChar(r, c, current, problemChar); | |
| onChange(joinGrid(grid).replace(/\s+$/g, "").replace(/\n\s+$/g, "\n")); | |
| } | |
| return ( | |
| <div | |
| className="char-board" | |
| style={{ gridTemplateColumns: `repeat(${width}, 24px)` }} | |
| > | |
| {Array.from({ length: boardLines.length }).flatMap((_, r) => | |
| Array.from({ length: width }).map((__, c) => { | |
| const problemChar = problemLines[r]?.[c] ?? " "; | |
| const current = boardLines[r]?.[c] ?? " "; | |
| const editable = canEdit(r, c, problemChar); | |
| return ( | |
| <button | |
| key={`${r}-${c}`} | |
| type="button" | |
| className={`board-char ${editable ? "clickable" : ""}`} | |
| disabled={!editable} | |
| onClick={() => updateCell(r, c)} | |
| > | |
| {current === " " ? "\u00A0" : current} | |
| </button> | |
| ); | |
| }), | |
| )} | |
| </div> | |
| ); | |
| } | |
| function LoopyEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) { | |
| return ( | |
| <CharGridEditor | |
| problemAscii={problemAscii} | |
| boardAscii={boardAscii} | |
| onChange={onChange} | |
| canEdit={(r, c, char) => { | |
| if (char !== " ") { | |
| return false; | |
| } | |
| const isHorizontal = r > 0 && r < splitLines(problemAscii).length - 1 && r % 2 === 1 && c % 2 === 0; | |
| const isVertical = r > 0 && r < splitLines(problemAscii).length - 1 && r % 2 === 0 && c % 2 === 1; | |
| return isHorizontal || isVertical; | |
| }} | |
| nextChar={(r, c, current, problemChar) => { | |
| if (problemChar !== " ") { | |
| return current; | |
| } | |
| const isHorizontal = r % 2 === 1 && c % 2 === 0; | |
| if (isHorizontal) { | |
| return current === " " ? "-" : current === "-" ? "x" : " "; | |
| } | |
| return current === " " ? "|" : current === "|" ? "x" : " "; | |
| }} | |
| /> | |
| ); | |
| } | |
| function GalaxiesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) { | |
| return ( | |
| <CharGridEditor | |
| problemAscii={problemAscii} | |
| boardAscii={boardAscii} | |
| onChange={onChange} | |
| canEdit={(r, c, char) => { | |
| if (char !== " ") { | |
| return false; | |
| } | |
| const isHorizontal = r % 2 === 0 && c % 2 === 1; | |
| const isVertical = r % 2 === 1 && c % 2 === 0; | |
| return isHorizontal || isVertical; | |
| }} | |
| nextChar={(r, _c, current, _problemChar) => { | |
| const isHorizontal = r % 2 === 0; | |
| if (isHorizontal) { | |
| return current === "-" ? " " : "-"; | |
| } | |
| return current === "|" ? " " : "|"; | |
| }} | |
| /> | |
| ); | |
| } | |
| function parsePattern(boardAscii: string) { | |
| const lines = splitLines(boardAscii); | |
| const firstContentIndex = lines.findIndex((line) => line.includes("|")); | |
| const topLines = lines.slice(0, firstContentIndex); | |
| const rows: Array<{ prefix: string; cells: string[]; border: string }> = []; | |
| for (let idx = firstContentIndex; idx < lines.length; idx += 2) { | |
| const content = lines[idx]; | |
| const border = lines[idx + 1]; | |
| if (!content || !border) { | |
| break; | |
| } | |
| const firstBar = content.indexOf("|"); | |
| const prefix = content.slice(0, firstBar); | |
| const parts = content.slice(firstBar).split("|").slice(1, -1); | |
| rows.push({ prefix, cells: parts, border }); | |
| } | |
| return { topLines, rows }; | |
| } | |
| function patternToAscii(template: ReturnType<typeof parsePattern>): string { | |
| const lines = [...template.topLines]; | |
| for (const row of template.rows) { | |
| lines.push(`${row.prefix}|${row.cells.join("|")}|`); | |
| lines.push(row.border); | |
| } | |
| return lines.join("\n"); | |
| } | |
| function PatternEditor({ boardAscii, onChange }: Omit<Props, "puzzleType" | "problemAscii"> & { problemAscii: string }) { | |
| const template = parsePattern(boardAscii); | |
| function cycle(rowIndex: number, cellIndex: number) { | |
| const next = parsePattern(boardAscii); | |
| const current = next.rows[rowIndex].cells[cellIndex]; | |
| next.rows[rowIndex].cells[cellIndex] = | |
| current === " " ? "##" : current === "##" ? ".." : " "; | |
| onChange(patternToAscii(next)); | |
| } | |
| return ( | |
| <div className="pattern-layout"> | |
| <pre className="ascii-preview">{template.topLines.join("\n")}</pre> | |
| <div className="pattern-grid"> | |
| {template.rows.map((row, rowIndex) => ( | |
| <div key={rowIndex} className="pattern-row"> | |
| <div className="pattern-clue">{row.prefix.trim()}</div> | |
| <div className="board-grid" style={{ gridTemplateColumns: `repeat(${row.cells.length}, 44px)` }}> | |
| {row.cells.map((cell, cellIndex) => ( | |
| <button | |
| key={`${rowIndex}-${cellIndex}`} | |
| type="button" | |
| className={`board-square ${cell === "##" ? "fill" : cell === ".." ? "empty" : ""}`} | |
| onClick={() => cycle(rowIndex, cellIndex)} | |
| > | |
| {cell === " " ? "" : cell} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function parseUndead(boardAscii: string) { | |
| const lines = splitLines(boardAscii); | |
| const header = lines[0] ?? ""; | |
| const topLine = lines[2] ?? ""; | |
| const bottomLine = lines.at(-1) ?? ""; | |
| const rows = lines.slice(3, -1).map((line) => { | |
| const tokens = line.trim().split(/\s+/); | |
| return { | |
| left: tokens[0] ?? "", | |
| cells: tokens.slice(1, -1), | |
| right: tokens.at(-1) ?? "", | |
| }; | |
| }); | |
| return { header, topLine, bottomLine, rows }; | |
| } | |
| function undeadToAscii(template: ReturnType<typeof parseUndead>): string { | |
| const lines = [template.header, "", template.topLine]; | |
| for (const row of template.rows) { | |
| lines.push(` ${row.left} ${row.cells.join(" ")} ${row.right}`); | |
| } | |
| lines.push(template.bottomLine); | |
| return lines.join("\n"); | |
| } | |
| function UndeadEditor({ boardAscii, onChange }: Omit<Props, "puzzleType" | "problemAscii"> & { problemAscii: string }) { | |
| const template = parseUndead(boardAscii); | |
| function cycle(rowIndex: number, cellIndex: number) { | |
| const current = template.rows[rowIndex].cells[cellIndex]; | |
| if (current === "/" || current === "\\") { | |
| return; | |
| } | |
| const next = parseUndead(boardAscii); | |
| next.rows[rowIndex].cells[cellIndex] = | |
| current === "." ? "G" : current === "G" ? "V" : current === "V" ? "Z" : "."; | |
| onChange(undeadToAscii(next)); | |
| } | |
| return ( | |
| <div className="pattern-layout"> | |
| <pre className="ascii-preview">{`${template.header}\n\n${template.topLine}\n${template.bottomLine}`}</pre> | |
| <div className="board-grid" style={{ gridTemplateColumns: `repeat(${template.rows[0]?.cells.length ?? 0}, 38px)` }}> | |
| {template.rows.flatMap((row, rowIndex) => | |
| row.cells.map((cell, cellIndex) => { | |
| const fixedMirror = cell === "/" || cell === "\\"; | |
| return ( | |
| <button | |
| key={`${rowIndex}-${cellIndex}`} | |
| type="button" | |
| className={`board-cell ${fixedMirror ? "mirror" : ""}`} | |
| onClick={() => cycle(rowIndex, cellIndex)} | |
| > | |
| {cell === "." ? "" : cell} | |
| </button> | |
| ); | |
| }), | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export function PuzzleEditor(props: Props) { | |
| if (props.puzzleType === "bridges") { | |
| return <BridgesEditor {...props} />; | |
| } | |
| if (props.puzzleType === "flow_free") { | |
| return <FlowFreeEditor {...props} />; | |
| } | |
| if (props.puzzleType === "galaxies") { | |
| return <GalaxiesEditor {...props} />; | |
| } | |
| if (props.puzzleType === "loopy") { | |
| return <LoopyEditor {...props} />; | |
| } | |
| if (props.puzzleType === "pattern") { | |
| return <PatternEditor {...props} />; | |
| } | |
| if (props.puzzleType === "undead") { | |
| return <UndeadEditor {...props} />; | |
| } | |
| return null; | |
| } | |