Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ This is the project root for Labyrinth Game Solver, a responsive, mobile-friendl
- **Mobile Standalone / PWA**: Configured with Apple Web App meta tags to allow launching full-screen from the iPhone home screen. Max viewport bounds prevent bouncing and double-tap zoom.
- **Path Resolving**: In `vite.config.ts`, `base: './'` is configured so that files compile with relative links to support hosting on GitHub Pages subpaths. Alias `@/` maps to the `src/` folder.
- **Safe Deep Copy**: `useLabyrinthHistory.ts` uses a custom `deepClone` (via `JSON.parse(JSON.stringify(...))`) instead of solver's `cloneBoard()` to avoid crash errors with `null` grid values.
- **Auto-Save / Game Storage**: The state synchronizes to the browser's `localStorage` database under `labyrinth_saved_slots_list` and `labyrinth_strategist_state`.
- **Auto-Save / Game Storage**: The board layout and game state are autosaved to the browser's `localStorage` under `labyrinth_strategist_state` and restored on reload. User preferences (theme, accent color, mute, 3D, active players) use separate keys (`labyrinth_theme`, `labyrinth_accent_color`, etc.).

## Release Pipeline

Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ The Labyrinth project manages its interface states directly inside `App.tsx` and
- **[src/components/Tile.tsx](src/components/Tile.tsx)**: Generates CSS-based corridors (straight, corner, t-junction) based on rotation degrees, and overlays pawn tokens.
- **[src/solver.js](src/solver.js)**: Legacy pathfinder algorithm (implements graph BFS and slide shifts).
- **[src/constants.ts](src/constants.ts)**: Configures diagnostic starts (colors, diagonal alignment presets, named treasures).
- **[src/hooks/useLabyrinthStorage.ts](src/hooks/useLabyrinthStorage.ts)**: Interacts with the browser's `LocalStorage` database to save and load named slots and the auto-save.
- **[src/hooks/useLabyrinthStorage.ts](src/hooks/useLabyrinthStorage.ts)**: Autosaves board and game state to `localStorage` (`labyrinth_strategist_state`) and restores it on reload.
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Complex pathfinder searches run inside `solver.worker.js` (Web Worker).

### 2. State & History Synchronization
- **`useLabyrinthHistory`**: Keeps deep cloned snapshots of `AppGameState` to manage custom undo/redo actions.
- **`useLabyrinthStorage`**: Provides quick read/write tools to standard `localStorage` to save game slots and sync setup options.
- **`useLabyrinthStorage`**: Autosaves board and game state to `localStorage` (`labyrinth_strategist_state`) and restores it on reload.

### 3. Native Web Audio Synth
Audio feedback is synthesized dynamically using the Web Audio API in `src/utils/audio.ts` (minimizing app footprint by not packing static mp3/wav files).
32 changes: 20 additions & 12 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,8 @@ import {
} from "lucide-react";
import { cn } from "./lib/utils";

// Search depth for the solver worker. Always searches this many turns ahead
// rather than exposing it as a user-facing option — solveAllHand already
// ranks shorter/safer paths first, so a wider search can only surface better
// suggestions, never worse ones.
const SOLVER_MAX_TURNS = 3;
// Default solver depth. Can be overridden via the Advanced settings panel.
const DEFAULT_SOLVER_DEPTH = 3;

