437 lines
13 KiB
TypeScript
437 lines
13 KiB
TypeScript
import { useState } from "react";
|
||
import type { PuzzleDefinition, VariantConstraint } from "../domain/types";
|
||
import {
|
||
removeKillerCagesAtCells,
|
||
replaceOverlappingKillerCages,
|
||
selectionTouchesKillerCage,
|
||
} from "./constraintEditing";
|
||
|
||
interface ConstraintEditorProps {
|
||
puzzle: PuzzleDefinition;
|
||
selection: readonly number[];
|
||
onChange: (puzzle: PuzzleDefinition) => void;
|
||
onNewGrid: (size: number) => void;
|
||
onCheck: () => void;
|
||
onGenerate: () => void;
|
||
busy: boolean;
|
||
}
|
||
|
||
function describeConstraint(constraint: VariantConstraint, size: number) {
|
||
const cell = (index: number) =>
|
||
`r${String(Math.floor(index / size) + 1)}c${String((index % size) + 1)}`;
|
||
switch (constraint.type) {
|
||
case "diagonal":
|
||
return `${constraint.direction} diagonal`;
|
||
case "anti-knight":
|
||
return "anti-knight";
|
||
case "anti-king":
|
||
return "anti-king";
|
||
case "non-consecutive":
|
||
return "non-consecutive";
|
||
case "killer-cage":
|
||
return `${String(constraint.sum)} cage · ${String(constraint.cells.length)} cells`;
|
||
case "thermo":
|
||
case "renban":
|
||
case "palindrome":
|
||
return `${constraint.type} · ${String(constraint.cells.length)} cells`;
|
||
case "arrow":
|
||
return `arrow · ${String(constraint.bulb.length)} bulb / ${String(constraint.line.length)} line`;
|
||
case "kropki":
|
||
return `${constraint.kind} dot · ${cell(constraint.a)}–${cell(constraint.b)}`;
|
||
case "xv":
|
||
return `sum ${String(constraint.total)} pair · ${cell(constraint.a)}–${cell(constraint.b)}`;
|
||
case "inequality":
|
||
return `${cell(constraint.lesser)} < ${cell(constraint.greater)}`;
|
||
}
|
||
}
|
||
|
||
export function ConstraintEditor({
|
||
puzzle,
|
||
selection,
|
||
onChange,
|
||
onNewGrid,
|
||
onCheck,
|
||
onGenerate,
|
||
busy,
|
||
}: ConstraintEditorProps) {
|
||
const [cageSum, setCageSum] = useState(10);
|
||
const [region, setRegion] = useState(1);
|
||
const constraints = puzzle.constraints ?? [];
|
||
|
||
const append = (constraint: VariantConstraint) =>
|
||
onChange({ ...puzzle, constraints: [...constraints, constraint] });
|
||
const need = (count: number) => selection.length === count;
|
||
const atLeast = (count: number) => selection.length >= count;
|
||
const cageCellCount = Math.max(1, selection.length);
|
||
const minimumCageSum = (cageCellCount * (cageCellCount + 1)) / 2;
|
||
const maximumCageSum =
|
||
(cageCellCount * (2 * puzzle.size - cageCellCount + 1)) / 2;
|
||
const validCage =
|
||
selection.length >= 1 &&
|
||
selection.length <= puzzle.size &&
|
||
Number.isInteger(cageSum) &&
|
||
cageSum >= minimumCageSum &&
|
||
cageSum <= maximumCageSum;
|
||
const selectedCageExists = selectionTouchesKillerCage(constraints, selection);
|
||
|
||
const toggleGlobal = (
|
||
type: "anti-knight" | "anti-king" | "non-consecutive",
|
||
) => {
|
||
const exists = constraints.some((constraint) => constraint.type === type);
|
||
onChange({
|
||
...puzzle,
|
||
constraints: exists
|
||
? constraints.filter((constraint) => constraint.type !== type)
|
||
: [...constraints, { type }],
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="setter-panel stack">
|
||
<section className="panel-section">
|
||
<div className="section-heading">
|
||
<div>
|
||
<p className="eyebrow">Puzzle definition</p>
|
||
<h2>Set a puzzle</h2>
|
||
</div>
|
||
<label className="compact-field">
|
||
Grid
|
||
<select
|
||
value={puzzle.size}
|
||
onChange={(event) => onNewGrid(Number(event.target.value))}
|
||
>
|
||
<option value="4">4 × 4</option>
|
||
<option value="6">6 × 6</option>
|
||
<option value="9">9 × 9</option>
|
||
<option value="12">12 × 12</option>
|
||
<option value="16">16 × 16</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="field-grid">
|
||
<label>
|
||
Title
|
||
<input
|
||
value={puzzle.title ?? ""}
|
||
maxLength={256}
|
||
onChange={(event) =>
|
||
onChange({ ...puzzle, title: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Setter
|
||
<input
|
||
value={puzzle.author ?? ""}
|
||
maxLength={256}
|
||
onChange={(event) =>
|
||
onChange({ ...puzzle, author: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
Rules
|
||
<textarea
|
||
rows={3}
|
||
maxLength={16_384}
|
||
value={puzzle.rules ?? ""}
|
||
onChange={(event) =>
|
||
onChange({ ...puzzle, rules: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
</section>
|
||
|
||
<section className="panel-section">
|
||
<p className="eyebrow">Selected cells</p>
|
||
<h3>
|
||
{selection.length
|
||
? `${String(selection.length)} selected`
|
||
: "Select cells on the grid"}
|
||
</h3>
|
||
<p className="muted">
|
||
Selection order defines lines and pair direction. Shift-click or drag
|
||
to build a selection.
|
||
</p>
|
||
<div className="inline-fields">
|
||
<label className="compact-field">
|
||
Cage sum
|
||
<input
|
||
type="number"
|
||
min={minimumCageSum}
|
||
max={maximumCageSum}
|
||
value={cageSum}
|
||
onChange={(event) => setCageSum(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
disabled={!validCage}
|
||
onClick={() =>
|
||
onChange({
|
||
...puzzle,
|
||
constraints: replaceOverlappingKillerCages(constraints, {
|
||
type: "killer-cage",
|
||
cells: selection,
|
||
sum: cageSum,
|
||
}),
|
||
})
|
||
}
|
||
>
|
||
Add / replace cage
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="danger"
|
||
disabled={!selectedCageExists}
|
||
onClick={() =>
|
||
onChange({
|
||
...puzzle,
|
||
constraints: removeKillerCagesAtCells(constraints, selection),
|
||
})
|
||
}
|
||
>
|
||
Remove selected cage
|
||
</button>
|
||
</div>
|
||
<p className="muted">
|
||
Adding replaces any cage touching the selection. Removing clears every
|
||
cage touching a selected cell.
|
||
</p>
|
||
<div className="button-grid">
|
||
<button
|
||
type="button"
|
||
disabled={!atLeast(2)}
|
||
onClick={() => append({ type: "thermo", cells: selection })}
|
||
>
|
||
Thermo
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!atLeast(2)}
|
||
onClick={() => append({ type: "renban", cells: selection })}
|
||
>
|
||
Renban
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!atLeast(2)}
|
||
onClick={() => append({ type: "palindrome", cells: selection })}
|
||
>
|
||
Palindrome
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!atLeast(2)}
|
||
onClick={() =>
|
||
append({
|
||
type: "arrow",
|
||
bulb: [selection[0]!],
|
||
line: selection.slice(1),
|
||
})
|
||
}
|
||
>
|
||
Arrow
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!need(2)}
|
||
onClick={() =>
|
||
append({
|
||
type: "kropki",
|
||
a: selection[0]!,
|
||
b: selection[1]!,
|
||
kind: "white",
|
||
})
|
||
}
|
||
>
|
||
White dot
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!need(2)}
|
||
onClick={() =>
|
||
append({
|
||
type: "kropki",
|
||
a: selection[0]!,
|
||
b: selection[1]!,
|
||
kind: "black",
|
||
})
|
||
}
|
||
>
|
||
Black dot
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!need(2)}
|
||
onClick={() =>
|
||
append({
|
||
type: "xv",
|
||
a: selection[0]!,
|
||
b: selection[1]!,
|
||
total: 5,
|
||
})
|
||
}
|
||
>
|
||
Sum 5 (XV)
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!need(2)}
|
||
onClick={() =>
|
||
append({
|
||
type: "xv",
|
||
a: selection[0]!,
|
||
b: selection[1]!,
|
||
total: 10,
|
||
})
|
||
}
|
||
>
|
||
Sum 10 (XV)
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!need(2)}
|
||
onClick={() =>
|
||
append({
|
||
type: "inequality",
|
||
lesser: selection[0]!,
|
||
greater: selection[1]!,
|
||
})
|
||
}
|
||
>
|
||
1st < 2nd (inequality)
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!need(2)}
|
||
onClick={() =>
|
||
append({
|
||
type: "inequality",
|
||
lesser: selection[1]!,
|
||
greater: selection[0]!,
|
||
})
|
||
}
|
||
>
|
||
1st > 2nd (inequality)
|
||
</button>
|
||
</div>
|
||
<div className="inline-fields">
|
||
<label className="compact-field">
|
||
Region
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max={puzzle.size}
|
||
value={region}
|
||
onChange={(event) => setRegion(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
disabled={!atLeast(1) || region < 1 || region > puzzle.size}
|
||
onClick={() => {
|
||
const regions = [...(puzzle.regions ?? [])];
|
||
for (const cell of selection) regions[cell] = region - 1;
|
||
onChange({ ...puzzle, regions });
|
||
}}
|
||
>
|
||
Paint region
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel-section">
|
||
<p className="eyebrow">Global rules</p>
|
||
<div className="button-grid">
|
||
{(["anti-knight", "anti-king", "non-consecutive"] as const).map(
|
||
(type) => (
|
||
<button
|
||
key={type}
|
||
type="button"
|
||
className={
|
||
constraints.some((constraint) => constraint.type === type)
|
||
? "is-active"
|
||
: ""
|
||
}
|
||
aria-pressed={constraints.some(
|
||
(constraint) => constraint.type === type,
|
||
)}
|
||
onClick={() => toggleGlobal(type)}
|
||
>
|
||
{type}
|
||
</button>
|
||
),
|
||
)}
|
||
{(["main", "anti"] as const).map((direction) => {
|
||
const active = constraints.some(
|
||
(constraint) =>
|
||
constraint.type === "diagonal" &&
|
||
constraint.direction === direction,
|
||
);
|
||
return (
|
||
<button
|
||
key={direction}
|
||
type="button"
|
||
className={active ? "is-active" : ""}
|
||
aria-pressed={active}
|
||
onClick={() =>
|
||
onChange({
|
||
...puzzle,
|
||
constraints: active
|
||
? constraints.filter(
|
||
(constraint) =>
|
||
constraint.type !== "diagonal" ||
|
||
constraint.direction !== direction,
|
||
)
|
||
: [...constraints, { type: "diagonal", direction }],
|
||
})
|
||
}
|
||
>
|
||
{direction} diagonal
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
{constraints.length > 0 && (
|
||
<section className="panel-section">
|
||
<p className="eyebrow">Constraints</p>
|
||
<ul className="constraint-list">
|
||
{constraints.map((constraint, index) => (
|
||
<li key={`${constraint.type}-${String(index)}`}>
|
||
<span>{describeConstraint(constraint, puzzle.size)}</span>
|
||
<button
|
||
className="text-button danger"
|
||
type="button"
|
||
onClick={() =>
|
||
onChange({
|
||
...puzzle,
|
||
constraints: constraints.filter(
|
||
(_, item) => item !== index,
|
||
),
|
||
})
|
||
}
|
||
>
|
||
Remove
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
)}
|
||
|
||
<section className="panel-section action-row">
|
||
<button type="button" onClick={onCheck} disabled={busy}>
|
||
Check definition & uniqueness
|
||
</button>
|
||
<button type="button" onClick={onGenerate} disabled={busy}>
|
||
Open generator
|
||
</button>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|