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
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+77
View File
@@ -0,0 +1,77 @@
# Lemonade Stand
A browser recreation of the Atari 8-bit BASIC classic — the one you typed in,
ran on a TV, and lost two dollars to on a cloudy day.
React + TypeScript + Vite. No art assets and no audio files: the weather is
drawn as pixel rectangles in SVG, and the music is generated by a small
chiptune engine built on the Web Audio API.
## Playing
```
npm install
npm run dev
```
One to four players share the keyboard, hot-seat style. Each morning you see
the weather, then decide three things: how many glasses to make, how many
15-cent signs to put up, and what to charge. Then the town votes with its
pocket money.
- Lemonade costs 2 cents a glass on days 12, 4 cents through day 7, and
5 cents after that.
- Everything you make is made *today*. Unsold glasses are poured away.
- A hot, dry day brings a bigger crowd that will pay more. A heat wave is
better still.
- A cloudy day can turn into a thunderstorm, and a thunderstorm ruins every
glass you made.
- Street crews close the road now and then, and they tend to stay for a
second day.
- Signs work, but with sharp diminishing returns — the fourth one barely
earns its 15 cents.
You are broke when you cannot afford a single glass. Otherwise the summer runs
as long as you like; **RETIRE** closes the books and shows the standings.
## How it is put together
```
src/
game/ pure simulation - no React, no DOM
constants.ts costs, weather profiles, the demand curve
engine.ts rolls each day and settles the takings
reducer.ts the day/turn state machine
rng.ts seeded mulberry32, so a run can be replayed
audio/
synth.ts pulse-wave voices, noise percussion, look-ahead sequencer
tunes.ts the title, trading and closing themes
components/ the screens, and the CRT they are drawn on
```
The simulation is deliberately kept out of React: `engine.ts` takes a player, a
decision and a day and hands back a `DayResult`. That is what makes it possible
to run a few hundred seasons in a script and check that a careful player grows
their two dollars while a reckless one goes broke about seven times in ten.
### The demand curve
The original BASIC listing is not reproduced line for line. `constants.ts`
holds a reconstruction tuned to behave the way the game plays: cheap lemonade
sells out, sales fall to nothing once the price stops being a bargain, heat
lifts both the crowd and the price people will tolerate, and signs help a lot
at first and hardly at all later.
### The television
An Atari 800 fed a 4:3 television, and a television has no scrollbar. The tube
is locked to 4:3 and everything inside it is sized in container units, so the
picture stays in proportion at any size. If a page ever did come out taller
than the tube, `Fit` scales it down rather than clipping or scrolling it.
## Sound
Audio starts on the first click or key press, as browsers require. Two pulse
voices, a triangle bass and filtered white noise for percussion, driven by a
scheduler that queues notes 200 ms ahead so the music does not stutter when
React re-renders. `MUSIC` and `SOUND` toggle independently.
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b0c12" />
<meta
name="description"
content="Lemonade Stand - a browser recreation of the Atari 8-bit BASIC classic, with chiptune music."
/>
<title>Lemonade Stand</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1349
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "lemonade",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0",
"typescript": "~6.0.2",
"vite": "^8.2.2"
}
}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" shape-rendering="crispEdges">
<rect width="32" height="32" fill="#2b45b8"/>
<rect x="6" y="4" width="20" height="3" fill="#d24b3e"/>
<rect x="6" y="7" width="20" height="3" fill="#f2f6ff"/>
<rect x="10" y="12" width="12" height="12" fill="#f7de5a"/>
<rect x="12" y="10" width="8" height="2" fill="#e0a92b"/>
<rect x="22" y="15" width="3" height="5" fill="#e0a92b"/>
<rect x="6" y="25" width="20" height="3" fill="#8a5a2b"/>
</svg>

After

Width:  |  Height:  |  Size: 505 B

