ready for publish

This commit is contained in:
2025-10-15 23:06:01 +01:00
parent 73ce666f53
commit 2e51b11a59
18 changed files with 258 additions and 75 deletions
-115
View File
@@ -1,115 +0,0 @@
import type { GameStatus, UseScoreType } from "../../src/types";
import { useIsMobile } from "./useIsMobile";
import { MobileGameOverlay } from "./MobileGameOverlay";
type GameOverlayProps = {
gameStatus: GameStatus;
scoreManager: UseScoreType;
};
export const GameOverlay = ({ gameStatus, scoreManager }: GameOverlayProps) => {
const isMobile = useIsMobile();
if (isMobile) return <MobileGameOverlay gameStatus={gameStatus} scoreManager={scoreManager} />
if (gameStatus === "started") return null;
return (
<GameOverlayContainer>
{gameStatus === "waiting" && <GameWaitingContent />}
{gameStatus === "game-over" && <GameOverContent />}
{gameStatus === "game-won" && <GameWonContent scoreManager={scoreManager} />}
</GameOverlayContainer>
);
};
export const GameWaitingContent = () => {
return (
<div
className="text-[#c8d3f5] text-[2em] font-mono text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<div className="flex flex-col gap-10">
<p className="text-[3rem]">Press <strong className="text-[#82aaff]">New Puzzle</strong> to start</p>
<div className="text-[1.2em]">
<p className="mb-4"><strong className="text-[#ffc777]">Navigation Mode</strong> (default):</p>
<p><strong className="text-[#82aaff]">h</strong>,<strong className="text-[#82aaff]">j</strong>,<strong className="text-[#82aaff]">k</strong>,<strong className="text-[#82aaff]">l</strong> to move around</p>
<p className="mt-2"><strong className="text-[#82aaff]">R</strong> or <strong className="text-[#82aaff]">C</strong> to enter edit mode</p>
<p className="mt-6 mb-4"><strong className="text-[#ffc777]">Edit Mode</strong>:</p>
<p>Type <strong className="text-[#82aaff]">1-9</strong> to fill cell</p>
<p className="mt-2"><strong className="text-[#82aaff]">Esc</strong> or <strong className="text-[#82aaff]">Enter</strong> to exit edit mode</p>
<p className="mt-6 text-[0.8em] text-[#828bb8]">
Try vim motions: <strong className="text-[#82aaff]">$</strong>, <strong className="text-[#82aaff]">^</strong>, <strong className="text-[#82aaff]">0</strong>, counted moves
</p>
</div>
</div>
</div>
);
};
export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) => {
const finalScore = scoreManager.finalScore ?? 0;
return (
<div
className="text-[#c8d3f5] text-[2em] grid gap-4 font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<p className="text-[#c3e88d]">🎉 Puzzle Solved!</p>
<p>Final Score:</p>
<p className="text-[#82aaff] text-[2.5em]">{finalScore.toLocaleString()}</p>
<p className="text-[#828bb8] text-[0.65em] normal-case">
<span className="text-[#c099ff]">max</span>
<span className="text-[#c8d3f5]">(</span>
<span className="text-[#ff966c]">0</span>
<span className="text-[#c8d3f5]">, </span>
<span className="text-[#ff966c]">100000</span>
<span className="text-[#c8d3f5]"> - </span>
<span className="text-[#82aaff]">timeInSeconds</span>
<span className="text-[#c8d3f5]"> * </span>
<span className="text-[#ff966c]">10</span>
<span className="text-[#c8d3f5]">)</span>
</p>
<p className="text-[#828bb8]">Click <strong className="text-[#ffc777] normal-case">New Puzzle</strong> to play again</p>
</div>
);
};
export const GameOverContent = () => {
return (
<div
className="text-[#ff757f] font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<p className="text-[5em]">Game Over</p>
<p className="text-[2em] text-[#828bb8]">Press <strong className="text-[#ffc777] normal-case">i</strong> to play again</p>
</div>
);
};
export const GameOverlayContainer = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<div
className="
absolute inset-0
flex justify-center items-center
z-40
pointer-events-none
"
style={{
backgroundColor: 'rgba(27, 29, 43, 0.75)',
backdropFilter: 'blur(1px)'
}}
>
{children}
</div>
);
};
-96
View File
@@ -1,96 +0,0 @@
import type { GameStatus, UseScoreType } from "../../src/types";
type MobileGameOverlayProps = {
gameStatus: GameStatus;
scoreManager: UseScoreType;
};
export const MobileGameOverlay = ({ gameStatus, scoreManager }: MobileGameOverlayProps) => {
if (gameStatus === "started") return null;
return (
<GameOverlayContainer>
{gameStatus === "waiting" && <GameWaitingContent />}
{gameStatus === "game-over" && <GameOverContent />}
{gameStatus === "game-won" && <GameWonContent scoreManager={scoreManager} />}
</GameOverlayContainer>
);
};
export const GameWaitingContent = () => {
return (
<div
className="text-[#c8d3f5] text-[1em] font-mono text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<div className="flex flex-col gap-3">
<p className="text-[1.5rem]">Tap New Puzzle to start</p>
<p>VIM motions available <br /> via on-screen keyboard</p>
</div>
</div>
);
};
export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) => {
const finalScore = scoreManager.finalScore ?? 0;
return (
<div
className="text-[#c8d3f5] text-[1em] grid gap-4 font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
<p className="text-[#c3e88d]">🎉 Solved!</p>
<p>Final Score:</p>
<p className="text-[#82aaff] text-[2.5em]">{finalScore.toLocaleString()}</p>
<p className="text-[#828bb8] text-[0.65em] normal-case">
<span className="text-[#c099ff]">max</span>
<span className="text-[#c8d3f5]">(</span>
<span className="text-[#ff966c]">0</span>
<span className="text-[#c8d3f5]">, </span>
<span className="text-[#ff966c]">100000</span>
<span className="text-[#c8d3f5]"> - </span>
<span className="text-[#82aaff]">time</span>
<span className="text-[#c8d3f5]"> * </span>
<span className="text-[#ff966c]">10</span>
<span className="text-[#c8d3f5]">)</span>
</p>
<p className="text-[#828bb8]">Tap to play again</p>
</div>
);
};
export const GameOverContent = () => {
return (
<div
className="text-[#ff757f] text-[5em] font-mono uppercase text-center select-none"
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
>
Game Over
</div>
);
};
export const GameOverlayContainer = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<div
className="
absolute inset-0
flex justify-center items-center
z-40
pointer-events-none
"
style={{
backgroundColor: 'rgba(27, 29, 43, 0.75)',
backdropFilter: 'blur(1px)'
}}
>
{children}
</div>
);
};
+9 -8
View File
@@ -51,11 +51,14 @@ function initialize() {
// ---------------------------------------------------------------------------
sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
let numericDifficulty: number;
if (typeof difficulty === "string" || typeof difficulty === "undefined") {
difficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
numericDifficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
} else {
numericDifficulty = difficulty;
}
difficulty = sudoku._force_range(difficulty, NR_SQUARES + 1, MIN_GIVENS);
numericDifficulty = sudoku._force_range(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS);
unique = unique ?? true;
const blank_board = sudoku.BLANK_BOARD;
@@ -74,7 +77,7 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
}
if (
single_candidates.length >= difficulty &&
single_candidates.length >= numericDifficulty &&
sudoku._strip_dups(single_candidates).length >= 8
) {
let board = "";
@@ -90,9 +93,9 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
}
}
if (givens_idxs.length > difficulty) {
if (givens_idxs.length > numericDifficulty) {
givens_idxs = sudoku._shuffle(givens_idxs);
for (let i = 0; i < givens_idxs.length - difficulty; ++i) {
for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) {
const target = givens_idxs[i];
board =
board.substring(0, target) +
@@ -105,7 +108,7 @@ sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
}
}
return sudoku.generate(difficulty);
return sudoku.generate(numericDifficulty);
};
// ---------------------------------------------------------------------------
@@ -187,12 +190,10 @@ sudoku._search = function (candidates: Record<string, string>, reverse: boolean)
reverse = reverse || false;
let max_nr_candidates = 0;
let max_square = "";
for (const s of SQUARES) {
const n = candidates[s].length;
if (n > max_nr_candidates) {
max_nr_candidates = n;
max_square = s;
}
}
if (max_nr_candidates === 1) return candidates;
+5 -19
View File
@@ -1,28 +1,14 @@
/* =========================================================
🔧 Controls (tweak these)
========================================================= */
/* :root { */
/* --cell-size: 32px; */
/* --cell-font-size: 16px; */
/* --grid-gap: 2px; */
/* --box-separator: 3px; */
/* } */
:root {
--cell-size: 48px;
--cell-font-size: 32px;
--grid-gap: 4px;
--box-separator: 6px;
--sudoku-size-multiplier: 3;
--cell-size: calc(32px * var(--sudoku-size-multiplier));
--cell-font-size: calc(16px * var(--sudoku-size-multiplier));
--grid-gap: calc(2px * var(--sudoku-size-multiplier));
--box-separator: calc(3px * var(--sudoku-size-multiplier));
}
/* :root { */
/* --cell-size: 96px; */
/* --cell-font-size: 48px; */
/* --grid-gap: 6px; */
/* --box-separator: 9px; */
/* } */
/* =========================================================
Container & Grid Base
========================================================= */
-1
View File
@@ -7,7 +7,6 @@ export type UseScoreType = ReturnType<typeof useScore>;
export type {
Difficulty,
DifficultyName,
BoardString,
} from "./lib/Sudoku";
export type GamePhase = "idle" | "playing";
+17 -12
View File
@@ -1,9 +1,14 @@
import { useState, useCallback, useMemo } from "react";
import { useState } from "react";
import { sudoku } from "../lib/Sudoku";
import type { UseCursorType } from "./useCursor";
export function useBoardState(cursorManager: UseCursorType) {
type UseBoardStateOptions = {
validateMoves?: boolean;
};
export function useBoardState(cursorManager: UseCursorType, options?: UseBoardStateOptions) {
const { cursor, setCursor } = cursorManager;
const { validateMoves = false } = options || {};
const [board, setBoard] = useState<string>(sudoku.generate("medium"));
const [initialBoard, setInitialBoard] = useState<string>(board);
const [solution, setSolution] = useState<string | null>(null);
@@ -26,8 +31,6 @@ export function useBoardState(cursorManager: UseCursorType) {
const isValidPlacement = (testBoard: string, row: number, col: number, digit: string): boolean => {
if (digit === ".") return true;
const idx = row * 9 + col;
for (let c = 0; c < 9; c++) {
const checkIdx = row * 9 + c;
if (c !== col && testBoard[checkIdx] === digit) {
@@ -131,7 +134,7 @@ export function useBoardState(cursorManager: UseCursorType) {
};
const setDigit = (d: string) => {
const i = cursor.r * 9 + cursor.c;
const cellIndex = cursor.r * 9 + cursor.c;
if (!canEditCell(cursor.r, cursor.c)) {
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
@@ -139,10 +142,10 @@ export function useBoardState(cursorManager: UseCursorType) {
}
const arr = board.split("");
arr[i] = d;
arr[cellIndex] = d;
const newBoard = arr.join("");
if (!isValidPlacement(newBoard, cursor.r, cursor.c, d)) {
if (validateMoves && !isValidPlacement(newBoard, cursor.r, cursor.c, d)) {
const conflicts = getConflicts(newBoard, cursor.r, cursor.c);
window.dispatchEvent(
new CustomEvent("sudoku-invalid-move", {
@@ -153,11 +156,13 @@ export function useBoardState(cursorManager: UseCursorType) {
}
setBoard(newBoard);
window.dispatchEvent(
new CustomEvent("sudoku-valid-move", {
detail: { row: cursor.r, col: cursor.c, digit: d },
})
);
if (validateMoves) {
window.dispatchEvent(
new CustomEvent("sudoku-valid-move", {
detail: { row: cursor.r, col: cursor.c, digit: d },
})
);
}
return true;
};
+7 -2
View File
@@ -6,14 +6,19 @@ import { useBoardState } from "./useBoardState";
import { useKeyBindings } from "./useKeyBindings";
import { useScore } from "../useScore";
export function useGame(platformHook?: unknown) {
export type UseGameOptions = {
validateMoves?: boolean;
};
export function useGame(platformHook?: unknown, options?: UseGameOptions) {
const { validateMoves = false } = options || {};
const rendererManager = useRenderer();
const { containerRef, renderEmptyBoard, renderBoard, highlightCursor } = rendererManager;
const cursorManager = useCursor(rendererManager);
const { cursor, moveCursor } = cursorManager;
const gameManager = useGameStatus(rendererManager, cursorManager);
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
const boardManager = useBoardState(cursorManager);
const boardManager = useBoardState(cursorManager, { validateMoves });
const {
board,
initialBoard,