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. |
|
| 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. |
|
| 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. |
|
| 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.
|
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
|
## Data model
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -61,6 +66,13 @@ interface GameState {
|
|||||||
|
|
||||||
Persisted to `localStorage` under the key `currentGame`. No game history yet — only one game at a time.
|
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
|
## Design system
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
@@ -72,8 +84,10 @@ Persisted to `localStorage` under the key `currentGame`. No game history yet —
|
|||||||
|
|
||||||
### PageFrame component
|
### 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
|
- 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`.
|
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
|
### 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)`
|
- **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.
|
- **`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.
|
- **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 === 'title') return <TitleScreen onNewGame={handleNewGame} />
|
||||||
if (page === 'setup') return <PlayerSetup onStart={handleStart} />
|
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} />
|
if (page === 'enterscores') return <EnterScores onSubmit={handleSubmitScores} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
position: relative;
|
||||||
padding: 1.25rem 1.5rem 0;
|
padding: 1.25rem 1.5rem 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerAction {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@@ -43,3 +50,13 @@
|
|||||||
.footer {
|
.footer {
|
||||||
padding: 1rem 1.5rem 1.5rem;
|
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
|
title: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
footer?: 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 (
|
return (
|
||||||
<div className={styles.frame}>
|
<div className={styles.frame}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1 className={styles.title}>{title}</h1>
|
<h1 className={styles.title}>{title}</h1>
|
||||||
<div className={styles.rule} role="separator" />
|
<div className={styles.rule} role="separator" />
|
||||||
|
{headerAction && <div className={styles.headerAction}>{headerAction}</div>}
|
||||||
</header>
|
</header>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{footer && <div className={styles.footer}>{footer}</div>}
|
{footer && <div className={styles.footer}>{footer}</div>}
|
||||||
|
{overlay && <div className={styles.overlay}>{overlay}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
.button {
|
.button {
|
||||||
composes: pressable from '../styles/shared.module.css';
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--color-blue);
|
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; }
|
*, *::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%; }
|
html, body, #root { margin: 0; height: 100%; }
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.circleBtn {
|
.circleBtn {
|
||||||
composes: pressable from '../styles/shared.module.css';
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: none;
|
||||||
line-height: 1;
|
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 PageFrame from '../components/PageFrame'
|
||||||
|
import PrimaryButton from '../components/PrimaryButton'
|
||||||
|
import styles from './ScoreSheet.module.css'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
game: GameState
|
||||||
onScoreRound: () => void
|
onScoreRound: () => void
|
||||||
onNewGame: () => 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 (
|
return (
|
||||||
<PageFrame title="Score Sheet">
|
<PageFrame
|
||||||
<button onClick={onScoreRound}>Score Round</button>
|
title="Score Sheet"
|
||||||
<button onClick={onNewGame} style={{ fontSize: '0.75em' }}>New Game</button>
|
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>
|
</PageFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -7,13 +7,3 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
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