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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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> · LEMONADE COSTS{' '}
|
||||
{conditions.costPerGlass}¢ 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¢</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}¢</span>
|
||||
</label>
|
||||
|
||||
<Line>
|
||||
TODAY'S OUTLAY <span className="money">{dollars(total)}</span> · 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>
|
||||
)
|
||||
}
|
||||
@@ -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 · {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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} — {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>
|
||||
)
|
||||
}
|
||||
@@ -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}¢ A GLASS
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 · 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>
|
||||
)
|
||||
}
|
||||
@@ -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, ' '))
|
||||
}
|
||||
Reference in New Issue
Block a user