Lemonade Stand: browser recreation of the Atari 8-bit BASIC game

Faithful day loop - weather report, cost of lemonade rising over the
summer, glasses/signs/price decisions, thunderstorms, heat waves and
street crews - with the $$ LEMONSVILLE DAILY FINANCIAL REPORT $$ laid
out the way it printed.

The simulation is kept out of React so a season can be run headlessly:
a careful player compounds $2.00 into roughly $22 over twelve days,
while an all-in player goes broke about seven times in ten.

Weather scenes are pixel rectangles in SVG and the music is a small
chiptune engine on the Web Audio API, so there are no binary assets.
The screen is locked to a 4:3 tube and never scrolls.
This commit is contained in:
2026-09-08 15:23:46 -07:00
commit 5f8ad511f0
32 changed files with 3913 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { WEATHER, costMessage } from '../game/constants'
import type { DayConditions } from '../game/types'
import { Btn, Line } from './Crt'
import { Scene } from './Scene'
export function BriefingScreen({
conditions,
onContinue,
}: {
conditions: DayConditions
onContinue: () => void
}) {
const cost = costMessage(conditions.day)
return (
<div className="stack">
<Line className="center inv-line">DAY {conditions.day} IN LEMONSVILLE</Line>
<Scene conditions={{ ...conditions, storm: false }} />
<Line className="center accent">
WEATHER REPORT: {WEATHER[conditions.weather].label}
</Line>
<Line />
{cost?.map((t, i) => (
<Line key={i}>{t}</Line>
))}
{conditions.heatWave && (
<>
<Line />
<Line className="warn">A HEAT WAVE IS PREDICTED FOR TODAY!</Line>
<Line className="warn">EVERYONE IN TOWN IS THIRSTY.</Line>
</>
)}
{conditions.streetCrew && (
<>
<Line />
<Line className="warn">THE STREET CREWS ARE WORKING TODAY.</Line>
<Line className="warn">THERE WILL BE NO TRAFFIC ON YOUR</Line>
<Line className="warn">STREET.</Line>
</>
)}
<Line />
<div className="row center">
<Btn kind="primary" onClick={onContinue}>
OPEN THE STAND
</Btn>
</div>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
export function Crt({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
return (
<div className="cabinet">
<div className="bezel">
<div className="screen">
<div className="screen-inner">{children}</div>
<div className="scanlines" aria-hidden />
<div className="vignette" aria-hidden />
</div>
</div>
{footer}
</div>
)
}
/**
* A television shows one fixed picture; it never scrolls. If a page is taller
* than the tube, shrink the picture until it fits rather than clipping it.
*/
export function Fit({ children }: { children: ReactNode }) {
const box = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
useLayoutEffect(() => {
const measure = () => {
const el = box.current
const page = el?.firstElementChild as HTMLElement | null
if (!el || !page) return
// offsetHeight is the laid-out size and ignores the transform, so
// measuring here cannot feed back into itself.
const h = page.offsetHeight
const avail = el.clientHeight
setScale(h > 0 && avail > 0 ? Math.min(1, avail / h) : 1)
}
measure()
const ro = new ResizeObserver(measure)
if (box.current) ro.observe(box.current)
if (box.current?.firstElementChild) ro.observe(box.current.firstElementChild)
return () => ro.disconnect()
})
return (
<div className="screen-body" ref={box} style={{ '--fit': scale } as React.CSSProperties}>
{children}
</div>
)
}
export function Line({ children, className = '' }: { children?: ReactNode; className?: string }) {
return <div className={`line ${className}`}>{children ?? ' '}</div>
}
/** Atari inverse video: light background, dark characters. */
export function Inv({ children }: { children: ReactNode }) {
return <span className="inv">{children}</span>
}
export function Btn({
children,
onClick,
kind = 'normal',
disabled,
title,
}: {
children: ReactNode
onClick: () => void
kind?: 'normal' | 'primary' | 'ghost'
disabled?: boolean
title?: string
}) {
return (
<button className={`btn btn-${kind}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { MAX_SIGNS, SIGN_COST } from '../game/constants'
import { dollars } from '../game/engine'
import type { DayConditions, Decision, Player } from '../game/types'
import { Btn, Line } from './Crt'
import { Scene } from './Scene'
const clampInt = (v: string, max: number) => {
const n = Math.floor(Number(v.replace(/[^0-9]/g, '')))
if (!Number.isFinite(n) || n < 0) return 0
return Math.min(n, max)
}
export function DecideScreen({
player,
conditions,
playerCount,
onSubmit,
onBlip,
onReject,
}: {
player: Player
conditions: DayConditions
playerCount: number
onSubmit: (d: Decision) => void
onBlip: () => void
onReject: () => void
}) {
// App remounts this per player per day, so plain initial state is the reset.
const [glasses, setGlasses] = useState('0')
const [signs, setSigns] = useState('0')
const [price, setPrice] = useState('5')
const first = useRef<HTMLInputElement>(null)
useEffect(() => {
first.current?.focus()
}, [])
const d: Decision = {
glasses: clampInt(glasses, 9999),
signs: clampInt(signs, MAX_SIGNS),
price: clampInt(price, 100),
}
const lemonadeCost = d.glasses * conditions.costPerGlass
const signCost = d.signs * SIGN_COST
const total = lemonadeCost + signCost
const left = player.assets - total
const error = useMemo(() => {
if (signCost > player.assets) return 'YOU CANNOT AFFORD THAT MANY SIGNS.'
if (total > player.assets) return "YOU DON'T HAVE ENOUGH MONEY TO MAKE THAT MANY GLASSES."
if (d.glasses === 0) return 'YOU MUST MAKE AT LEAST ONE GLASS.'
return null
}, [signCost, total, player.assets, d.glasses])
const submit = () => {
if (error) {
onReject()
return
}
onBlip()
onSubmit(d)
}
const onKey = (e: React.KeyboardEvent) => {
if (e.key !== 'Enter') return
const form = e.currentTarget.closest('.stack')
const inputs = Array.from(form?.querySelectorAll('input') ?? [])
const i = inputs.indexOf(e.target as HTMLInputElement)
if (i >= 0 && i < inputs.length - 1) inputs[i + 1].focus()
else submit()
}
return (
<div className="stack">
<Line className="center inv-line">
{playerCount > 1 ? `${player.name} - DAY ${conditions.day}` : `DAY ${conditions.day}`}
</Line>
<Scene conditions={{ ...conditions, storm: false }} price={d.price} />
<Line>
ASSETS <span className="money">{dollars(player.assets)}</span> &middot; LEMONADE COSTS{' '}
{conditions.costPerGlass}&#162; A GLASS
</Line>
<Line />
<label className="field" onKeyDown={onKey}>
<span className="field-label">GLASSES TO MAKE</span>
<input
ref={first}
className="field-input"
inputMode="numeric"
value={glasses}
onFocus={(e) => e.target.select()}
onChange={(e) => setGlasses(e.target.value)}
/>
<span className="field-note">{dollars(lemonadeCost)}</span>
</label>
<label className="field" onKeyDown={onKey}>
<span className="field-label">SIGNS AT 15&#162;</span>
<input
className="field-input"
inputMode="numeric"
value={signs}
onFocus={(e) => e.target.select()}
onChange={(e) => setSigns(e.target.value)}
/>
<span className="field-note">{dollars(signCost)}</span>
</label>
<label className="field" onKeyDown={onKey}>
<span className="field-label">PRICE IN CENTS</span>
<input
className="field-input"
inputMode="numeric"
value={price}
onFocus={(e) => e.target.select()}
onChange={(e) => setPrice(e.target.value)}
/>
<span className="field-note">{d.price}&#162;</span>
</label>
<Line>
TODAY&apos;S OUTLAY <span className="money">{dollars(total)}</span> &middot; LEFT IN TIN{' '}
<span className={left < 0 ? 'warn' : 'money'}>{dollars(left)}</span>
</Line>
{error && <Line className="warn">{error}</Line>}
<div className="row center">
<Btn kind="primary" onClick={submit} disabled={!!error}>
SELL LEMONADE
</Btn>
</div>
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
import { STARTING_ASSETS } from '../game/constants'
import { dollars } from '../game/engine'
import type { DayResult, Player } from '../game/types'
import { Btn, Line } from './Crt'
import { bannerRows } from './logo'
const BANNER = bannerRows('LEMONADE')
export function GameOverScreen({
players,
history,
days,
onRestart,
}: {
players: Player[]
history: DayResult[]
days: number
onRestart: () => void
}) {
const ranked = [...players].sort((a, b) => b.assets - a.assets)
const best = history.reduce<DayResult | null>(
(acc, r) => (acc === null || r.profit > acc.profit ? r : acc),
null,
)
const bestPlayer = best ? players.find((p) => p.id === best.playerId) : null
const totalGlasses = history.reduce((n, r) => n + r.glassesSold, 0)
return (
<div className="stack">
<pre className="banner small" aria-hidden>
{BANNER.join('\n')}
</pre>
<Line className="center inv-line">THE SUMMER IS OVER</Line>
<Line />
<Line className="center">
{days} {days === 1 ? 'DAY' : 'DAYS'} OF TRADING &middot; {totalGlasses} GLASSES SOLD
</Line>
<Line />
{ranked.map((p, i) => {
const net = p.assets - STARTING_ASSETS
return (
<div className="report-row" key={p.id}>
<span className="report-label">
{i + 1}. {p.name}
{p.bankrupt ? ' (BROKE)' : ''}
</span>
<span className="report-dots" aria-hidden />
<span className={`report-value ${net >= 0 ? 'money' : 'warn'}`}>
{dollars(p.assets)}
</span>
</div>
)
})}
<Line />
{best && bestPlayer && (
<Line className="center dim">
BEST DAY: {bestPlayer.name} MADE {dollars(best.profit)} ON DAY{' '}
{Math.floor(history.indexOf(best) / Math.max(1, players.length)) + 1}
</Line>
)}
<Line />
<Line className="center accent">
{ranked[0].assets > STARTING_ASSETS
? 'NOT BAD FOR A CARD TABLE AND A PITCHER.'
: 'THE LEMONS WON THIS TIME.'}
</Line>
<Line />
<div className="row center">
<Btn kind="primary" onClick={onRestart}>
PLAY AGAIN
</Btn>
</div>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { Btn, Line } from './Crt'
const PAGES: string[][] = [
[
'HI! WELCOME TO LEMONSVILLE, CALIFORNIA!',
'',
'IN THIS SMALL TOWN YOU ARE IN CHARGE OF',
'RUNNING YOUR OWN LEMONADE STAND. YOU CAN',
'COMPETE WITH AS MANY OTHER PEOPLE AS YOU',
'WISH, BUT HOW MUCH PROFIT YOU MAKE IS UP',
'TO YOU. IF YOU MAKE THE MOST MONEY,',
"YOU'RE THE WINNER!",
'',
'TO MAKE LEMONADE YOU WILL NEED LEMONS,',
'SUGAR, ICE AND PAPER CUPS. THE COST OF A',
'GLASS STARTS AT 2 CENTS AND CLIMBS AS',
'THE SUMMER WEARS ON.',
],
[
'EACH DAY YOU DECIDE THREE THINGS:',
'',
' 1. HOW MANY GLASSES TO MAKE',
' 2. HOW MANY SIGNS TO PUT UP',
' (15 CENTS EACH)',
' 3. WHAT TO CHARGE PER GLASS',
'',
'SIGNS BRING CUSTOMERS, BUT THE FOURTH',
'SIGN HELPS A LOT LESS THAN THE FIRST.',
'',
'WATCH THE WEATHER. A HOT DAY IS WORTH',
'MORE GLASSES AND A HIGHER PRICE. A',
'CLOUDY DAY MIGHT TURN INTO A STORM AND',
'RUIN EVERY GLASS YOU MADE.',
'',
'YOU START WITH $2.00. GOOD LUCK!',
],
]
export function IntroScreen({ onDone }: { onDone: () => void }) {
const [page, setPage] = useState(0)
const last = page === PAGES.length - 1
return (
<div className="stack">
<Line className="center inv-line">HOW TO RUN A LEMONADE STAND</Line>
<Line />
{PAGES[page].map((t, i) => (
<Line key={i}>{t}</Line>
))}
<Line />
<div className="row center">
{page > 0 && <Btn onClick={() => setPage(page - 1)}>BACK</Btn>}
<Btn kind="primary" onClick={() => (last ? onDone() : setPage(page + 1))}>
{last ? 'START' : 'MORE'}
</Btn>
<Line className="dim">
PAGE {page + 1} OF {PAGES.length}
</Line>
</div>
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { useState } from 'react'
import { WEATHER } from '../game/constants'
import { dollars } from '../game/engine'
import type { DayConditions, DayResult, Player } from '../game/types'
import { Btn, Line } from './Crt'
import { Scene } from './Scene'
function Row({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<div className={`report-row ${strong ? 'strong' : ''}`}>
<span className="report-label">{label}</span>
<span className="report-dots" aria-hidden />
<span className="report-value">{value}</span>
</div>
)
}
export function ReportScreen({
results,
players,
conditions,
onNextDay,
onRetire,
onBlip,
}: {
results: DayResult[]
players: Player[]
conditions: DayConditions
onNextDay: () => void
onRetire: () => void
onBlip: () => void
}) {
// App remounts this each day, so these start fresh without a reset effect.
const [index, setIndex] = useState(0)
// The storm gets its own screen before the books are opened.
const [stormSeen, setStormSeen] = useState(false)
const r = results[index]
const player = players.find((p) => p.id === r.playerId)!
const isLast = index === results.length - 1
const everyoneBroke = players.every((p) => p.bankrupt)
if (conditions.storm && !stormSeen) {
return (
<div className="stack">
<Line className="center inv-line">DAY {conditions.day} IN LEMONSVILLE</Line>
<Scene conditions={conditions} />
<Line className="warn">A THUNDERSTORM HIT LEMONSVILLE EARLIER</Line>
<Line className="warn">TODAY, JUST AS THE STANDS WERE BEING</Line>
<Line className="warn">SET UP. EVERYTHING WAS RUINED!!</Line>
<Line />
<div className="row center">
<Btn
kind="primary"
onClick={() => {
onBlip()
setStormSeen(true)
}}
>
SEE THE DAMAGE
</Btn>
</div>
</div>
)
}
const events = [
WEATHER[conditions.weather].label,
conditions.heatWave ? 'HEAT WAVE' : null,
conditions.streetCrew ? 'STREET CREWS' : null,
conditions.storm ? 'THUNDERSTORM' : null,
]
.filter(Boolean)
.join(' \u00b7 ')
return (
<div className="stack">
<Line className="center inv-line">$$ LEMONSVILLE DAILY FINANCIAL REPORT $$</Line>
<Line />
<Line className="center accent">
DAY {conditions.day} &mdash; {player.name}
</Line>
<Line className="center dim">{events}</Line>
<Line />
<Row label="GLASSES SOLD" value={String(r.glassesSold)} />
<Row label="PRICE PER GLASS" value={`${r.decision.price}¢`} />
<Row label="INCOME" value={dollars(r.income)} />
<Line />
<Row label="GLASSES MADE" value={String(r.decision.glasses)} />
<Row label="COST OF LEMONADE" value={dollars(r.lemonadeCost)} />
<Row label="COST OF SIGNS" value={dollars(r.signCost)} />
<Row label="EXPENSES" value={dollars(r.expenses)} />
<Line />
<Row label="PROFIT" value={dollars(r.profit)} strong />
<Row label="ASSETS" value={dollars(r.assetsAfter)} strong />
{player.bankrupt && (
<>
<Line />
<Line className="warn">{player.name}, YOU DO NOT HAVE ENOUGH</Line>
<Line className="warn">MONEY LEFT TO STAY IN BUSINESS.</Line>
</>
)}
<Line />
<div className="row center">
{!isLast && (
<Btn
kind="primary"
onClick={() => {
onBlip()
setIndex(index + 1)
}}
>
NEXT REPORT ({index + 2}/{results.length})
</Btn>
)}
{isLast && !everyoneBroke && (
<Btn kind="primary" onClick={onNextDay}>
DAY {conditions.day + 1}
</Btn>
)}
{isLast && (
<Btn kind={everyoneBroke ? 'primary' : 'normal'} onClick={onRetire}>
{everyoneBroke ? 'SEE FINAL STANDINGS' : 'RETIRE'}
</Btn>
)}
</div>
</div>
)
}
+218
View File
@@ -0,0 +1,218 @@
import { useMemo } from 'react'
import type { DayConditions } from '../game/types'
const PALETTE: Record<string, string> = {
r: '#d24b3e', // awning red
w: '#f2f6ff', // awning white
n: '#8a5a2b', // timber
N: '#5e3c1c', // timber shadow
y: '#e0a92b', // lemon shade
Y: '#f7de5a', // lemon highlight
g: '#9aa6bd', // cloud grey
G: '#5f6b85', // storm grey
k: '#20263a',
c: '#7fd4ff',
o: '#f08a24',
e: '#5fae4a',
}
const CELL = 8
const COLS = 40
const ROWS = 22
/** Body of the stand, 28 cells wide. The awning above it is generated. */
const STAND = [
' N yyyyy N ',
' N yYYYYYy N ',
' N yYYYYYyy N ',
' N yYYYYYy N ',
' N yyyyy N ',
' nnnnnnnnnnnnnnnnnnnnn ',
' nwwwwwwwwwwwwwwwwwwwn ',
' nwwwwwwwwwwwwwwwwwwwn ',
' nwwwwwwwwwwwwwwwwwwwn ',
' nwwwwwwwwwwwwwwwwwwwn ',
' N N ',
' N N ',
]
const STAND_X = 6
const AWNING_Y = 5
const STAND_Y = 8
/** Each awning row is symmetric about the counter, and one wider than the last. */
const AWNING_ROWS: [number, number][] = [
[5, 21],
[4, 22],
[3, 23],
]
interface Rect {
x: number
y: number
w: number
fill: string
}
/** Turn a character grid into horizontal runs so the SVG stays small. */
function runs(rows: string[], ox: number, oy: number): Rect[] {
const out: Rect[] = []
rows.forEach((row, ry) => {
let x = 0
while (x < row.length) {
const ch = row[x]
if (ch === ' ' || !PALETTE[ch]) {
x++
continue
}
let w = 1
while (row[x + w] === ch) w++
out.push({ x: ox + x, y: oy + ry, w, fill: PALETTE[ch] })
x += w
}
})
return out
}
/** A filled circle, quantised to the pixel grid. */
function disc(cx: number, cy: number, r: number, fill: string): Rect[] {
const out: Rect[] = []
for (let y = -r; y <= r; y++) {
const half = Math.floor(Math.sqrt(Math.max(0, r * r - y * y)))
out.push({ x: cx - half, y: cy + y, w: half * 2 + 1, fill })
}
return out
}
/** Red and white stripes that line up vertically from row to row. */
function awning(): Rect[] {
const out: Rect[] = []
AWNING_ROWS.forEach(([from, to], row) => {
for (let col = from; col <= to; col++) {
out.push({
x: STAND_X + col,
y: AWNING_Y + row,
w: 1,
fill: (col >> 1) % 2 === 0 ? PALETTE.r : PALETTE.w,
})
}
})
return out
}
function cloud(cx: number, cy: number, fill: string): Rect[] {
return [
...disc(cx, cy, 2, fill),
...disc(cx - 3, cy + 1, 2, fill),
...disc(cx + 3, cy + 1, 2, fill),
{ x: cx - 6, y: cy + 2, w: 13, fill },
{ x: cx - 5, y: cy + 3, w: 11, fill },
]
}
export function Scene({ conditions, price }: { conditions: DayConditions; price?: number }) {
const { weather, heatWave, streetCrew, storm } = conditions
const sky = useMemo(() => {
if (storm) return ['#2b3350', '#3d4468']
if (weather === 'cloudy') return ['#5b6b93', '#8fa0c4']
if (weather === 'hot') return heatWave ? ['#c9541f', '#f0a13a'] : ['#e07a24', '#f5c164']
return ['#2f7fd8', '#8fd0f5']
}, [weather, heatWave, storm])
const rects: Rect[] = []
if (storm) {
rects.push(...cloud(10, 4, PALETTE.G), ...cloud(24, 3, PALETTE.G), ...cloud(33, 5, PALETTE.G))
} else if (weather === 'cloudy') {
rects.push(...cloud(9, 4, PALETTE.g), ...cloud(26, 3, PALETTE.g), ...cloud(34, 6, PALETTE.g))
} else {
const sunX = weather === 'hot' ? 32 : 33
const sunR = weather === 'hot' ? 5 : 3
rects.push(...disc(sunX, 5, sunR, heatWave ? '#fff3b0' : PALETTE.Y))
if (weather === 'sunny') rects.push(...cloud(9, 3, PALETTE.w))
}
rects.push(...awning(), ...runs(STAND, STAND_X, STAND_Y))
// Grass line under the stand.
rects.push({ x: 0, y: ROWS - 1, w: COLS, fill: PALETTE.e })
rects.push({ x: 0, y: ROWS - 2, w: COLS, fill: '#6fc258' })
if (streetCrew) {
// A pair of road cones and a barrier where the customers used to walk.
for (const cx of [2, 36]) {
rects.push(
{ x: cx, y: ROWS - 5, w: 1, fill: PALETTE.o },
{ x: cx - 1, y: ROWS - 4, w: 3, fill: PALETTE.o },
{ x: cx - 1, y: ROWS - 3, w: 3, fill: PALETTE.o },
{ x: cx - 2, y: ROWS - 2, w: 5, fill: '#3a3f52' },
)
}
}
return (
<svg
className={`scene ${storm ? 'is-storm' : ''} ${heatWave ? 'is-heat' : ''}`}
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
role="img"
aria-label={`${weather} day at the lemonade stand`}
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={sky[0]} />
<stop offset="100%" stopColor={sky[1]} />
</linearGradient>
</defs>
<rect width={COLS * CELL} height={ROWS * CELL} fill="url(#sky)" />
{heatWave && (
<g className="shimmer" opacity="0.35">
{[13, 15, 17].map((y) => (
<rect key={y} x={0} y={y * CELL} width={COLS * CELL} height={CELL / 2} fill="#ffd9a0" />
))}
</g>
)}
{rects.map((r, i) => (
<rect
key={i}
x={r.x * CELL}
y={r.y * CELL}
width={r.w * CELL}
height={CELL}
fill={r.fill}
shapeRendering="crispEdges"
/>
))}
{storm && (
<>
<g className="rain">
{Array.from({ length: 26 }, (_, i) => (
<rect
key={i}
x={((i * 37) % (COLS * CELL))}
y={((i * 53) % 120) + 40}
width={2}
height={10}
fill="#bcd8ff"
opacity="0.75"
/>
))}
</g>
<polygon className="bolt" points="212,40 188,96 208,96 192,148 232,84 210,84 228,40" fill="#fff3a8" />
</>
)}
<text className="stand-sign" x={156} y={price === undefined ? 132 : 125} textAnchor="middle">
LEMONADE
</text>
{price !== undefined && (
<text className="stand-price" x={156} y={137} textAnchor="middle">
{price}&#162; A GLASS
</text>
)}
</svg>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useState } from 'react'
import { MAX_PLAYERS } from '../game/constants'
import { Btn, Line } from './Crt'
export function SetupScreen({
onStart,
onBlip,
}: {
onStart: (names: string[]) => void
onBlip: () => void
}) {
const [count, setCount] = useState(1)
const [names, setNames] = useState<string[]>(['', '', '', ''])
const setName = (i: number, v: string) =>
setNames((prev) => prev.map((n, j) => (j === i ? v.slice(0, 12) : n)))
return (
<div className="stack">
<Line className="center inv-line">LEMONSVILLE BUSINESS LICENCE</Line>
<Line />
<Line>HOW MANY PEOPLE WILL PLAY?</Line>
<div className="row">
{Array.from({ length: MAX_PLAYERS }, (_, i) => i + 1).map((n) => (
<Btn
key={n}
kind={count === n ? 'primary' : 'normal'}
onClick={() => {
onBlip()
setCount(n)
}}
>
{n}
</Btn>
))}
</div>
<Line />
<Line>WHAT ARE THEIR NAMES?</Line>
{Array.from({ length: count }, (_, i) => (
<label className="field" key={i}>
<span className="field-label">STAND {i + 1}</span>
<input
className="field-input field-input-name"
value={names[i]}
placeholder={`PLAYER ${i + 1}`}
onChange={(e) => setName(i, e.target.value)}
maxLength={12}
/>
</label>
))}
<Line />
<Line className="dim">EVERY STAND OPENS WITH $2.00 IN THE TIN.</Line>
<Line />
<div className="row center">
<Btn kind="primary" onClick={() => onStart(names.slice(0, count))}>
OPEN FOR BUSINESS
</Btn>
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { Btn, Line } from './Crt'
import { bannerRows } from './logo'
const BANNER = bannerRows('LEMONADE')
export function TitleScreen({
onStart,
onInstructions,
seed,
}: {
onStart: () => void
onInstructions: () => void
seed: number
}) {
return (
<div className="stack">
<pre className="banner" aria-label="LEMONADE">
{BANNER.join('\n')}
</pre>
<Line className="center accent">S T A N D</Line>
<Line />
<Line className="center">LEMONSVILLE, CALIFORNIA</Line>
<Line className="center dim">ATARI 8-BIT EDITION &middot; SEED {seed}</Line>
<Line />
<Line className="center blink">PRESS START</Line>
<Line />
<div className="row center">
<Btn kind="primary" onClick={onStart}>
START
</Btn>
<Btn onClick={onInstructions}>INSTRUCTIONS</Btn>
</div>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
/** 4x5 block letters, drawn the way you would on graph paper. */
const GLYPHS: Record<string, string[]> = {
L: ['#...', '#...', '#...', '#...', '####'],
E: ['####', '#...', '###.', '#...', '####'],
M: ['#..#', '####', '####', '#..#', '#..#'],
O: ['####', '#..#', '#..#', '#..#', '####'],
N: ['#..#', '##.#', '#.##', '#..#', '#..#'],
A: ['####', '#..#', '####', '#..#', '#..#'],
D: ['###.', '#..#', '#..#', '#..#', '###.'],
S: ['####', '#...', '####', '...#', '####'],
T: ['####', '.#..', '.#..', '.#..', '.#..'],
' ': ['....', '....', '....', '....', '....'],
}
export function bannerRows(word: string): string[] {
const rows = ['', '', '', '', '']
for (const ch of word.toUpperCase()) {
const g = GLYPHS[ch] ?? GLYPHS[' ']
for (let r = 0; r < 5; r++) rows[r] += (rows[r] ? ' ' : '') + g[r]
}
return rows.map((r) => r.replace(/#/g, '█').replace(/\./g, ' '))
}