diff --git a/.gitignore b/.gitignore index 508b03a..d2e686d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules .DS_Store /test-harness/test-results/ /test-harness/playwright-report/ +/test-harness/dist/ /blob-report/ /playwright/.cache/ /dist/ diff --git a/src/helpers/calculate-base-points.ts b/src/helpers/calculate-base-points.ts index 16851ab..09dcb5a 100644 --- a/src/helpers/calculate-base-points.ts +++ b/src/helpers/calculate-base-points.ts @@ -19,11 +19,7 @@ export const calculateBasePoints = ( const bottomY = boardHeight - borderWidth - checkerWidth / 2 - checkerStroke; const xLeft = (i: number) => borderWidth + pointWidth / 2 + pointWidth * i; const xRight = (i: number) => - borderWidth + - checkerWidth / 2 + - checkerStroke * 3 + - barWidth + - pointWidth * (6 + i); + borderWidth + barWidth + pointWidth / 2 + pointWidth * (6 + i); const bar = { x: boardWidth / 2, y: boardHeight / 2 }; diff --git a/src/helpers/presets.ts b/src/helpers/presets.ts index b3007c5..1b986ed 100644 --- a/src/helpers/presets.ts +++ b/src/helpers/presets.ts @@ -1,5 +1,29 @@ export const presets = { default: { + backgroundColor: "#e9e9e9", + borderColor: "#555555", + pointColor: "#666666", + pointNumberColor: "#f8f8f8", + altPointColor: "#999999", + playerCheckerColor: "#ffffff", + playerCheckerBorderColor: "#111111", + opponentCheckerColor: "#111111", + opponentCheckerBorderColor: "#ffffff", + doublingCubeColor: "#ffffff", + }, + felt: { + backgroundColor: "#1b4332", + borderColor: "#2d6a4f", + pointColor: "#40916c", + pointNumberColor: "#d8f3dc", + altPointColor: "#0d2b1e", + playerCheckerColor: "#fffde7", + playerCheckerBorderColor: "#f9a825", + opponentCheckerColor: "#b71c1c", + opponentCheckerBorderColor: "#7f0000", + doublingCubeColor: "#fffde7", + }, + coral: { backgroundColor: "#fff5e1", borderColor: "#ff7f50", pointColor: "#008080", @@ -11,16 +35,52 @@ export const presets = { opponentCheckerBorderColor: "#fff", doublingCubeColor: "#fff", }, - warm: { - backgroundColor: "#deb887", - borderColor: "#8b4513", - pointColor: "#8d0000", - pointNumberColor: "#ffffff", - altPointColor: "#df5f5dc", - playerCheckerColor: "#b22222", - playerCheckerBorderColor: "#8b0000", - opponentCheckerColor: "#fffafa", - opponentCheckerBorderColor: "#696969", + forest: { + backgroundColor: "#1a2e1a", + borderColor: "#5c4a1e", + pointColor: "#6b8f47", + pointNumberColor: "#e8f5e0", + altPointColor: "#2d4a1e", + playerCheckerColor: "#e8f5e0", + playerCheckerBorderColor: "#a8c97a", + opponentCheckerColor: "#0f1a0f", + opponentCheckerBorderColor: "#6b8f47", + doublingCubeColor: "#e8f5e0", + }, + rose: { + backgroundColor: "#f9eff2", + borderColor: "#c2485a", + pointColor: "#c2485a", + pointNumberColor: "#fff", + altPointColor: "#e8b4bc", + playerCheckerColor: "#fff", + playerCheckerBorderColor: "#c2485a", + opponentCheckerColor: "#6b1a2a", + opponentCheckerBorderColor: "#f4c2cb", doublingCubeColor: "#fff", }, + arctic: { + backgroundColor: "#e8f4fd", + borderColor: "#4a9eca", + pointColor: "#1d6fa4", + pointNumberColor: "#fff", + altPointColor: "#b3d9f0", + playerCheckerColor: "#ffffff", + playerCheckerBorderColor: "#1d6fa4", + opponentCheckerColor: "#0d3a5c", + opponentCheckerBorderColor: "#4a9eca", + doublingCubeColor: "#fff", + }, + sand: { + backgroundColor: "#f0e0c0", + borderColor: "#c07840", + pointColor: "#a05020", + pointNumberColor: "#fff", + altPointColor: "#d8b888", + playerCheckerColor: "#fff8f0", + playerCheckerBorderColor: "#a05020", + opponentCheckerColor: "#4a2800", + opponentCheckerBorderColor: "#c07840", + doublingCubeColor: "#fff8f0", + }, }; diff --git a/test-harness/editor/Editor.tsx b/test-harness/editor/Editor.tsx new file mode 100644 index 0000000..9f15ba9 --- /dev/null +++ b/test-harness/editor/Editor.tsx @@ -0,0 +1,1020 @@ +import React, { useRef, useState } from "react"; +import { Backgammon } from "../../src/components/Backgammon"; +import { presets } from "../../src/helpers/presets"; +import { positionsPresets } from "../tests/presets"; +import type { + BoardStateState, + DieValue, + Direction, + DoublingCube, + Theme, +} from "../../src/types"; + +// ── Position state types ──────────────────────────────────────────────────── + +type PointData = { owner: "player" | "opponent" | null; count: number }; + +type PositionEditorState = { + points: PointData[]; // length 24, index 0 = board point 1 + barPlayer: number; + barOpponent: number; + bornOffPlayer: number; + bornOffOpponent: number; +}; + +const EMPTY_POS_STATE: PositionEditorState = { + points: Array.from({ length: 24 }, () => ({ owner: null, count: 0 })), + barPlayer: 0, + barOpponent: 0, + bornOffPlayer: 0, + bornOffOpponent: 0, +}; + +function positionsToState( + positions: BoardStateState["positions"], +): PositionEditorState { + const state = structuredClone(EMPTY_POS_STATE); + for (const pos of positions ?? []) { + if (pos.position === "bar") { + if (pos.playerType === "player") state.barPlayer = pos.numberOfCheckers; + else state.barOpponent = pos.numberOfCheckers; + } else if (pos.position === 0) { + state.bornOffPlayer = pos.numberOfCheckers; + } else if (pos.position === 25) { + state.bornOffOpponent = pos.numberOfCheckers; + } else { + const idx = (pos.position as number) - 1; + if (idx >= 0 && idx < 24) { + state.points[idx] = { + owner: pos.playerType, + count: pos.numberOfCheckers, + }; + } + } + } + return state; +} + +function stateToPositions( + s: PositionEditorState, +): BoardStateState["positions"] { + const result: NonNullable = []; + s.points.forEach((pt, i) => { + if (pt.owner && pt.count > 0) { + result.push({ + position: (i + 1) as never, + playerType: pt.owner, + numberOfCheckers: pt.count as never, + }); + } + }); + if (s.barPlayer > 0) + result.push({ + position: "bar", + playerType: "player", + numberOfCheckers: s.barPlayer as never, + }); + if (s.barOpponent > 0) + result.push({ + position: "bar", + playerType: "opponent", + numberOfCheckers: s.barOpponent as never, + }); + if (s.bornOffPlayer > 0) + result.push({ + position: 0, + playerType: "player", + numberOfCheckers: s.bornOffPlayer as never, + }); + if (s.bornOffOpponent > 0) + result.push({ + position: 25, + playerType: "opponent", + numberOfCheckers: s.bornOffOpponent as never, + }); + return result; +} + +// ── Position editor component ─────────────────────────────────────────────── + +function CountInput({ + value, + onChange, + min = 1, +}: { + value: number; + onChange: (v: number) => void; + min?: number; +}) { + return ( + + onChange(Math.max(min, Math.min(15, Number(e.target.value)))) + } + style={{ + width: 40, + fontSize: 11, + padding: "1px 3px", + border: "1px solid #ddd", + borderRadius: 3, + textAlign: "center", + fontFamily: "ui-monospace, monospace", + }} + /> + ); +} + +function OwnerBtn({ + label, + active, + color, + onClick, +}: { + label: string; + active: boolean; + color: string; + onClick: () => void; +}) { + return ( + + ); +} + +function PositionEditor({ + value, + onChange, +}: { + value: PositionEditorState; + onChange: (v: PositionEditorState) => void; +}) { + const toggleOwner = (i: number, owner: "player" | "opponent") => { + const pt = value.points[i]; + const newOwner = pt.owner === owner ? null : owner; + const newPoints = value.points.map((p, j) => + j === i ? { owner: newOwner, count: newOwner ? p.count || 1 : 0 } : p, + ); + onChange({ ...value, points: newPoints }); + }; + + const setCount = (i: number, count: number) => { + const newPoints = value.points.map((p, j) => + j === i ? { ...p, count } : p, + ); + onChange({ ...value, points: newPoints }); + }; + + return ( +
+ {/* Points 1–24 */} +
+ + + + + + + + + + {value.points.map((pt, i) => ( + + + + + + ))} + +
+ Pt + + Owner + + Count +
+ {i + 1} + +
+ toggleOwner(i, "player")} + /> + toggleOwner(i, "opponent")} + /> +
+
+ {pt.owner && ( + setCount(i, v)} + /> + )} +
+
+ + {/* Bar */} +
+
+ Bar +
+
+ + Player + + onChange({ ...value, barPlayer: v })} + /> +
+
+ + Opponent + + onChange({ ...value, barOpponent: v })} + /> +
+
+ + {/* Borne off */} +
+
+ Borne Off +
+
+ + Player + + onChange({ ...value, bornOffPlayer: v })} + /> +
+
+ + Opponent + + onChange({ ...value, bornOffOpponent: v })} + /> +
+
+
+ ); +} + +// ── Theme helpers ─────────────────────────────────────────────────────────── + +type ThemeKey = keyof Theme; + +const THEME_LABELS: [ThemeKey, string][] = [ + ["backgroundColor", "Background"], + ["borderColor", "Border"], + ["pointColor", "Point"], + ["altPointColor", "Alt Point"], + ["pointNumberColor", "Point Number"], + ["playerCheckerColor", "Player Checker"], + ["playerCheckerBorderColor", "Player Checker Border"], + ["opponentCheckerColor", "Opponent Checker"], + ["opponentCheckerBorderColor", "Opponent Checker Border"], + ["doublingCubeColor", "Doubling Cube"], +]; + +const DOUBLING_VALUES: DoublingCube["value"][] = [2, 4, 8, 16, 32]; + +// ── Shared UI primitives ──────────────────────────────────────────────────── + +function ColorField({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (v: string) => void; +}) { + const isValidHex = + value.startsWith("#") && (value.length === 4 || value.length === 7); + return ( +
+ onChange(e.target.value)} + style={{ + width: 32, + height: 24, + padding: 1, + border: "1px solid #ccc", + borderRadius: 3, + cursor: "pointer", + flexShrink: 0, + }} + /> + {label} + onChange(e.target.value)} + style={{ + width: 80, + fontSize: 11, + fontFamily: "ui-monospace, monospace", + padding: "2px 4px", + border: "1px solid #ddd", + borderRadius: 3, + color: "#555", + }} + /> +
+ ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Section({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +const selectStyle: React.CSSProperties = { + width: "100%", + padding: "5px 8px", + fontSize: 13, + border: "1px solid #ccc", + borderRadius: 4, + background: "#fff", + cursor: "pointer", +}; + +function SegmentedControl({ + options, + value, + onChange, +}: { + options: { label: string; value: T }[]; + value: T; + onChange: (v: T) => void; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +// ── Main editor ───────────────────────────────────────────────────────────── + +export function Editor() { + const [direction, setDirection] = useState("clockwise"); + const [diceCount, setDiceCount] = useState<0 | 1 | 2>(2); + const [die1, setDie1] = useState(1); + const [die2, setDie2] = useState(6); + const [showDoublingCube, setShowDoublingCube] = useState(false); + const [doublingValue, setDoublingValue] = useState(2); + const [doublingOwner, setDoublingOwner] = + useState(null); + const [posState, setPosState] = useState(() => + positionsToState(positionsPresets.default), + ); + const [theme, setTheme] = useState({ ...presets.default }); + + const previewRef = useRef(null); + + const dice: BoardStateState["dice"] = + diceCount === 0 ? [] : diceCount === 1 ? [die1] : [die1, die2]; + + const positions = stateToPositions(posState); + + const doublingCube: DoublingCube | undefined = showDoublingCube + ? { value: doublingValue, owner: doublingOwner } + : undefined; + + const updateThemeColor = (key: ThemeKey, value: string) => + setTheme((t) => ({ ...t, [key]: value })); + + const applyThemePreset = (name: keyof typeof presets) => + setTheme({ ...presets[name] }); + + const downloadSVG = () => { + const svg = previewRef.current?.querySelector("svg"); + if (!svg) return; + const xml = new XMLSerializer().serializeToString(svg); + const blob = new Blob([xml], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "backgammon.svg"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const generateCode = (): string => { + const parts: string[] = []; + + if (direction !== "clockwise") parts.push(` direction="${direction}"`); + + const diceArr = + diceCount === 0 + ? "[]" + : diceCount === 1 + ? `[${die1}]` + : `[${die1}, ${die2}]`; + parts.push(` dice={${diceArr}}`); + + if (doublingCube) { + const ownerVal = + doublingCube.owner == null ? "null" : `"${doublingCube.owner}"`; + parts.push( + ` doublingCube={{ value: ${doublingCube.value}, owner: ${ownerVal} }}`, + ); + } + + if (positions?.length) { + const posStr = JSON.stringify(positions, null, 2).replace(/\n/g, "\n "); + parts.push(` positions={${posStr}}`); + } + + const themeLines = THEME_LABELS.map( + ([key]) => ` ${key}: "${theme[key]}"`, + ).join(",\n"); + parts.push(` theme={{\n${themeLines},\n }}`); + + return ``; + }; + + const presetBtnStyle: React.CSSProperties = { + flex: 1, + padding: "4px 0", + fontSize: 11, + border: "1px solid #ccc", + borderRadius: 4, + cursor: "pointer", + background: "#fafafa", + color: "#444", + textTransform: "capitalize", + }; + + return ( +
+ {/* ── Controls panel ── */} +
+

+ Board Editor +

+ + {/* Direction */} +
+ Direction + +
+ + {/* Dice */} +
+ Dice +
+ +
+ {diceCount >= 1 && ( +
+
+
+ Die 1 +
+ +
+ {diceCount === 2 && ( +
+
+ Die 2 +
+ +
+ )} +
+ )} +
+ + {/* Doubling cube */} +
+ + + + {showDoublingCube && ( +
+
+
+ Value +
+ +
+
+
+ Owner +
+ +
+
+ )} +
+ + {/* Positions */} +
+ Positions +
+ {(["default", "overload", "random"] as const).map((name) => ( + + ))} + +
+ +
+ + {/* Theme */} +
+ Theme + + {THEME_LABELS.map(([key, label]) => ( + updateThemeColor(key, v)} + /> + ))} +
+
+ + {/* ── Preview + Code panel ── */} +
+ {/* Toolbar */} +
+ + Live Preview + + +
+ + {/* Preview */} +
+
+ +
+
+ + {/* Generated code */} +
+
+ + React Code + +
+