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