Compare commits
10 Commits
e5fa0ae66f
...
efde532885
| Author | SHA1 | Date | |
|---|---|---|---|
| efde532885 | |||
| 14b413ebbd | |||
| 5f872d1608 | |||
| 23621da468 | |||
| 906f106b26 | |||
| 8cd4bba4fd | |||
| 79832ed818 | |||
| aa121bbcf3 | |||
| 35c1694e18 | |||
| 9c1bbae359 |
@@ -42,7 +42,7 @@ Navigation is a simple `useState<Page>` state machine in `App.tsx` — no router
|
||||
| Page | Key | Description |
|
||||
|------|-----|-------------|
|
||||
| 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–8 player names, then "Start" saves the game and goes to scoresheet. |
|
||||
| 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. |
|
||||
|
||||
@@ -50,7 +50,7 @@ On mount, `App.tsx` checks `localStorage` for a saved game and jumps straight to
|
||||
|
||||
## Game rules
|
||||
|
||||
- Each round every player scores some points; all players' scores in a round must sum to **-110**
|
||||
- Each round every player scores some points; scores must sum to **-95** (3 players), **-110** (4–6 players), or **-100** (7–8 players)
|
||||
- Each player starts at 0; the game ends when any player reaches **-200**
|
||||
|
||||
## Data model
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="theme-color" content="#a8d5e8" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Black 7">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Black 7</title>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#0a0a0a"/>
|
||||
<g fill="none" stroke="#f0ede6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- horizontal top bar + diagonal leg of the 7 -->
|
||||
<path d="M 90 120 L 408 112 L 206 450" stroke-width="30"/>
|
||||
<!-- European crossbar -->
|
||||
<path d="M 248 268 L 354 262" stroke-width="23"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 23 KiB |
@@ -24,6 +24,7 @@ export default function App() {
|
||||
const [game, setGame] = useState<GameState | null>(loadGame)
|
||||
const [page, setPage] = useState<Page>(() => (game ? 'scoresheet' : 'title'))
|
||||
const [previousPlayers, setPreviousPlayers] = useState<string[] | undefined>(undefined)
|
||||
const [editingRound, setEditingRound] = useState<number | null>(null)
|
||||
|
||||
function handleNewGame() {
|
||||
setPreviousPlayers(game?.players)
|
||||
@@ -39,15 +40,24 @@ export default function App() {
|
||||
setPage('scoresheet')
|
||||
}
|
||||
|
||||
function handleEditRound(i: number) {
|
||||
setEditingRound(i)
|
||||
setPage('enterscores')
|
||||
}
|
||||
|
||||
function handleSubmitScores(scores: number[]) {
|
||||
const updated: GameState = { ...game!, rounds: [...game!.rounds, scores] }
|
||||
const rounds = editingRound !== null
|
||||
? game!.rounds.map((r, i) => (i === editingRound ? scores : r))
|
||||
: [...game!.rounds, scores]
|
||||
const updated: GameState = { ...game!, rounds }
|
||||
saveGame(updated)
|
||||
setGame(updated)
|
||||
setEditingRound(null)
|
||||
setPage('scoresheet')
|
||||
}
|
||||
|
||||
if (page === 'title') return <TitleScreen onNewGame={handleNewGame} />
|
||||
if (page === 'setup') return <PlayerSetup onStart={handleStart} initialNames={previousPlayers} />
|
||||
if (page === 'scoresheet') return <ScoreSheet game={game!} onScoreRound={() => setPage('enterscores')} onNewGame={handleNewGame} />
|
||||
if (page === 'enterscores') return <EnterScores game={game!} onSubmit={handleSubmitScores} />
|
||||
if (page === 'scoresheet') return <ScoreSheet game={game!} onScoreRound={() => setPage('enterscores')} onNewGame={handleNewGame} onEditRound={handleEditRound} />
|
||||
if (page === 'enterscores') return <EnterScores game={game!} onSubmit={handleSubmitScores} onBack={() => { setEditingRound(null); setPage('scoresheet') }} initialValues={editingRound !== null ? game!.rounds[editingRound] : undefined} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
.box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-parchment);
|
||||
border: 2px solid black;
|
||||
border-radius: 1.5rem;
|
||||
width: calc(100% - 4rem);
|
||||
max-width: 22rem;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 1.5rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dismiss {
|
||||
margin: 0 1.5rem 1.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--color-blue);
|
||||
color: black;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import styles from './HelpModal.module.css'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function HelpModal({ onClose }: Props) {
|
||||
return (
|
||||
<div className={styles.box}>
|
||||
<div className={styles.body}>
|
||||
<h3>Setup</h3>
|
||||
<ul>
|
||||
<li>3 players, remove all 1s and 2s</li>
|
||||
<li>4-6 players, use everything</li>
|
||||
<li>7-8 players, remove non-yellow 2s</li>
|
||||
<li>Pass 3 cards for 3-5 players, 2 cards for 6-8</li>
|
||||
</ul>
|
||||
<h3>Scoring</h3>
|
||||
<ul>
|
||||
<li>Gold good! Each gold card is +5 points</li>
|
||||
<li>Red bad! Each red card -10 points</li>
|
||||
<li>Black seven <em>very</em> bad: -50 points each</li>
|
||||
<li>Last trick very good: +50 points</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button className={styles.dismiss} onClick={onClose}>Got it</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,10 +11,16 @@
|
||||
|
||||
.headerAction {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.headerActionLeft {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
|
||||
@@ -5,16 +5,18 @@ interface Props {
|
||||
children: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
headerAction?: React.ReactNode
|
||||
headerActionLeft?: React.ReactNode
|
||||
overlay?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function PageFrame({ title, children, footer, headerAction, overlay }: Props) {
|
||||
export default function PageFrame({ title, children, footer, headerAction, headerActionLeft, 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>}
|
||||
{headerActionLeft && <div className={styles.headerActionLeft}>{headerActionLeft}</div>}
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
{children}
|
||||
|
||||
@@ -2,8 +2,15 @@ export function isIndividualScoreValid(n: number): boolean {
|
||||
return n % 5 === 0 && n <= 110
|
||||
}
|
||||
|
||||
export function requiredRoundSum(playerCount: number): number {
|
||||
if (playerCount === 3) return -95
|
||||
if (playerCount >= 7) return -100
|
||||
return -110
|
||||
}
|
||||
|
||||
export function isRoundScoreValid(scores: number[]): boolean {
|
||||
return scores.every(isIndividualScoreValid) && scores.reduce((a, b) => a + b, 0) === -110
|
||||
return scores.every(isIndividualScoreValid)
|
||||
&& scores.reduce((a, b) => a + b, 0) === requiredRoundSum(scores.length)
|
||||
}
|
||||
|
||||
// Returns totals[i][j] = cumulative score for player j after round i.
|
||||
|
||||
@@ -27,6 +27,18 @@
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.signToggle {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-parchment-border);
|
||||
background: #fdfaf4;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sumLine {
|
||||
margin: 0.5rem 0 0;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import PageFrame from '../components/PageFrame'
|
||||
import PrimaryButton from '../components/PrimaryButton'
|
||||
import { isIndividualScoreValid, isRoundScoreValid } from '../game/scoring'
|
||||
import HelpModal from '../components/HelpModal'
|
||||
import { isIndividualScoreValid, isRoundScoreValid, requiredRoundSum } from '../game/scoring'
|
||||
import { type GameState } from '../types'
|
||||
import styles from './EnterScores.module.css'
|
||||
import sharedStyles from '../styles/shared.module.css'
|
||||
|
||||
interface Props {
|
||||
game: GameState
|
||||
onSubmit: (scores: number[]) => void
|
||||
onBack: () => void
|
||||
initialValues?: number[]
|
||||
}
|
||||
|
||||
export default function EnterScores({ game, onSubmit }: Props) {
|
||||
const [values, setValues] = useState<string[]>(() => game.players.map(() => ''))
|
||||
export default function EnterScores({ game, onSubmit, onBack, initialValues }: Props) {
|
||||
const [values, setValues] = useState<string[]>(() =>
|
||||
game.players.map((_, i) => initialValues ? String(initialValues[i]) : '')
|
||||
)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
function updateValue(i: number, v: string) {
|
||||
setValues(vs => vs.map((val, idx) => (idx === i ? v : val)))
|
||||
}
|
||||
|
||||
function toggleSign(i: number) {
|
||||
setValues(vs => vs.map((val, idx) => {
|
||||
if (idx !== i) return val
|
||||
const digits = val.startsWith('-') ? val.slice(1) : val
|
||||
if (!digits) return val
|
||||
return val.startsWith('-') ? digits : '-' + digits
|
||||
}))
|
||||
}
|
||||
|
||||
const target = requiredRoundSum(game.players.length)
|
||||
const parsed = values.map(v => parseInt(v, 10))
|
||||
const fieldError = values.map((v, i) => v !== '' && (isNaN(parsed[i]) || !isIndividualScoreValid(parsed[i])))
|
||||
const allFilled = parsed.every(n => !isNaN(n))
|
||||
@@ -26,6 +43,9 @@ export default function EnterScores({ game, onSubmit }: Props) {
|
||||
return (
|
||||
<PageFrame
|
||||
title="Enter Scores"
|
||||
headerActionLeft={<button className={sharedStyles.helpBtn} onClick={onBack}>←</button>}
|
||||
headerAction={<button className={sharedStyles.helpBtn} onClick={() => setShowHelp(true)}>?</button>}
|
||||
overlay={showHelp ? <HelpModal onClose={() => setShowHelp(false)} /> : undefined}
|
||||
footer={
|
||||
<PrimaryButton onClick={() => onSubmit(parsed)} disabled={!canSubmit}>
|
||||
Submit
|
||||
@@ -35,17 +55,20 @@ export default function EnterScores({ game, onSubmit }: Props) {
|
||||
{game.players.map((name, i) => (
|
||||
<div key={i} className={styles.row}>
|
||||
<span className={styles.name}>{name}</span>
|
||||
<button type="button" className={styles.signToggle} onClick={() => toggleSign(i)}>
|
||||
{values[i].startsWith('-') ? '−' : '+'}
|
||||
</button>
|
||||
<input
|
||||
className={`${styles.scoreInput}${fieldError[i] ? ` ${styles.scoreInputError}` : ''}`}
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={values[i]}
|
||||
onChange={e => updateValue(i, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<p className={`${styles.sumLine}${sum === -110 ? ` ${styles.sumValid}` : ''}`}>
|
||||
Sum: {sum} / −110
|
||||
<p className={`${styles.sumLine}${sum === target ? ` ${styles.sumValid}` : ''}`}>
|
||||
Sum: {sum} / {target}
|
||||
</p>
|
||||
</PageFrame>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import PageFrame from '../components/PageFrame'
|
||||
import PrimaryButton from '../components/PrimaryButton'
|
||||
import HelpModal from '../components/HelpModal'
|
||||
import styles from './PlayerSetup.module.css'
|
||||
import sharedStyles from '../styles/shared.module.css'
|
||||
|
||||
interface Props {
|
||||
onStart: (players: string[]) => void
|
||||
@@ -14,6 +16,7 @@ function normalize(s: string) {
|
||||
|
||||
export default function PlayerSetup({ onStart, initialNames }: Props) {
|
||||
const [names, setNames] = useState<string[]>(initialNames ?? ['', '', ''])
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
function updateName(i: number, value: string) {
|
||||
setNames(n => n.map((v, idx) => (idx === i ? value : v)))
|
||||
@@ -36,6 +39,8 @@ export default function PlayerSetup({ onStart, initialNames }: Props) {
|
||||
return (
|
||||
<PageFrame
|
||||
title="Player Setup"
|
||||
headerAction={<button className={sharedStyles.helpBtn} onClick={() => setShowHelp(true)}>?</button>}
|
||||
overlay={showHelp ? <HelpModal onClose={() => setShowHelp(false)} /> : undefined}
|
||||
footer={
|
||||
<PrimaryButton onClick={() => onStart(names.map(n => n.trim()))} disabled={!isValid}>
|
||||
Start
|
||||
@@ -58,7 +63,7 @@ export default function PlayerSetup({ onStart, initialNames }: Props) {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{names.length < 6 && (
|
||||
{names.length < 8 && (
|
||||
<div className={styles.addRow}>
|
||||
<button className={styles.addBtn} onClick={addName}>+</button>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.editBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
min-height: unset;
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.scoreCell {
|
||||
|
||||
@@ -9,9 +9,10 @@ interface Props {
|
||||
game: GameState
|
||||
onScoreRound: () => void
|
||||
onNewGame: () => void
|
||||
onEditRound: (i: number) => void
|
||||
}
|
||||
|
||||
export default function ScoreSheet({ game, onScoreRound, onNewGame }: Props) {
|
||||
export default function ScoreSheet({ game, onScoreRound, onNewGame, onEditRound }: Props) {
|
||||
const [confirmingNewGame, setConfirmingNewGame] = useState(false)
|
||||
|
||||
const totals = computeRunningTotals(game.rounds)
|
||||
@@ -62,7 +63,10 @@ export default function ScoreSheet({ game, onScoreRound, onNewGame }: Props) {
|
||||
|
||||
{game.rounds.map((roundScores, i) => (
|
||||
<Fragment key={i}>
|
||||
<div className={styles.labelCell}>Round {i + 1}</div>
|
||||
<div className={styles.labelCell}>
|
||||
<button className={styles.editBtn} onClick={() => onEditRound(i)}>✎</button>
|
||||
Round {i + 1}
|
||||
</div>
|
||||
{roundScores.map((score, j) => (
|
||||
<div key={j} className={styles.scoreCell + (j === winnerIndex ? ' ' + styles.winnerCell : '')}>{score}</div>
|
||||
))}
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 300;
|
||||
color: #1a1a1a;
|
||||
margin-top: 0.75rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
|
||||
@@ -11,6 +11,7 @@ export default function TitleScreen({ onNewGame }: Props) {
|
||||
<div className={styles.title}>
|
||||
<span className={styles.titleWord}>Black</span>
|
||||
<span className={styles.titleNumber}>7</span>
|
||||
<span className={styles.email}>ross.andrews@gmail.com</span>
|
||||
</div>
|
||||
<div className={styles.buttonRow}>
|
||||
<PrimaryButton onClick={onNewGame}>New Game</PrimaryButton>
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
.helpBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
min-height: unset;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #aaa;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: fixed;
|
||||
inset: 1rem;
|
||||
|
||||
@@ -12,9 +12,10 @@ export default defineConfig({
|
||||
name: 'Black 7',
|
||||
short_name: 'Black 7',
|
||||
description: 'Scoring app for Last Panther',
|
||||
theme_color: '#000000',
|
||||
background_color: '#000000',
|
||||
theme_color: '#a8d5e8',
|
||||
background_color: '#a8d5e8',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
|
||||