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}
)}