Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
187
apps/frontend/src/components/board/BoardView.css
Normal file
187
apps/frontend/src/components/board/BoardView.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
@ -1,81 +1,88 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent, useState } from "react";
|
||||||
import { Context } from "../../context/context";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BoardViewModel, PitViewModel } from "@mancala/core";
|
import { BoardViewModel, Game, getSowingPath } from "@mancala/core";
|
||||||
import PitView from "./PitView";
|
|
||||||
import StoreView from "./StoreView";
|
|
||||||
import { Game } from "@mancala/core";
|
|
||||||
import { Pit } from "mancala.js";
|
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<{
|
const BoardView: FunctionComponent<{
|
||||||
game: Game;
|
game: Game;
|
||||||
context: Context;
|
context: Context;
|
||||||
boardId: string;
|
|
||||||
boardViewModel: BoardViewModel;
|
boardViewModel: BoardViewModel;
|
||||||
|
// Show player 2 at the bottom.
|
||||||
revert: boolean;
|
revert: boolean;
|
||||||
onPitSelect: (index: number, pit: Pit) => void;
|
topName: string;
|
||||||
// Width in px, e.g. for previews; by default the board is as wide as the viewport.
|
bottomName: string;
|
||||||
width?: number;
|
// Makes the bottom player's pits buttons.
|
||||||
}> = ({ game, context, boardId, boardViewModel, revert, onPitSelect, width }) => {
|
onPitSelect?: (index: number, pit: Pit) => void;
|
||||||
const mancalaGame = game?.mancalaGame;
|
// The bottom player can move now: their pits with stones are highlighted and preview their move.
|
||||||
const theme = context.themeManager.theme;
|
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<number | undefined>(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.
|
// Pits the hovered move drops stones into, with how many each, and where its last stone lands.
|
||||||
const unit = width !== undefined ? width / 92 : (typeof window !== "undefined" ? window.innerWidth / 100 : 10);
|
const preview: Record<number, number> = {};
|
||||||
const pitWidth = unit * 11;
|
const path = canPlay && previewIndex !== undefined ? getSowingPath(stones, previewIndex) : [];
|
||||||
const sizeVariables = {
|
path.forEach((index) => (preview[index] = (preview[index] ?? 0) + 1));
|
||||||
"--board-unit": width !== undefined ? `${unit}px` : "1vw",
|
const previewLand = path.length > 0 ? path[path.length - 1] : undefined;
|
||||||
"--board-spacing": width !== undefined ? `${unit * 0.3}px` : "5px",
|
|
||||||
} as React.CSSProperties;
|
|
||||||
|
|
||||||
const createPitView = (key: any, pit: Pit, pitViewModel: PitViewModel) => (
|
const marksOf = (index: number): PitMarks => ({
|
||||||
|
highlight: boardViewModel.pits[index]?.highlight,
|
||||||
|
preview: preview[index],
|
||||||
|
land: previewLand !== undefined ? index === previewLand : index === landIndex,
|
||||||
|
last: index === lastMoveIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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 (
|
||||||
<PitView
|
<PitView
|
||||||
key={key}
|
key={pit.index}
|
||||||
pitViewModel={pitViewModel}
|
index={pit.index}
|
||||||
context={context}
|
stoneCount={stoneCount}
|
||||||
onClick={() => onPitSelect(pit.index, pit)}
|
top={top}
|
||||||
fontSize={pitWidth / 6}
|
position={position}
|
||||||
/>
|
themeId={themeId}
|
||||||
|
label={t("PitLabel", { name: top ? topName : bottomName, pit: pitNumber, count: stoneCount })}
|
||||||
|
marks={marksOf(pit.index)}
|
||||||
|
playable={playable}
|
||||||
|
onClick={!top && onPitSelect ? () => onPitSelect(pit.index, board.pits[pit.index]) : undefined}
|
||||||
|
onPreview={(active) => setPreviewIndex(active ? pit.index : undefined)} />
|
||||||
);
|
);
|
||||||
|
};
|
||||||
const createPitViewList = (pits: Pit[]) =>
|
|
||||||
pits.map((pit, index) => createPitView(index, pit, boardViewModel.pits[pit.index]));
|
|
||||||
|
|
||||||
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];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="board" style={{ background: theme.boardColor, ...sizeVariables }}>
|
<div className="bwrap">
|
||||||
<StoreView
|
<div className="board">
|
||||||
context={context}
|
<StoreView index={topStore} stoneCount={stones[topStore]} side="l" themeId={themeId}
|
||||||
pitViewModel={revert ? player2BankViewModel : player1BankViewModel}
|
label={t("StoreLabel", { name: topName, count: stones[topStore] })} marks={marksOf(topStore)} />
|
||||||
gridColumn="8 / 9"
|
{[...topPits].reverse().map((pit, position) => pitView(pit, position, true))}
|
||||||
gridRow="1 / 3"
|
{bottomPits.map((pit, position) => pitView(pit, position, false))}
|
||||||
fontSize={pitWidth / 6}
|
<StoreView index={bottomStore} stoneCount={stones[bottomStore]} side="r" themeId={themeId}
|
||||||
/>
|
label={t("StoreLabel", { name: bottomName, count: stones[bottomStore] })} marks={marksOf(bottomStore)} />
|
||||||
<StoreView
|
</div>
|
||||||
context={context}
|
|
||||||
pitViewModel={revert ? player1BankViewModel : player2BankViewModel}
|
|
||||||
gridColumn="1 / 2"
|
|
||||||
gridRow="1 / 3"
|
|
||||||
fontSize={pitWidth / 6}
|
|
||||||
/>
|
|
||||||
{revert ? player1Pits?.reverse() : player2Pits?.reverse()}
|
|
||||||
{revert ? player2Pits : player1Pits}
|
|
||||||
<style jsx>{`
|
|
||||||
.board {
|
|
||||||
padding: calc(2 * var(--board-unit));
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(8, calc(11 * var(--board-unit)));
|
|
||||||
grid-template-rows: repeat(2, calc(11 * var(--board-unit)));
|
|
||||||
border-radius: calc(3 * var(--board-unit));
|
|
||||||
transition: background-color 0.5s;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,82 +1,70 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
import { getColorByBrightness, PitViewModel } from "@mancala/core";
|
import { PitHighlight } from "@mancala/core";
|
||||||
import { Context } from "../../context/context";
|
import Stones from "./Stones";
|
||||||
import Util from "../../util/Util";
|
|
||||||
import StoneView from "./StoneView";
|
|
||||||
|
|
||||||
interface PitViewProps {
|
export type PitMarks = {
|
||||||
pitViewModel: PitViewModel;
|
highlight?: PitHighlight;
|
||||||
context: Context;
|
// Stones a hovered move would drop here.
|
||||||
onClick: () => void;
|
preview?: number;
|
||||||
fontSize: 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<PitViewProps> = ({ pitViewModel, context, onClick, fontSize }) => {
|
// A pit in the top or bottom row. Position is its column from the left, 0-5. The viewer's pits are buttons.
|
||||||
const textColor = getColorByBrightness(
|
const PitView: FunctionComponent<{
|
||||||
pitViewModel.pitColor,
|
index: number;
|
||||||
context.themeManager.theme.textColor,
|
stoneCount: number;
|
||||||
context.themeManager.theme.textLightColor
|
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 = (
|
||||||
|
<>
|
||||||
|
<Stones count={stoneCount} seed={index * 5.3 + 1} themeId={themeId} />
|
||||||
|
<span className={top ? "badge t" : "badge b"}>{stoneCount}</span>
|
||||||
|
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
if (!onClick) {
|
||||||
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => (
|
return <div className={className} style={style} role="img" aria-label={label}>{content}</div>;
|
||||||
<StoneView key={index} color={pitViewModel.stoneColor} />
|
}
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
className="pit"
|
className={className}
|
||||||
|
style={style}
|
||||||
|
aria-label={label}
|
||||||
|
aria-disabled={!playable}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{
|
onMouseEnter={() => playable && onPreview?.(true)}
|
||||||
background: pitViewModel.pitColor,
|
onMouseLeave={() => onPreview?.(false)}
|
||||||
position: "relative",
|
onFocus={() => playable && onPreview?.(true)}
|
||||||
}}
|
onBlur={() => onPreview?.(false)}>
|
||||||
>
|
{content}
|
||||||
{stones}
|
</button>
|
||||||
{pitViewModel.stoneCount > 0 && (
|
|
||||||
<span
|
|
||||||
className="stone-count"
|
|
||||||
style={{
|
|
||||||
color: textColor,
|
|
||||||
position: "absolute",
|
|
||||||
bottom: fontSize / 2,
|
|
||||||
left: "50%",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: fontSize,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pitViewModel.stoneCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<style jsx>{`
|
|
||||||
.pit {
|
|
||||||
margin: var(--board-spacing);
|
|
||||||
padding: var(--board-spacing);
|
|
||||||
border-radius: calc(10 * var(--board-unit));
|
|
||||||
transition: background-color 0.5s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
align-content: center;
|
|
||||||
justify-content: center;
|
|
||||||
justify-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.stone-count {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(1.0 * var(--board-unit));
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: calc(1.5 * var(--board-unit));
|
|
||||||
font-weight: bold;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { FunctionComponent } from "react";
|
|
||||||
|
|
||||||
const StoneView: FunctionComponent<{ color: string }> = ({ color }) => {
|
|
||||||
return (
|
|
||||||
<div className="stone" style={{ background: color }}>
|
|
||||||
<style jsx>{`
|
|
||||||
.stone {
|
|
||||||
margin: 1px;
|
|
||||||
width: calc(1 * var(--board-unit));
|
|
||||||
height: calc(1 * var(--board-unit));
|
|
||||||
border-radius: calc(10 * var(--board-unit));
|
|
||||||
transition: background-color 0.5s;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StoneView;
|
|
||||||
37
apps/frontend/src/components/board/Stones.tsx
Normal file
37
apps/frontend/src/components/board/Stones.tsx
Normal file
@ -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<string, string[][]> = {
|
||||||
|
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(<span key={i} className="st" style={{
|
||||||
|
left: `${x.toFixed(2)}%`,
|
||||||
|
top: `${y.toFixed(2)}%`,
|
||||||
|
background: `radial-gradient(circle at 34% 28%, ${light} 0%, ${body} 55%, ${dark} 100%)`,
|
||||||
|
}} />);
|
||||||
|
}
|
||||||
|
return <>{stones}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stones;
|
||||||
@ -1,59 +1,22 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent } from "react";
|
import { FunctionComponent } from "react";
|
||||||
import { Context } from "../../context/context";
|
import { getMarkClasses, PitMarks } from "./PitView";
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
import Stones from "./Stones";
|
||||||
import Util from "../../util/Util";
|
|
||||||
import { PitViewModel } from "@mancala/core";
|
|
||||||
import StoneView from "./StoneView";
|
|
||||||
|
|
||||||
|
// A store: the top player's on the left ("l"), the viewer's on the right ("r").
|
||||||
const StoreView: FunctionComponent<{
|
const StoreView: FunctionComponent<{
|
||||||
context: Context;
|
index: number;
|
||||||
pitViewModel: PitViewModel;
|
stoneCount: number;
|
||||||
gridColumn: string;
|
side: "l" | "r";
|
||||||
gridRow: string;
|
themeId: string;
|
||||||
}> = ({ context, pitViewModel, gridColumn, gridRow }) => {
|
label: string;
|
||||||
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => (
|
marks: PitMarks;
|
||||||
<StoneView key={index} color={pitViewModel.stoneColor} />
|
}> = ({ index, stoneCount, side, themeId, label, marks }) => (
|
||||||
));
|
<div className={`store ${side}${getMarkClasses(marks)}`} role="img" aria-label={label}>
|
||||||
const textColor = getColorByBrightness(
|
<Stones count={stoneCount} seed={index * 3.1 + 7} themeId={themeId} />
|
||||||
pitViewModel.pitColor,
|
<span className="sbadge">{stoneCount}</span>
|
||||||
context.themeManager.theme.textColor,
|
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
|
||||||
context.themeManager.theme.textLightColor
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="store"
|
|
||||||
style={{
|
|
||||||
background: pitViewModel.pitColor,
|
|
||||||
gridColumn: gridColumn,
|
|
||||||
gridRow: gridRow
|
|
||||||
}}>
|
|
||||||
{stones}
|
|
||||||
<span className="store-stone-count-text" style={{ color: textColor, transition: 'color 0.5s' }}>
|
|
||||||
{stones.length}
|
|
||||||
</span>
|
|
||||||
<style jsx>{`
|
|
||||||
.store {
|
|
||||||
margin: var(--board-spacing);
|
|
||||||
border-radius: calc(10 * var(--board-unit));
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
align-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
position: relative;
|
|
||||||
transition: background-color 0.5s;
|
|
||||||
}
|
|
||||||
.store-stone-count-text {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(2 * var(--board-unit));
|
|
||||||
font-family: monospace;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: calc(2 * var(--board-unit));
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
export default StoreView;
|
export default StoreView;
|
||||||
@ -185,10 +185,11 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
|
|||||||
<BoardView
|
<BoardView
|
||||||
game={createGameAtMove(baseGame, moves, moveNumber)}
|
game={createGameAtMove(baseGame, moves, moveNumber)}
|
||||||
context={context}
|
context={context}
|
||||||
boardId={boardViewModel.id}
|
|
||||||
boardViewModel={boardViewModel}
|
boardViewModel={boardViewModel}
|
||||||
revert={revert}
|
revert={revert}
|
||||||
onPitSelect={() => undefined} />
|
topName={name(topPlayer)}
|
||||||
|
bottomName={name(bottomPlayer)}
|
||||||
|
lastMoveIndex={moves[moveNumber - 1]?.moveIndex} />
|
||||||
<div className="player" style={{ color: textColor }}>{`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}</div>
|
<div className="player" style={{ color: textColor }}>{`${name(bottomPlayer)} · ${bottomStore}`}{renderClock(bottomPlayer)}</div>
|
||||||
|
|
||||||
<div className="controls" style={{ color: textColor }}>
|
<div className="controls" style={{ color: textColor }}>
|
||||||
|
|||||||
@ -55,11 +55,10 @@ const GamePreviewCard: FunctionComponent<{
|
|||||||
<BoardView
|
<BoardView
|
||||||
game={game}
|
game={game}
|
||||||
context={context}
|
context={context}
|
||||||
boardId={id}
|
|
||||||
boardViewModel={boardViewModel}
|
boardViewModel={boardViewModel}
|
||||||
revert={!!revert}
|
revert={!!revert}
|
||||||
onPitSelect={() => undefined}
|
topName={name(topPlayer)}
|
||||||
width={PREVIEW_BOARD_WIDTH} />
|
bottomName={name(bottomPlayer)} />
|
||||||
</div>
|
</div>
|
||||||
{lines.map((line, index) => <div key={index} className="line">{line}</div>)}
|
{lines.map((line, index) => <div key={index} className="line">{line}</div>)}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
@ -90,6 +89,7 @@ const GamePreviewCard: FunctionComponent<{
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
.board {
|
.board {
|
||||||
|
width: 100%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
/* Built in a map callback, which styled-jsx does not scope. */
|
/* Built in a map callback, which styled-jsx does not scope. */
|
||||||
|
|||||||
@ -230,8 +230,9 @@ const GamePage: FunctionComponent<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setHasOngoingAction(true);
|
setHasOngoingAction(true);
|
||||||
boardViewModel.pits[getBoardIndex(pitIndexForUser)].pitColor =
|
const selectedPit = boardViewModel.pits[getBoardIndex(pitIndexForUser)];
|
||||||
context.themeManager.theme.pitSelectedColor;
|
selectedPit.pitColor = context.themeManager.theme.pitSelectedColor;
|
||||||
|
selectedPit.highlight = "selected";
|
||||||
updateBoardViewModel(boardViewModel);
|
updateBoardViewModel(boardViewModel);
|
||||||
const gameMove: GameMove = { index: pitIndexForUser };
|
const gameMove: GameMove = { index: pitIndexForUser };
|
||||||
context.rtmt.sendMessage(channel_game_move, gameMove);
|
context.rtmt.sendMessage(channel_game_move, gameMove);
|
||||||
@ -350,10 +351,13 @@ const GamePage: FunctionComponent<{
|
|||||||
{showBoardView && (
|
{showBoardView && (
|
||||||
<BoardView
|
<BoardView
|
||||||
game={game}
|
game={game}
|
||||||
boardId={isReviewing ? `review-${viewedMove}` : boardId}
|
|
||||||
boardViewModel={shownBoardViewModel!}
|
boardViewModel={shownBoardViewModel!}
|
||||||
context={context}
|
context={context}
|
||||||
onPitSelect={onPitSelect}
|
onPitSelect={isSpectator ? undefined : onPitSelect}
|
||||||
|
canPlay={isYourTurn && !hasOngoingAction && !isReviewing}
|
||||||
|
lastMoveIndex={moves[(isReviewing ? viewedMove! : moves.length) - 1]?.moveIndex}
|
||||||
|
topName={topLocatedUser.name}
|
||||||
|
bottomName={bottomLocatedUser.name}
|
||||||
revert={isPlayer2} />
|
revert={isPlayer2} />
|
||||||
)}
|
)}
|
||||||
{showBoardView && (
|
{showBoardView && (
|
||||||
|
|||||||
@ -7,7 +7,7 @@ Work through the tasks in order; each ends with its checks passing and a commit.
|
|||||||
- [x] 2. **Backend**: `GET /games/popular?kind=finished`; test.
|
- [x] 2. **Backend**: `GET /games/popular?kind=finished`; test.
|
||||||
- [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu,
|
- [x] 3. **Web app shell**: the three themes, font, base styles, `AppHeader` (logo, theme swatches, account menu,
|
||||||
action) on every page; restyle the lobby, privacy page, loading and connection panels.
|
action) on every page; restyle the lobby, privacy page, loading and connection panels.
|
||||||
- [ ] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview.
|
- [x] 4. **Web app board**: `BoardView`, `PitView`, `StoreView`, stones, upright layout, highlights and move preview.
|
||||||
- [ ] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner.
|
- [ ] 5. **Web app game page**: players, turn, clocks, game-over card, move list and review banner.
|
||||||
- [ ] 6. **Web app replay page**: summary card, controls card, move list.
|
- [ ] 6. **Web app replay page**: summary card, controls card, move list.
|
||||||
- [ ] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards.
|
- [ ] 7. **Web app home page**: game-in-progress hero, mini boards, live / popular / recent game cards.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user