From 18fca17e14ff85e1714368eb1e2b839b10c68741 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sun, 10 May 2026 23:53:54 +0800 Subject: [PATCH 01/14] feat: add puzzle rules explaination --- src/app/EditorPage.test.tsx | 22 ++ src/app/EditorPage.tsx | 32 +-- src/app/WorkspacePage.test.tsx | 30 ++- src/app/workspace.css | 133 +++++++++++ src/domain/plugins/slitherPlugin.ts | 66 +++++- src/domain/plugins/types.ts | 27 +++ src/features/puzzleInfo/PuzzleInfoButton.tsx | 218 +++++++++++++++++++ src/features/solver/ControlPanel.tsx | 7 +- 8 files changed, 515 insertions(+), 20 deletions(-) create mode 100644 src/features/puzzleInfo/PuzzleInfoButton.tsx diff --git a/src/app/EditorPage.test.tsx b/src/app/EditorPage.test.tsx index 6771072..66e4a96 100644 --- a/src/app/EditorPage.test.tsx +++ b/src/app/EditorPage.test.tsx @@ -285,6 +285,28 @@ describe('EditorPage', () => { expect(within(dialog).queryByText(/default slitherlink 1/i)).not.toBeInTheDocument() }) + it('opens slitherlink rules from the editor puzzle type row', () => { + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: /show slitherlink rules/i })) + + const dialog = screen.getByRole('dialog', { name: /slitherlink rules/i }) + expect(dialog).toHaveAttribute('aria-modal', 'false') + expect(within(dialog).getByText(/draw lines along the edges/i)).toBeInTheDocument() + expect(within(dialog).getByText(/the loop cannot branch off or cross itself/i)).toBeInTheDocument() + expect(within(dialog).getByText(/a number indicates the amount of edges/i)).toBeInTheDocument() + expect(within(dialog).queryByText(/in puzzlekit/i)).not.toBeInTheDocument() + expect(within(dialog).getByLabelText(/before example canvas/i)).toBeInTheDocument() + expect(within(dialog).getByLabelText(/after example canvas/i)).toBeInTheDocument() + + fireEvent.click(within(dialog).getByRole('button', { name: /close slitherlink rules/i })) + expect(screen.queryByRole('dialog', { name: /slitherlink rules/i })).not.toBeInTheDocument() + }) + it('uses the shared workspace grid columns on the editor page', () => { render( diff --git a/src/app/EditorPage.tsx b/src/app/EditorPage.tsx index e22f7ac..7cbedd6 100644 --- a/src/app/EditorPage.tsx +++ b/src/app/EditorPage.tsx @@ -10,6 +10,7 @@ import { puzzleRegistry } from '../domain/plugins/registry' import { SlitherlinkEditorBoard } from '../features/editor/SlitherlinkEditorBoard' import { useEditorStore } from '../features/editor/editorStore' import { puzzlePresets, type PuzzlePreset } from '../features/editor/presets' +import { PuzzleInfoButton } from '../features/puzzleInfo/PuzzleInfoButton' import { useSolverStore } from '../features/solver/solverStore' import './workspace.css' @@ -416,20 +417,23 @@ export const EditorPage = () => {

Puzzle Builder

- +
+ Puzzle Type +
+ + +
+
Grid
diff --git a/src/app/WorkspacePage.test.tsx b/src/app/WorkspacePage.test.tsx index b4de8f7..009bdfe 100644 --- a/src/app/WorkspacePage.test.tsx +++ b/src/app/WorkspacePage.test.tsx @@ -90,19 +90,43 @@ describe('WorkspacePage', () => { expect(exportDialog).toBeInTheDocument() expect(exportDialog).toHaveClass('export-panel') expect(exportDialog).toHaveAttribute('aria-modal', 'false') - expect(screen.getByRole('button', { name: /close export/i })).toHaveAttribute( + expect(screen.getByRole('button', { name: /^close export$/i })).toHaveAttribute( 'data-active', 'true', ) - fireEvent.click(screen.getByRole('button', { name: /close export/i })) + fireEvent.click(screen.getByRole('button', { name: /^close export$/i })) expect(screen.queryByRole('dialog', { name: /export puzzle/i })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /export/i })) - fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + fireEvent.click(screen.getByRole('button', { name: /close export panel/i })) expect(screen.queryByRole('dialog', { name: /export puzzle/i })).not.toBeInTheDocument() }) + it('opens slitherlink rules as a closeable puzzle info popout', () => { + renderWorkspace() + + fireEvent.click(screen.getByRole('button', { name: /show slitherlink rules/i })) + + const infoDialog = screen.getByRole('dialog', { name: /slitherlink rules/i }) + expect(infoDialog).toBeInTheDocument() + expect(infoDialog).toHaveClass('puzzle-info-panel') + expect(infoDialog).toHaveAttribute('aria-modal', 'false') + expect(screen.getByText(/draw lines along the edges/i)).toBeInTheDocument() + expect(screen.getByText(/the loop cannot branch off or cross itself/i)).toBeInTheDocument() + expect(screen.getByText(/a number indicates the amount of edges/i)).toBeInTheDocument() + expect(screen.queryByText(/in puzzlekit/i)).not.toBeInTheDocument() + expect(screen.getByLabelText(/before example canvas/i)).toBeInTheDocument() + expect(screen.getByLabelText(/after example canvas/i)).toBeInTheDocument() + + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: /slitherlink rules/i })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /show slitherlink rules/i })) + fireEvent.click(screen.getByRole('button', { name: /close slitherlink rules/i })) + expect(screen.queryByRole('dialog', { name: /slitherlink rules/i })).not.toBeInTheDocument() + }) + it('shows solve progress, then terminal report, and keeps solve buttons disabled after close', async () => { const puzzle = createSolvedLoopPuzzle() useSolverStore.setState((state) => ({ diff --git a/src/app/workspace.css b/src/app/workspace.css index 43868b3..a939dac 100644 --- a/src/app/workspace.css +++ b/src/app/workspace.css @@ -268,6 +268,129 @@ button[data-active='true'] { min-width: 140px; } +.puzzle-info-anchor { + position: relative; + flex: 0 0 auto; +} + +.puzzle-info-button { + display: inline-grid; + width: 34px; + height: 34px; + place-items: center; + border-radius: 999px; + padding: 0; + font-weight: 800; + line-height: 1; +} + +.puzzle-info-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 35; + width: min(432px, calc(100vw - 48px)); + max-height: min(620px, calc(100vh - 120px)); + overflow: auto; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #f9fafb; + box-shadow: 0 18px 48px rgb(15 23 42 / 0.16); + padding: 12px; + color: #4b5563; +} + +.puzzle-info-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.puzzle-info-panel-header h2 { + margin: 0; + color: #0f172a; + font-size: 1rem; +} + +.puzzle-info-summary { + margin: 0 0 10px; + color: #1f2937; + line-height: 1.4; +} + +.puzzle-info-section { + margin-top: 10px; +} + +.puzzle-info-section h3 { + margin: 0 0 6px; + color: #0f172a; + font-size: 0.9rem; +} + +.puzzle-info-section ul { + margin: 0; + padding-left: 18px; + line-height: 1.45; +} + +.puzzle-info-section li + li { + margin-top: 4px; +} + +.puzzle-info-example-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.puzzle-info-example-card { + min-width: 0; + margin: 0; + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; +} + +.puzzle-info-example-card canvas { + display: block; + width: 100%; + height: auto; + border-bottom: 1px solid #e5e7eb; +} + +.puzzle-info-example-card figcaption { + display: flex; + flex-direction: column; + gap: 2px; + padding: 7px; + color: #6b7280; + font-size: 0.76rem; + line-height: 1.3; +} + +.puzzle-info-example-card strong { + color: #0f172a; + font-size: 0.82rem; +} + +.panel-icon-close { + display: inline-grid; + flex: 0 0 auto; + width: 30px; + height: 30px; + place-items: center; + border-radius: 999px; + padding: 0; + color: #475569; + font-size: 1.15rem; + font-weight: 700; + line-height: 1; +} + .custom-grid-anchor { position: relative; } @@ -1035,6 +1158,16 @@ details pre { flex-direction: column; } + .puzzle-info-panel { + right: auto; + left: 50%; + transform: translateX(-50%); + } + + .puzzle-info-example-grid { + grid-template-columns: 1fr; + } + .io-action-row { grid-template-columns: 1fr; } diff --git a/src/domain/plugins/slitherPlugin.ts b/src/domain/plugins/slitherPlugin.ts index 760e023..9c2b0f0 100644 --- a/src/domain/plugins/slitherPlugin.ts +++ b/src/domain/plugins/slitherPlugin.ts @@ -1,7 +1,7 @@ import { decodeSlitherFromPuzzlink, encodeSlitherToPuzzlink } from '../parsers/puzzlink' import { decodeSlitherFromPenpa } from '../parsers/penpa' import { slitherRules } from '../rules/slither/rules' -import type { PuzzlePlugin } from './types' +import type { PuzzleHelpContent, PuzzlePlugin } from './types' const parseSlitherInput = (input: string) => { try { @@ -20,9 +20,73 @@ const parseSlitherInput = (input: string) => { } } +const slitherHelp: PuzzleHelpContent = { + title: 'Slitherlink Rules', + summary: 'Draw lines along the edges of some cells to form a loop.', + rules: [ + 'The loop cannot branch off or cross itself.', + 'A number indicates the amount of edges surrounding the cell that are visited by the loop.', + ], + example: { + title: 'Before and after', + before: { + label: 'Before', + description: 'Only the given clues are known.', + rows: 3, + cols: 3, + clues: [ + { row: 0, col: 1, value: 3 }, + { row: 1, col: 0, value: 3 }, + { row: 1, col: 1, value: 0 }, + { row: 2, col: 1, value: 3 }, + ], + edges: [], + }, + after: { + label: 'After', + description: 'One valid loop satisfies every clue.', + rows: 3, + cols: 3, + clues: [ + { row: 0, col: 1, value: 3 }, + { row: 1, col: 0, value: 3 }, + { row: 1, col: 1, value: 0 }, + { row: 2, col: 1, value: 3 }, + ], + edges: [ + { edge: [[0, 0], [0, 1]], mark: 'blank' }, + { edge: [[0, 1], [0, 2]], mark: 'line' }, + { edge: [[0, 2], [0, 3]], mark: 'blank' }, + { edge: [[1, 0], [1, 1]], mark: 'line' }, + { edge: [[1, 1], [1, 2]], mark: 'blank' }, + { edge: [[1, 2], [1, 3]], mark: 'line' }, + { edge: [[2, 0], [2, 1]], mark: 'line' }, + { edge: [[2, 1], [2, 2]], mark: 'blank' }, + { edge: [[2, 2], [2, 3]], mark: 'line' }, + { edge: [[3, 0], [3, 1]], mark: 'blank' }, + { edge: [[3, 1], [3, 2]], mark: 'line' }, + { edge: [[3, 2], [3, 3]], mark: 'blank' }, + { edge: [[0, 0], [1, 0]], mark: 'blank' }, + { edge: [[0, 1], [1, 1]], mark: 'line' }, + { edge: [[0, 2], [1, 2]], mark: 'line' }, + { edge: [[0, 3], [1, 3]], mark: 'blank' }, + { edge: [[1, 0], [2, 0]], mark: 'line' }, + { edge: [[1, 1], [2, 1]], mark: 'blank' }, + { edge: [[1, 2], [2, 2]], mark: 'blank' }, + { edge: [[1, 3], [2, 3]], mark: 'line' }, + { edge: [[2, 0], [3, 0]], mark: 'blank' }, + { edge: [[2, 1], [3, 1]], mark: 'line' }, + { edge: [[2, 2], [3, 2]], mark: 'line' }, + { edge: [[2, 3], [3, 3]], mark: 'blank' }, + ], + }, + }, +} + export const slitherPlugin: PuzzlePlugin = { id: 'slitherlink', displayName: 'Slitherlink', + help: slitherHelp, parse: parseSlitherInput, encode: encodeSlitherToPuzzlink, getRules: () => slitherRules, diff --git a/src/domain/plugins/types.ts b/src/domain/plugins/types.ts index ac3da29..2b35eaf 100644 --- a/src/domain/plugins/types.ts +++ b/src/domain/plugins/types.ts @@ -1,9 +1,36 @@ import type { PuzzleIR } from '../ir/types' import type { Rule } from '../rules/types' +export type PuzzleHelpExampleEdge = { + edge: [[row: number, col: number], [row: number, col: number]] + mark: 'line' | 'blank' +} + +export type PuzzleHelpExample = { + label: string + description: string + rows: number + cols: number + clues: Array<{ row: number; col: number; value: number | '?' }> + edges: PuzzleHelpExampleEdge[] +} + +export type PuzzleHelpContent = { + title: string + summary: string + rules: string[] + notes?: string[] + example?: { + title: string + before: PuzzleHelpExample + after: PuzzleHelpExample + } +} + export interface PuzzlePlugin { id: string displayName: string + help?: PuzzleHelpContent parse: (input: string) => PuzzleIR encode: (puzzle: PuzzleIR) => string getRules: () => Rule[] diff --git a/src/features/puzzleInfo/PuzzleInfoButton.tsx b/src/features/puzzleInfo/PuzzleInfoButton.tsx new file mode 100644 index 0000000..7568818 --- /dev/null +++ b/src/features/puzzleInfo/PuzzleInfoButton.tsx @@ -0,0 +1,218 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { puzzleRegistry } from '../../domain/plugins/registry' +import type { PuzzleHelpExample, PuzzleHelpExampleEdge } from '../../domain/plugins/types' + +type Props = { + pluginId: string +} + +const EXAMPLE_WIDTH = 142 +const EXAMPLE_HEIGHT = 116 +const EXAMPLE_PADDING = 18 + +const edgeKey = (edge: PuzzleHelpExampleEdge['edge']): string => { + const [a, b] = edge + return `${a[0]},${a[1]}-${b[0]},${b[1]}` +} + +const PuzzleInfoExampleCanvas = ({ example }: { example: PuzzleHelpExample }) => { + const canvasRef = useRef(null) + const edgeMarks = useMemo( + () => new Map(example.edges.map((edge) => [edgeKey(edge.edge), edge.mark])), + [example.edges], + ) + + useEffect(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) { + return + } + + canvas.width = EXAMPLE_WIDTH + canvas.height = EXAMPLE_HEIGHT + const boardWidth = EXAMPLE_WIDTH - EXAMPLE_PADDING * 2 + const boardHeight = EXAMPLE_HEIGHT - EXAMPLE_PADDING * 2 + const cellSize = Math.min(boardWidth / example.cols, boardHeight / example.rows) + const gridWidth = cellSize * example.cols + const gridHeight = cellSize * example.rows + const offsetX = (EXAMPLE_WIDTH - gridWidth) / 2 + const offsetY = (EXAMPLE_HEIGHT - gridHeight) / 2 + + ctx.clearRect(0, 0, EXAMPLE_WIDTH, EXAMPLE_HEIGHT) + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, EXAMPLE_WIDTH, EXAMPLE_HEIGHT) + + ctx.strokeStyle = '#cbd5e1' + ctx.lineWidth = 1 + for (let row = 0; row <= example.rows; row += 1) { + const y = offsetY + row * cellSize + ctx.beginPath() + ctx.moveTo(offsetX, y) + ctx.lineTo(offsetX + gridWidth, y) + ctx.stroke() + } + for (let col = 0; col <= example.cols; col += 1) { + const x = offsetX + col * cellSize + ctx.beginPath() + ctx.moveTo(x, offsetY) + ctx.lineTo(x, offsetY + gridHeight) + ctx.stroke() + } + + for (const [key, mark] of edgeMarks) { + const [start, end] = key.split('-') + const [rowA, colA] = start.split(',').map(Number) + const [rowB, colB] = end.split(',').map(Number) + const x1 = offsetX + colA * cellSize + const y1 = offsetY + rowA * cellSize + const x2 = offsetX + colB * cellSize + const y2 = offsetY + rowB * cellSize + + if (mark === 'line') { + ctx.strokeStyle = '#0284c7' + ctx.lineWidth = 3 + ctx.beginPath() + ctx.moveTo(x1, y1) + ctx.lineTo(x2, y2) + ctx.stroke() + } else { + const midX = (x1 + x2) / 2 + const midY = (y1 + y2) / 2 + const crossSize = 4 + ctx.strokeStyle = '#94a3b8' + ctx.lineWidth = 1.7 + ctx.beginPath() + ctx.moveTo(midX - crossSize, midY - crossSize) + ctx.lineTo(midX + crossSize, midY + crossSize) + ctx.moveTo(midX + crossSize, midY - crossSize) + ctx.lineTo(midX - crossSize, midY + crossSize) + ctx.stroke() + } + } + + ctx.fillStyle = '#111827' + ctx.font = `700 ${Math.max(14, cellSize * 0.48)}px Inter, sans-serif` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + for (const clue of example.clues) { + ctx.fillText( + String(clue.value), + offsetX + clue.col * cellSize + cellSize / 2, + offsetY + clue.row * cellSize + cellSize / 2, + ) + } + + ctx.fillStyle = '#111827' + for (let row = 0; row <= example.rows; row += 1) { + for (let col = 0; col <= example.cols; col += 1) { + ctx.beginPath() + ctx.arc(offsetX + col * cellSize, offsetY + row * cellSize, 1.8, 0, Math.PI * 2) + ctx.fill() + } + } + }, [edgeMarks, example]) + + return ( +
+ +
+ {example.label} + {example.description} +
+
+ ) +} + +export const PuzzleInfoButton = ({ pluginId }: Props) => { + const [openPluginId, setOpenPluginId] = useState(null) + const plugin = puzzleRegistry.get(pluginId) + const help = plugin?.help + const titleId = `${pluginId}-puzzle-info-title` + const isOpen = openPluginId === pluginId + + useEffect(() => { + if (!isOpen) { + return + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpenPluginId(null) + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen]) + + if (!plugin || !help) { + return null + } + + return ( +
+ + {isOpen ? ( +
+
+

{help.title}

+ +
+

{help.summary}

+
+

Core Rules

+
    + {help.rules.map((rule) => ( +
  • {rule}
  • + ))} +
+
+ {help.notes && help.notes.length > 0 ? ( +
+

In PuzzleKit

+
    + {help.notes.map((note) => ( +
  • {note}
  • + ))} +
+
+ ) : null} + {help.example ? ( +
+

{help.example.title}

+
+ + +
+
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/features/solver/ControlPanel.tsx b/src/features/solver/ControlPanel.tsx index 9134870..2b42f3a 100644 --- a/src/features/solver/ControlPanel.tsx +++ b/src/features/solver/ControlPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { exportPuzzle, exporters, tryEncodePuzzlink } from '../../domain/exporters' import type { ExportFormat } from '../../domain/exporters/types' import { puzzleRegistry } from '../../domain/plugins/registry' +import { PuzzleInfoButton } from '../puzzleInfo/PuzzleInfoButton' import { buildDifficultySnapshot, MAX_SOLVE_CHUNK_SIZE, useSolverStore } from './solverStore' export const ControlPanel = () => { @@ -87,6 +88,7 @@ export const ControlPanel = () => { ))} +