From f0e919d1cfe8d4a6cf36bba4b86d46122d842c5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:11:18 +0000 Subject: [PATCH 1/2] Add Sandcastle shape with a 1-3 spires feature, schedule it for National Sand Castle Day Mirrors the Fried Egg / yolks pattern: a new shape-only "spires" feature (types.ts, features/index.ts, CardSvg.tsx) lets the Sandcastle turret count vary per card. Registers "Sandcastle" in the shape registry and schedules it as the August 1st daily puzzle. Also extends the deckbuilder-shape skill's render script to plot the new spires axis alongside yolks. --- .../deckbuilder-shape/scripts/card-markup.tsx | 15 ++- src/daily/schedule.ts | 13 +++ src/deckBuilder/CardSvg.tsx | 17 +++- src/deckBuilder/__tests__/cardSvg.test.tsx | 1 + src/deckBuilder/__tests__/deckRules.test.ts | 4 +- src/deckBuilder/__tests__/registry.test.ts | 1 + src/deckBuilder/features/index.ts | 4 + src/deckBuilder/shapes/Sandcastle.tsx | 91 +++++++++++++++++++ src/deckBuilder/shapes/index.ts | 2 + src/deckBuilder/types.ts | 9 ++ 10 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 src/deckBuilder/shapes/Sandcastle.tsx diff --git a/.claude/skills/deckbuilder-shape/scripts/card-markup.tsx b/.claude/skills/deckbuilder-shape/scripts/card-markup.tsx index 3e306c7..5252033 100644 --- a/.claude/skills/deckbuilder-shape/scripts/card-markup.tsx +++ b/.claude/skills/deckbuilder-shape/scripts/card-markup.tsx @@ -14,7 +14,7 @@ import * as React from "react"; import * as ReactDOMServer from "react-dom/server"; import { CardSvg, MAIN_VIEWPORT_SIZE } from "../../../../src/deckBuilder/CardSvg"; -import { CardData, DEFAULT_CARD, NUMBERS, ROTATIONS, YOLKS } from "../../../../src/deckBuilder/features"; +import { CardData, DEFAULT_CARD, NUMBERS, ROTATIONS, SPIRES, YOLKS } from "../../../../src/deckBuilder/features"; import { COLOR_SETS, ColorName, clampColorSet } from "../../../../src/deckBuilder/features/colors"; import { PATTERN_DEFS, PATTERN_NAMES, PatternName } from "../../../../src/deckBuilder/features/patterns"; import { SHAPE_NAMES, SHAPE_REGISTRY, ShapeName } from "../../../../src/deckBuilder/shapes"; @@ -83,9 +83,16 @@ const supportedYolks = (): (typeof YOLKS)[number][] => { return [...supports.yolks]; }; +const supportedSpires = (): (typeof SPIRES)[number][] => { + if (supports.spires === false || supports.spires === undefined) return [1]; + if (supports.spires === true) return [...SPIRES]; + return [...supports.spires]; +}; + const rotations = supportedRotations(); const patterns = supportedPatterns(); const yolks = supportedYolks(); +const spires = supportedSpires(); const numbers: (typeof NUMBERS)[number][] = [1, 2, 3]; interface Variant { @@ -107,6 +114,7 @@ const buildVariants = (): Variant[] => { rotations: rotations[0], patterns: patterns[0], yolks: yolks[0], + spires: spires[0], }; const variants: Variant[] = [{ label: `${SAMPLE_COLORS[0]} · ${patterns[0]} · 1x`, card: base }]; @@ -125,6 +133,7 @@ const buildVariants = (): Variant[] => { rotations.slice(1).forEach((rotation) => push(`rotate ${rotation}°`, { rotations: rotation })); SAMPLE_COLORS.slice(1).forEach((color) => push(color, { colors: color })); yolks.slice(1).forEach((count) => push(`yolks: ${count}`, { yolks: count })); + spires.slice(1).forEach((count) => push(`spires: ${count}`, { spires: count })); // Then a few combinations, which is where clipping and overlap show up. SAMPLE_COLORS.slice(1).forEach((color) => @@ -151,7 +160,7 @@ const renderBareSymbol = (): string => { {paint.defs && {paint.defs}} - + ); @@ -165,7 +174,7 @@ process.stdout.write( colorCount, symbolSize: SYMBOL_SIZE, cardViewport: MAIN_VIEWPORT_SIZE, - axes: { numbers, patterns, rotations, colors: SAMPLE_COLORS, yolks }, + axes: { numbers, patterns, rotations, colors: SAMPLE_COLORS, yolks, spires }, bare: renderBareSymbol(), variants: buildVariants().map((variant, index) => ({ label: variant.label, diff --git a/src/daily/schedule.ts b/src/daily/schedule.ts index 80d19a0..50e4902 100644 --- a/src/daily/schedule.ts +++ b/src/daily/schedule.ts @@ -72,6 +72,19 @@ export const DAILY_PUZZLE_SCHEDULE: DailyPuzzleSchedule = { { idPrefix: "daily-egg" } ), }, + "2026-08-01": { + name: "National Sand Castle Day", + createDeck: () => + new GeometricDeckGenerator( + { + numbers: [1, 2, 3], + spires: [1, 2, 3], + colors: ["Beige", "Orange", "Brown"], + }, + { shapes: "Sandcastle" }, + { idPrefix: "daily-sandcastle" } + ), + }, }; /** The deck used whenever no scheduled puzzle matches the date. */ diff --git a/src/deckBuilder/CardSvg.tsx b/src/deckBuilder/CardSvg.tsx index d2af6a5..0446a3b 100644 --- a/src/deckBuilder/CardSvg.tsx +++ b/src/deckBuilder/CardSvg.tsx @@ -52,6 +52,15 @@ const resolveYolks = ( return supported.includes(yolks as 1 | 2 | 3) ? yolks : supported[0]; }; +const resolveSpires = ( + supported: ShapeFeatureSupport["spires"], + spires: number +): number => { + if (supported === false || supported === undefined) return 1; + if (supported === true) return spires; + return supported.includes(spires as 1 | 2 | 3) ? spires : supported[0]; +}; + interface Props { card: CardData; /** Document-unique id, used to namespace this card's SVG defs. */ @@ -73,6 +82,7 @@ export const CardSvg = ({ card, cardId }: Props) => { const filterName: FilterName = supports.filters === false ? "none" : card.filters; const rotation = resolveRotation(supports.rotations, card.rotations); const yolks = resolveYolks(supports.yolks, card.yolks); + const spires = resolveSpires(supports.spires, card.spires); let patternName: PatternName = supports.patterns === false ? "solid" : card.patterns; if (PATTERN_DEFS[patternName].colorsUsed > colorCount) { patternName = "solid"; @@ -98,7 +108,12 @@ export const CardSvg = ({ card, cardId }: Props) => { rotation ? `rotate(${rotation}, ${rotationCenter.x}, ${rotationCenter.y})` : undefined } > - + ); diff --git a/src/deckBuilder/__tests__/cardSvg.test.tsx b/src/deckBuilder/__tests__/cardSvg.test.tsx index 38965bb..9b7df5c 100644 --- a/src/deckBuilder/__tests__/cardSvg.test.tsx +++ b/src/deckBuilder/__tests__/cardSvg.test.tsx @@ -36,6 +36,7 @@ const CARD: CardData = { filters: "shadow", patterns: "striped", yolks: 1, + spires: 1, }; const renderCard = (card: CardData) => diff --git a/src/deckBuilder/__tests__/deckRules.test.ts b/src/deckBuilder/__tests__/deckRules.test.ts index c979c6d..050966b 100644 --- a/src/deckBuilder/__tests__/deckRules.test.ts +++ b/src/deckBuilder/__tests__/deckRules.test.ts @@ -42,8 +42,8 @@ const DECK: GeneratedDeckMetaData = { numbers: [9, 3, 4], }; -test("yolks is the only shape-only feature today", () => { - expect(SHAPE_ONLY_FEATURES).toEqual(["yolks"]); +test("yolks and spires are the shape-only features today", () => { + expect(SHAPE_ONLY_FEATURES).toEqual(["yolks", "spires"]); }); test("a shape owning no internal feature backs a single card", () => { diff --git a/src/deckBuilder/__tests__/registry.test.ts b/src/deckBuilder/__tests__/registry.test.ts index 5789407..9683d60 100644 --- a/src/deckBuilder/__tests__/registry.test.ts +++ b/src/deckBuilder/__tests__/registry.test.ts @@ -18,6 +18,7 @@ const LEGACY_SHAPE_NAMES = [ "Tracks - Wolf", "Tracks - Frog", "Fried Egg", + "Sandcastle", ]; test("shape registry keeps the legacy shape names", () => { diff --git a/src/deckBuilder/features/index.ts b/src/deckBuilder/features/index.ts index ff725b7..3c78dc4 100644 --- a/src/deckBuilder/features/index.ts +++ b/src/deckBuilder/features/index.ts @@ -7,6 +7,7 @@ import { PATTERN_NAMES, PatternName } from "./patterns"; export const NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; export const ROTATIONS: readonly Rotation[] = [0, 90, 180, 270]; export const YOLKS = [1, 2, 3] as const; +export const SPIRES = [1, 2, 3] as const; /** * The features a generated deck can vary, mapped to their option value type. @@ -22,6 +23,7 @@ interface FeatureOptionMap { filters: FilterName; patterns: PatternName; yolks: (typeof YOLKS)[number]; + spires: (typeof SPIRES)[number]; } export type FeatureName = keyof FeatureOptionMap; @@ -57,6 +59,7 @@ export const FEATURES: { readonly [F in FeatureName]: FeatureConfig } = { filters: { label: "Filter", options: FILTER_NAMES }, patterns: { label: "Pattern", options: PATTERN_NAMES }, yolks: { label: "Yolks", options: YOLKS, requiresShapeSupport: true }, + spires: { label: "Spires", options: SPIRES, requiresShapeSupport: true }, }; export const FEATURE_NAMES = Object.keys(FEATURES) as FeatureName[]; @@ -77,6 +80,7 @@ export const DEFAULT_CARD: CardData = { filters: "none", patterns: "solid", yolks: 1, + spires: 1, }; export function getFeatureOptions(feature: F): readonly FeatureValue[] { diff --git a/src/deckBuilder/shapes/Sandcastle.tsx b/src/deckBuilder/shapes/Sandcastle.tsx new file mode 100644 index 0000000..806a2d6 --- /dev/null +++ b/src/deckBuilder/shapes/Sandcastle.tsx @@ -0,0 +1,91 @@ +import * as React from "react"; +import { ShapeDefinition, ShapeProps } from "../types"; + +/** The main mound of the castle: a wall tapering inward from the ground up. */ +const WALL = "M16,110 L28,78 L92,78 L104,110 Z"; + +/** An arched doorway cut into the base of the wall. */ +const DOOR = "M50,110 L50,94 Q60,78 70,94 L70,110 Z"; + +/** + * `[cx, y, r]` per window, fixed regardless of spire count — they sit low on + * the wall, well clear of any turret rising above. + */ +const WINDOWS: number[][] = [ + [40, 90, 7], + [80, 90, 7], +]; + +/** + * Turret center x-positions keyed by spire count. Turrets sit flush on the + * wall's top edge (y=78) and spread further apart as more of them appear, + * without ever crowding past the wall's own top edge (x: 28-92). + */ +const SPIRE_CENTERS: Record = { + 1: [60], + 2: [42, 78], + 3: [38, 60, 82], +}; + +const TURRET_TOP = 54; +const TURRET_HALF_WIDTH = 8; +const ROOF_APEX_Y = 28; +const FLAG_TOP_Y = 14; + +const turretRect = (cx: number) => + `M${cx - TURRET_HALF_WIDTH},${TURRET_TOP} L${cx + TURRET_HALF_WIDTH},${TURRET_TOP} ` + + `L${cx + TURRET_HALF_WIDTH},78 L${cx - TURRET_HALF_WIDTH},78 Z`; + +const roofPath = (cx: number) => + `M${cx - TURRET_HALF_WIDTH},${TURRET_TOP} L${cx},${ROOF_APEX_Y} L${cx + TURRET_HALF_WIDTH},${TURRET_TOP} Z`; + +const flagPennant = (cx: number) => `M${cx},${FLAG_TOP_Y} L${cx + 14},19 L${cx},24 Z`; + +const Component = ({ colors, fill, spires = 1 }: ShapeProps) => { + const centers = SPIRE_CENTERS[spires] || SPIRE_CENTERS[1]; + + return ( + <> + + + {centers.map((cx) => ( + + ))} + {centers.map((cx) => ( + + ))} + {centers.map((cx) => ( + + ))} + + {WINDOWS.map(([cx, cy, r]) => ( + + ))} + + + + {centers.map((cx) => ( + + ))} + {centers.map((cx) => ( + + ))} + {centers.map((cx) => ( + + + + + ))} + + {WINDOWS.map(([cx, cy, r]) => ( + + ))} + + + ); +}; + +export const Sandcastle: ShapeDefinition = { + Component, + supports: { colors: 2, spires: [1, 2, 3] }, +}; diff --git a/src/deckBuilder/shapes/index.ts b/src/deckBuilder/shapes/index.ts index f27c6b2..f508e96 100644 --- a/src/deckBuilder/shapes/index.ts +++ b/src/deckBuilder/shapes/index.ts @@ -1,6 +1,7 @@ import { ShapeDefinition } from "../types"; import { CircleQuarter, CircleSemi, CircleThreeQuarter } from "./Circles"; import { FriedEgg } from "./FriedEgg"; +import { Sandcastle } from "./Sandcastle"; import { TetrisJBlock, TetrisLBlock, TetrisSBlock, TetrisTBlock } from "./Tetris"; import { TracksDeer, TracksFrog, TracksWolf } from "./Tracks"; import { Triangle } from "./Triangle"; @@ -28,6 +29,7 @@ export const SHAPE_REGISTRY = defineShapes({ "Tracks - Wolf": TracksWolf, "Tracks - Frog": TracksFrog, "Fried Egg": FriedEgg, + "Sandcastle": Sandcastle, }); export type ShapeName = keyof typeof SHAPE_REGISTRY; diff --git a/src/deckBuilder/types.ts b/src/deckBuilder/types.ts index 7141253..516846c 100644 --- a/src/deckBuilder/types.ts +++ b/src/deckBuilder/types.ts @@ -30,6 +30,8 @@ export interface ShapeProps { fill: string; /** Yolk count for shapes that declare `supports.yolks`. */ yolks?: 1 | 2 | 3; + /** Spire count for shapes that declare `supports.spires`. */ + spires?: 1 | 2 | 3; } /** @@ -61,6 +63,13 @@ export interface ShapeFeatureSupport { * neutral yolk count, so unsupported always falls back to 1. */ yolks?: boolean | readonly (1 | 2 | 3)[]; + /** + * true = all spire counts, false/undefined = none (always 1), or the exact + * subset of counts the shape supports. Same fallback semantics as yolks: + * there's no universal neutral spire count, so unsupported always falls + * back to 1. + */ + spires?: boolean | readonly (1 | 2 | 3)[]; } /** A registered shape: a React component plus the features it supports. */ From ab533aeeff4d532037e86342697fe51618363e18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:17:52 +0000 Subject: [PATCH 2/2] Redesign Sandcastle as a 4-stage progression instead of just spire count Each spire value now draws a genuinely different castle rather than the same silhouette with an extra identical turret bolted on: a lumpy mound with one flag, a modest crenellated keep with two flat-topped towers, a towered castle with cone-roofed spires, and a grand five-spired castle. Widens the global spires feature from 3 to 4 options (types.ts, features/index.ts, CardSvg.tsx) and bumps the shape to 3 declared colors so roofs/flags and doors/windows read as separate accents. Updates the National Sand Castle Day daily puzzle to use all 4 steps. --- src/daily/schedule.ts | 6 +- src/deckBuilder/CardSvg.tsx | 4 +- src/deckBuilder/features/index.ts | 2 +- src/deckBuilder/shapes/Sandcastle.tsx | 228 +++++++++++++++++++------- src/deckBuilder/types.ts | 4 +- 5 files changed, 173 insertions(+), 71 deletions(-) diff --git a/src/daily/schedule.ts b/src/daily/schedule.ts index 50e4902..3456e55 100644 --- a/src/daily/schedule.ts +++ b/src/daily/schedule.ts @@ -77,9 +77,9 @@ export const DAILY_PUZZLE_SCHEDULE: DailyPuzzleSchedule = { createDeck: () => new GeometricDeckGenerator( { - numbers: [1, 2, 3], - spires: [1, 2, 3], - colors: ["Beige", "Orange", "Brown"], + numbers: [1, 2, 3, 4], + spires: [1, 2, 3, 4], + colors: ["Beige", "Orange", "Brown", "Maroon"], }, { shapes: "Sandcastle" }, { idPrefix: "daily-sandcastle" } diff --git a/src/deckBuilder/CardSvg.tsx b/src/deckBuilder/CardSvg.tsx index 0446a3b..52d4845 100644 --- a/src/deckBuilder/CardSvg.tsx +++ b/src/deckBuilder/CardSvg.tsx @@ -58,7 +58,7 @@ const resolveSpires = ( ): number => { if (supported === false || supported === undefined) return 1; if (supported === true) return spires; - return supported.includes(spires as 1 | 2 | 3) ? spires : supported[0]; + return supported.includes(spires as 1 | 2 | 3 | 4) ? spires : supported[0]; }; interface Props { @@ -112,7 +112,7 @@ export const CardSvg = ({ card, cardId }: Props) => { colors={colors} fill={paint.fill} yolks={yolks as 1 | 2 | 3} - spires={spires as 1 | 2 | 3} + spires={spires as 1 | 2 | 3 | 4} /> diff --git a/src/deckBuilder/features/index.ts b/src/deckBuilder/features/index.ts index 3c78dc4..8cbe4e8 100644 --- a/src/deckBuilder/features/index.ts +++ b/src/deckBuilder/features/index.ts @@ -7,7 +7,7 @@ import { PATTERN_NAMES, PatternName } from "./patterns"; export const NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; export const ROTATIONS: readonly Rotation[] = [0, 90, 180, 270]; export const YOLKS = [1, 2, 3] as const; -export const SPIRES = [1, 2, 3] as const; +export const SPIRES = [1, 2, 3, 4] as const; /** * The features a generated deck can vary, mapped to their option value type. diff --git a/src/deckBuilder/shapes/Sandcastle.tsx b/src/deckBuilder/shapes/Sandcastle.tsx index 806a2d6..88facfb 100644 --- a/src/deckBuilder/shapes/Sandcastle.tsx +++ b/src/deckBuilder/shapes/Sandcastle.tsx @@ -1,84 +1,186 @@ import * as React from "react"; import { ShapeDefinition, ShapeProps } from "../types"; -/** The main mound of the castle: a wall tapering inward from the ground up. */ -const WALL = "M16,110 L28,78 L92,78 L104,110 Z"; +/** An arched opening (door or window), wider at the base than the arch springs. */ +const arch = (cx: number, hw: number, top: number, bottom: number) => + `M${cx - hw},${bottom} L${cx - hw},${top + hw} Q${cx},${top} ${cx + hw},${top + hw} L${cx + hw},${bottom} Z`; -/** An arched doorway cut into the base of the wall. */ -const DOOR = "M50,110 L50,94 Q60,78 70,94 L70,110 Z"; +/** A triangular spire roof sitting on a tower of half-width `hw`. */ +const cone = (cx: number, hw: number, baseY: number, apexY: number) => + `M${cx - hw},${baseY} L${cx},${apexY} L${cx + hw},${baseY} Z`; -/** - * `[cx, y, r]` per window, fixed regardless of spire count — they sit low on - * the wall, well clear of any turret rising above. - */ -const WINDOWS: number[][] = [ - [40, 90, 7], - [80, 90, 7], -]; +/** A pennant on a pole planted at `(cx, baseY)`, rising `poleLen` units. */ +const flag = (cx: number, baseY: number, poleLen: number, pennantW = 14, pennantH = 10) => { + const topY = baseY - poleLen; + return { + pole: { x1: cx, y1: baseY, x2: cx, y2: topY }, + pennant: `M${cx},${topY} L${cx + pennantW},${topY + pennantH / 2} L${cx},${topY + pennantH} Z`, + }; +}; + +interface RectSpec { + x: number; + y: number; + width: number; + height: number; +} + +/** One stage's full set of drawing instructions, in fill-then-outline order. */ +interface Stage { + fills: { d?: string; rect?: RectSpec; line?: never; color: string }[]; + lines: { x1: number; y1: number; x2: number; y2: number }[]; +} + +// ---------------------------------------------------------------- Stage 1: a +// lumpy hand-piled mound with a single notch and one flag. No towers at all. +const MOUND_BODY = + "M40,58 L44,58 L44,42 L52,42 L52,58 L66,58 L66,42 L74,42 L74,58 L80,58 " + + "Q86,64 94,68 Q104,72 102,88 Q110,96 104,110 L16,110 " + + "Q10,96 18,88 Q16,72 26,68 Q34,64 40,58 Z"; -/** - * Turret center x-positions keyed by spire count. Turrets sit flush on the - * wall's top edge (y=78) and spread further apart as more of them appear, - * without ever crowding past the wall's own top edge (x: 28-92). - */ -const SPIRE_CENTERS: Record = { - 1: [60], - 2: [42, 78], - 3: [38, 60, 82], +const stage1 = (colors: ShapeProps["colors"], fill: string): Stage => { + // The flag rises clear of the notch's teeth (apex y=42) before its + // pennant unfurls, so the pole reads as a pole and not a third tooth. + const mouthFlag = flag(59, 58, 38, 18, 12); + return { + fills: [ + { d: MOUND_BODY, color: fill }, + { d: arch(60, 12, 90, 110), color: colors.tertiary }, + { d: mouthFlag.pennant, color: colors.secondary }, + ], + lines: [mouthFlag.pole], + }; }; -const TURRET_TOP = 54; -const TURRET_HALF_WIDTH = 8; -const ROOF_APEX_Y = 28; -const FLAG_TOP_Y = 14; +// ------------------------------------------------------ Stage 2: a modest +// keep — a squat crenellated block flanked by two flat-topped towers, one +// flag per tower. No spires yet. +const stage2 = (colors: ShapeProps["colors"], fill: string): Stage => { + const leftFlag = flag(32, 50, 20); + const rightFlag = flag(88, 50, 20); + const wallRect: RectSpec = { x: 44, y: 64, width: 32, height: 46 }; + const leftTower: RectSpec = { x: 20, y: 50, width: 24, height: 60 }; + const rightTower: RectSpec = { x: 76, y: 50, width: 24, height: 60 }; + const merlons: RectSpec[] = [ + { x: 46, y: 54, width: 6, height: 10 }, + { x: 57, y: 54, width: 6, height: 10 }, + { x: 68, y: 54, width: 6, height: 10 }, + // Tower teeth leave a wide gap (10 units) at the flag's center so the + // pole's 6-wide stroke never touches them. + { x: 22, y: 40, width: 5, height: 10 }, + { x: 37, y: 40, width: 5, height: 10 }, + { x: 78, y: 40, width: 5, height: 10 }, + { x: 93, y: 40, width: 5, height: 10 }, + ]; + return { + fills: [ + { rect: leftTower, color: fill }, + { rect: rightTower, color: fill }, + { rect: wallRect, color: fill }, + ...merlons.map((rect) => ({ rect, color: fill })), + { d: arch(60, 10, 88, 110), color: colors.tertiary }, + { d: arch(32, 5, 84, 100), color: colors.tertiary }, + { d: arch(88, 5, 84, 100), color: colors.tertiary }, + { d: arch(52, 5, 80, 96), color: colors.tertiary }, + { d: arch(68, 5, 80, 96), color: colors.tertiary }, + { d: leftFlag.pennant, color: colors.secondary }, + { d: rightFlag.pennant, color: colors.secondary }, + ], + lines: [leftFlag.pole, rightFlag.pole], + }; +}; -const turretRect = (cx: number) => - `M${cx - TURRET_HALF_WIDTH},${TURRET_TOP} L${cx + TURRET_HALF_WIDTH},${TURRET_TOP} ` + - `L${cx + TURRET_HALF_WIDTH},78 L${cx - TURRET_HALF_WIDTH},78 Z`; +// -------------------------------------------------------- Stage 3: spires +// arrive — a trapezoid wall with two shoulder towers and a taller center +// tower, all three roofed with cones and flying flags. +const stage3 = (colors: ShapeProps["colors"], fill: string): Stage => { + const left = flag(38, 32, 18); + const center = flag(60, 22, 14); + const right = flag(82, 32, 18); + const merlons: RectSpec[] = [ + { x: 46, y: 70, width: 6, height: 8 }, + { x: 68, y: 70, width: 6, height: 8 }, + ]; + return { + fills: [ + { d: "M16,110 L28,78 L92,78 L104,110 Z", color: fill }, + ...merlons.map((rect) => ({ rect, color: fill })), + { d: "M30,54 L46,54 L46,78 L30,78 Z", color: fill }, + { d: "M74,54 L90,54 L90,78 L74,78 Z", color: fill }, + { d: "M52,44 L68,44 L68,78 L52,78 Z", color: fill }, + { d: cone(38, 8, 54, 32), color: colors.secondary }, + { d: cone(82, 8, 54, 32), color: colors.secondary }, + { d: cone(60, 8, 44, 22), color: colors.secondary }, + { d: arch(60, 12, 92, 110), color: colors.tertiary }, + { d: arch(40, 5, 86, 102), color: colors.tertiary }, + { d: arch(80, 5, 86, 102), color: colors.tertiary }, + { d: left.pennant, color: colors.secondary }, + { d: center.pennant, color: colors.secondary }, + { d: right.pennant, color: colors.secondary }, + ], + lines: [left.pole, center.pole, right.pole], + }; +}; -const roofPath = (cx: number) => - `M${cx - TURRET_HALF_WIDTH},${TURRET_TOP} L${cx},${ROOF_APEX_Y} L${cx + TURRET_HALF_WIDTH},${TURRET_TOP} Z`; +// -------------------------------------------------------- Stage 4: a grand +// castle — five spires stepping up to a tall center keep, a bannered gate, +// and a hanging pennant over the door. +const stage4 = (colors: ShapeProps["colors"], fill: string): Stage => { + const towers: { cx: number; top: number; roof: number; poleLen: number }[] = [ + { cx: 28, top: 58, roof: 40, poleLen: 14 }, + { cx: 44, top: 50, roof: 28, poleLen: 14 }, + { cx: 60, top: 42, roof: 20, poleLen: 12 }, + { cx: 76, top: 50, roof: 28, poleLen: 14 }, + { cx: 92, top: 58, roof: 40, poleLen: 14 }, + ]; + const flags = towers.map((t) => flag(t.cx, t.roof, t.poleLen, 12)); + return { + fills: [ + { d: "M10,110 L22,74 L98,74 L110,110 Z", color: fill }, + ...towers.map((t) => ({ d: `M${t.cx - 7},${t.top} L${t.cx + 7},${t.top} L${t.cx + 7},82 L${t.cx - 7},82 Z`, color: fill })), + ...towers.map((t) => ({ d: cone(t.cx, 7, t.top, t.roof), color: colors.secondary })), + { d: "M54,76 L66,76 L66,86 L60,90 L54,86 Z", color: colors.secondary }, + { d: arch(60, 12, 84, 110), color: colors.tertiary }, + { d: arch(44, 4, 62, 74), color: colors.tertiary }, + { d: arch(76, 4, 62, 74), color: colors.tertiary }, + ...flags.map((f) => ({ d: f.pennant, color: colors.secondary })), + ], + lines: flags.map((f) => f.pole), + }; +}; -const flagPennant = (cx: number) => `M${cx},${FLAG_TOP_Y} L${cx + 14},19 L${cx},24 Z`; +const STAGES: Record Stage> = { + 1: stage1, + 2: stage2, + 3: stage3, + 4: stage4, +}; const Component = ({ colors, fill, spires = 1 }: ShapeProps) => { - const centers = SPIRE_CENTERS[spires] || SPIRE_CENTERS[1]; + const build = STAGES[spires] || STAGES[1]; + const stage = build(colors, fill); return ( <> - - {centers.map((cx) => ( - - ))} - {centers.map((cx) => ( - - ))} - {centers.map((cx) => ( - - ))} - - {WINDOWS.map(([cx, cy, r]) => ( - - ))} + {stage.fills.map((part, i) => + part.rect ? ( + + ) : ( + + ) + )} - - {centers.map((cx) => ( - - ))} - {centers.map((cx) => ( - - ))} - {centers.map((cx) => ( - - - - - ))} - - {WINDOWS.map(([cx, cy, r]) => ( - + {stage.fills.map((part, i) => + part.rect ? ( + + ) : ( + + ) + )} + {stage.lines.map((line, i) => ( + ))} @@ -87,5 +189,5 @@ const Component = ({ colors, fill, spires = 1 }: ShapeProps) => { export const Sandcastle: ShapeDefinition = { Component, - supports: { colors: 2, spires: [1, 2, 3] }, + supports: { colors: 3, spires: [1, 2, 3, 4] }, }; diff --git a/src/deckBuilder/types.ts b/src/deckBuilder/types.ts index 516846c..a6ceb5d 100644 --- a/src/deckBuilder/types.ts +++ b/src/deckBuilder/types.ts @@ -31,7 +31,7 @@ export interface ShapeProps { /** Yolk count for shapes that declare `supports.yolks`. */ yolks?: 1 | 2 | 3; /** Spire count for shapes that declare `supports.spires`. */ - spires?: 1 | 2 | 3; + spires?: 1 | 2 | 3 | 4; } /** @@ -69,7 +69,7 @@ export interface ShapeFeatureSupport { * there's no universal neutral spire count, so unsupported always falls * back to 1. */ - spires?: boolean | readonly (1 | 2 | 3)[]; + spires?: boolean | readonly (1 | 2 | 3 | 4)[]; } /** A registered shape: a React component plus the features it supports. */