Implement scoresheet page with scoring logic and confirmation dialog
- Add src/game/scoring.ts with computeRunningTotals and isGameOver - Build ScoreSheet grid: player columns, per-round scores, running totals after round 2+ - Small red "New Game" button in header top-right with full-screen confirmation overlay - Extend PageFrame with headerAction and overlay props - Move press animation/shadow from .pressable class to global button selector Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -43,11 +43,16 @@ Navigation is a simple `useState<Page>` state machine in `App.tsx` — no router
|
||||
|------|-----|-------------|
|
||||
| Title screen | `'title'` | Shown on first load (no saved game). "New Game" button navigates to setup. |
|
||||
| Player setup | `'setup'` | Enter 3–6 player names, then "Start" saves the game and goes to scoresheet. |
|
||||
| Score sheet | `'scoresheet'` | Shows current scores. "Score Round" → enter scores; "New Game" → clears state, back to title. |
|
||||
| Score sheet | `'scoresheet'` | Shows per-round scores and running totals. Small red "New Game" button (top-right) opens a confirmation dialog before clearing state and going to setup. "Score Round" (footer) → enter scores. |
|
||||
| Enter scores | `'enterscores'` | Form to enter each player's score for the round. "Submit" → back to scoresheet. |
|
||||
|
||||
On mount, `App.tsx` checks `localStorage` for a saved game and jumps straight to `'scoresheet'` if found.
|
||||
|
||||
## Game rules
|
||||
|
||||
- Each round every player scores some points; all players' scores in a round must sum to **-110**
|
||||
- Each player starts at 0; the game ends when any player reaches **-200**
|
||||
|
||||
## Data model
|
||||
|
||||
```ts
|
||||
@@ -61,6 +66,13 @@ interface GameState {
|
||||
|
||||
Persisted to `localStorage` under the key `currentGame`. No game history yet — only one game at a time.
|
||||
|
||||
## Scoring logic
|
||||
|
||||
Pure functions live in `src/game/scoring.ts` (no React imports):
|
||||
|
||||
- `computeRunningTotals(rounds)` — returns `totals[i][j]`, the cumulative score for player `j` after round `i`
|
||||
- `isGameOver(totals)` — true if any player's total in the last round is ≤ -200
|
||||
|
||||
## Design system
|
||||
|
||||
### Layout
|
||||
@@ -72,8 +84,10 @@ Persisted to `localStorage` under the key `currentGame`. No game history yet —
|
||||
|
||||
### PageFrame component
|
||||
|
||||
Shared layout for non-title pages. Props: `title`, `children`, `footer?`. Renders:
|
||||
Shared layout for non-title pages. Props: `title`, `children`, `footer?`, `headerAction?`, `overlay?`. Renders:
|
||||
- Fixed-position card with header (h1 + diamond rule divider), scrollable content area, optional sticky footer
|
||||
- `headerAction` — rendered absolutely in the top-right of the header (used for the scoresheet's "New Game" button)
|
||||
- `overlay` — rendered `position: fixed; inset: 0` over the full viewport with a dim backdrop; the caller provides the dialog box content centered inside it
|
||||
|
||||
Diamond rule: CSS `::before` pseudo-element with `content: '◆'` centered on a thin border line. Color `#c8b99a`.
|
||||
|
||||
@@ -91,10 +105,10 @@ Diamond rule: CSS `::before` pseudo-element with `content: '◆'` centered on a
|
||||
|
||||
### Buttons
|
||||
|
||||
All buttons share the same press animation: `translateY(2px)` + shadow reduction, 80ms ease transition.
|
||||
Shadow and press animation are on the global `button` selector in `src/index.css` — every `<button>` gets them automatically.
|
||||
|
||||
- **Resting shadow**: `0 4px 12px rgba(0,0,0,0.25)`
|
||||
- **Pressed shadow**: `0 2px 6px rgba(0,0,0,0.2)`
|
||||
- **Pressed shadow**: `0 2px 6px rgba(0,0,0,0.2)` via `button:active:not(:disabled)`
|
||||
- **`PrimaryButton`**: full-width, blue, `border-radius: 1rem`, `font-size: 1.25rem`, `font-weight: 600`. Accepts `disabled` prop — disabled state: `opacity: 0.45`, no shadow, `cursor: not-allowed`, no press animation.
|
||||
- **Round buttons**: circular, `border: none`. Add button: `3rem`, blue. Remove button: `2.5rem`, red.
|
||||
|
||||
|
||||
+1
-1
@@ -43,6 +43,6 @@ export default function App() {
|
||||
|
||||
if (page === 'title') return <TitleScreen onNewGame={handleNewGame} />
|
||||
if (page === 'setup') return <PlayerSetup onStart={handleStart} />
|
||||
if (page === 'scoresheet') return <ScoreSheet onScoreRound={() => setPage('enterscores')} onNewGame={handleNewGame} />
|
||||
if (page === 'scoresheet') return <ScoreSheet game={game!} onScoreRound={() => setPage('enterscores')} onNewGame={handleNewGame} />
|
||||
if (page === 'enterscores') return <EnterScores onSubmit={handleSubmitScores} />
|
||||
}
|
||||
|
||||
@@ -4,10 +4,17 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
padding: 1.25rem 1.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerAction {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
@@ -43,3 +50,13 @@
|
||||
.footer {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
@@ -4,19 +4,23 @@ interface Props {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
headerAction?: React.ReactNode
|
||||
overlay?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function PageFrame({ title, children, footer }: Props) {
|
||||
export default function PageFrame({ title, children, footer, headerAction, overlay }: Props) {
|
||||
return (
|
||||
<div className={styles.frame}>
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<div className={styles.rule} role="separator" />
|
||||
{headerAction && <div className={styles.headerAction}>{headerAction}</div>}
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
{children}
|
||||
</div>
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
{overlay && <div className={styles.overlay}>{overlay}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.button {
|
||||
composes: pressable from '../styles/shared.module.css';
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: var(--color-blue);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Returns totals[i][j] = cumulative score for player j after round i.
|
||||
export function computeRunningTotals(rounds: number[][]): number[][] {
|
||||
const totals: number[][] = []
|
||||
for (let i = 0; i < rounds.length; i++) {
|
||||
if (i === 0) {
|
||||
totals.push([...rounds[0]])
|
||||
} else {
|
||||
totals.push(rounds[i].map((score, j) => totals[i - 1][j] + score))
|
||||
}
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
// Game ends when any player reaches -200 (checked against the most recent round's totals).
|
||||
export function isGameOver(totals: number[][]): boolean {
|
||||
const last = totals[totals.length - 1]
|
||||
return last?.some((t: number) => t <= -200) ?? false
|
||||
}
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
button { min-height: 2.75rem; font-size: 1rem; font-family: inherit; cursor: pointer; }
|
||||
button { min-height: 2.75rem; font-size: 1rem; font-family: inherit; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 80ms ease, box-shadow 80ms ease; }
|
||||
button:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 2px 6px rgba(0,0,0,0.2); }
|
||||
html, body, #root { margin: 0; height: 100%; }
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
}
|
||||
|
||||
.circleBtn {
|
||||
composes: pressable from '../styles/shared.module.css';
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
line-height: 1;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 -1.5rem;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem repeat(var(--player-count), 1fr);
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.headerCell {
|
||||
padding: 0.5rem 0.4rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--color-parchment-border);
|
||||
}
|
||||
|
||||
.labelCell {
|
||||
padding: 0.35rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scoreCell {
|
||||
padding: 0.35rem 0.4rem;
|
||||
text-align: right;
|
||||
font-size: 0.95rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.totalCell {
|
||||
padding: 0.35rem 0.4rem;
|
||||
text-align: right;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid var(--color-parchment-border);
|
||||
}
|
||||
|
||||
/* Small red "New Game" button in header top-right */
|
||||
.newGameBtn {
|
||||
background: #d94f4f;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
min-height: unset;
|
||||
padding: 0.25rem 0.5rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Confirmation overlay content */
|
||||
.confirmContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
background: var(--color-parchment);
|
||||
border: 2px solid black;
|
||||
border-radius: 1.5rem;
|
||||
width: calc(100% - 4rem);
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.confirmText {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.confirmButtons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border: 2px solid var(--color-parchment-border);
|
||||
border-radius: 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.endGameBtn {
|
||||
flex: 1;
|
||||
background: #d94f4f;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,15 +1,78 @@
|
||||
import { Fragment, useState, type CSSProperties } from 'react'
|
||||
import { type GameState } from '../types'
|
||||
import { computeRunningTotals, isGameOver } from '../game/scoring'
|
||||
import PageFrame from '../components/PageFrame'
|
||||
import PrimaryButton from '../components/PrimaryButton'
|
||||
import styles from './ScoreSheet.module.css'
|
||||
|
||||
interface Props {
|
||||
game: GameState
|
||||
onScoreRound: () => void
|
||||
onNewGame: () => void
|
||||
}
|
||||
|
||||
export default function ScoreSheet({ onScoreRound, onNewGame }: Props) {
|
||||
export default function ScoreSheet({ game, onScoreRound, onNewGame }: Props) {
|
||||
const [confirmingNewGame, setConfirmingNewGame] = useState(false)
|
||||
|
||||
const totals = computeRunningTotals(game.rounds)
|
||||
const gameOver = isGameOver(totals)
|
||||
|
||||
const newGameButton = (
|
||||
<button className={styles.newGameBtn} onClick={() => setConfirmingNewGame(true)}>
|
||||
New Game
|
||||
</button>
|
||||
)
|
||||
|
||||
const confirmOverlay = (
|
||||
<div className={styles.confirmContent}>
|
||||
<p className={styles.confirmText}>End this game?</p>
|
||||
<div className={styles.confirmButtons}>
|
||||
<button className={styles.cancelBtn} onClick={() => setConfirmingNewGame(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className={styles.endGameBtn} onClick={onNewGame}>
|
||||
End Game
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageFrame title="Score Sheet">
|
||||
<button onClick={onScoreRound}>Score Round</button>
|
||||
<button onClick={onNewGame} style={{ fontSize: '0.75em' }}>New Game</button>
|
||||
<PageFrame
|
||||
title="Score Sheet"
|
||||
headerAction={newGameButton}
|
||||
overlay={confirmingNewGame ? confirmOverlay : undefined}
|
||||
footer={<PrimaryButton onClick={onScoreRound} disabled={gameOver}>Score Round</PrimaryButton>}
|
||||
>
|
||||
<div className={styles.tableWrapper}>
|
||||
<div
|
||||
className={styles.grid}
|
||||
style={{ '--player-count': game.players.length } as CSSProperties}
|
||||
>
|
||||
<div className={styles.labelCell} />
|
||||
{game.players.map((name, j) => (
|
||||
<div key={j} className={styles.headerCell}>{name}</div>
|
||||
))}
|
||||
|
||||
{game.rounds.map((roundScores, i) => (
|
||||
<Fragment key={i}>
|
||||
<div className={styles.labelCell}>Round {i + 1}</div>
|
||||
{roundScores.map((score, j) => (
|
||||
<div key={j} className={styles.scoreCell}>{score}</div>
|
||||
))}
|
||||
|
||||
{i > 0 && (
|
||||
<>
|
||||
<div className={`${styles.labelCell} ${styles.divider}`} />
|
||||
{totals[i].map((total, j) => (
|
||||
<div key={j} className={`${styles.totalCell} ${styles.divider}`}>{total}</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageFrame>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,3 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pressable {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
transition: transform 80ms ease, box-shadow 80ms ease;
|
||||
}
|
||||
|
||||
.pressable:active:not(:disabled) {
|
||||
transform: translateY(2px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user