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:
+24
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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 1–2, 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
@@ -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>
|
||||
Generated
+1349
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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} />
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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', '.'),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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, ' '))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user