1import { useSignal } from "@preact/signals";2import { useEffect, useMemo, useRef } from "preact/hooks";34import { shouldReloadSavedProgress } from "@/lib/shikaku/game_session.ts";5import {6 createRectangleFromCorners,7 getRectangleCells,8 isPuzzleSolved,9 validateRectanglePlacement,10} from "@/lib/shikaku/engine.ts";11import { getGridPointFromBoardPosition } from "@/lib/shikaku/interaction.ts";12import { getClueSymbol } from "@/lib/shikaku/clue_symbol.ts";13import { getBoardColor } from "@/lib/shikaku/palette.ts";14import { formatElapsed, getSolveRank } from "@/lib/shikaku/rank.ts";15import {16 canRemovePlacedRectangle,17 getStatusTone,18} from "@/lib/shikaku/play_state.ts";19import {20 getDailyStreak,21 loadPuzzleProgress,22 savePuzzleProgress,23} from "@/lib/storage/local_progress.ts";24import { isSfxEnabled, playSfx, setSfxEnabled } from "@/lib/audio/sfx.ts";25import type {26 GridPoint,27 PlacedRectangle,28 PuzzleProgress,29 ShikakuPuzzle,30} from "@/lib/shikaku/types.ts";31import ShareDailyStackButton from "./ShareDailyStreakButton.tsx";3233interface ShikakuGameProps {34 puzzle: ShikakuPuzzle;35 readOnly?: boolean;36 resetEnabled?: boolean;37 lockedMessage?: string;38 initialMessage?: string;39 solvedMessage?: string;40 onReset?: () => void;41 onSolved?: () => void;42}4344const RANK_LABEL = {45 squire: "Floor cleared · Squire",46 knight: "Floor cleared · Knight",47 paladin: "Floor cleared · Paladin",48} as const;4950export default function ShikakuGame(props: ShikakuGameProps) {51 const {52 puzzle,53 readOnly = false,54 resetEnabled,55 lockedMessage = "View only.",56 initialMessage,57 solvedMessage = "Solved!",58 onReset,59 onSolved,60 } = props;61 const resolvedInitialMessage = initialMessage ??62 (readOnly ? lockedMessage : "Drag a box around one number.");63 const resolvedResetEnabled = resetEnabled ?? !readOnly;6465 const boardRef = useRef<HTMLDivElement>(null);66 const previousPuzzleIdRef = useRef("");67 const startedAtRef = useRef<string>("");68 const resetsRef = useRef(0);69 const mistakesRef = useRef(0);70 const elapsedRef = useRef(0);7172 const rectangles = useSignal<PlacedRectangle[]>([]);73 const activeClueId = useSignal<number | null>(null);74 const dragAnchor = useSignal<GridPoint | null>(null);75 const dragCurrent = useSignal<GridPoint | null>(null);76 const dragPointerId = useSignal<number | null>(null);77 const status = useSignal<"in_progress" | "solved">("in_progress");78 const message = useSignal(resolvedInitialMessage);79 const hydrated = useSignal(false);80 const sfxOn = useSignal(true);81 const shaking = useSignal(false);82 const justPlacedClueId = useSignal<number | null>(null);83 const celebrate = useSignal(false);84 const streak = useSignal(0);8586 function persistProgress(87 nextRectangles: PlacedRectangle[],88 nextStatus: "in_progress" | "solved",89 ) {90 const now = new Date().toISOString();91 if (!startedAtRef.current) startedAtRef.current = now;92 if (nextStatus === "solved" && elapsedRef.current === 0) {93 elapsedRef.current = Date.parse(now) -94 Date.parse(startedAtRef.current);95 }96 const progress: PuzzleProgress = {97 date: puzzle.date,98 puzzleId: puzzle.id,99 startedAt: startedAtRef.current,100 updatedAt: now,101 status: nextStatus,102 elapsedMs: elapsedRef.current,103 mistakes: mistakesRef.current,104 selections: nextRectangles.map((rectangle) => ({105 clueId: rectangle.clueId,106 cells: getRectangleCells(rectangle),107 value: rectangle.value,108 })),109 notes: { resets: String(resetsRef.current) },110 lastViewedAt: now,111 clientVersion: 1,112 };113 savePuzzleProgress(globalThis.localStorage, progress);114 }115116 useEffect(() => {117 sfxOn.value = isSfxEnabled(globalThis.localStorage);118 }, []);119120 useEffect(() => {121 if (!shouldReloadSavedProgress(previousPuzzleIdRef.current, puzzle.id)) {122 return;123 }124125 previousPuzzleIdRef.current = puzzle.id;126 const saved = loadPuzzleProgress(globalThis.localStorage, puzzle.id);127 rectangles.value = saved ? selectionsToRectangles(saved, puzzle) : [];128 status.value = saved?.status === "solved" ? "solved" : "in_progress";129 message.value = saved?.status === "solved"130 ? solvedMessage131 : resolvedInitialMessage;132 startedAtRef.current = saved?.startedAt ?? "";133 elapsedRef.current = saved?.elapsedMs ?? 0;134 mistakesRef.current = saved?.mistakes ?? 0;135 resetsRef.current = Number(saved?.notes.resets ?? 0) || 0;136 celebrate.value = false;137 streak.value = getDailyStreak(globalThis.localStorage, puzzle.date);138 hydrated.value = true;139 }, [puzzle.id, resolvedInitialMessage, solvedMessage]);140141 useEffect(() => {142 if (!hydrated.value || readOnly) return;143 persistProgress(rectangles.value, status.value);144 }, [puzzle.id, rectangles.value, status.value, hydrated.value, readOnly]);145146 const clueEntries = useMemo(() => {147 return puzzle.givens.map((given, index) => {148 const rectangle = rectangles.value.find((item) =>149 item.clueId === given.id150 );151 return {152 given,153 color: getBoardColor(index),154 placed: Boolean(rectangle),155 active: activeClueId.value === given.id,156 };157 });158 }, [puzzle.givens, rectangles.value, activeClueId.value]);159160 const colorByClueId = useMemo(() => {161 const map = new Map<number, string>();162 puzzle.givens.forEach((given, index) =>163 map.set(given.id, getBoardColor(index))164 );165 return map;166 }, [puzzle.givens]);167168 const previewRectangle = dragAnchor.value && dragCurrent.value169 ? createRectangleFromCorners(dragAnchor.value, dragCurrent.value)170 : null;171 const previewCells = new Set(172 previewRectangle ? getRectangleCells(previewRectangle) : [],173 );174175 function sfx(name: Parameters<typeof playSfx>[0]) {176 playSfx(name, sfxOn.value);177 }178179 function shake() {180 shaking.value = true;181 setTimeout(() => (shaking.value = false), 240);182 }183184 function getContainingRectangle(point: GridPoint) {185 return rectangles.value.find((rectangle) =>186 point.row >= rectangle.row &&187 point.row < rectangle.row + rectangle.height &&188 point.col >= rectangle.col && point.col < rectangle.col + rectangle.width189 );190 }191192 function getBoardPointFromPointer(event: PointerEvent): GridPoint | null {193 const board = boardRef.current;194 if (!board) return null;195196 const rect = board.getBoundingClientRect();197 return getGridPointFromBoardPosition(198 {199 left: rect.left,200 top: rect.top,201 width: rect.width,202 height: rect.height,203 },204 { clientX: event.clientX, clientY: event.clientY },205 { rows: puzzle.height, cols: puzzle.width },206 );207 }208209 function beginDrag(point: GridPoint, pointerId: number) {210 dragAnchor.value = point;211 dragCurrent.value = point;212 dragPointerId.value = pointerId;213 if (!startedAtRef.current) startedAtRef.current = new Date().toISOString();214215 const startingClue = puzzle.givens.find((given) =>216 given.row === point.row && given.col === point.col217 );218 activeClueId.value = startingClue?.id ?? null;219 message.value = "Release when the box holds exactly one number.";220 sfx("tap");221 }222223 function updateDrag(point: GridPoint, pointerId: number) {224 if (dragPointerId.value !== pointerId || !dragAnchor.value) return;225 dragCurrent.value = point;226227 const candidate = createRectangleFromCorners(dragAnchor.value, point);228 const cells = new Set(getRectangleCells(candidate));229 const enclosedClues = puzzle.givens.filter((given) =>230 cells.has(`${given.row},${given.col}`)231 );232 activeClueId.value = enclosedClues.length === 1233 ? enclosedClues[0].id234 : null;235 }236237 function clearDrag() {238 dragAnchor.value = null;239 dragCurrent.value = null;240 dragPointerId.value = null;241 activeClueId.value = null;242 }243244 function commitDrag(point: GridPoint, pointerId: number) {245 if (dragPointerId.value !== pointerId || !dragAnchor.value) return;246247 const candidate = createRectangleFromCorners(dragAnchor.value, point);248 const activeRectangle = getContainingRectangle(dragAnchor.value);249 const existingWithoutActive = activeRectangle250 ? rectangles.value.filter((rectangle) => rectangle !== activeRectangle)251 : rectangles.value;252253 const validation = validateRectanglePlacement(254 puzzle,255 candidate,256 existingWithoutActive,257 );258 clearDrag();259260 if (!validation.ok) {261 mistakesRef.current += 1;262 message.value = humanizeValidation(validation.reason);263 sfx("invalid");264 shake();265 return;266 }267268 const nextRectangle: PlacedRectangle = {269 clueId: validation.given.id,270 ...candidate,271 value: validation.given.value,272 };273274 const nextRectangles = [...existingWithoutActive, nextRectangle];275 rectangles.value = nextRectangles;276 justPlacedClueId.value = nextRectangle.clueId;277 setTimeout(() => (justPlacedClueId.value = null), 200);278279 if (isPuzzleSolved(puzzle, nextRectangles)) {280 status.value = "solved";281 persistProgress(nextRectangles, "solved");282 message.value = solvedMessage;283 celebrate.value = true;284 streak.value = getDailyStreak(globalThis.localStorage, puzzle.date);285 sfx("solve");286 onSolved?.();287 return;288 }289290 message.value =291 `${nextRectangles.length} of ${puzzle.givens.length} rooms sealed.`;292 sfx("place");293 }294295 function handleCellPointerDown(point: GridPoint, pointerId: number) {296 if (readOnly) {297 message.value = lockedMessage;298 clearDrag();299 return;300 }301302 const containing = getContainingRectangle(point);303 if (containing) {304 if (!canRemovePlacedRectangle(status.value)) {305 message.value = "Solved board locked.";306 clearDrag();307 return;308 }309310 rectangles.value = rectangles.value.filter((rectangle) =>311 rectangle !== containing312 );313 if (status.value === "solved") status.value = "in_progress";314 clearDrag();315 message.value = "Room opened.";316 sfx("remove");317 return;318 }319320 beginDrag(point, pointerId);321 }322323 function handleBoardPointerDown(event: PointerEvent) {324 if (readOnly) return;325 event.preventDefault();326 const point = getBoardPointFromPointer(event);327 if (!point) return;328329 boardRef.current?.setPointerCapture?.(event.pointerId);330 handleCellPointerDown(point, event.pointerId);331 }332333 function handleBoardPointerMove(event: PointerEvent) {334 if (readOnly) return;335 const point = getBoardPointFromPointer(event);336 if (!point) return;337 updateDrag(point, event.pointerId);338 }339340 function handleBoardPointerUp(event: PointerEvent) {341 if (readOnly) return;342 event.preventDefault();343 const point = getBoardPointFromPointer(event);344 if (!point) {345 clearDrag();346 return;347 }348349 commitDrag(point, event.pointerId);350 boardRef.current?.releasePointerCapture?.(event.pointerId);351 }352353 function handleBoardPointerCancel(event: PointerEvent) {354 if (readOnly) return;355 clearDrag();356 boardRef.current?.releasePointerCapture?.(event.pointerId);357 }358359 function clearBoard() {360 if (readOnly) {361 if (!resolvedResetEnabled) {362 message.value = lockedMessage;363 return;364 }365 onReset?.();366 return;367 }368369 if (rectangles.value.length > 0) resetsRef.current += 1;370 rectangles.value = [];371 clearDrag();372 status.value = "in_progress";373 celebrate.value = false;374 elapsedRef.current = 0;375 message.value = "Board cleared.";376 sfx("clear");377 onReset?.();378 }379380 function toggleSfx() {381 sfxOn.value = !sfxOn.value;382 setSfxEnabled(globalThis.localStorage, sfxOn.value);383 if (sfxOn.value) playSfx("tap", true);384 }385386 const placedCount = rectangles.value.length;387 const total = puzzle.givens.length;388 const solved = status.value === "solved";389 const rank = getSolveRank({390 elapsedMs: elapsedRef.current,391 resets: resetsRef.current,392 });393394 return (395 <section class="game">396 <div class={`hud panel hud--${getStatusTone(status.value)}`}>397 <span class="hud__rooms" aria-hidden="true">398 {puzzle.givens.map((_, index) => (399 <i key={index} class={index < placedCount ? "on" : ""} />400 ))}401 </span>402 <span403 class="hud__count"404 aria-label={`${placedCount} of ${total} rooms`}405 >406 {placedCount}407 <small>/{total}</small>408 </span>409 <span class="hud__msg" role="status">{message}</span>410 <div class="hud__actions">411 <button412 class="btn btn--ghost btn--sm btn--icon"413 type="button"414 onClick={toggleSfx}415 aria-label={sfxOn.value ? "Mute sounds" : "Unmute sounds"}416 aria-pressed={sfxOn.value}417 >418 {sfxOn.value ? "♪" : "✕"}419 </button>420 <button421 class="btn btn--ghost btn--sm"422 type="button"423 onClick={clearBoard}424 disabled={readOnly && !resolvedResetEnabled}425 aria-disabled={readOnly && !resolvedResetEnabled}426 >427 Reset428 </button>429 </div>430 </div>431432 <div class="board-wrap">433 <div434 ref={boardRef}435 class={`board${shaking.value ? " is-shake" : ""}`}436 style={{437 gridTemplateColumns: `repeat(${puzzle.width}, minmax(0, 1fr))`,438 }}439 onContextMenu={(event) => event.preventDefault()}440 onPointerDown={handleBoardPointerDown}441 onPointerMove={handleBoardPointerMove}442 onPointerUp={handleBoardPointerUp}443 onPointerCancel={handleBoardPointerCancel}444 >445 {Array.from({ length: puzzle.height * puzzle.width }, (_, index) => {446 const row = Math.floor(index / puzzle.width);447 const col = index % puzzle.width;448 const given = puzzle.givens.find((item) =>449 item.row === row && item.col === col450 );451 const rectangle = rectangles.value.find((item) =>452 row >= item.row && row < item.row + item.height &&453 col >= item.col && col < item.col + item.width454 );455 const isActive = given ? activeClueId.value === given.id : false;456 const isPreview = !rectangle && previewCells.has(`${row},${col}`);457 const color = rectangle458 ? colorByClueId.get(rectangle.clueId) ?? getBoardColor(0)459 : undefined;460461 const classes = ["cell"];462 if (rectangle) classes.push("is-filled");463 if (isPreview) classes.push("is-preview");464 if (isActive) classes.push("is-active");465 if (rectangle && rectangle.clueId === justPlacedClueId.value) {466 classes.push("is-new");467 }468 if (rectangle && celebrate.value) classes.push("is-lit");469470 return (471 <button472 type="button"473 key={`${row}-${col}`}474 data-row={row}475 data-col={col}476 class={classes.join(" ")}477 tabIndex={-1}478 style={{479 ["--cell-color" as string]: color,480 animationDelay: celebrate.value481 ? `${(row + col) * 45}ms`482 : undefined,483 }}484 >485 {given ? <span class="cell__num">{given.value}</span> : null}486 </button>487 );488 })}489 </div>490 </div>491492 <div class="tray">493 {solved494 ? (495 <div class="result panel">496 <div class="result__rank">{RANK_LABEL[rank]}</div>497 <div class="result__stats">498 <div>499 <b>{formatElapsed(elapsedRef.current)}</b>500 <span class="label">time</span>501 </div>502 <div>503 <b>{resetsRef.current}</b>504 <span class="label">resets</span>505 </div>506 <div>507 <b>{streak.value}</b>508 <span class="label">streak</span>509 </div>510 </div>511 <div class="result__actions">512 <ShareDailyStackButton date={puzzle.date} class="btn" />513 </div>514 </div>515 )516 : (517 <div class="tokens" aria-label="Clue legend">518 {clueEntries.map((entry) => (519 <button520 key={entry.given.id}521 type="button"522 class={`token${entry.placed ? " is-placed" : ""}${523 entry.active ? " is-active" : ""524 }`}525 style={{ ["--token-color" as string]: entry.color }}526 onClick={() => {527 if (readOnly) {528 message.value = lockedMessage;529 return;530 }531 activeClueId.value = entry.given.id;532 message.value = `Clue ${entry.given.value} highlighted.`;533 sfx("tap");534 }}535 aria-label={`Clue ${entry.given.value} at row ${536 entry.given.row + 1537 }, col ${entry.given.col + 1}`}538 >539 <span class="token__sym" aria-hidden="true">540 {getClueSymbol(entry.given.value)}541 </span>542 <span>{entry.given.value}</span>543 </button>544 ))}545 </div>546 )}547 </div>548 </section>549 );550}551552function selectionsToRectangles(553 progress: PuzzleProgress,554 puzzle: ShikakuPuzzle,555): PlacedRectangle[] {556 return progress.selections.map((selection) => {557 const coordinates = selection.cells.map(parseCell);558 const rows = coordinates.map((cell) => cell.row);559 const cols = coordinates.map((cell) => cell.col);560 const row = Math.min(...rows);561 const col = Math.min(...cols);562 const height = Math.max(...rows) - row + 1;563 const width = Math.max(...cols) - col + 1;564 const clueId = selection.clueId >= 0565 ? selection.clueId566 : puzzle.givens.find((given) =>567 given.row >= row && given.row < row + height && given.col >= col &&568 given.col < col + width && given.value === selection.value569 )?.id ?? -1;570571 return { clueId, row, col, width, height, value: selection.value };572 }).filter((rectangle) => rectangle.clueId >= 0);573}574575function parseCell(cell: string): GridPoint {576 const [row, col] = cell.split(",").map(Number);577 return { row, col };578}579580function humanizeValidation(reason: string): string {581 switch (reason) {582 case "out-of-bounds":583 return "Outside the board.";584 case "wrong-area":585 return "Box size must match the number.";586 case "missing-given":587 return "The box needs one number inside.";588 case "multiple-givens":589 return "Only one number per box.";590 case "overlap":591 return "Boxes can't overlap.";592 default:593 return "That move is not valid.";594 }595}