+337
View File
@@ -0,0 +1,337 @@
.app {
flex: 1;
width: 100%;
display: flex;
justify-content: center;
}
.cabinet {
/* Wide as the room allows, but never so wide that the 4:3 tube plus its
bezel and controls run off the bottom of the window. */
width: min(1000px, 100%, calc((100dvh - 175px) * 4 / 3));
display: flex;
flex-direction: column;
gap: 14px;
}
/* ------------------------------------------------------------- the box */
.bezel {
background: linear-gradient(180deg, #3a3d47 0%, #23252c 45%, #16171c 100%);
border-radius: 22px;
padding: clamp(12px, 2.4vw, 24px);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.14) inset,
0 -2px 0 rgba(0, 0, 0, 0.6) inset,
0 24px 60px rgba(0, 0, 0, 0.65);
}
.screen {
position: relative;
/* An Atari 800 fed a 4:3 television. The picture never changed shape, so
neither does this one - it scales to the tube instead. */
aspect-ratio: 4 / 3;
/* Everything inside is sized in cqi, so the whole picture stays in
proportion no matter how big the set is. */
container-type: inline-size;
background: var(--atari-screen);
border: clamp(6px, 1.2vw, 12px) solid var(--atari-border);
border-radius: 14px;
overflow: hidden;
box-shadow:
0 0 0 2px #000,
0 0 60px rgba(70, 120, 255, 0.35),
0 0 120px rgba(40, 80, 220, 0.25) inset;
animation: power-on 900ms ease-out 1;
}
@keyframes power-on {
0% { filter: brightness(3) blur(2px); transform: scaleY(0.02); }
35% { filter: brightness(1.6) blur(1px); transform: scaleY(1); }
100% { filter: none; transform: none; }
}
.screen-inner {
position: relative;
z-index: 2;
--pad: 1.25em;
padding: var(--pad);
font-size: 2.15cqi;
line-height: 1.4;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--atari-text);
text-shadow: 0 0 6px rgba(150, 190, 255, 0.55);
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
/* No scrollbars, ever - a television has none. */
overflow: hidden;
}
.screen-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
overflow: hidden;
}
.scanlines {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0.22) 0px,
rgba(0, 0, 0, 0.22) 1px,
transparent 1px,
transparent 3px
);
mix-blend-mode: multiply;
}
.vignette {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
background: radial-gradient(120% 100% at 50% 50%, transparent 55%, rgba(0, 0, 0, 0.55) 100%);
}
/* --------------------------------------------------------------- text */
.stack {
display: flex;
flex-direction: column;
max-width: 46ch;
width: 100%;
height: max-content;
margin: auto;
transform: scale(var(--fit, 1));
transform-origin: center;
}
.line {
white-space: pre-wrap;
min-height: 1.4em;
}
.center { text-align: center; }
.dim { color: #7f9adf; text-shadow: none; }
.accent { color: var(--atari-yellow); text-shadow: 0 0 8px rgba(247, 222, 90, 0.5); }
.warn { color: var(--atari-red); text-shadow: 0 0 8px rgba(255, 138, 122, 0.5); }
.money { color: var(--atari-green); }
.inv,
.inv-line {
background: var(--atari-text);
color: var(--atari-screen-dark);
text-shadow: none;
padding: 0 0.4ch;
}
.inv-line {
display: block;
padding: 2px 0;
}
.blink { animation: blink 1.1s steps(1, end) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.banner {
margin: 0 auto;
font: inherit;
font-size: 0.56em;
line-height: 1.05;
margin-bottom: 0.5em;
letter-spacing: 0;
color: var(--atari-yellow);
text-shadow: 0 0 10px rgba(247, 222, 90, 0.6);
white-space: pre;
}
.banner.small { opacity: 0.35; font-size: 0.38em; }
.statusbar {
display: flex;
justify-content: space-between;
gap: 1ch;
background: var(--atari-text);
color: var(--atari-screen-dark);
text-shadow: none;
padding: 0.15em 0.7em;
margin: calc(-1 * var(--pad)) calc(-1 * var(--pad)) 0;
flex: 0 0 auto;
font-weight: 700;
}
/* -------------------------------------------------------------- scene */
.scene {
display: block;
width: 100%;
max-width: 32ch;
margin: 0.5em auto;
border: 2px solid rgba(0, 0, 0, 0.5);
image-rendering: pixelated;
background: #000;
}
.stand-sign,
.stand-price {
font-family: inherit;
fill: #b03a2e;
text-shadow: none;
}
.stand-sign { font-size: 11px; font-weight: 700; letter-spacing: 0.5px; }
.stand-price { font-size: 8px; fill: #2b45b8; }
.shimmer { animation: shimmer 2.4s ease-in-out infinite; }
@keyframes shimmer {
0%, 100% { transform: translateY(0); opacity: 0.3; }
50% { transform: translateY(-3px); opacity: 0.55; }
}
.rain { animation: rain 0.5s linear infinite; }
@keyframes rain {
from { transform: translateY(-14px); }
to { transform: translateY(0); }
}
.bolt { animation: bolt 3s steps(1, end) infinite; opacity: 0; }
@keyframes bolt {
0%, 6% { opacity: 0; }
7%, 9% { opacity: 1; }
10%, 13% { opacity: 0; }
14%, 16% { opacity: 1; }
17%, 100% { opacity: 0; }
}
/* ------------------------------------------------------------- fields */
.field {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 1ch;
padding: 0.1em 0;
}
.field-label { color: var(--atari-text); }
.field-input {
width: 7ch;
font: inherit;
text-transform: uppercase;
text-align: right;
color: var(--atari-screen-dark);
background: var(--atari-text);
border: 0;
border-radius: 0.15em;
padding: 0.1em 0.3em;
outline: 0.13em solid transparent;
}
.field-input-name {
width: 14ch;
text-align: left;
justify-self: end;
}
.field-input::placeholder { color: #5a6ba8; }
.field-input:focus { outline-color: var(--atari-yellow); }
.field-note {
min-width: 7ch;
text-align: right;
color: var(--atari-yellow);
}
/* ------------------------------------------------------------ reports */
.report-row {
display: flex;
align-items: baseline;
gap: 0.5ch;
}
.report-label { white-space: nowrap; }
.report-dots {
flex: 1;
border-bottom: 1px dotted rgba(185, 207, 255, 0.35);
transform: translateY(-0.25em);
}
.report-value { white-space: nowrap; }
.report-row.strong { color: var(--atari-bright); font-weight: 700; }
/* ------------------------------------------------------------ buttons */
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
align-items: center;
padding: 0.3em 0;
}
.row.center { justify-content: center; }
.btn {
font: inherit;
text-transform: uppercase;
letter-spacing: 0.06em;
cursor: pointer;
padding: 0.35em 0.85em;
border-radius: 0.25em;
border: 0.13em solid var(--atari-text);
background: transparent;
color: var(--atari-text);
transition: transform 60ms ease, background 120ms ease, color 120ms ease;
}
.btn:hover:not(:disabled) { background: rgba(185, 207, 255, 0.18); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn-primary {
background: var(--atari-yellow);
border-color: var(--atari-yellow);
color: #2a2000;
font-weight: 700;
}
.btn-primary:hover:not(:disabled) { background: #fff0a0; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-ghost {
border-color: #3d4453;
color: #8fa0c4;
background: #14161d;
padding: 5px 10px;
font-size: 0.85em;
}
.btn-ghost:hover:not(:disabled) { background: #1e222c; color: var(--atari-bright); }
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: center;
font-family: inherit;
font-size: clamp(11px, 1.1vw, 14px);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.seed { color: #566079; margin-left: auto; }
@media (max-width: 560px) {
.seed { margin-left: 0; }
}
+242
View File
@@ -0,0 +1,242 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { DAY_TUNE, END_TUNE, TITLE_TUNE } from './audio/tunes'
import { synth } from './audio/synth'
import { WEATHER } from './game/constants'
import { dollars, randomSeed, rollDay, simulate } from './game/engine'
import { makeRng, type Rng } from './game/rng'
import { activePlayers, initialState, reducer } from './game/reducer'
import type { Decision } from './game/types'
import { BriefingScreen } from './components/BriefingScreen'
import { Btn, Crt, Fit } from './components/Crt'
import { DecideScreen } from './components/DecideScreen'
import { GameOverScreen } from './components/GameOverScreen'
import { IntroScreen } from './components/IntroScreen'
import { ReportScreen } from './components/ReportScreen'
import { SetupScreen } from './components/SetupScreen'
import { TitleScreen } from './components/TitleScreen'
import './App.css'
export default function App() {
const [seed] = useState(randomSeed)
const [state, dispatch] = useReducer(reducer, seed, initialState)
const rng = useRef<Rng>(makeRng(seed))
const [music, setMusic] = useState(true)
const [sfx, setSfx] = useState(true)
const started = useRef(false)
const active = activePlayers(state)
const current = active[Math.min(state.turn, Math.max(0, active.length - 1))]
// ------------------------------------------------------------ audio glue
const wake = useCallback(() => {
if (started.current) return
started.current = true
synth.ensure()
synth.setMusic(music)
synth.setSfx(sfx)
}, [music, sfx])
useEffect(() => {
if (!started.current) return
const tune =
state.phase === 'gameover'
? END_TUNE
: state.phase === 'title' || state.phase === 'intro' || state.phase === 'setup'
? TITLE_TUNE
: DAY_TUNE
synth.playTune(tune, false)
}, [state.phase])
useEffect(() => {
synth.setMusic(music)
}, [music])
useEffect(() => {
synth.setSfx(sfx)
}, [sfx])
const blip = useCallback(() => synth.blip(), [])
const goSetup = useCallback(() => {
wake()
synth.select()
dispatch({ type: 'SHOW_SETUP' })
}, [wake])
const goIntro = useCallback(() => {
wake()
synth.select()
dispatch({ type: 'SHOW_INTRO' })
}, [wake])
// -------------------------------------------------------------- handlers
const beginDay = useCallback(
(day: number, streetCrewYesterday: boolean) => {
const conditions = rollDay(day, rng.current, streetCrewYesterday)
dispatch({ type: 'BEGIN_DAY', conditions })
if (conditions.heatWave) synth.heat()
else if (conditions.weather === 'sunny') synth.sunshine()
},
[],
)
const start = (names: string[]) => {
wake()
synth.select()
dispatch({ type: 'START', names })
beginDay(1, false)
}
const submit = (decision: Decision) => {
if (!state.conditions || !current) return
const decisions = { ...state.decisions, [current.id]: decision }
dispatch({ type: 'SUBMIT', playerId: current.id, decision })
const everyoneIn = active.every((p) => decisions[p.id] !== undefined)
if (!everyoneIn) return
const results = active.map((p) => simulate(p, decisions[p.id], state.conditions!, rng.current))
dispatch({ type: 'RESOLVE', results })
if (state.conditions.storm) synth.thunder()
else if (results.some((r) => r.profit > 0)) synth.cash()
else synth.sad()
}
const nextDay = () => {
synth.select()
dispatch({ type: 'NEXT_DAY' })
beginDay(state.day + 1, state.streetCrewYesterday)
}
const retire = () => {
synth.fanfare()
dispatch({ type: 'RETIRE' })
}
const restart = () => {
const s = randomSeed()
rng.current = makeRng(s)
dispatch({ type: 'RESTART', seed: s })
}
// ---------------------------------------------------------------- render
const status = useMemo(() => {
if (state.phase === 'title' || state.phase === 'intro' || state.phase === 'setup') return null
const w = state.conditions ? WEATHER[state.conditions.weather].label : ''
return { day: state.day, weather: w, player: current }
}, [state.phase, state.day, state.conditions, current])
return (
<div className="app" onPointerDown={wake} onKeyDown={wake}>
<Crt
footer={
<div className="controls">
<Btn
kind="ghost"
onClick={() => {
wake()
setMusic((m) => !m)
}}
title="Background music"
>
MUSIC {music ? 'ON' : 'OFF'}
</Btn>
<Btn
kind="ghost"
onClick={() => {
wake()
setSfx((s) => !s)
}}
title="Sound effects"
>
SOUND {sfx ? 'ON' : 'OFF'}
</Btn>
<Btn kind="ghost" onClick={restart} title="Abandon this run">
NEW GAME
</Btn>
<span className="seed">SEED {state.seed}</span>
</div>
}
>
{status && (
<div className="statusbar">
<span>DAY {String(status.day).padStart(2, '0')}</span>
<span>{status.weather}</span>
<span>{status.player ? dollars(status.player.assets) : '--'}</span>
</div>
)}
<Fit>
{state.phase === 'title' && (
<TitleGate seed={state.seed} onStart={goSetup} onInstructions={goIntro} />
)}
{state.phase === 'intro' && <IntroScreen onDone={() => dispatch({ type: 'SHOW_SETUP' })} />}
{state.phase === 'setup' && <SetupScreen onStart={start} onBlip={blip} />}
{state.phase === 'briefing' && state.conditions && (
<BriefingScreen
conditions={state.conditions}
onContinue={() => {
synth.select()
dispatch({ type: 'OPEN_STAND' })
}}
/>
)}
{state.phase === 'decide' && state.conditions && current && (
<DecideScreen
key={`${state.day}-${current.id}`}
player={current}
conditions={state.conditions}
playerCount={active.length}
onSubmit={submit}
onBlip={blip}
onReject={() => synth.reject()}
/>
)}
{state.phase === 'report' && state.conditions && (
<ReportScreen
key={state.day}
results={state.results}
players={state.players}
conditions={state.conditions}
onNextDay={nextDay}
onRetire={retire}
onBlip={blip}
/>
)}
{state.phase === 'gameover' && (
<GameOverScreen
players={state.players}
history={state.history}
days={state.day}
onRestart={restart}
/>
)}
</Fit>
</Crt>
</div>
)
}
/** Enter or Space works as the START key, the way the console did. */
function TitleGate(props: { seed: number; onStart: () => void; onInstructions: () => void }) {
const { onStart } = props
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') onStart()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onStart])
return <TitleScreen {...props} />
}
+297
View File
@@ -0,0 +1,297 @@
/**
* A very small chiptune engine: pulse waves for voices, filtered white noise
* for percussion, and a look-ahead scheduler so patterns stay in time even
* when React is busy re-rendering.
*/
const NOTE_INDEX: Record<string, number> = {
C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5,
'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11,
}
/** "C#4" -> Hz. A4 = 440. */
export function noteToFreq(note: string): number {
const m = /^([A-G]#?)(-?\d)$/.exec(note)
if (!m) return 0
const semis = NOTE_INDEX[m[1]] + (Number(m[2]) + 1) * 12
return 440 * Math.pow(2, (semis - 69) / 12)
}
export type Wave = 'pulse12' | 'pulse25' | 'pulse50' | 'triangle' | 'saw' | 'noise'
export interface Track {
wave: Wave
gain: number
/** One entry per step. '.' rest, '=' sustain previous, otherwise a note. */
notes: string[]
}
export interface Tune {
bpm: number
stepsPerBeat: number
tracks: Track[]
}
/** Fourier series for a pulse wave of the given duty cycle. */
function pulseWave(ctx: AudioContext, duty: number, harmonics = 24): PeriodicWave {
const real = new Float32Array(harmonics + 1)
const imag = new Float32Array(harmonics + 1)
for (let n = 1; n <= harmonics; n++) {
imag[n] = (2 / (n * Math.PI)) * Math.sin(n * Math.PI * duty)
}
return ctx.createPeriodicWave(real, imag, { disableNormalization: false })
}
export class Synth {
private ctx: AudioContext | null = null
private master!: GainNode
private musicBus!: GainNode
private sfxBus!: GainNode
private waves: Partial<Record<Wave, PeriodicWave>> = {}
private noiseBuffer!: AudioBuffer
private tune: Tune | null = null
private step = 0
private nextStepTime = 0
private timer: number | null = null
musicOn = true
sfxOn = true
/** Must be called from a user gesture the first time. */
ensure(): AudioContext {
if (this.ctx) {
if (this.ctx.state === 'suspended') void this.ctx.resume()
return this.ctx
}
const ctx = new AudioContext()
this.ctx = ctx
this.master = ctx.createGain()
this.master.gain.value = 0.5
this.master.connect(ctx.destination)
this.musicBus = ctx.createGain()
this.musicBus.gain.value = 0.55
this.musicBus.connect(this.master)
this.sfxBus = ctx.createGain()
this.sfxBus.gain.value = 0.9
this.sfxBus.connect(this.master)
this.waves.pulse12 = pulseWave(ctx, 0.125)
this.waves.pulse25 = pulseWave(ctx, 0.25)
this.waves.pulse50 = pulseWave(ctx, 0.5)
const len = Math.floor(ctx.sampleRate * 1.5)
const buf = ctx.createBuffer(1, len, ctx.sampleRate)
const data = buf.getChannelData(0)
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
this.noiseBuffer = buf
return ctx
}
setMusic(on: boolean) {
this.musicOn = on
if (!this.ctx) return
this.musicBus.gain.setTargetAtTime(on ? 0.55 : 0, this.ctx.currentTime, 0.05)
}
setSfx(on: boolean) {
this.sfxOn = on
if (!this.ctx) return
this.sfxBus.gain.setTargetAtTime(on ? 0.9 : 0, this.ctx.currentTime, 0.02)
}
// ---------------------------------------------------------------- voices
private voice(
dest: AudioNode,
wave: Wave,
freq: number,
at: number,
dur: number,
gain: number,
) {
const ctx = this.ensure()
const osc = ctx.createOscillator()
if (wave === 'triangle') osc.type = 'triangle'
else if (wave === 'saw') osc.type = 'sawtooth'
else osc.setPeriodicWave(this.waves[wave] ?? this.waves.pulse50!)
osc.frequency.setValueAtTime(freq, at)
const env = ctx.createGain()
const peak = Math.max(0.0001, gain)
env.gain.setValueAtTime(0.0001, at)
env.gain.exponentialRampToValueAtTime(peak, at + 0.008)
env.gain.setValueAtTime(peak, at + Math.max(0.02, dur * 0.6))
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
osc.connect(env).connect(dest)
osc.start(at)
osc.stop(at + dur + 0.02)
}
private percussion(dest: AudioNode, kind: string, at: number, gain: number) {
const ctx = this.ensure()
const src = ctx.createBufferSource()
src.buffer = this.noiseBuffer
const filter = ctx.createBiquadFilter()
const env = ctx.createGain()
let dur = 0.06
if (kind === 'K') {
filter.type = 'lowpass'
filter.frequency.value = 220
dur = 0.12
} else if (kind === 'S') {
filter.type = 'bandpass'
filter.frequency.value = 1800
dur = 0.12
} else {
filter.type = 'highpass'
filter.frequency.value = 6000
dur = 0.04
}
env.gain.setValueAtTime(gain, at)
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
src.connect(filter).connect(env).connect(dest)
src.start(at)
src.stop(at + dur + 0.02)
}
// ------------------------------------------------------------- sequencer
playTune(tune: Tune, restart = true) {
this.ensure()
if (this.tune === tune && this.timer !== null && !restart) return
this.stopTune()
this.tune = tune
this.step = 0
this.nextStepTime = this.ctx!.currentTime + 0.08
this.timer = window.setInterval(() => this.schedule(), 25)
}
stopTune() {
if (this.timer !== null) window.clearInterval(this.timer)
this.timer = null
this.tune = null
}
get playing() {
return this.timer !== null
}
private schedule() {
const ctx = this.ctx
const tune = this.tune
if (!ctx || !tune) return
const stepDur = 60 / tune.bpm / tune.stepsPerBeat
const length = Math.max(...tune.tracks.map((t) => t.notes.length))
while (this.nextStepTime < ctx.currentTime + 0.2) {
for (const track of tune.tracks) {
const note = track.notes[this.step % track.notes.length]
if (!note || note === '.' || note === '=') continue
// A note runs until the next step that is not a sustain marker.
let held = 1
for (let i = 1; i < length; i++) {
if (track.notes[(this.step + i) % track.notes.length] === '=') held++
else break
}
const dur = held * stepDur * 0.95
if (track.wave === 'noise') {
this.percussion(this.musicBus, note, this.nextStepTime, track.gain)
} else {
const f = noteToFreq(note)
if (f) this.voice(this.musicBus, track.wave, f, this.nextStepTime, dur, track.gain)
}
}
this.nextStepTime += stepDur
this.step = (this.step + 1) % length
}
}
// ------------------------------------------------------------------ sfx
private seq(notes: [string, number][], wave: Wave = 'pulse25', gain = 0.22) {
const ctx = this.ensure()
let t = ctx.currentTime + 0.01
for (const [note, dur] of notes) {
if (note !== '.') this.voice(this.sfxBus, wave, noteToFreq(note), t, dur, gain)
t += dur
}
}
blip() {
this.seq([['E5', 0.05]], 'pulse12', 0.15)
}
select() {
this.seq([['C5', 0.05], ['G5', 0.09]], 'pulse25', 0.18)
}
reject() {
this.seq([['A3', 0.09], ['E3', 0.16]], 'saw', 0.2)
}
cash() {
this.seq([['C5', 0.07], ['E5', 0.07], ['G5', 0.07], ['C6', 0.22]], 'pulse25', 0.2)
}
sad() {
this.seq([['G4', 0.12], ['F#4', 0.12], ['F4', 0.12], ['E4', 0.4]], 'triangle', 0.22)
}
thunder() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
const src = ctx.createBufferSource()
src.buffer = this.noiseBuffer
src.loop = true
const filter = ctx.createBiquadFilter()
filter.type = 'lowpass'
filter.frequency.setValueAtTime(1400, at)
filter.frequency.exponentialRampToValueAtTime(120, at + 1.4)
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, at)
env.gain.exponentialRampToValueAtTime(0.35, at + 0.05)
env.gain.exponentialRampToValueAtTime(0.0001, at + 1.5)
src.connect(filter).connect(env).connect(this.sfxBus)
src.start(at)
src.stop(at + 1.6)
}
sunshine() {
this.seq([['C5', 0.06], ['D5', 0.06], ['E5', 0.06], ['G5', 0.06], ['A5', 0.18]], 'pulse12', 0.16)
}
heat() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.setValueAtTime(300, at)
osc.frequency.exponentialRampToValueAtTime(900, at + 0.5)
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, at)
env.gain.exponentialRampToValueAtTime(0.2, at + 0.1)
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.6)
osc.connect(env).connect(this.sfxBus)
osc.start(at)
osc.stop(at + 0.7)
}
fanfare() {
this.seq(
[['C5', 0.11], ['E5', 0.11], ['G5', 0.11], ['C6', 0.11], ['G5', 0.11], ['C6', 0.4]],
'pulse25',
0.2,
)
}
}
export const synth = new Synth()
+142
View File
@@ -0,0 +1,142 @@
import type { Tune } from './synth'
const bar = (...notes: string[]) => notes
/**
* Title theme - the sort of thing an ice cream van would play if it had a
* POKEY chip. Four bars of C - F - G - C, twice around.
*/
export const TITLE_TUNE: Tune = {
bpm: 132,
stepsPerBeat: 2,
tracks: [
{
wave: 'pulse25',
gain: 0.16,
notes: [
...bar('G4', '.', 'E4', '.', 'G4', '.', 'C5', '='),
...bar('B4', '.', 'C5', '.', 'D5', '=', '=', '.'),
...bar('A4', '.', 'F4', '.', 'A4', '.', 'C5', '='),
...bar('D5', '.', 'C5', '.', 'B4', '=', '=', '.'),
...bar('G4', '.', 'B4', '.', 'D5', '.', 'G5', '='),
...bar('F5', '.', 'E5', '.', 'D5', '=', '=', '.'),
...bar('E5', '.', 'C5', '.', 'G4', '.', 'E4', '.'),
...bar('C4', '=', '=', '=', '=', '.', '.', '.'),
],
},
{
wave: 'pulse12',
gain: 0.07,
notes: [
...bar('E5', '.', '.', '.', 'G5', '.', '.', '.'),
...bar('.', '.', 'G5', '.', '.', '.', 'F5', '.'),
...bar('F5', '.', '.', '.', 'A5', '.', '.', '.'),
...bar('.', '.', 'A5', '.', '.', '.', 'G5', '.'),
...bar('D5', '.', '.', '.', 'G5', '.', '.', '.'),
...bar('.', '.', 'B5', '.', '.', '.', 'A5', '.'),
...bar('G5', '.', '.', '.', 'E5', '.', '.', '.'),
...bar('C5', '=', '=', '=', '.', '.', '.', '.'),
],
},
{
wave: 'triangle',
gain: 0.3,
notes: [
...bar('C2', '.', 'G2', '.', 'C3', '.', 'G2', '.'),
...bar('C2', '.', 'G2', '.', 'E2', '.', 'G2', '.'),
...bar('F2', '.', 'C3', '.', 'F3', '.', 'C3', '.'),
...bar('G2', '.', 'D3', '.', 'G3', '.', 'D3', '.'),
...bar('G2', '.', 'D3', '.', 'G3', '.', 'D3', '.'),
...bar('G2', '.', 'B2', '.', 'D3', '.', 'B2', '.'),
...bar('C2', '.', 'G2', '.', 'C3', '.', 'G2', '.'),
...bar('C2', '=', '=', '=', 'G2', '.', 'C2', '.'),
],
},
{
wave: 'noise',
gain: 0.16,
notes: [
...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'),
...bar('K', 'H', '.', 'H', 'S', 'H', 'K', 'H'),
...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'),
...bar('K', 'H', '.', 'H', 'S', 'H', 'K', 'H'),
...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'),
...bar('K', 'H', '.', 'H', 'S', 'H', 'K', 'H'),
...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'),
...bar('K', '.', 'S', '.', 'K', 'S', 'S', 'S'),
],
},
],
}
/** Trading music: slower, sparser, meant to sit under the numbers. */
export const DAY_TUNE: Tune = {
bpm: 104,
stepsPerBeat: 2,
tracks: [
{
wave: 'pulse12',
gain: 0.1,
notes: [
...bar('C5', '.', 'E5', '.', 'G5', '.', 'E5', '.'),
...bar('D5', '.', 'F5', '.', 'A5', '=', '.', '.'),
...bar('B4', '.', 'D5', '.', 'G5', '.', 'D5', '.'),
...bar('C5', '=', '=', '.', 'E5', '.', 'G4', '.'),
...bar('A4', '.', 'C5', '.', 'E5', '.', 'C5', '.'),
...bar('F4', '.', 'A4', '.', 'D5', '=', '.', '.'),
...bar('G4', '.', 'B4', '.', 'D5', '.', 'F5', '.'),
...bar('E5', '=', '=', '.', 'C5', '=', '.', '.'),
],
},
{
wave: 'triangle',
gain: 0.26,
notes: [
...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'),
...bar('D2', '.', '.', '.', 'A2', '.', '.', '.'),
...bar('G2', '.', '.', '.', 'D3', '.', '.', '.'),
...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'),
...bar('A2', '.', '.', '.', 'E3', '.', '.', '.'),
...bar('F2', '.', '.', '.', 'C3', '.', '.', '.'),
...bar('G2', '.', '.', '.', 'D3', '.', '.', '.'),
...bar('C2', '.', '.', '.', 'G2', '.', 'B2', '.'),
],
},
{
wave: 'noise',
gain: 0.1,
notes: [
...bar('.', '.', 'H', '.', '.', '.', 'H', '.'),
...bar('.', '.', 'H', '.', '.', '.', 'H', 'H'),
],
},
],
}
/** Plays once behind the closing standings. */
export const END_TUNE: Tune = {
bpm: 96,
stepsPerBeat: 2,
tracks: [
{
wave: 'pulse25',
gain: 0.15,
notes: [
...bar('G4', '.', 'C5', '=', 'E5', '.', 'G5', '='),
...bar('E5', '.', 'C5', '=', 'G4', '=', '=', '.'),
...bar('A4', '.', 'D5', '=', 'F5', '.', 'A5', '='),
...bar('G5', '.', 'E5', '=', 'C5', '=', '=', '.'),
],
},
{
wave: 'triangle',
gain: 0.28,
notes: [
...bar('C2', '.', '.', '.', 'C3', '.', '.', '.'),
...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'),
...bar('D2', '.', '.', '.', 'D3', '.', '.', '.'),
...bar('C2', '.', '.', '.', 'G2', '.', 'C2', '.'),
],
},
],
}
+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, ' '))
}
+76
View File
@@ -0,0 +1,76 @@
import type { Weather } from './types'
/** Everything is held in whole cents so the books never drift. */
export const STARTING_ASSETS = 200 // $2.00
export const SIGN_COST = 15 // 15 cents per advertising sign
export const MAX_PLAYERS = 4
export const MAX_SIGNS = 50
/**
* The cost of a glass climbs as the season goes on, exactly as in the
* original: 2 cents for days 1-2, 4 cents through day 7, 5 cents after that.
*/
export function costPerGlass(day: number): number {
if (day <= 2) return 2
if (day <= 7) return 4
return 5
}
export function costMessage(day: number): string[] | null {
if (day === 1)
return [
'ON DAY 1 THE COST OF LEMONADE IS',
'2 CENTS PER GLASS.',
]
if (day === 3)
return [
'YOUR COST OF LEMONADE HAS GONE UP',
'TO 4 CENTS PER GLASS.',
]
if (day === 8)
return [
'YOUR COST OF LEMONADE HAS GONE UP',
'TO 5 CENTS PER GLASS.',
]
return null
}
/**
* Demand model.
*
* The original BASIC listing is not reproduced here line for line - this is a
* reconstruction tuned to behave the way the game plays: cheap lemonade sells
* out, nobody buys at any price once it stops being a bargain, heat pushes both the crowd and the
* price people will tolerate upwards, and signs help a lot at first and
* hardly at all after the fourth one.
*/
export interface WeatherProfile {
label: string
/** Passers-by on an average day. */
traffic: number
/** Price (in cents) at which sales fall to nothing. */
priceCeiling: number
}
export const WEATHER: Record<Weather, WeatherProfile> = {
sunny: { label: 'SUNNY', traffic: 90, priceCeiling: 20 },
cloudy: { label: 'CLOUDY', traffic: 60, priceCeiling: 15 },
hot: { label: 'HOT AND DRY', traffic: 120, priceCeiling: 30 },
}
/** A heat wave on a hot day turns the street into a desert full of buyers. */
export const HEAT_WAVE_TRAFFIC = 1.5
export const HEAT_WAVE_CEILING = 1.25
/** Street crews close the road; a handful of regulars still find you. */
export const STREET_CREW_TRAFFIC = 0.2
/** Signs: +20% for the first, tailing off to a hard ceiling near +50%. */
export function signFactor(signs: number): number {
return 1 + 0.5 * (1 - Math.pow(0.6, Math.max(0, signs)))
}
export function priceFactor(price: number, ceiling: number): number {
if (price >= ceiling) return 0
if (price <= 0) return 1
return Math.pow((ceiling - price) / ceiling, 1.6)
}
+100
View File
@@ -0,0 +1,100 @@
import {
HEAT_WAVE_CEILING,
HEAT_WAVE_TRAFFIC,
STREET_CREW_TRAFFIC,
SIGN_COST,
WEATHER,
costPerGlass,
priceFactor,
signFactor,
} from './constants'
import { chance, type Rng } from './rng'
import type { DayConditions, DayResult, Decision, Player, Weather } from './types'
/**
* Roll the weather and the day's events. The storm is decided here but must
* not be shown to the player until their money is committed - that is the
* whole cruelty of a cloudy day.
*/
export function rollDay(day: number, rng: Rng, streetCrewYesterday: boolean): DayConditions {
const roll = rng()
let weather: Weather
if (roll < 0.5) weather = 'sunny'
else if (roll < 0.8) weather = 'cloudy'
else weather = 'hot'
// Day 1 is always sunny so nobody loses their stake before they have played.
if (day === 1) weather = 'sunny'
const heatWave = weather === 'hot' && chance(rng, 0.35)
const storm = weather === 'cloudy' && chance(rng, 0.25)
// Road works run for two days once they start, and never on day 1 or 2.
const streetCrew = day > 2 && (streetCrewYesterday ? chance(rng, 0.5) : chance(rng, 0.12))
return { day, weather, heatWave, streetCrew, storm, costPerGlass: costPerGlass(day) }
}
/** The most glasses a player can pay for today, given signs already planned. */
export function affordableGlasses(assets: number, day: number, signs = 0): number {
return Math.max(0, Math.floor((assets - signs * SIGN_COST) / costPerGlass(day)))
}
export function simulate(
player: Player,
decision: Decision,
cond: DayConditions,
rng: Rng,
): DayResult {
const lemonadeCost = decision.glasses * cond.costPerGlass
const signCost = decision.signs * SIGN_COST
const expenses = lemonadeCost + signCost
let glassesSold = 0
if (!cond.storm) {
const profile = WEATHER[cond.weather]
let traffic = profile.traffic
let ceiling = profile.priceCeiling
if (cond.heatWave) {
traffic *= HEAT_WAVE_TRAFFIC
ceiling *= HEAT_WAVE_CEILING
}
if (cond.streetCrew) traffic *= STREET_CREW_TRAFFIC
const demand =
traffic *
priceFactor(decision.price, ceiling) *
signFactor(decision.signs) *
// A little daily luck, +/- 10%, so identical days are never identical.
(0.9 + rng() * 0.2)
glassesSold = Math.min(decision.glasses, Math.round(demand))
}
const income = glassesSold * decision.price
const profit = income - expenses
return {
playerId: player.id,
decision,
glassesSold,
income,
lemonadeCost,
signCost,
expenses,
profit,
assetsAfter: player.assets + profit,
}
}
/** Broke means you cannot even make a single glass tomorrow. */
export function isBankrupt(assets: number, nextDay: number): boolean {
return assets < costPerGlass(nextDay)
}
export const dollars = (cents: number): string => {
const sign = cents < 0 ? '-' : ''
const abs = Math.abs(cents)
return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`
}
export const randomSeed = () => Math.floor(Math.random() * 1_000_000) + 1
+139
View File
@@ -0,0 +1,139 @@
import { STARTING_ASSETS } from './constants'
import { isBankrupt } from './engine'
import type { DayConditions, DayResult, Decision, Phase, Player } from './types'
export interface GameState {
phase: Phase
seed: number
day: number
players: Player[]
/** Index into players; only the ones still solvent take a turn. */
turn: number
conditions: DayConditions | null
decisions: Record<number, Decision>
results: DayResult[]
history: DayResult[]
/** True while road works were in progress yesterday, so they can run on. */
streetCrewYesterday: boolean
retired: boolean
}
export const initialState = (seed: number): GameState => ({
phase: 'title',
seed,
day: 0,
players: [],
turn: 0,
conditions: null,
decisions: {},
results: [],
history: [],
streetCrewYesterday: false,
retired: false,
})
export type Action =
| { type: 'SHOW_INTRO' }
| { type: 'SHOW_SETUP' }
| { type: 'START'; names: string[] }
| { type: 'BEGIN_DAY'; conditions: DayConditions }
| { type: 'OPEN_STAND' }
| { type: 'SUBMIT'; playerId: number; decision: Decision }
| { type: 'RESOLVE'; results: DayResult[] }
| { type: 'NEXT_DAY' }
| { type: 'RETIRE' }
| { type: 'RESTART'; seed: number }
/** Players who can still afford to open the stand, in seating order. */
export const activePlayers = (s: GameState) => s.players.filter((p) => !p.bankrupt)
export function reducer(state: GameState, action: Action): GameState {
switch (action.type) {
case 'SHOW_INTRO':
return { ...state, phase: 'intro' }
case 'SHOW_SETUP':
return { ...state, phase: 'setup' }
case 'START':
return {
...state,
phase: 'briefing',
day: 1,
players: action.names.map((name, i) => ({
id: i,
name: name.trim().toUpperCase() || `PLAYER ${i + 1}`,
assets: STARTING_ASSETS,
bankrupt: false,
bankruptDay: null,
})),
turn: 0,
decisions: {},
results: [],
history: [],
}
case 'BEGIN_DAY':
return {
...state,
phase: 'briefing',
conditions: action.conditions,
decisions: {},
results: [],
turn: 0,
}
case 'OPEN_STAND':
return { ...state, phase: 'decide', turn: 0 }
case 'SUBMIT': {
const decisions = { ...state.decisions, [action.playerId]: action.decision }
const active = activePlayers(state)
const done = active.every((p) => decisions[p.id] !== undefined)
return {
...state,
decisions,
turn: done ? state.turn : state.turn + 1,
phase: done ? 'resolve' : 'decide',
}
}
case 'RESOLVE': {
const byId = new Map(action.results.map((r) => [r.playerId, r]))
const nextDay = state.day + 1
return {
...state,
phase: 'report',
results: action.results,
history: [...state.history, ...action.results],
streetCrewYesterday: state.conditions?.streetCrew ?? false,
players: state.players.map((p) => {
const r = byId.get(p.id)
if (!r) return p
const assets = r.assetsAfter
const broke = isBankrupt(assets, nextDay)
return {
...p,
assets,
bankrupt: p.bankrupt || broke,
bankruptDay: p.bankrupt ? p.bankruptDay : broke ? state.day : null,
}
}),
}
}
case 'NEXT_DAY': {
if (activePlayers(state).length === 0) return { ...state, phase: 'gameover' }
return { ...state, phase: 'briefing', day: state.day + 1, turn: 0 }
}
case 'RETIRE':
return { ...state, phase: 'gameover', retired: true }
case 'RESTART':
return initialState(action.seed)
default:
return state
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* mulberry32 — small, fast, seedable PRNG.
* The original ran on RND(0); we keep a seed so a run can be replayed or shared.
*/
export function makeRng(seed: number) {
let a = seed >>> 0
return function rng(): number {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export type Rng = ReturnType<typeof makeRng>
export const randInt = (rng: Rng, min: number, max: number) =>
min + Math.floor(rng() * (max - min + 1))
export const chance = (rng: Rng, p: number) => rng() < p
+49
View File
@@ -0,0 +1,49 @@
export type Weather = 'sunny' | 'cloudy' | 'hot'
export interface Player {
id: number
name: string
assets: number // in cents, always integer
bankrupt: boolean
bankruptDay: number | null
}
/** What a player typed in for a single day. */
export interface Decision {
glasses: number
signs: number
price: number // cents per glass
}
/** Everything the world decided for a given day, before anyone buys anything. */
export interface DayConditions {
day: number
weather: Weather
heatWave: boolean
streetCrew: boolean
/** Only ever true on a cloudy day, and only revealed after decisions are locked in. */
storm: boolean
costPerGlass: number
}
export interface DayResult {
playerId: number
decision: Decision
glassesSold: number
income: number
lemonadeCost: number
signCost: number
expenses: number
profit: number
assetsAfter: number
}
export type Phase =
| 'title'
| 'intro'
| 'setup'
| 'briefing'
| 'decide'
| 'resolve'
| 'report'
| 'gameover'
+57
View File
@@ -0,0 +1,57 @@
:root {
/* Atari 8-bit GR.0 defaults: dark border, blue field, light blue characters. */
--atari-border: #0a1030;
--atari-screen: #2b45b8;
--atari-screen-dark: #1d3193;
--atari-text: #b9cfff;
--atari-bright: #eaf2ff;
--atari-yellow: #f7de5a;
--atari-amber: #f0a13a;
--atari-red: #ff8a7a;
--atari-green: #8ef0a8;
color-scheme: dark;
font-synthesis: none;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
/* The root is a flex item of body; without this the cabinet shrinks to fit. */
#root {
flex: 1;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
body {
background:
radial-gradient(120% 80% at 50% 0%, #1a1c26 0%, #0b0c12 55%, #050609 100%);
color: var(--atari-text);
font-family: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(8px, 2vw, 28px);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})