export default function App() {
const sensors = useSensors(
Expand Down Expand Up @@ -91,7 +88,11 @@ export default function App() {
const [turnPhase, setTurnPhase] = useState<"slide" | "move">("slide");
const [stagedArrow, setStagedArrow] = useState<string | null>(null);
const [stagedRotation, setStagedRotation] = useState<0 | 90 | 180 | 270>(0);
const [showOneMoveTargets, setShowOneMoveTargets] = useState(false);
const [showOneMoveTargets, setShowOneMoveTargets] = useState(true);
const [solverDepth, setSolverDepthState] = useState<number>(() => {
const saved = parseInt(localStorage.getItem("labyrinth_solver_depth") ?? "");
return [1, 2, 3, 4, 5].includes(saved) ? saved : DEFAULT_SOLVER_DEPTH;
});
const [mobilePanelStop, setMobilePanelStop] = useState<"peek" | "expanded">("expanded");
const [hasShownSetupHint, setHasShownSetupHint] = useState(false);
const [showWelcomeGuide, setShowWelcomeGuide] = useState(
Expand Down Expand Up @@ -152,6 +153,15 @@ export default function App() {
}
}, [baseTheme]);

const setSolverDepth = useCallback((depth: number) => {
setSolverDepthState(depth);
try {
localStorage.setItem("labyrinth_solver_depth", String(depth));
} catch {
/* storage full */
}
}, []);

// ── Accent color ──────────────────────────────────────────────────────────────
const applyAccentColor = useCallback((hex: string) => {
if (!hex) return;
Expand Down Expand Up @@ -309,11 +319,6 @@ export default function App() {
game.handleRedo();
return;
}
if (ctrl && e.key === "s") {
e.preventDefault();
showToast("Autosave keeps your layout ready next time ✨");
return;
}
if (e.key === "?" && !ctrl) {
e.preventDefault();
setIsSettingsOpen(true);
Expand Down Expand Up @@ -441,7 +446,7 @@ export default function App() {
pawnPos: currentPawnCoord,
handCards,
lastShiftArrowId: game.lastShiftArrowId,
maxTurns: SOLVER_MAX_TURNS,
maxTurns: solverDepth,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
Expand All @@ -455,6 +460,7 @@ export default function App() {
game.getSolverFormattedBoard,
game.getSolverFormattedSpare,
game.customTargetCoords,
solverDepth,
]);

// ── Drag and Drop ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -789,6 +795,8 @@ export default function App() {
onToggleTimer={toggleTimer}
is3D={is3D}
onToggle3D={toggle3D}
solverDepth={solverDepth}
onSetSolverDepth={setSolverDepth}
/>

<main className="flex-1 flex flex-col md:flex-row relative z-10 w-full px-2 sm:px-3 md:px-4 lg:px-6 pt-2 sm:pt-3 pb-[72px] md:pb-3 gap-3 md:gap-4 lg:gap-8 justify-center overflow-hidden min-h-0">
Expand Down
36 changes: 29 additions & 7 deletions src/components/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Settings2,
HelpCircle,
Clock,
BarChart2,
} from "lucide-react";

export interface AppHeaderProps {
Expand Down Expand Up @@ -56,6 +57,8 @@ export interface AppHeaderProps {
onToggleTimer?: () => void;
is3D?: boolean;
onToggle3D: () => void;
solverDepth?: number;
onSetSolverDepth?: (depth: number) => void;
}

const iconBtnCls =
Expand All @@ -70,8 +73,8 @@ const STEPS = [
},
{
id: "game" as const,
label: "Play Game",
shortLabel: "Play",
label: "Game",
shortLabel: "Game",
icon: <Play className="w-3 h-3" />,
},
];
Expand All @@ -81,7 +84,7 @@ export function AppHeader({
canUndo,
canRedo,
isMuted,
showStats: _showStats,
showStats,
baseTheme: _baseTheme,
activePlayers,
activePawn,
Expand All @@ -96,7 +99,7 @@ export function AppHeader({
onRedo,
onOpenHistory,
onRotateBoard,
onToggleStats: _onToggleStats,
onToggleStats,
onStartGame,
onEndGame,
onToggleMute,
Expand All @@ -111,6 +114,8 @@ export function AppHeader({
onToggleTimer,
is3D: _is3D,
onToggle3D: _onToggle3D,
solverDepth,
onSetSolverDepth,
}: AppHeaderProps) {
const [showEndGameConfirm, setShowEndGameConfirm] = useState(false);

Expand All @@ -135,7 +140,7 @@ export function AppHeader({
</span>
</a>

{/* Center — Step Nav (timer baked into Play Game button) */}
{/* Center — Step Nav (timer integrated in Game button) */}
<div className="flex-1 flex items-center justify-center min-w-0 gap-2">
<div className="flex items-center bg-card neo-brutalism-card rounded-xl px-1 py-0.5 sm:p-1 gap-1">
{STEPS.map((s) => {
Expand Down Expand Up @@ -272,8 +277,8 @@ export function AppHeader({
</div>
)}

{/* Desktop actions toolbar */}
<div className="hidden lg:flex items-center gap-1.5">
{/* Tablet/desktop actions toolbar — visible at md+ */}
<div className="hidden md:flex items-center gap-1.5">
<Tooltip content="Undo (Ctrl+Z)" side="bottom">
<Button
variant="outline" size="icon"
Expand Down Expand Up @@ -319,6 +324,21 @@ export function AppHeader({
<RotateCw className="w-3.5 h-3.5" />
</Button>
</Tooltip>
{isGameStarted && onToggleStats && (
<>
<div className="w-px h-4 bg-stone-800 mx-0.5" />
<Tooltip content={showStats ? "Hide stats" : "Game stats"} side="bottom">
<Button
variant="outline" size="icon"
onClick={() => { if (!isMuted) playClickSound(); onToggleStats(); }}
className={cn(iconBtnCls, showStats ? "bg-theme-primary-10 text-theme-primary border-theme-primary-20" : "")}
aria-label="Toggle game statistics"
>
<BarChart2 className="w-3.5 h-3.5" />
</Button>
</Tooltip>
</>
)}
<div className="w-px h-4 bg-stone-800 mx-0.5" />
<Tooltip content={isMuted ? "Unmute audio" : "Mute audio"} side="bottom">
<Button
Expand Down Expand Up @@ -371,6 +391,8 @@ export function AppHeader({
setAccentColor={setAccentColor}
is3D={_is3D}
onToggle3D={_onToggle3D}
solverDepth={solverDepth}
onSetSolverDepth={onSetSolverDepth}
/>
</div>
</header>
Expand Down
4 changes: 2 additions & 2 deletions src/components/board/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,9 @@ export const Board: React.FC<BoardProps> = ({
className={cn(
"w-full h-full max-w-[70%] max-h-[70%] mx-auto p-0.5 rounded-lg transition-all focus:outline-none flex items-center justify-center",
isForbidden
? "opacity-20 cursor-not-allowed border border-stone-850 text-stone-600 bg-stone-950/60"
? "opacity-20 cursor-not-allowed border border-stone-700 text-stone-600 bg-stone-950/60"
: turnPhase === "move"
? "opacity-25 cursor-not-allowed border border-stone-850 text-stone-600 bg-stone-950/60"
? "opacity-25 cursor-not-allowed border border-stone-700 text-stone-600 bg-stone-950/60"
: isStaged
? "neo-brutalism-button border-stone-950 bg-theme-primary text-stone-950 scale-105 translate-x-[1px] translate-y-[1px] shadow-[1px_1px_0_0_#000000] cursor-pointer"
: isHighlighted
Expand Down
2 changes: 1 addition & 1 deletion src/components/modals/BoardScanModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ export function BoardScanModal({ open, onClose, onApply }: Props) {
y: c.y * scanH,
})) as [CornerPoint, CornerPoint, CornerPoint, CornerPoint];

const scanResults = await scanBoard(scanSource, absCorners, "", setProgress);
const scanResults = await scanBoard(scanSource, absCorners, setProgress);
setResults(scanResults);
setStep("results");
} catch (err) {
Expand Down
40 changes: 35 additions & 5 deletions src/components/modals/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ interface SettingsDialogProps {
setAccentColor: (hex: string) => void;
is3D?: boolean;
onToggle3D?: () => void;
solverDepth?: number;
onSetSolverDepth?: (depth: number) => void;
}

const ACCENT_PRESETS = [
Expand All @@ -44,7 +46,6 @@ const KEYBOARD_SHORTCUTS = [
shortcuts: [
{ keys: ["Ctrl", "Z"], desc: "Undo last action" },
{ keys: ["Ctrl", "Y"], desc: "Redo last action" },
{ keys: ["Ctrl", "S"], desc: "Show autosave reminder" },
],
},
{
Expand All @@ -67,6 +68,8 @@ export function SettingsDialog({
setAccentColor,
is3D = false,
onToggle3D,
solverDepth = 3,
onSetSolverDepth,
}: SettingsDialogProps) {
const [settingsTab, setSettingsTab] =
useState<"preferences" | "appearance" | "application">("preferences");
Expand Down Expand Up @@ -288,6 +291,34 @@ export function SettingsDialog({
{is3D ? "On" : "Off"}
</button>
</div>
{onSetSolverDepth && (
<div className="flex flex-col gap-2 pt-2 border-t border-stone-800">
<div>
<div className="text-xs font-semibold text-stone-200">Solver Search Depth</div>
<div className="text-[11px] text-stone-500 mt-0.5">
Turns looked ahead. Higher values find more creative solutions but take longer to compute.
</div>
</div>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((d) => (
<button
key={d}
onClick={() => { if (!isMuted) playClickSound(); onSetSolverDepth(d); }}
className={`neo-brutalism-button rounded-lg w-9 h-9 text-xs font-bold cursor-pointer transition-all ${
solverDepth === d
? "bg-theme-primary border-stone-950 text-stone-950 translate-x-[1px] translate-y-[1px] shadow-[1px_1px_0_0_#000000]"
: "bg-card border-stone-950 text-stone-400 hover:text-stone-200"
}`}
>
{d}
</button>
))}
<span className="text-[10px] text-stone-600 ml-1">
{solverDepth === 3 ? "(default)" : solverDepth > 3 ? "(slower)" : "(faster)"}
</span>
</div>
</div>
)}
</div>
</div>
)}
Expand Down Expand Up @@ -529,9 +560,8 @@ export function SettingsDialog({
Troubleshooting
</span>
<p className="text-[11px] text-stone-400 mt-1 leading-relaxed">
If you encounter issues with saved games or stale settings, clearing the
app cache will reset all local data. Your disk-saved game files (.json)
remain untouched.
If you encounter issues with stale settings or a corrupted board layout,
clearing the app cache will reset your saved board and all preferences.
</p>
</div>
<Button
Expand All @@ -549,7 +579,7 @@ export function SettingsDialog({
<p className="font-bold text-stone-400">Privacy & Disclaimers</p>
<p>
Labyrinth Game Solver processes and stores all data locally on your
system. No board data, saved games, settings, or metadata are ever uploaded
system. No board data, settings, or metadata are ever uploaded
to remote servers.
</p>
</div>
Expand Down
20 changes: 14 additions & 6 deletions src/components/modals/WelcomeGuide.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Compass, Layers, MapPin, Play, Sparkles } from "lucide-react";
import { Compass, Layers, MapPin, Play, Sparkles, ArrowRightLeft } from "lucide-react";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
import guideStep1Tiles from "../../assets/guide-step1-tiles.png";
Expand Down Expand Up @@ -30,6 +30,12 @@ const STEPS = [
body: "Slide a tile in, then move your pawn toward its target treasure. The panel beside (or below) the board always shows the solver's best slide-and-move combo to reach your current target in the fewest turns.",
preview: guideStep3Solve,
},
{
icon: ArrowRightLeft,
title: "4. Each turn has two phases",
body: "First, slide: tap an arrow on the board edge to stage a shift, then confirm it. Second, move: tap any green-highlighted cell to walk your pawn there. Both phases must complete before the turn ends. To set a custom target, tap any tile on the board during your move phase.",
preview: null,
},
];

export function WelcomeGuide({ open, onOpenChange, onDismiss }: WelcomeGuideProps) {
Expand Down Expand Up @@ -65,11 +71,13 @@ export function WelcomeGuide({ open, onOpenChange, onDismiss }: WelcomeGuideProp
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-stone-100">{step.title}</div>
<p className="text-xs text-stone-400 mt-0.5 leading-relaxed">{step.body}</p>
<img
src={step.preview}
alt=""
className="mt-2 w-full rounded-lg border border-stone-800 object-cover max-h-24 md:max-h-40"
/>
{step.preview && (
<img
src={step.preview}
alt=""
className="mt-2 w-full rounded-lg border border-stone-800 object-cover max-h-24 md:max-h-40"
/>
)}
</div>
</div>
))}
Expand Down
8 changes: 4 additions & 4 deletions src/components/panels/SetupPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// sheet (< md) and the tablet/desktop side column (md+). Unprefixed classes
// target the phone sheet; `md:` targets the tablet column; `lg:` targets the
// wider desktop column. Interactive controls get a 44px phone floor.
import { Sparkles, Layers, Users, Compass, Play, RotateCw, Camera } from "lucide-react";
import { Sparkles, Layers, Users, Compass, Play, RefreshCcw, Camera } from "lucide-react";
import { SidePanel } from "./SidePanel";
import { Button } from "../ui/button";
import { PAWNS, TREASURES } from "../../constants";
Expand Down Expand Up @@ -128,7 +128,7 @@ export function SetupPanel({
className={`flex items-center justify-center gap-1.5 px-3 md:px-4 py-2 md:py-1.5 min-h-11 md:min-h-0 rounded-lg text-xs md:text-sm font-bold transition-all cursor-pointer border-2 ${
setupTab === tab.id
? "bg-theme-primary text-stone-950 border-stone-950 shadow-[2px_2px_0_0_#000000]"
: "text-stone-500 hover:text-foreground hover:bg-stone-100 dark:hover:bg-stone-850 border-transparent"
: "text-stone-500 hover:text-foreground hover:bg-stone-800 border-transparent"
}`}
>
{tab.icon}
Expand Down Expand Up @@ -170,7 +170,7 @@ export function SetupPanel({
aria-label="Reset layout"
className="border-stone-800 text-stone-400 hover:text-stone-200 hover:bg-stone-900 min-h-11 w-11 shrink-0 px-0 cursor-pointer"
>
<RotateCw className="w-4 h-4" />
<RefreshCcw className="w-4 h-4" />
</Button>
</div>
<div className="flex-1 overflow-hidden">
Expand Down Expand Up @@ -285,7 +285,7 @@ export function SetupPanel({
size="sm"
variant={alreadyInHand ? "secondary" : "outline"}
onClick={() => (alreadyInHand ? onRemoveCard(t.id) : onAddCard(t.id))}
className={`text-[10px] md:text-xs py-1 justify-start h-11 md:h-9 lg:h-8 px-2 truncate neo-brutalism-button bg-card hover:bg-stone-100 dark:hover:bg-stone-850 text-foreground border-2 border-stone-950 shadow-[2px_2px_0_0_#000000] ${alreadyInHand ? "bg-theme-primary text-stone-950 shadow-[1px_1px_0_0_#000000] translate-x-[1px] translate-y-[1px]" : ""}`}
className={`text-[10px] md:text-xs py-1 justify-start h-11 md:h-9 lg:h-8 px-2 truncate neo-brutalism-button bg-card hover:bg-stone-800 text-foreground border-2 border-stone-950 shadow-[2px_2px_0_0_#000000] ${alreadyInHand ? "bg-theme-primary text-stone-950 shadow-[1px_1px_0_0_#000000] translate-x-[1px] translate-y-[1px]" : ""}`}
>
{t.name}
</Button>
Expand Down
Loading