diff --git a/src/components/game/card/index.css b/src/components/game/card/index.css index 1d20323..67d7827 100644 --- a/src/components/game/card/index.css +++ b/src/components/game/card/index.css @@ -30,7 +30,7 @@ } .card svg { - margin: 20px; + margin: 8px; max-height: 10vh; } diff --git a/src/deckBuilder/CardSvg.tsx b/src/deckBuilder/CardSvg.tsx index d2af6a5..32af3db 100644 --- a/src/deckBuilder/CardSvg.tsx +++ b/src/deckBuilder/CardSvg.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { layoutCard, MAIN_VIEWPORT_SIZE } from "./cardLayout"; import { CardData } from "./features"; import { COLOR_SETS, clampColorSet } from "./features/colors"; import { FILTER_DEFS, FilterName } from "./features/filters"; @@ -6,29 +7,9 @@ import { PATTERN_DEFS, PatternName, createPattern } from "./features/patterns"; import { SHAPE_REGISTRY } from "./shapes"; import { Rotation, ShapeFeatureSupport } from "./types"; -export const MAIN_VIEWPORT_SIZE = 120; +export { MAIN_VIEWPORT_SIZE } from "./cardLayout"; const DEFAULT_SHAPE_VIEW_BOX = "0 0 120 120"; -const SYMBOL_MARGIN = 5; -const SYMBOL_SIZE = MAIN_VIEWPORT_SIZE / 3 - SYMBOL_MARGIN; - -/** The nine symbol slots on a card, center first so odd counts stay centered. */ -const POSITIONS = (() => { - const start = 0; - const middle = MAIN_VIEWPORT_SIZE / 2 - SYMBOL_SIZE / 2; - const end = MAIN_VIEWPORT_SIZE - SYMBOL_SIZE; - return [ - { x: middle, y: middle }, - { x: start, y: end }, - { x: end, y: start }, - { x: end, y: end }, - { x: start, y: start }, - { x: end, y: middle }, - { x: start, y: middle }, - { x: middle, y: start }, - { x: middle, y: end }, - ]; -})(); const resolveRotation = ( supported: ShapeFeatureSupport["rotations"], @@ -56,6 +37,15 @@ interface Props { card: CardData; /** Document-unique id, used to namespace this card's SVG defs. */ cardId: string; + /** + * Grid capacity (1-9) used to size every shape cell on this card. Defaults + * to this card's own `card.numbers` — sizing it as large as its own shape + * count allows, independent of any other card ("Max Size"). Pass the + * deck's largest `numbers` value instead to size every card off that one + * shared capacity, so shapes stay one uniform size across the deck + * ("Full Size"). + */ + capacity?: number; } /** @@ -64,7 +54,7 @@ interface Props { * (rotation to 0, filter to none, pattern to solid — including patterns that * use more colors than the shape declares). */ -export const CardSvg = ({ card, cardId }: Props) => { +export const CardSvg = ({ card, cardId, capacity = card.numbers }: Props) => { const shape = SHAPE_REGISTRY[card.shapes]; const supports = shape.supports || {}; const colorCount = supports.colors === undefined ? 3 : supports.colors; @@ -86,12 +76,11 @@ export const CardSvg = ({ card, cardId }: Props) => { const [minX, minY, width, height] = viewBox.split(" ").map(Number); const rotationCenter = { x: minX + width / 2, y: minY + height / 2 }; + const { cellSize, slots } = layoutCard(capacity, card.numbers); const symbols: JSX.Element[] = []; - const offset = card.numbers % 2 ? 0 : 1; - for (let i = 0; i < card.numbers; i++) { - const { x, y } = POSITIONS[i + offset]; + for (const { x, y } of slots) { symbols.push( - + , - options?: { idPrefix?: string } + options?: { idPrefix?: string; cardSizeMode?: CardSizeMode } ) { this.metaData = metaData; this.features = Object.keys(metaData); @@ -39,9 +42,20 @@ export default class GeometricDeckGenerator implements Deck { this.numOptions = optionLists[0] ? optionLists[0].length : 0; this.defaults = { ...DEFAULT_CARD, ...defaultCardData }; this.idPrefix = (options && options.idPrefix) || `d${instanceCounter++}`; + this.cardSizeMode = (options && options.cardSizeMode) || "full"; + this.fullSizeCapacity = this.computeFullSizeCapacity(); this.cards = this.createDeck(); } + /** Largest `numbers` value actually in play, capped to `numOptions` the + * same defensive way the editor's `shapesInPlay` is — the shared grid + * capacity every card sizes off of in "full" mode. */ + private computeFullSizeCapacity(): number { + const numbersInPlay = getEnabledOptions(this.metaData, "numbers"); + const values = numbersInPlay ? numbersInPlay.slice(0, this.numOptions) : []; + return values.length > 0 ? Math.max(...values) : this.defaults.numbers; + } + private setFeature( card: CardData, feature: F, @@ -73,8 +87,14 @@ export default class GeometricDeckGenerator implements Deck { looper(loopNumber + 1); } else { const id = indexes.join("_"); + const capacity = this.cardSizeMode === "full" ? this.fullSizeCapacity : undefined; deck[id] = ( - + ); } } diff --git a/src/deckBuilder/__tests__/__snapshots__/deck.test.tsx.snap b/src/deckBuilder/__tests__/__snapshots__/deck.test.tsx.snap index 34ba392..e2edb31 100644 --- a/src/deckBuilder/__tests__/__snapshots__/deck.test.tsx.snap +++ b/src/deckBuilder/__tests__/__snapshots__/deck.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`card markup applies rotation, filter, and pattern 1`] = `""`; +exports[`card markup applies rotation, filter, and pattern 1`] = `""`; diff --git a/src/deckBuilder/__tests__/cardLayout.test.ts b/src/deckBuilder/__tests__/cardLayout.test.ts new file mode 100644 index 0000000..9a02ee4 --- /dev/null +++ b/src/deckBuilder/__tests__/cardLayout.test.ts @@ -0,0 +1,61 @@ +import { computeGrid, layoutCard } from "../cardLayout"; + +// The deck builder's original fixed layout: 9 slots on a 3x3 grid, center +// first, then corners, then edge-midpoints, cell size 35. +const LEGACY_POSITIONS = [ + { x: 42.5, y: 42.5 }, + { x: 0, y: 85 }, + { x: 85, y: 0 }, + { x: 85, y: 85 }, + { x: 0, y: 0 }, + { x: 85, y: 42.5 }, + { x: 0, y: 42.5 }, + { x: 42.5, y: 0 }, + { x: 42.5, y: 85 }, +]; + +test("capacities 7-9 all resolve to the legacy 3x3 grid", () => { + expect(computeGrid(9)).toEqual({ cols: 3, rows: 3, cellSize: 35 }); + expect(computeGrid(8)).toEqual({ cols: 3, rows: 3, cellSize: 35 }); + expect(computeGrid(7)).toEqual({ cols: 3, rows: 3, cellSize: 35 }); +}); + +test("capacity 1 fills nearly the whole card", () => { + expect(computeGrid(1)).toEqual({ cols: 1, rows: 1, cellSize: 115 }); +}); + +test("capacity 6 ties between 2x3 and 3x2, resolved toward fewer columns", () => { + expect(computeGrid(6)).toEqual({ cols: 2, rows: 3, cellSize: 35 }); +}); + +test("layoutCard(9, n) reproduces the legacy position table exactly", () => { + for (let n = 1; n <= 9; n++) { + const { cellSize, slots } = layoutCard(9, n); + expect(cellSize).toBe(35); + const offset = n % 2 ? 0 : 1; + expect(slots).toEqual(LEGACY_POSITIONS.slice(offset, offset + n)); + } +}); + +test("a single shape is always dead center, even on a capacity grid with no true center cell", () => { + // capacity 5 resolves to a 2x3 grid, which has no cell centered on both + // axes — the nearest-cell selection alone would put a lone shape off-center. + expect(computeGrid(5)).toEqual({ cols: 2, rows: 3, cellSize: 35 }); + expect(layoutCard(5, 1)).toEqual({ cellSize: 35, slots: [{ x: 42.5, y: 42.5 }] }); +}); + +test("no two slots ever overlap, for any capacity/count combination", () => { + for (let capacity = 1; capacity <= 9; capacity++) { + for (let count = 1; count <= capacity; count++) { + const { cellSize, slots } = layoutCard(capacity, count); + expect(slots).toHaveLength(count); + for (let i = 0; i < slots.length; i++) { + for (let j = i + 1; j < slots.length; j++) { + const dx = Math.abs(slots[i].x - slots[j].x); + const dy = Math.abs(slots[i].y - slots[j].y); + expect(dx >= cellSize || dy >= cellSize).toBe(true); + } + } + } + } +}); diff --git a/src/deckBuilder/__tests__/cardSvg.test.tsx b/src/deckBuilder/__tests__/cardSvg.test.tsx index 38965bb..0a25bca 100644 --- a/src/deckBuilder/__tests__/cardSvg.test.tsx +++ b/src/deckBuilder/__tests__/cardSvg.test.tsx @@ -68,3 +68,22 @@ test("color support clamps the set and downgrades color-hungry patterns", () => const triangles = renderCard({ ...CARD, shapes: "Circle - Semi", patterns: "triangles" }); expect(triangles).toContain('fill="#e6194B"'); }); + +test("without a capacity prop, a card sizes off its own shape count (Max Size)", () => { + const markup = ReactDOMServer.renderToStaticMarkup( + + ); + expect(markup).toContain('width="115"'); +}); + +test("a capacity prop sizes the card off that shared grid (Full Size)", () => { + const markup = ReactDOMServer.renderToStaticMarkup( + + ); + expect(markup).toContain('width="35"'); + expect(markup).not.toContain('width="115"'); + // legacy center + first two corner slots for a 3-shape card + expect(markup).toContain('x="42.5" y="42.5"'); + expect(markup).toContain('x="0" y="85"'); + expect(markup).toContain('x="85" y="0"'); +}); diff --git a/src/deckBuilder/__tests__/deck.test.tsx b/src/deckBuilder/__tests__/deck.test.tsx index 7269efa..f812412 100644 --- a/src/deckBuilder/__tests__/deck.test.tsx +++ b/src/deckBuilder/__tests__/deck.test.tsx @@ -24,6 +24,7 @@ test("card markup applies rotation, filter, and pattern", () => { { shapes: ["Tetris - L Block"], colors: ["Blue"], + numbers: [9], rotations: [90], filters: ["shadow"], patterns: ["striped"], @@ -31,10 +32,10 @@ test("card markup applies rotation, filter, and pattern", () => { undefined, { idPrefix: "test" } ); - const markup = ReactDOMServer.renderToStaticMarkup(deck.cards["0_0_0_0_0"]); + const markup = ReactDOMServer.renderToStaticMarkup(deck.cards["0_0_0_0_0_0"]); expect(markup).toContain("rotate(90, 60, 60)"); - expect(markup).toContain('filter="url(#flt-test-0_0_0_0_0)"'); - expect(markup).toContain('fill="url(#pat-test-0_0_0_0_0)"'); + expect(markup).toContain('filter="url(#flt-test-0_0_0_0_0_0)"'); + expect(markup).toContain('fill="url(#pat-test-0_0_0_0_0_0)"'); expect(markup).toMatchSnapshot(); }); diff --git a/src/deckBuilder/cardLayout.ts b/src/deckBuilder/cardLayout.ts new file mode 100644 index 0000000..1326e3a --- /dev/null +++ b/src/deckBuilder/cardLayout.ts @@ -0,0 +1,129 @@ +export const MAIN_VIEWPORT_SIZE = 120; +export const SYMBOL_MARGIN = 5; + +export interface GridSlot { + x: number; + y: number; +} + +export interface CardGrid { + cols: number; + rows: number; + cellSize: number; +} + +// The deck builder's original fixed 3x3 layout, in its hand-picked +// center-first, then corners, then edge-midpoints order. Kept as a literal +// constant (rather than derived) so every capacity that resolves to this +// same grid (7, 8, and 9) renders pixel-identical to that original layout. +const LEGACY_CELL_SIZE = MAIN_VIEWPORT_SIZE / 3 - SYMBOL_MARGIN; +const LEGACY_START = 0; +const LEGACY_MIDDLE = MAIN_VIEWPORT_SIZE / 2 - LEGACY_CELL_SIZE / 2; +const LEGACY_END = MAIN_VIEWPORT_SIZE - LEGACY_CELL_SIZE; +const LEGACY_SLOTS: GridSlot[] = [ + { x: LEGACY_MIDDLE, y: LEGACY_MIDDLE }, + { x: LEGACY_START, y: LEGACY_END }, + { x: LEGACY_END, y: LEGACY_START }, + { x: LEGACY_END, y: LEGACY_END }, + { x: LEGACY_START, y: LEGACY_START }, + { x: LEGACY_END, y: LEGACY_MIDDLE }, + { x: LEGACY_START, y: LEGACY_MIDDLE }, + { x: LEGACY_MIDDLE, y: LEGACY_START }, + { x: LEGACY_MIDDLE, y: LEGACY_END }, +]; + +/** + * The cols x rows split (rows = ceil(capacity / cols)) that maximizes each + * cell's size — min(120/cols, 120/rows) minus the fixed symbol margin — for + * `capacity` shape slots. Ties favor the more square split, then fewer + * columns, so results are deterministic. + */ +export function computeGrid(capacity: number): CardGrid { + let best: CardGrid | undefined; + for (let cols = 1; cols <= capacity; cols++) { + const rows = Math.ceil(capacity / cols); + const cellSize = Math.min(MAIN_VIEWPORT_SIZE / cols, MAIN_VIEWPORT_SIZE / rows) - SYMBOL_MARGIN; + const squareness = Math.abs(cols - rows); + if ( + !best || + cellSize > best.cellSize || + (cellSize === best.cellSize && + (squareness < Math.abs(best.cols - best.rows) || + (squareness === Math.abs(best.cols - best.rows) && cols < best.cols))) + ) { + best = { cols, rows, cellSize }; + } + } + return best as CardGrid; +} + +/** Evenly spaced positions along one 120-unit axis for `count` cells of + * `cellSize`: a single cell centers, more than one runs edge to edge. */ +function axisPositions(count: number, cellSize: number): number[] { + if (count === 1) { + return [(MAIN_VIEWPORT_SIZE - cellSize) / 2]; + } + const pitch = (MAIN_VIEWPORT_SIZE - cellSize) / (count - 1); + return Array.from({ length: count }, (_, i) => i * pitch); +} + +function gridSlots(grid: CardGrid): GridSlot[] { + const xs = axisPositions(grid.cols, grid.cellSize); + const ys = axisPositions(grid.rows, grid.cellSize); + const slots: GridSlot[] = []; + ys.forEach((y) => xs.forEach((x) => slots.push({ x, y }))); + return slots; +} + +/** True when the grid has a single, unambiguous center cell (odd columns + * and odd rows) — the case the legacy layout special-cases by skipping the + * center slot for an even count. */ +function hasCenterCell(grid: CardGrid): boolean { + return grid.cols % 2 === 1 && grid.rows % 2 === 1; +} + +const CENTER = MAIN_VIEWPORT_SIZE / 2; + +function distanceFromCenter(slot: GridSlot, cellSize: number): number { + const cx = slot.x + cellSize / 2; + const cy = slot.y + cellSize / 2; + return Math.hypot(cx - CENTER, cy - CENTER); +} + +/** + * The cell size and slot positions to render `count` shapes at `capacity` + * grid resolution (`count` must be <= `capacity`). Reproduces the deck + * builder's original fixed 3x3 layout exactly whenever the capacity + * resolves to that grid (capacities 7-9); every other capacity uses an + * evenly spaced grid, filled from the center outward, skipping the true + * center cell for an even count the same way the legacy layout does. A + * single shape always sits dead center, regardless of the capacity grid's + * own shape (e.g. an even-columned grid has no cell that's exactly + * centered on both axes). + * + * Cells are disjoint axis-aligned boxes by construction — computeGrid only + * ever picks a cellSize small enough that the per-axis pitch between + * adjacent cells exceeds the cell size — so slots never overlap. + */ +export function layoutCard(capacity: number, count: number): { cellSize: number; slots: GridSlot[] } { + const grid = computeGrid(capacity); + const isLegacyGrid = grid.cols === 3 && grid.rows === 3; + const cellSize = isLegacyGrid ? LEGACY_CELL_SIZE : grid.cellSize; + + if (count === 1) { + const center = (MAIN_VIEWPORT_SIZE - cellSize) / 2; + return { cellSize, slots: [{ x: center, y: center }] }; + } + + if (isLegacyGrid) { + const offset = count % 2 ? 0 : 1; + return { cellSize, slots: LEGACY_SLOTS.slice(offset, offset + count) }; + } + + const slots = gridSlots(grid); + const skipCenter = hasCenterCell(grid) && count % 2 === 0; + const ordered = slots + .filter((slot) => !skipCenter || distanceFromCenter(slot, cellSize) > 1e-6) + .sort((a, b) => distanceFromCenter(a, cellSize) - distanceFromCenter(b, cellSize)); + return { cellSize, slots: ordered.slice(0, count) }; +} diff --git a/src/deckBuilder/types.ts b/src/deckBuilder/types.ts index 7141253..0a6751c 100644 --- a/src/deckBuilder/types.ts +++ b/src/deckBuilder/types.ts @@ -82,10 +82,22 @@ export interface DeckMetaData { [feature: string]: (string | number)[]; } +/** + * How CardSvg sizes shapes within a card. "full" sizes every card in the + * deck off the deck's largest `numbers` value, so shapes stay one uniform + * size across the whole deck. "max" sizes each card off its own count, + * independent of siblings, always filling as much of that card as its + * count allows. + */ +export type CardSizeMode = "full" | "max"; + /** The deck contract consumed by redux, the board, and the menu. */ export interface Deck { metaData: { readonly [feature: string]: readonly (string | number)[] | undefined }; features: string[]; numOptions: number; cards: FeatureDeck; + /** Present on GeometricDeckGenerator decks; undefined on PresetDecks, + * which render pre-baked images and have no notion of shape sizing. */ + cardSizeMode?: CardSizeMode; } diff --git a/src/views/gameEditor/GameEditor.tsx b/src/views/gameEditor/GameEditor.tsx index df03dd2..dd77443 100644 --- a/src/views/gameEditor/GameEditor.tsx +++ b/src/views/gameEditor/GameEditor.tsx @@ -25,9 +25,11 @@ import { getSupportedFeatureValues, } from "deckBuilder/deckRules"; import { ShapeName } from "deckBuilder/shapes"; +import { CardSizeMode } from "deckBuilder/types"; import { actions } from "views/actions"; import { FeatureSelect } from "./featureSelect"; import { CardSelector } from "./cardSelector"; +import { CardSizeSelect } from "./cardSizeSelect"; import { EnableFeature } from "./enableFeature"; import { DECK_DATA } from "views/reducers"; @@ -40,9 +42,12 @@ export const GameEditor = () => { const [deckData, setDeckData] = useState(() => isGeneratedMetaData(globalDeck.metaData) ? globalDeck.metaData : DECK_DATA ); + const [cardSizeMode, setCardSizeMode] = useState( + () => globalDeck.cardSizeMode || "full" + ); const localDeck = useMemo( - () => new GeometricDeckGenerator(deckData, deckDefaults), - [deckData, deckDefaults] + () => new GeometricDeckGenerator(deckData, deckDefaults, { cardSizeMode }), + [deckData, deckDefaults, cardSizeMode] ); const deck = localDeck.cards; const numberOfCards = localDeck.numOptions; @@ -133,7 +138,11 @@ export const GameEditor = () => { const handleShow = () => setShow(true); const handleClose = () => setShow(false); const handleSave = () => { - dispatch(actions.updateDeck({ deck: new GeometricDeckGenerator(deckData, deckDefaults) })); + dispatch( + actions.updateDeck({ + deck: new GeometricDeckGenerator(deckData, deckDefaults, { cardSizeMode }), + }) + ); setShow(false); }; @@ -156,6 +165,7 @@ export const GameEditor = () => { card={card} setCard={setCard} /> + {FEATURE_NAMES.filter((feature) => !isFeatureLocked(feature)).map((feature) => { const values = deckData[feature]; return ( diff --git a/src/views/gameEditor/cardSizeSelect.tsx b/src/views/gameEditor/cardSizeSelect.tsx new file mode 100644 index 0000000..f66ecd4 --- /dev/null +++ b/src/views/gameEditor/cardSizeSelect.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import Form from "react-bootstrap/Form"; +import { CardSizeMode } from "deckBuilder/types"; + +interface Props { + value: CardSizeMode; + onChange: (value: CardSizeMode) => void; +} + +/** + * "Full Size" keeps every card's shapes one uniform size across the deck, + * sized off the card with the most shapes. "Max Size" sizes each card's + * shapes as large as that card alone allows, independent of its siblings. + */ +export const CardSizeSelect = ({ value, onChange }: Props) => ( + + Card Size +
+ onChange("full")} + /> + onChange("max")} + /> +
+ +);