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 { 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<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.
|
||||
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<number, number> = {};
|
||||
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) => (
|
||||
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
|
||||
key={key}
|
||||
pitViewModel={pitViewModel}
|
||||
context={context}
|
||||
onClick={() => onPitSelect(pit.index, pit)}
|
||||
fontSize={pitWidth / 6}
|
||||
/>
|
||||
key={pit.index}
|
||||
index={pit.index}
|
||||
stoneCount={stoneCount}
|
||||
top={top}
|
||||
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 (
|
||||
<div className="board" style={{ background: theme.boardColor, ...sizeVariables }}>
|
||||
<StoreView
|
||||
context={context}
|
||||
pitViewModel={revert ? player2BankViewModel : player1BankViewModel}
|
||||
gridColumn="8 / 9"
|
||||
gridRow="1 / 3"
|
||||
fontSize={pitWidth / 6}
|
||||
/>
|
||||
<StoreView
|
||||
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 className="bwrap">
|
||||
<div className="board">
|
||||
<StoreView index={topStore} stoneCount={stones[topStore]} side="l" themeId={themeId}
|
||||
label={t("StoreLabel", { name: topName, count: stones[topStore] })} marks={marksOf(topStore)} />
|
||||
{[...topPits].reverse().map((pit, position) => pitView(pit, position, true))}
|
||||
{bottomPits.map((pit, position) => pitView(pit, position, false))}
|
||||
<StoreView index={bottomStore} stoneCount={stones[bottomStore]} side="r" themeId={themeId}
|
||||
label={t("StoreLabel", { name: bottomName, count: stones[bottomStore] })} marks={marksOf(bottomStore)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,82 +1,70 @@
|
||||
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<PitViewProps> = ({ 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 = (
|
||||
<>
|
||||
<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>}
|
||||
</>
|
||||
);
|
||||
|
||||
const stones = [...Util.range(pitViewModel.stoneCount)].map((i, index) => (
|
||||
<StoneView key={index} color={pitViewModel.stoneColor} />
|
||||
));
|
||||
|
||||
if (!onClick) {
|
||||
return <div className={className} style={style} role="img" aria-label={label}>{content}</div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="pit"
|
||||
<button
|
||||
className={className}
|
||||
style={style}
|
||||
aria-label={label}
|
||||
aria-disabled={!playable}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: pitViewModel.pitColor,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{stones}
|
||||
{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>
|
||||
onMouseEnter={() => playable && onPreview?.(true)}
|
||||
onMouseLeave={() => onPreview?.(false)}
|
||||
onFocus={() => playable && onPreview?.(true)}
|
||||
onBlur={() => onPreview?.(false)}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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 { 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) => (
|
||||
<StoneView key={index} color={pitViewModel.stoneColor} />
|
||||
));
|
||||
const textColor = getColorByBrightness(
|
||||
pitViewModel.pitColor,
|
||||
context.themeManager.theme.textColor,
|
||||
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>
|
||||
index: number;
|
||||
stoneCount: number;
|
||||
side: "l" | "r";
|
||||
themeId: string;
|
||||
label: string;
|
||||
marks: PitMarks;
|
||||
}> = ({ index, stoneCount, side, themeId, label, marks }) => (
|
||||
<div className={`store ${side}${getMarkClasses(marks)}`} role="img" aria-label={label}>
|
||||
<Stones count={stoneCount} seed={index * 3.1 + 7} themeId={themeId} />
|
||||
<span className="sbadge">{stoneCount}</span>
|
||||
{!!marks.preview && <span className="pvchip">{`+${marks.preview}`}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoreView;
|
||||
@ -185,10 +185,11 @@ const ReplayView: FunctionComponent<{ context: Context, theme: Theme, record: Ga
|
||||
<BoardView
|
||||
game={createGameAtMove(baseGame, moves, moveNumber)}
|
||||
context={context}
|
||||
boardId={boardViewModel.id}
|
||||
boardViewModel={boardViewModel}
|
||||
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="controls" style={{ color: textColor }}>
|
||||
|
||||
@ -55,11 +55,10 @@ const GamePreviewCard: FunctionComponent<{
|
||||
<BoardView
|
||||
game={game}
|
||||
context={context}
|
||||
boardId={id}
|
||||
boardViewModel={boardViewModel}
|
||||
revert={!!revert}
|
||||
onPitSelect={() => undefined}
|
||||
width={PREVIEW_BOARD_WIDTH} />
|
||||
topName={name(topPlayer)}
|
||||
bottomName={name(bottomPlayer)} />
|
||||
</div>
|
||||
{lines.map((line, index) => <div key={index} className="line">{line}</div>)}
|
||||
<style jsx>{`
|
||||
@ -90,6 +89,7 @@ const GamePreviewCard: FunctionComponent<{
|
||||
opacity: 0.6;
|
||||
}
|
||||
.board {
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Built in a map callback, which styled-jsx does not scope. */
|
||||
|
||||
@ -230,8 +230,9 @@ const GamePage: FunctionComponent<{
|
||||
return;
|
||||
}
|
||||
setHasOngoingAction(true);
|
||||
boardViewModel.pits[getBoardIndex(pitIndexForUser)].pitColor =
|
||||
context.themeManager.theme.pitSelectedColor;
|
||||
const selectedPit = boardViewModel.pits[getBoardIndex(pitIndexForUser)];
|
||||
selectedPit.pitColor = context.themeManager.theme.pitSelectedColor;
|
||||
selectedPit.highlight = "selected";
|
||||
updateBoardViewModel(boardViewModel);
|
||||
const gameMove: GameMove = { index: pitIndexForUser };
|
||||
context.rtmt.sendMessage(channel_game_move, gameMove);
|
||||
@ -350,10 +351,13 @@ const GamePage: FunctionComponent<{
|
||||
{showBoardView && (
|
||||
<BoardView
|
||||
game={game}
|
||||
boardId={isReviewing ? `review-${viewedMove}` : boardId}
|
||||
boardViewModel={shownBoardViewModel!}
|
||||
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} />
|
||||
)}
|
||||
{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] 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.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user