From e3e7916265e41a3e929226347f6897ab9e045492 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 09:37:50 +0300 Subject: [PATCH] feat(frontend): draw the new board Pits and stores are wells with scattered stones and count badges, the viewer's store on the right. Below 600 px the board stands upright. Playable pits preview their move on hover, animated pits are ringed and the pit of the last move has a dashed ring. --- .../src/components/board/BoardView.css | 187 ++++++++++++++++++ .../src/components/board/BoardView.tsx | 135 +++++++------ .../frontend/src/components/board/PitView.tsx | 132 ++++++------- .../src/components/board/StoneView.tsx | 20 -- apps/frontend/src/components/board/Stones.tsx | 37 ++++ .../src/components/board/StoreView.tsx | 71 ++----- .../src/components/history/ReplayView.tsx | 5 +- .../src/components/home/GamePreviewCard.tsx | 6 +- apps/frontend/src/routes/GamePage.tsx | 12 +- docs/specs/web-redesign/tasks.md | 2 +- 10 files changed, 387 insertions(+), 220 deletions(-) create mode 100644 apps/frontend/src/components/board/BoardView.css delete mode 100644 apps/frontend/src/components/board/StoneView.tsx create mode 100644 apps/frontend/src/components/board/Stones.tsx diff --git a/apps/frontend/src/components/board/BoardView.css b/apps/frontend/src/components/board/BoardView.css new file mode 100644 index 0000000..246ad18 --- /dev/null +++ b/apps/frontend/src/components/board/BoardView.css @@ -0,0 +1,187 @@ +/* The board. Sized by its container: pits are a ninth of the width, up to 124 px. Below 600 px the board stands + upright: the top player's store at the top, the viewer's at the bottom, the viewer's pits in the left column. */ + +.bwrap { container-type: inline-size; } + +.board { + --pit: min(124px, calc(100cqw / 9.7)); + --pw: var(--pit); + --ph: var(--pit); + --stone: calc(var(--ph) * .21); + display: grid; + grid-template-columns: calc(var(--pit) * 1.1) repeat(6, var(--pit)) calc(var(--pit) * 1.1); + grid-template-rows: var(--pit) var(--pit); + column-gap: calc(var(--pit) * .13); + row-gap: calc(var(--pit) * .26); + width: max-content; + margin: 0 auto; + padding: calc(var(--pit) * .22); + border-radius: calc(var(--pit) * .34); + background: var(--boardBg); + box-shadow: var(--boardShadow); + transition: background .3s; +} + +.pit { + position: relative; + grid-row: var(--hr); + grid-column: var(--hc); + width: var(--pw); + height: var(--ph); + border-radius: 50%; + border: none; + padding: 0; + margin: 0; + background: var(--pit); + box-shadow: var(--pitShadow); + font: inherit; + color: inherit; + cursor: default; + transition: transform .18s ease, box-shadow .18s ease; +} + +.pit.mine { cursor: pointer; } + +@media (hover: hover) { + .pit.mine:hover { transform: translateY(-2px); box-shadow: var(--pitShadow), 0 0 0 2px var(--accentSoft); } +} + +.pit:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; } + +.pit.pv, .store.pv, .pit.sel { box-shadow: var(--pitShadow), 0 0 0 2px var(--accentSoft); } + +.pit.land, .store.land, .pit.drop, .store.drop { box-shadow: var(--pitShadow), 0 0 0 3px var(--accent), 0 0 22px var(--accentGlow); } + +.pit.cap, .store.cap { box-shadow: var(--pitShadow), 0 0 0 3px var(--live), 0 0 22px var(--live); } + +.pit.last::after { + content: ''; + position: absolute; + inset: -7px; + border-radius: 50%; + border: 2px dashed var(--lastRing); + pointer-events: none; +} + +.store { + position: relative; + grid-row: 1 / 3; + border-radius: 999px; + background: var(--pit); + box-shadow: var(--pitShadow); + transition: box-shadow .18s; +} + +.store.l { grid-column: 1; } + +.store.r { grid-column: 8; } + +.st { + position: absolute; + width: var(--stone); + height: var(--stone); + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: var(--stoneShadow), inset 0 -2px 4px rgba(0, 0, 0, .25); + transition: left .3s ease, top .3s ease; + pointer-events: none; +} + +.st::after { + content: ''; + position: absolute; + left: 22%; + top: 15%; + width: 32%; + height: 20%; + border-radius: 50%; + background: rgba(255, 255, 255, .6); +} + +.badge { + position: absolute; + left: 50%; + transform: translateX(-50%); + min-width: 30px; + height: 22px; + padding: 0 8px; + border-radius: 11px; + background: var(--chip); + color: var(--chipInk); + font-weight: 700; + font-size: 13px; + line-height: 22px; + box-sizing: border-box; + z-index: 2; + box-shadow: 0 0 0 2px var(--rim); + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; +} + +.badge.t { top: -11px; } + +.badge.b { bottom: -11px; } + +.sbadge { + position: absolute; + left: 50%; + bottom: -14px; + transform: translateX(-50%); + min-width: 44px; + height: 28px; + padding: 0 10px; + border-radius: 14px; + background: var(--chip); + color: var(--chipInk); + font-weight: 800; + font-size: 16px; + line-height: 28px; + box-sizing: border-box; + box-shadow: 0 0 0 2px var(--rim); + text-align: center; + z-index: 2; + font-variant-numeric: tabular-nums; +} + +.store.r .sbadge { background: var(--accent); color: var(--onAccent); } + +.pvchip { + position: absolute; + right: -6px; + top: -6px; + height: 22px; + min-width: 26px; + padding: 0 6px; + box-sizing: border-box; + border-radius: 11px; + background: var(--accent); + color: var(--onAccent); + font-weight: 800; + font-size: 12px; + line-height: 22px; + z-index: 3; + text-align: center; + pointer-events: none; +} + +@container (max-width: 600px) { + .board { + --pw: min(170px, calc((100cqw - 70px) / 2)); + --ph: clamp(52px, 15cqw, 66px); + --stone: calc(var(--ph) * .24); + grid-template-columns: var(--pw) var(--pw); + grid-template-rows: calc(var(--ph) * 1.05) repeat(6, var(--ph)) calc(var(--ph) * 1.05); + column-gap: 22px; + row-gap: 10px; + padding: 24px 22px; + border-radius: 32px; + } + .pit { grid-row: var(--vr); grid-column: var(--vc); } + .store.l, .store.r { grid-column: 1 / 3; } + .store.l { grid-row: 1; } + .store.r { grid-row: 8; } + .badge.t { top: 50%; left: auto; right: -13px; transform: translateY(-50%); } + .badge.b { top: 50%; bottom: auto; left: -13px; transform: translateY(-50%); } + .store.l .sbadge { bottom: auto; top: -14px; } +} diff --git a/apps/frontend/src/components/board/BoardView.tsx b/apps/frontend/src/components/board/BoardView.tsx index bd8566e..172f965 100644 --- a/apps/frontend/src/components/board/BoardView.tsx +++ b/apps/frontend/src/components/board/BoardView.tsx @@ -1,81 +1,88 @@ import * as React from "react"; -import { FunctionComponent } from "react"; -import { Context } from "../../context/context"; -import { BoardViewModel, PitViewModel } from "@mancala/core"; -import PitView from "./PitView"; -import StoreView from "./StoreView"; -import { Game } from "@mancala/core"; +import { FunctionComponent, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BoardViewModel, Game, getSowingPath } from "@mancala/core"; import { Pit } from "mancala.js"; +import { Context } from "../../context/context"; +import { getWebTheme } from "../../theme/themes"; +import PitView, { PitMarks } from "./PitView"; +import StoreView from "./StoreView"; +import "./BoardView.css"; +// The board as the bottom player sees it: their pits along the bottom from left to right, their store on the right. +// It fills its container's width. const BoardView: FunctionComponent<{ game: Game; context: Context; - boardId: string; boardViewModel: BoardViewModel; + // Show player 2 at the bottom. revert: boolean; - onPitSelect: (index: number, pit: Pit) => void; - // Width in px, e.g. for previews; by default the board is as wide as the viewport. - width?: number; -}> = ({ game, context, boardId, boardViewModel, revert, onPitSelect, width }) => { - const mancalaGame = game?.mancalaGame; - const theme = context.themeManager.theme; + topName: string; + bottomName: string; + // Makes the bottom player's pits buttons. + onPitSelect?: (index: number, pit: Pit) => void; + // The bottom player can move now: their pits with stones are highlighted and preview their move. + canPlay?: boolean; + // Board index of the pit the last move was played from. + lastMoveIndex?: number; + // Board index where the shown move's last stone landed. + landIndex?: number; +}> = ({ game, context, boardViewModel, revert, topName, bottomName, onPitSelect, canPlay, lastMoveIndex, landIndex }) => { + const { t } = useTranslation(); + const [previewIndex, setPreviewIndex] = useState(undefined); + const board = game.mancalaGame.board; + const themeId = getWebTheme(context.themeManager.theme).id; + const stones = boardViewModel.pits.map((pit) => pit.stoneCount); - // The board is 92 units wide (8 columns of 11 plus padding); all board parts are sized in units. - const unit = width !== undefined ? width / 92 : (typeof window !== "undefined" ? window.innerWidth / 100 : 10); - const pitWidth = unit * 11; - const sizeVariables = { - "--board-unit": width !== undefined ? `${unit}px` : "1vw", - "--board-spacing": width !== undefined ? `${unit * 0.3}px` : "5px", - } as React.CSSProperties; + // Pits the hovered move drops stones into, with how many each, and where its last stone lands. + const preview: Record = {}; + const path = canPlay && previewIndex !== undefined ? getSowingPath(stones, previewIndex) : []; + path.forEach((index) => (preview[index] = (preview[index] ?? 0) + 1)); + const previewLand = path.length > 0 ? path[path.length - 1] : undefined; - const createPitView = (key: any, pit: Pit, pitViewModel: PitViewModel) => ( - onPitSelect(pit.index, pit)} - fontSize={pitWidth / 6} - /> - ); + const marksOf = (index: number): PitMarks => ({ + highlight: boardViewModel.pits[index]?.highlight, + preview: preview[index], + land: previewLand !== undefined ? index === previewLand : index === landIndex, + last: index === lastMoveIndex, + }); - const createPitViewList = (pits: Pit[]) => - pits.map((pit, index) => createPitView(index, pit, boardViewModel.pits[pit.index])); + const topPits = revert ? board.player1Pits : board.player2Pits; + const bottomPits = revert ? board.player2Pits : board.player1Pits; + const [topStore, bottomStore] = revert + ? [board.player1BankIndex(), board.player2BankIndex()] + : [board.player2BankIndex(), board.player1BankIndex()]; - const player1Pits = createPitViewList(mancalaGame?.board.player1Pits); - const player2Pits = createPitViewList(mancalaGame?.board.player2Pits); - const player1BankIndex = mancalaGame?.board.player1BankIndex(); - const player2BankIndex = mancalaGame?.board.player2BankIndex(); - const player1BankViewModel = boardViewModel.pits[player1BankIndex]; - const player2BankViewModel = boardViewModel.pits[player2BankIndex]; + const pitView = (pit: Pit, position: number, top: boolean) => { + const stoneCount = stones[pit.index]; + const playable = !top && !!canPlay && stoneCount > 0; + const pitNumber = (top ? 5 - position : position) + 1; + return ( + onPitSelect(pit.index, board.pits[pit.index]) : undefined} + onPreview={(active) => setPreviewIndex(active ? pit.index : undefined)} /> + ); + }; return ( -
- - - {revert ? player1Pits?.reverse() : player2Pits?.reverse()} - {revert ? player2Pits : player1Pits} - +
+
+ + {[...topPits].reverse().map((pit, position) => pitView(pit, position, true))} + {bottomPits.map((pit, position) => pitView(pit, position, false))} + +
); }; diff --git a/apps/frontend/src/components/board/PitView.tsx b/apps/frontend/src/components/board/PitView.tsx index 1029084..a55d968 100644 --- a/apps/frontend/src/components/board/PitView.tsx +++ b/apps/frontend/src/components/board/PitView.tsx @@ -1,83 +1,71 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { getColorByBrightness, PitViewModel } from "@mancala/core"; -import { Context } from "../../context/context"; -import Util from "../../util/Util"; -import StoneView from "./StoneView"; +import { PitHighlight } from "@mancala/core"; +import Stones from "./Stones"; -interface PitViewProps { - pitViewModel: PitViewModel; - context: Context; - onClick: () => void; - fontSize: number; +export type PitMarks = { + highlight?: PitHighlight; + // Stones a hovered move would drop here. + preview?: number; + // The hovered move's last stone lands here, or the shown move's did. + land?: boolean; + // The last move was played from here. + last?: boolean; +}; + +export function getMarkClasses(marks: PitMarks): string { + const highlight = marks.highlight === "capture" ? " cap" : marks.highlight === "selected" ? " sel" + : marks.highlight ? " drop" : ""; + return highlight + (marks.preview ? " pv" : "") + (marks.land ? " land" : "") + (marks.last ? " last" : ""); } -const PitView: FunctionComponent = ({ pitViewModel, context, onClick, fontSize }) => { - const textColor = getColorByBrightness( - pitViewModel.pitColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor +// A pit in the top or bottom row. Position is its column from the left, 0-5. The viewer's pits are buttons. +const PitView: FunctionComponent<{ + index: number; + stoneCount: number; + top: boolean; + position: number; + themeId: string; + label: string; + marks: PitMarks; + // The viewer can play this pit now. + playable?: boolean; + // Set for pits the viewer can click. + onClick?: () => void; + // Called with true when a playable pit is hovered or focused, with false when that ends. + onPreview?: (active: boolean) => void; +}> = ({ index, stoneCount, top, position, themeId, label, marks, playable, onClick, onPreview }) => { + const className = "pit" + (playable ? " mine" : "") + getMarkClasses(marks); + const style = { + "--hr": top ? "1" : "2", + "--hc": String(position + 2), + "--vr": String(position + 2), + "--vc": top ? "2" : "1", + } as React.CSSProperties; + const content = ( + <> + + {stoneCount} + {!!marks.preview && {`+${marks.preview}`}} + ); - - const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => ( - - )); - + if (!onClick) { + return
{content}
; + } return ( -
- {stones} - {pitViewModel.stoneCount > 0 && ( - - {pitViewModel.stoneCount} - - )} - -
+ onMouseEnter={() => playable && onPreview?.(true)} + onMouseLeave={() => onPreview?.(false)} + onFocus={() => playable && onPreview?.(true)} + onBlur={() => onPreview?.(false)}> + {content} + ); }; -export default PitView; \ No newline at end of file +export default PitView; diff --git a/apps/frontend/src/components/board/StoneView.tsx b/apps/frontend/src/components/board/StoneView.tsx deleted file mode 100644 index 790546c..0000000 --- a/apps/frontend/src/components/board/StoneView.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from "react"; -import { FunctionComponent } from "react"; - -const StoneView: FunctionComponent<{ color: string }> = ({ color }) => { - return ( -
- -
- ); -}; - -export default StoneView; \ No newline at end of file diff --git a/apps/frontend/src/components/board/Stones.tsx b/apps/frontend/src/components/board/Stones.tsx new file mode 100644 index 0000000..51fbac5 --- /dev/null +++ b/apps/frontend/src/components/board/Stones.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; + +// Stone colours per theme: highlight, body and shadow of a radial gradient. +const PALETTES: Record = { + ceviz: [["#fff1c9", "#e39a32", "#7d4610"], ["#d4fff5", "#2fa597", "#0c4a42"], ["#fffaf0", "#e2d3b6", "#94815f"], ["#ffd0c7", "#c94a3b", "#621a12"], ["#e6ecff", "#6b84c9", "#263a73"]], + gece: [["#ffffff", "#dfe3ee", "#8a92ad"], ["#f3f6ff", "#8fa8ff", "#2b3f92"], ["#fff6e6", "#e8bd78", "#8b5d1f"], ["#ffeef6", "#e7a3c4", "#8a3c62"]], + yesim: [["#e2ffee", "#3f9f6f", "#134a2d"], ["#8d939a", "#2b2f35", "#08090b"], ["#ffffff", "#efe9df", "#a59a88"], ["#ffe8c7", "#d38a3a", "#6e3c0d"]], +}; + +// A repeatable pseudo random number in [0, 1). +function random(seed: number): number { + const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); +} + +// The stones of a pit or store, scattered around its centre on a sunflower spiral. The same count and seed always +// give the same picture, so stones only move when the count changes. +const Stones: FunctionComponent<{ count: number, seed: number, themeId: string }> = ({ count, seed, themeId }) => { + const palette = PALETTES[themeId] ?? PALETTES.ceviz; + const stones = []; + for (let i = 0; i < count; i++) { + const angle = i * 2.39996 + seed; + const distance = count === 1 ? 0 : Math.sqrt((i + 0.6) / count); + const x = 50 + Math.cos(angle) * distance * 33 + (random(seed + i) - 0.5) * 3; + const y = 50 + Math.sin(angle) * distance * 33 + (random(seed + i * 3) - 0.5) * 3; + const [light, body, dark] = palette[Math.floor(random(seed * 5 + i * 7.1) * palette.length)]; + stones.push(); + } + return <>{stones}; +}; + +export default Stones; diff --git a/apps/frontend/src/components/board/StoreView.tsx b/apps/frontend/src/components/board/StoreView.tsx index 358be1a..6d79b1a 100644 --- a/apps/frontend/src/components/board/StoreView.tsx +++ b/apps/frontend/src/components/board/StoreView.tsx @@ -1,59 +1,22 @@ import * as React from "react"; import { FunctionComponent } from "react"; -import { Context } from "../../context/context"; -import { getColorByBrightness } from "@mancala/core"; -import Util from "../../util/Util"; -import { PitViewModel } from "@mancala/core"; -import StoneView from "./StoneView"; +import { getMarkClasses, PitMarks } from "./PitView"; +import Stones from "./Stones"; +// A store: the top player's on the left ("l"), the viewer's on the right ("r"). const StoreView: FunctionComponent<{ - context: Context; - pitViewModel: PitViewModel; - gridColumn: string; - gridRow: string; -}> = ({ context, pitViewModel, gridColumn, gridRow }) => { - const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => ( - - )); - const textColor = getColorByBrightness( - pitViewModel.pitColor, - context.themeManager.theme.textColor, - context.themeManager.theme.textLightColor - ); - return ( -
- {stones} - - {stones.length} - - -
- ); -}; + index: number; + stoneCount: number; + side: "l" | "r"; + themeId: string; + label: string; + marks: PitMarks; +}> = ({ index, stoneCount, side, themeId, label, marks }) => ( +
+ + {stoneCount} + {!!marks.preview && {`+${marks.preview}`}} +
+); -export default StoreView; \ No newline at end of file +export default StoreView; diff --git a/apps/frontend/src/components/history/ReplayView.tsx b/apps/frontend/src/components/history/ReplayView.tsx index 0a03491..530fc1d 100644 --- a/apps/frontend/src/components/history/ReplayView.tsx +++ b/apps/frontend/src/components/history/ReplayView.tsx @@ -185,10 +185,11 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga undefined} /> + topName={name(topPlayer)} + bottomName={name(bottomPlayer)} + lastMoveIndex={moves[moveNumber - 1]?.moveIndex} />
{`${name(bottomPlayer)} ยท ${bottomStore}`}{renderClock(bottomPlayer)}
diff --git a/apps/frontend/src/components/home/GamePreviewCard.tsx b/apps/frontend/src/components/home/GamePreviewCard.tsx index fec02b6..3708e03 100644 --- a/apps/frontend/src/components/home/GamePreviewCard.tsx +++ b/apps/frontend/src/components/home/GamePreviewCard.tsx @@ -55,11 +55,10 @@ const GamePreviewCard: FunctionComponent<{ undefined} - width={PREVIEW_BOARD_WIDTH} /> + topName={name(topPlayer)} + bottomName={name(bottomPlayer)} />
{lines.map((line, index) =>
{line}
)}