Remaster: cel-shaded skin, a crowd that tracks trade, and a funk band
Two skins over one game. 1979 is the machine as it was; the remaster is the same rules in cel-shaded vector art with a modern palette. The simulation is untouched - only the paint differs - and the toggle sits under the set. The street is now driven by the game. Before prices are in, the number of people walking on comes from the forecast; afterwards it comes from the glasses that actually sold, so the report shows the crowd you earned. A heat wave has them fanning themselves; a downpour leaves one figure hurrying past under an umbrella. Selling no longer snaps straight to the books. The stand trades for a few seconds first, tally climbing and till filling, which is where that crowd animation pays off. Skippable. The synth grew a kit (pitched-sine kick, snare with body, hats), a sweeping resonant lowpass for the bass, chords and a shuffle - enough for a funk band. The chiptunes stay for the 1979 skin. Boot is now a cassette load, which doubles as the gesture browsers require before audio will play. Fixes found on the way: the picture scaled about its centre while flex auto-margins collapsed to zero on overflow, which pushed the report's buttons off the bottom; and the validation line moved the button when it appeared, so it is now reserved and faded. Sibling tabs in one browser also keep their score tables in step.
This commit is contained in:
@@ -3,9 +3,14 @@
|
||||
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.
|
||||
It ships in two skins over one game. **1979** is the machine as it was: Atari
|
||||
blue, block letters, scanlines, chiptune. **Remaster** is the same rules with
|
||||
cel-shaded art, a crowd that turns up in proportion to trade, and a funk band.
|
||||
The rules never differ between them — only the paint. Switch any time with the
|
||||
button under the set.
|
||||
|
||||
React + TypeScript + Vite, and not one binary asset: every pixel is SVG drawn
|
||||
in code and every note is generated by a small synth on the Web Audio API.
|
||||
|
||||
## Playing
|
||||
|
||||
@@ -14,6 +19,11 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
It loads from cassette first, because that is how it arrived. Pressing PLAY on
|
||||
the recorder is also what unlocks the audio — browsers want a gesture before
|
||||
they will make a sound, and that happens to be exactly the gesture the game
|
||||
already wanted.
|
||||
|
||||
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
|
||||
@@ -31,6 +41,10 @@ pocket money.
|
||||
- Signs work, but with sharp diminishing returns — the fourth one barely
|
||||
earns its 15 cents.
|
||||
|
||||
Once the prices are in, the stand trades for a few seconds before the books
|
||||
open: the crowd arrives at the rate you actually sold, the tally climbs, and
|
||||
the till fills. It can be skipped, but the day is worth watching.
|
||||
|
||||
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.
|
||||
|
||||
@@ -63,9 +77,14 @@ src/
|
||||
rng.ts seeded mulberry32, so a run can be replayed
|
||||
highscores.ts the persisted table, with validation on load
|
||||
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
|
||||
synth.ts pulse voices, a drum kit, filters, look-ahead sequencer
|
||||
tunes.ts the 1979 chiptunes
|
||||
funk.ts the remaster's band
|
||||
skin.ts which of the two skins is on, and where it is kept
|
||||
components/
|
||||
PixelScene the 1979 artwork, in chunky rectangles
|
||||
CelScene the remaster's artwork, and the people on the street
|
||||
Scene picks between them, so no screen needs to care
|
||||
```
|
||||
|
||||
The simulation is deliberately kept out of React: `engine.ts` takes a player, a
|
||||
@@ -88,9 +107,37 @@ 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.
|
||||
|
||||
### The people on the street
|
||||
|
||||
How many figures walk on is driven by trade, not decoration. Before you have
|
||||
priced anything it comes from the forecast — a hot day fills the pavement, road
|
||||
works empty it, a downpour leaves one soul hurrying past under an umbrella.
|
||||
Once the day has traded it comes from the glasses that actually crossed the
|
||||
counter, so the report shows you the crowd you earned. In a heat wave they fan
|
||||
themselves.
|
||||
|
||||
## 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.
|
||||
Audio starts on the first gesture, as browsers require. The 1979 skin gets the
|
||||
chiptune: two pulse voices, a triangle bass and filtered noise, with real
|
||||
Fourier-built duty cycles rather than a plain square.
|
||||
|
||||
The remaster gets a band. Same engine, more of it — a sine kick with a pitch
|
||||
envelope, a snare with body under the crack, sixteenth hats, and a sawtooth
|
||||
bass through a sweeping resonant lowpass, which is where the funk actually
|
||||
lives. Patterns are written in sixteenths with a light shuffle, and chords are
|
||||
just slash-separated notes in a step (`F4/A4/C5`).
|
||||
|
||||
Both run off one scheduler that queues notes 200 ms ahead, so the groove does
|
||||
not stutter when React re-renders. `MUSIC` and `SOUND` toggle independently.
|
||||
|
||||
## Two people, two browsers
|
||||
|
||||
There is no server and no shared state: the whole game is a static bundle and
|
||||
lives entirely in the page. Two people on two machines, two browsers, or two
|
||||
profiles are completely independent — different seeds, different weather,
|
||||
different books.
|
||||
|
||||
The one thing two tabs in the *same* browser profile share is `localStorage`,
|
||||
which holds the high score table and the skin. They stay in step: each tab
|
||||
listens for the other's writes and refreshes rather than going stale.
|
||||
|
||||
+452
-55
@@ -1,3 +1,60 @@
|
||||
/* =====================================================================
|
||||
Two skins, one game. Layout is shared; every colour and every piece of
|
||||
chrome comes from a token that the skin swaps.
|
||||
===================================================================== */
|
||||
|
||||
:root {
|
||||
/* --- 1979 --------------------------------------------------------- */
|
||||
--ink: var(--atari-text);
|
||||
--ink-dim: #7f9adf;
|
||||
--ink-bright: var(--atari-bright);
|
||||
--accent: var(--atari-yellow);
|
||||
--warn: var(--atari-red);
|
||||
--money: var(--atari-green);
|
||||
--outline: #000;
|
||||
--screen-bg: var(--atari-screen);
|
||||
--screen-radius: 14px;
|
||||
--bar-bg: var(--atari-text);
|
||||
--bar-fg: var(--atari-screen-dark);
|
||||
--dots: rgba(185, 207, 255, 0.35);
|
||||
--glow: 0 0 6px rgba(150, 190, 255, 0.55);
|
||||
--btn-radius: 0.25em;
|
||||
--btn-shadow: none;
|
||||
--field-bg: var(--atari-text);
|
||||
--field-fg: var(--atari-screen-dark);
|
||||
--scene-radius: 0;
|
||||
--scene-border: 2px solid rgba(0, 0, 0, 0.5);
|
||||
--scene-shadow: none;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] {
|
||||
/* --- now ---------------------------------------------------------- */
|
||||
--ink: #fff6e6;
|
||||
--ink-dim: #b9a9e0;
|
||||
--ink-bright: #ffffff;
|
||||
--accent: #ffd93d;
|
||||
--warn: #ff7a9c;
|
||||
--money: #7be495;
|
||||
--outline: #241640;
|
||||
--screen-bg: linear-gradient(170deg, #4a2d86 0%, #341e63 45%, #24144a 100%);
|
||||
--screen-radius: 26px;
|
||||
--bar-bg: #ffd93d;
|
||||
--bar-fg: #241640;
|
||||
--dots: rgba(185, 169, 224, 0.4);
|
||||
--glow: none;
|
||||
--btn-radius: 999px;
|
||||
--btn-shadow: 4px 4px 0 var(--outline);
|
||||
--field-bg: #fff6e6;
|
||||
--field-fg: #241640;
|
||||
--scene-radius: 18px;
|
||||
--scene-border: 4px solid var(--outline);
|
||||
--scene-shadow: 6px 6px 0 rgba(36, 22, 64, 0.55);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Layout
|
||||
===================================================================== */
|
||||
|
||||
.app {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
@@ -14,8 +71,6 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- the box */
|
||||
|
||||
.bezel {
|
||||
background: linear-gradient(180deg, #3a3d47 0%, #23252c 45%, #16171c 100%);
|
||||
border-radius: 22px;
|
||||
@@ -24,6 +79,18 @@
|
||||
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);
|
||||
transition: background 400ms ease, box-shadow 400ms ease;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .bezel {
|
||||
background: linear-gradient(165deg, #6d4bb0 0%, #422a7d 55%, #2b1a55 100%);
|
||||
border-radius: 34px;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.22) inset,
|
||||
0 -3px 0 rgba(0, 0, 0, 0.4) inset,
|
||||
0 0 0 3px var(--outline),
|
||||
0 30px 70px rgba(10, 4, 26, 0.7),
|
||||
0 0 90px rgba(255, 93, 143, 0.22);
|
||||
}
|
||||
|
||||
.screen {
|
||||
@@ -34,15 +101,23 @@
|
||||
/* 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);
|
||||
background: var(--screen-bg);
|
||||
border: clamp(6px, 1.2vw, 12px) solid var(--atari-border);
|
||||
border-radius: 14px;
|
||||
border-radius: var(--screen-radius);
|
||||
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;
|
||||
transition: border-radius 400ms ease;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .screen {
|
||||
border-color: var(--outline);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.08) inset,
|
||||
0 20px 50px rgba(10, 4, 26, 0.55);
|
||||
}
|
||||
|
||||
@keyframes power-on {
|
||||
@@ -52,24 +127,28 @@
|
||||
}
|
||||
|
||||
.screen-inner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
--pad: 1.25em;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
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;
|
||||
color: var(--ink);
|
||||
text-shadow: var(--glow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* No scrollbars, ever - a television has none. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .screen-inner {
|
||||
letter-spacing: 0.01em;
|
||||
font-size: 2.2cqi;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.screen-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -77,6 +156,22 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 46ch;
|
||||
width: 100%;
|
||||
height: max-content;
|
||||
margin: auto;
|
||||
transform: scale(var(--fit, 1));
|
||||
/* Scale from the top edge: flex auto-margins collapse to zero once the
|
||||
content overflows, so a centre origin would push the page off the
|
||||
bottom instead of shrinking it into view. */
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .stack { max-width: 44ch; }
|
||||
|
||||
.scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -100,42 +195,113 @@
|
||||
background: radial-gradient(120% 100% at 50% 50%, transparent 55%, rgba(0, 0, 0, 0.55) 100%);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- text */
|
||||
/* The modern set is a flat panel: no phosphor, no glass curvature. */
|
||||
html[data-skin='modern'] .scanlines { opacity: 0.12; mix-blend-mode: overlay; }
|
||||
html[data-skin='modern'] .vignette { opacity: 0.35; }
|
||||
|
||||
.stack {
|
||||
/* =====================================================================
|
||||
Cassette boot - always the 1979 machine, whichever skin is loaded
|
||||
===================================================================== */
|
||||
|
||||
.boot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
background: var(--atari-screen);
|
||||
color: var(--atari-text);
|
||||
font-family: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace;
|
||||
font-size: 2.4cqi;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 0 6px rgba(150, 190, 255, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 46ch;
|
||||
width: 100%;
|
||||
height: max-content;
|
||||
margin: auto;
|
||||
transform: scale(var(--fit, 1));
|
||||
transform-origin: center;
|
||||
justify-content: center;
|
||||
padding: 2.2em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.boot-text { position: relative; z-index: 2; }
|
||||
|
||||
.boot-cursor { animation: blink 0.55s steps(1, end) infinite; }
|
||||
|
||||
/* The bands a tape loader threw up the screen while the data came in. */
|
||||
.boot-stripes {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
z-index: 1;
|
||||
opacity: 0.55;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
#f7de5a 0px, #f7de5a 7px,
|
||||
#2b45b8 7px, #2b45b8 12px,
|
||||
#8ef0a8 12px, #8ef0a8 16px,
|
||||
#ff8a7a 16px, #ff8a7a 23px,
|
||||
#b9cfff 23px, #b9cfff 27px
|
||||
);
|
||||
animation: boot-stripes 0.28s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes boot-stripes {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-27px); }
|
||||
}
|
||||
|
||||
.boot-controls {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: 1.6em;
|
||||
display: flex;
|
||||
gap: 0.6em;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Text
|
||||
===================================================================== */
|
||||
|
||||
.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-line {
|
||||
background: var(--atari-text);
|
||||
color: var(--atari-screen-dark);
|
||||
text-shadow: none;
|
||||
padding: 0 0.4ch;
|
||||
/* Reserved space for a message that comes and goes without a jolt. */
|
||||
.hint {
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.hint.is-on { opacity: 1; }
|
||||
.dim { color: var(--ink-dim); text-shadow: none; }
|
||||
.accent { color: var(--accent); text-shadow: 0 0 8px rgba(247, 222, 90, 0.5); }
|
||||
.warn { color: var(--warn); text-shadow: 0 0 8px rgba(255, 138, 122, 0.5); }
|
||||
.money { color: var(--money); }
|
||||
|
||||
html[data-skin='modern'] .accent,
|
||||
html[data-skin='modern'] .warn { text-shadow: none; font-weight: 800; }
|
||||
|
||||
.inv-line {
|
||||
display: block;
|
||||
background: var(--bar-bg);
|
||||
color: var(--bar-fg);
|
||||
text-shadow: none;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .inv-line {
|
||||
border-radius: 999px;
|
||||
padding: 0.3em 1.1em;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.02em;
|
||||
border: 3px solid var(--outline);
|
||||
box-shadow: 4px 4px 0 var(--outline);
|
||||
width: fit-content;
|
||||
/* Clears the drop shadow, so a two-line heading never touches the art. */
|
||||
margin: 0 auto 0.5em;
|
||||
}
|
||||
|
||||
.blink { animation: blink 1.1s steps(1, end) infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
@@ -146,19 +312,51 @@
|
||||
line-height: 1.05;
|
||||
margin-bottom: 0.5em;
|
||||
letter-spacing: 0;
|
||||
color: var(--atari-yellow);
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 10px rgba(247, 222, 90, 0.6);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.banner.small { opacity: 0.35; font-size: 0.38em; }
|
||||
|
||||
/* The modern wordmark, in place of the block-letter banner. */
|
||||
.wordmark {
|
||||
text-align: center;
|
||||
line-height: 0.92;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
.wordmark b {
|
||||
display: block;
|
||||
font-size: 2.6em;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--accent);
|
||||
-webkit-text-stroke: 3px var(--outline);
|
||||
paint-order: stroke fill;
|
||||
text-shadow: 5px 5px 0 var(--outline);
|
||||
}
|
||||
|
||||
.wordmark span {
|
||||
display: block;
|
||||
font-size: 0.95em;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5em;
|
||||
text-indent: 0.5em;
|
||||
color: #ff5d8f;
|
||||
-webkit-text-stroke: 2px var(--outline);
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.wordmark.small b { font-size: 1.5em; opacity: 0.85; }
|
||||
.wordmark.small span { font-size: 0.7em; }
|
||||
|
||||
.statusbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1ch;
|
||||
background: var(--atari-text);
|
||||
color: var(--atari-screen-dark);
|
||||
background: var(--bar-bg);
|
||||
color: var(--bar-fg);
|
||||
text-shadow: none;
|
||||
padding: 0.15em 0.7em;
|
||||
margin: calc(-1 * var(--pad)) calc(-1 * var(--pad)) 0;
|
||||
@@ -166,18 +364,49 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- scene */
|
||||
html[data-skin='modern'] .statusbar {
|
||||
font-weight: 900;
|
||||
border-bottom: 3px solid var(--outline);
|
||||
}
|
||||
|
||||
.scene {
|
||||
/* =====================================================================
|
||||
Scenes
|
||||
===================================================================== */
|
||||
|
||||
.scene,
|
||||
.cel {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 32ch;
|
||||
margin: 0.5em auto;
|
||||
border: 2px solid rgba(0, 0, 0, 0.5);
|
||||
border: var(--scene-border);
|
||||
border-radius: var(--scene-radius);
|
||||
box-shadow: var(--scene-shadow);
|
||||
image-rendering: pixelated;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.cel {
|
||||
/* The art is the point of the remaster: let it run the full width, and
|
||||
bleed a little past the text column. */
|
||||
max-width: none;
|
||||
width: calc(100% + 2 * var(--pad));
|
||||
margin: 0.2em calc(-1 * var(--pad)) 0.5em;
|
||||
image-rendering: auto;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
margin-top: 0.35em;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Cropped to the counter and the pavement, for screens short on room. */
|
||||
.cel.cel-strip {
|
||||
aspect-ratio: 640 / 124;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.stand-sign,
|
||||
.stand-price {
|
||||
font-family: inherit;
|
||||
@@ -209,7 +438,111 @@
|
||||
17%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- fields */
|
||||
/* ---------------------------------------------------------- cel scene */
|
||||
|
||||
.cel-sign,
|
||||
.cel-price {
|
||||
font-family: inherit;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
stroke: none;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.cel-sign { font-size: 21px; fill: #d1483f; letter-spacing: 0.5px; }
|
||||
.cel-price { font-size: 12px; fill: #5a3ea8; }
|
||||
|
||||
.cel-rays { animation: cel-spin 72s linear infinite; }
|
||||
@keyframes cel-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.cel-clouds { animation: cel-drift 26s ease-in-out infinite alternate; }
|
||||
@keyframes cel-drift {
|
||||
from { transform: translateX(-14px); }
|
||||
to { transform: translateX(14px); }
|
||||
}
|
||||
|
||||
/* One pair of keyframes serves every figure; --stop places the queue spot. */
|
||||
.cel-walker {
|
||||
animation-name: cel-walk-left;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.cel-walker.from-right { animation-name: cel-walk-right; }
|
||||
.cel-walker.is-hurrying.from-left { animation-name: cel-hurry-left; }
|
||||
.cel-walker.is-hurrying.from-right { animation-name: cel-hurry-right; }
|
||||
|
||||
/* People come to the stand from their own side and leave the way they came,
|
||||
so nobody parks in front of the sign. */
|
||||
@keyframes cel-walk-left {
|
||||
0% { transform: translateX(-150px); }
|
||||
24% { transform: translateX(var(--stop)); }
|
||||
72% { transform: translateX(var(--stop)); }
|
||||
100% { transform: translateX(-150px); }
|
||||
}
|
||||
|
||||
@keyframes cel-walk-right {
|
||||
0% { transform: translateX(800px); }
|
||||
24% { transform: translateX(var(--stop)); }
|
||||
72% { transform: translateX(var(--stop)); }
|
||||
100% { transform: translateX(800px); }
|
||||
}
|
||||
|
||||
@keyframes cel-hurry-left {
|
||||
from { transform: translateX(-150px); }
|
||||
to { transform: translateX(800px); }
|
||||
}
|
||||
|
||||
@keyframes cel-hurry-right {
|
||||
from { transform: translateX(800px); }
|
||||
to { transform: translateX(-150px); }
|
||||
}
|
||||
|
||||
.cel-bob { animation: cel-bob 0.46s ease-in-out infinite alternate; }
|
||||
@keyframes cel-bob {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
.leg-a,
|
||||
.leg-b { animation: cel-step 0.46s ease-in-out infinite alternate; }
|
||||
.leg-a { transform-origin: -6px 272px; }
|
||||
.leg-b { transform-origin: 6px 272px; animation-direction: alternate-reverse; }
|
||||
|
||||
@keyframes cel-step {
|
||||
from { transform: rotate(-20deg); }
|
||||
to { transform: rotate(20deg); }
|
||||
}
|
||||
|
||||
.cel-fan { transform-origin: 16px 240px; animation: cel-fan 0.3s ease-in-out infinite alternate; }
|
||||
@keyframes cel-fan {
|
||||
from { transform: rotate(-24deg); }
|
||||
to { transform: rotate(24deg); }
|
||||
}
|
||||
|
||||
.cel-rain { animation: cel-rain 0.42s linear infinite; }
|
||||
@keyframes cel-rain {
|
||||
from { transform: translate(9px, -30px); }
|
||||
to { transform: translate(0, 0); }
|
||||
}
|
||||
|
||||
.cel-bolt { opacity: 0; animation: bolt 4.5s steps(1, end) infinite; }
|
||||
.cel-flash { opacity: 0; animation: cel-flash 4.5s steps(1, end) infinite; }
|
||||
@keyframes cel-flash {
|
||||
0%, 6% { opacity: 0; }
|
||||
7%, 8% { opacity: 0.55; }
|
||||
9%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.cel-shimmer { animation: cel-heat 3.2s ease-in-out infinite alternate; }
|
||||
@keyframes cel-heat {
|
||||
from { transform: translateX(-10px); opacity: 0.32; }
|
||||
to { transform: translateX(10px); opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Fields, reports, buttons
|
||||
===================================================================== */
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
@@ -219,21 +552,28 @@
|
||||
padding: 0.1em 0;
|
||||
}
|
||||
|
||||
.field-label { color: var(--atari-text); }
|
||||
.field-label { color: var(--ink); }
|
||||
|
||||
.field-input {
|
||||
width: 7ch;
|
||||
font: inherit;
|
||||
text-transform: uppercase;
|
||||
text-align: right;
|
||||
color: var(--atari-screen-dark);
|
||||
background: var(--atari-text);
|
||||
color: var(--field-fg);
|
||||
background: var(--field-bg);
|
||||
border: 0;
|
||||
border-radius: 0.15em;
|
||||
padding: 0.1em 0.3em;
|
||||
outline: 0.13em solid transparent;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .field-input {
|
||||
border-radius: 0.5em;
|
||||
border: 3px solid var(--outline);
|
||||
font-weight: 800;
|
||||
box-shadow: 3px 3px 0 var(--outline);
|
||||
}
|
||||
|
||||
.field-input-name {
|
||||
width: 14ch;
|
||||
text-align: left;
|
||||
@@ -241,16 +581,14 @@
|
||||
}
|
||||
|
||||
.field-input::placeholder { color: #5a6ba8; }
|
||||
.field-input:focus { outline-color: var(--atari-yellow); }
|
||||
.field-input:focus { outline-color: var(--accent); }
|
||||
|
||||
.field-note {
|
||||
min-width: 7ch;
|
||||
text-align: right;
|
||||
color: var(--atari-yellow);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ reports */
|
||||
|
||||
.report-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -261,14 +599,12 @@
|
||||
|
||||
.report-dots {
|
||||
flex: 1;
|
||||
border-bottom: 1px dotted rgba(185, 207, 255, 0.35);
|
||||
border-bottom: 1px dotted var(--dots);
|
||||
transform: translateY(-0.25em);
|
||||
}
|
||||
|
||||
.report-value { white-space: nowrap; }
|
||||
.report-row.strong { color: var(--atari-bright); font-weight: 700; }
|
||||
|
||||
/* ------------------------------------------------------- high scores */
|
||||
.report-row.strong { color: var(--ink-bright); font-weight: 700; }
|
||||
|
||||
.score-days {
|
||||
white-space: nowrap;
|
||||
@@ -276,18 +612,38 @@
|
||||
padding-right: 0.8ch;
|
||||
}
|
||||
|
||||
/* Runs added by the summer just finished. */
|
||||
.score-row.is-new,
|
||||
.score-row.is-new .report-value,
|
||||
.score-row.is-new .score-days {
|
||||
color: var(--atari-yellow);
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 8px rgba(247, 222, 90, 0.45);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.score-row.is-new .report-dots { border-bottom-color: rgba(247, 222, 90, 0.4); }
|
||||
|
||||
/* ------------------------------------------------------------ buttons */
|
||||
/* The day's takings filling up while the stand trades. */
|
||||
.till {
|
||||
height: 0.5em;
|
||||
margin: 0.35em auto 0.2em;
|
||||
width: 70%;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.till span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .till {
|
||||
border: 3px solid var(--outline);
|
||||
opacity: 1;
|
||||
box-shadow: 3px 3px 0 var(--outline);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
@@ -297,7 +653,10 @@
|
||||
padding: 0.3em 0;
|
||||
}
|
||||
|
||||
.row.center { justify-content: center; }
|
||||
.row.center {
|
||||
justify-content: center;
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font: inherit;
|
||||
@@ -305,10 +664,10 @@
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
padding: 0.35em 0.85em;
|
||||
border-radius: 0.25em;
|
||||
border: 0.13em solid var(--atari-text);
|
||||
border-radius: var(--btn-radius);
|
||||
border: 0.13em solid var(--ink);
|
||||
background: transparent;
|
||||
color: var(--atari-text);
|
||||
color: var(--ink);
|
||||
transition: transform 60ms ease, background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
@@ -316,16 +675,35 @@
|
||||
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--atari-yellow);
|
||||
border-color: var(--atari-yellow);
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #2a2000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) { background: #fff0a0; }
|
||||
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* Modern buttons are stickers: flat fill, hard outline, hard offset shadow. */
|
||||
html[data-skin='modern'] .btn {
|
||||
border: 3px solid var(--outline);
|
||||
background: #ffffff;
|
||||
color: var(--outline);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: var(--btn-shadow);
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .btn:hover:not(:disabled) { background: #ffe9c9; }
|
||||
|
||||
html[data-skin='modern'] .btn:active:not(:disabled) {
|
||||
transform: translate(3px, 3px);
|
||||
box-shadow: 1px 1px 0 var(--outline);
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .btn-primary { background: var(--accent); }
|
||||
html[data-skin='modern'] .btn-primary:hover:not(:disabled) { background: #ffe884; }
|
||||
|
||||
.btn-ghost {
|
||||
border-color: #3d4453;
|
||||
color: #8fa0c4;
|
||||
@@ -336,6 +714,24 @@
|
||||
|
||||
.btn-ghost:hover:not(:disabled) { background: #1e222c; color: var(--atari-bright); }
|
||||
|
||||
html[data-skin='modern'] .btn-ghost {
|
||||
background: rgba(255, 246, 230, 0.1);
|
||||
border: 2px solid rgba(255, 246, 230, 0.28);
|
||||
color: #e7dcff;
|
||||
box-shadow: none;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .btn-ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 246, 230, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
html[data-skin='modern'] .btn-ghost:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -349,6 +745,7 @@
|
||||
}
|
||||
|
||||
.seed { color: #566079; margin-left: auto; }
|
||||
html[data-skin='modern'] .seed { color: #9b8ac4; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.seed { margin-left: 0; }
|
||||
|
||||
+68
-13
@@ -1,12 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||
import { FUNK_DAY, FUNK_END, FUNK_TITLE } from './audio/funk'
|
||||
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 { addScores, loadScores, type Score } from './game/highscores'
|
||||
import { HIGH_SCORE_KEY, addScores, loadScores, type Score } from './game/highscores'
|
||||
import { activePlayers, initialState, reducer } from './game/reducer'
|
||||
import type { Decision } from './game/types'
|
||||
import { useSkin } from './skin'
|
||||
import { BootScreen } from './components/BootScreen'
|
||||
import { BriefingScreen } from './components/BriefingScreen'
|
||||
import { Btn, Crt, Fit } from './components/Crt'
|
||||
import { DecideScreen } from './components/DecideScreen'
|
||||
@@ -14,6 +17,7 @@ import { GameOverScreen } from './components/GameOverScreen'
|
||||
import { HighScoreScreen } from './components/HighScoreScreen'
|
||||
import { IntroScreen } from './components/IntroScreen'
|
||||
import { ReportScreen } from './components/ReportScreen'
|
||||
import { TradingScreen } from './components/TradingScreen'
|
||||
import { SetupScreen } from './components/SetupScreen'
|
||||
import { TitleScreen } from './components/TitleScreen'
|
||||
import './App.css'
|
||||
@@ -25,6 +29,7 @@ export default function App() {
|
||||
const [music, setMusic] = useState(true)
|
||||
const [sfx, setSfx] = useState(true)
|
||||
const started = useRef(false)
|
||||
const { skin, toggle: toggleSkin } = useSkin()
|
||||
const [scores, setScores] = useState<Score[]>(loadScores)
|
||||
const [freshScores, setFreshScores] = useState<string[]>([])
|
||||
|
||||
@@ -41,16 +46,17 @@ export default function App() {
|
||||
synth.setSfx(sfx)
|
||||
}, [music, sfx])
|
||||
|
||||
// 1979 gets the chiptune; the remaster gets the band.
|
||||
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
|
||||
const front = state.phase === 'title' || state.phase === 'intro' || state.phase === 'setup'
|
||||
const set =
|
||||
skin === 'modern'
|
||||
? { title: FUNK_TITLE, day: FUNK_DAY, end: FUNK_END }
|
||||
: { title: TITLE_TUNE, day: DAY_TUNE, end: END_TUNE }
|
||||
const tune = state.phase === 'gameover' ? set.end : front ? set.title : set.day
|
||||
synth.playTune(tune, false)
|
||||
}, [state.phase])
|
||||
}, [state.phase, skin])
|
||||
|
||||
useEffect(() => {
|
||||
synth.setMusic(music)
|
||||
@@ -59,8 +65,27 @@ export default function App() {
|
||||
synth.setSfx(sfx)
|
||||
}, [sfx])
|
||||
|
||||
/**
|
||||
* Two tabs in one browser share localStorage. Nothing else is shared - the
|
||||
* game itself lives entirely in the page - but the table should not go
|
||||
* stale just because the other tab finished a season first.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === HIGH_SCORE_KEY || e.key === null) setScores(loadScores())
|
||||
}
|
||||
window.addEventListener('storage', onStorage)
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
}, [])
|
||||
|
||||
const blip = useCallback(() => synth.blip(), [])
|
||||
|
||||
// Pressing PLAY was the gesture that unlocked audio, so the band can start.
|
||||
const booted = useCallback(() => {
|
||||
wake()
|
||||
dispatch({ type: 'BOOTED' })
|
||||
}, [wake])
|
||||
|
||||
const goSetup = useCallback(() => {
|
||||
wake()
|
||||
synth.select()
|
||||
@@ -102,12 +127,16 @@ export default function App() {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/** The day has finished trading; ring up the takings and open the books. */
|
||||
const closeTill = useCallback(() => {
|
||||
if (state.conditions?.storm) synth.sad()
|
||||
else if (state.results.some((r) => r.profit > 0)) synth.cash()
|
||||
else synth.sad()
|
||||
dispatch({ type: 'SHOW_REPORT' })
|
||||
}, [state.conditions, state.results])
|
||||
|
||||
const nextDay = () => {
|
||||
synth.select()
|
||||
dispatch({ type: 'NEXT_DAY' })
|
||||
@@ -152,7 +181,10 @@ export default function App() {
|
||||
// Only the trading screens have a day, a sky and a till to report on.
|
||||
const status = useMemo(() => {
|
||||
const inPlay =
|
||||
state.phase === 'briefing' || state.phase === 'decide' || state.phase === 'report'
|
||||
state.phase === 'briefing' ||
|
||||
state.phase === 'decide' ||
|
||||
state.phase === 'trading' ||
|
||||
state.phase === 'report'
|
||||
if (!inPlay || !state.conditions) return null
|
||||
return {
|
||||
day: state.day,
|
||||
@@ -186,6 +218,17 @@ export default function App() {
|
||||
>
|
||||
SOUND {sfx ? 'ON' : 'OFF'}
|
||||
</Btn>
|
||||
<Btn
|
||||
kind="ghost"
|
||||
onClick={() => {
|
||||
wake()
|
||||
synth.select()
|
||||
toggleSkin()
|
||||
}}
|
||||
title={skin === 'crt' ? 'Switch to the remaster' : 'Switch to the 1979 machine'}
|
||||
>
|
||||
{skin === 'crt' ? 'REMASTER' : '1979'}
|
||||
</Btn>
|
||||
<Btn kind="ghost" onClick={restart} title="Abandon this run">
|
||||
NEW GAME
|
||||
</Btn>
|
||||
@@ -193,6 +236,8 @@ export default function App() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{state.phase === 'boot' && <BootScreen onLoaded={booted} />}
|
||||
|
||||
{status && (
|
||||
<div className="statusbar">
|
||||
<span>DAY {String(status.day).padStart(2, '0')}</span>
|
||||
@@ -237,6 +282,16 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.phase === 'trading' && state.conditions && (
|
||||
<TradingScreen
|
||||
key={state.day}
|
||||
results={state.results}
|
||||
players={state.players}
|
||||
conditions={state.conditions}
|
||||
onDone={closeTill}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.phase === 'report' && state.conditions && (
|
||||
<ReportScreen
|
||||
key={state.day}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { SKIN_KEY, SkinContext, readSkin, writeSkin, type Skin, type SkinApi } from './skin'
|
||||
|
||||
export function SkinProvider({ children }: { children: ReactNode }) {
|
||||
const [skin, setSkinState] = useState<Skin>(readSkin)
|
||||
|
||||
const setSkin = useCallback((s: Skin) => {
|
||||
setSkinState(s)
|
||||
writeSkin(s)
|
||||
}, [])
|
||||
|
||||
// The attribute lives on <html> so the page background can follow the skin.
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.skin = skin
|
||||
}, [skin])
|
||||
|
||||
// Keep sibling tabs in the same browser in step.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === SKIN_KEY || e.key === null) setSkinState(readSkin())
|
||||
}
|
||||
window.addEventListener('storage', onStorage)
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<SkinApi>(
|
||||
() => ({ skin, setSkin, toggle: () => setSkin(skin === 'crt' ? 'modern' : 'crt') }),
|
||||
[skin, setSkin],
|
||||
)
|
||||
|
||||
return <SkinContext value={value}>{children}</SkinContext>
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Track, Tune } from './synth'
|
||||
|
||||
/**
|
||||
* The remaster's band. D dorian, light shuffle, everything syncopated:
|
||||
* a slap-ish filtered bass, clavinet stabs off the beat, and a kit that
|
||||
* leaves the downbeat alone more often than it hits it.
|
||||
*
|
||||
* Patterns are written in sixteenths, sixteen to a bar.
|
||||
*/
|
||||
const bar = (...steps: string[]) => steps
|
||||
|
||||
const KIT_GAIN = { kick: 0.5, snare: 0.32, hat: 0.12 }
|
||||
|
||||
const kick = (notes: string[]): Track => ({ wave: 'noise', gain: KIT_GAIN.kick, notes })
|
||||
const snare = (notes: string[]): Track => ({ wave: 'noise', gain: KIT_GAIN.snare, notes })
|
||||
const hats = (notes: string[]): Track => ({ wave: 'noise', gain: KIT_GAIN.hat, notes })
|
||||
|
||||
/** Sixteenth hats with the off-beats ducked, so the groove breathes. */
|
||||
const HAT_BAR = bar('H', 'h', 'H', 'h', 'H', 'h', 'H', 'h', 'H', 'h', 'H', 'h', 'H', 'h', 'O', 'h')
|
||||
const hatPattern = HAT_BAR.map((n) => (n === 'h' ? 'H' : n))
|
||||
|
||||
export const FUNK_DAY: Tune = {
|
||||
bpm: 102,
|
||||
stepsPerBeat: 4,
|
||||
swing: 0.16,
|
||||
tracks: [
|
||||
// Bass: the engine. Short, filtered, and never on every beat.
|
||||
{
|
||||
wave: 'saw',
|
||||
gain: 0.22,
|
||||
gate: 0.55,
|
||||
filter: { from: 1600, to: 260, q: 7 },
|
||||
notes: [
|
||||
...bar('D2', '.', '.', 'D2', '.', '.', 'D3', '.', 'A2', '.', '.', 'C3', '.', 'D2', '.', '.'),
|
||||
...bar('D2', '.', '.', 'D2', '.', 'F2', '.', '.', 'G2', '.', 'A2', '.', '.', 'C3', '.', '.'),
|
||||
...bar('G2', '.', '.', 'G2', '.', '.', 'G3', '.', 'D3', '.', '.', 'F3', '.', 'G2', '.', '.'),
|
||||
...bar('A2', '.', '.', 'A2', '.', 'C3', '.', '.', 'E3', '.', 'G3', '.', 'A3', '.', 'G3', '.'),
|
||||
],
|
||||
},
|
||||
// Clavinet: stabs in the gaps the bass leaves.
|
||||
{
|
||||
wave: 'pulse25',
|
||||
gain: 0.09,
|
||||
gate: 0.3,
|
||||
filter: { from: 3200, to: 900, q: 9 },
|
||||
notes: [
|
||||
...bar('.', '.', 'F4/A4/C5', '.', '.', 'F4/A4/C5', '.', '.', '.', '.', 'E4/A4/C5', '.', '.', '.', '.', '.'),
|
||||
...bar('.', '.', 'F4/A4/C5', '.', '.', '.', '.', 'F4/A4/D5', '.', '.', '.', '.', 'E4/G4/C5', '.', '.', '.'),
|
||||
...bar('.', '.', 'G4/B4/D5', '.', '.', 'G4/B4/D5', '.', '.', '.', '.', 'F4/B4/D5', '.', '.', '.', '.', '.'),
|
||||
...bar('.', '.', 'E4/A4/C5', '.', '.', '.', 'E4/G4/C5', '.', '.', 'E4/A4/C5', '.', '.', '.', '.', '.', '.'),
|
||||
],
|
||||
},
|
||||
// A thin lead line that comes and goes.
|
||||
{
|
||||
wave: 'pulse12',
|
||||
gain: 0.07,
|
||||
gate: 0.7,
|
||||
notes: [
|
||||
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'D5', '.', 'F5', '.', 'E5', '=', '.', '.'),
|
||||
...bar('.', '.', '.', '.', 'C5', '.', 'D5', '=', '=', '.', '.', '.', '.', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'G5', '.', 'F5', '.', 'D5', '=', '.', '.'),
|
||||
...bar('C5', '.', 'D5', '.', 'E5', '=', '=', '.', '.', '.', '.', '.', 'A4', '.', 'C5', '.'),
|
||||
],
|
||||
},
|
||||
kick([
|
||||
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
|
||||
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', 'K', '.', '.'),
|
||||
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
|
||||
...bar('K', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', 'K', '.'),
|
||||
]),
|
||||
snare([
|
||||
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', 'C', 'S', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', 'S', '.', '.', 'C', '.', '.', '.', '.', 'S', '.', 'S', 'S'),
|
||||
]),
|
||||
hats([...hatPattern, ...hatPattern, ...hatPattern, ...hatPattern]),
|
||||
],
|
||||
}
|
||||
|
||||
/** Title theme: the same band, louder and showing off. */
|
||||
export const FUNK_TITLE: Tune = {
|
||||
bpm: 108,
|
||||
stepsPerBeat: 4,
|
||||
swing: 0.15,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'saw',
|
||||
gain: 0.24,
|
||||
gate: 0.5,
|
||||
filter: { from: 2200, to: 300, q: 8 },
|
||||
notes: [
|
||||
...bar('D2', '.', 'D3', '.', '.', 'D2', '.', '.', 'A2', '.', 'C3', '.', 'D3', '.', 'A2', '.'),
|
||||
...bar('D2', '.', 'D3', '.', '.', 'D2', '.', 'F2', '.', 'G2', '.', 'A2', '.', 'C3', '.', '.'),
|
||||
...bar('B2', '.', 'B3', '.', '.', 'B2', '.', '.', 'F3', '.', 'A3', '.', 'B3', '.', 'F3', '.'),
|
||||
...bar('G2', '.', 'G3', '.', '.', 'G2', '.', 'A2', '.', 'B2', '.', 'C3', '.', 'C3', 'C3', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'pulse25',
|
||||
gain: 0.11,
|
||||
gate: 0.26,
|
||||
filter: { from: 4200, to: 1100, q: 10 },
|
||||
notes: [
|
||||
...bar('.', 'F4/A4/C5', '.', '.', 'F4/A4/C5', '.', '.', '.', '.', 'E4/A4/C5', '.', '.', '.', 'F4/A4/C5', '.', '.'),
|
||||
...bar('.', 'F4/A4/C5', '.', '.', '.', '.', 'F4/A4/D5', '.', '.', '.', 'E4/G4/C5', '.', '.', '.', '.', '.'),
|
||||
...bar('.', 'D4/F4/B4', '.', '.', 'D4/F4/B4', '.', '.', '.', '.', 'D4/F4/A4', '.', '.', '.', 'D4/F4/B4', '.', '.'),
|
||||
...bar('.', 'D4/G4/B4', '.', '.', '.', 'D4/G4/B4', '.', '.', '.', 'E4/G4/C5', '.', '.', '.', '.', '.', '.'),
|
||||
],
|
||||
},
|
||||
// Horn-ish hook.
|
||||
{
|
||||
wave: 'pulse50',
|
||||
gain: 0.1,
|
||||
gate: 0.85,
|
||||
filter: { from: 2600, to: 1400, q: 3 },
|
||||
notes: [
|
||||
...bar('D5', '.', 'F5', '.', 'G5', '=', '=', '.', 'F5', '.', 'D5', '.', 'C5', '=', '.', '.'),
|
||||
...bar('.', '.', 'A4', '.', 'C5', '.', 'D5', '=', '=', '=', '.', '.', '.', '.', '.', '.'),
|
||||
...bar('B4', '.', 'D5', '.', 'F5', '=', '=', '.', 'A5', '.', 'F5', '.', 'D5', '=', '.', '.'),
|
||||
...bar('G5', '.', 'F5', '.', 'D5', '.', 'C5', '.', 'D5', '=', '=', '=', '=', '.', '.', '.'),
|
||||
],
|
||||
},
|
||||
kick([
|
||||
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
|
||||
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', 'K', '.', '.'),
|
||||
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
|
||||
...bar('K', '.', 'K', '.', '.', '.', 'K', '.', '.', 'K', '.', '.', 'K', '.', 'K', '.'),
|
||||
]),
|
||||
snare([
|
||||
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', 'C', '.', '.', 'S', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', 'C', 'S', '.', '.', 'S'),
|
||||
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', 'C', '.', '.', 'S', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', 'S', '.', 'S', '.', '.', '.', 'S', '.', 'S', 'S', '.', 'S'),
|
||||
]),
|
||||
hats([...hatPattern, ...hatPattern, ...hatPattern, ...hatPattern]),
|
||||
],
|
||||
}
|
||||
|
||||
/** Closing theme: slower, fatter, a little smug. */
|
||||
export const FUNK_END: Tune = {
|
||||
bpm: 92,
|
||||
stepsPerBeat: 4,
|
||||
swing: 0.18,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'saw',
|
||||
gain: 0.24,
|
||||
gate: 0.6,
|
||||
filter: { from: 1800, to: 240, q: 7 },
|
||||
notes: [
|
||||
...bar('D2', '.', '.', 'D2', '.', '.', 'A2', '.', 'D3', '.', '.', 'C3', '.', 'A2', '.', '.'),
|
||||
...bar('G2', '.', '.', 'G2', '.', '.', 'D3', '.', 'G3', '.', '.', 'F3', '.', 'D3', '.', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'pulse25',
|
||||
gain: 0.12,
|
||||
gate: 0.9,
|
||||
filter: { from: 3000, to: 1200, q: 5 },
|
||||
notes: [
|
||||
...bar('D5/F5/A5', '=', '=', '=', '.', '.', 'C5/E5/A5', '=', '=', '.', '.', '.', 'A4/D5/F5', '=', '=', '.'),
|
||||
...bar('D5/G5/B5', '=', '=', '=', '.', '.', 'D5/F5/A5', '=', '=', '.', '.', '.', 'C5/E5/G5', '=', '=', '.'),
|
||||
],
|
||||
},
|
||||
kick([
|
||||
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
|
||||
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', 'K', 'K', '.'),
|
||||
]),
|
||||
snare([
|
||||
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', '.', '.'),
|
||||
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', 'S', 'S'),
|
||||
]),
|
||||
hats([...hatPattern, ...hatPattern]),
|
||||
],
|
||||
}
|
||||
+142
-23
@@ -22,14 +22,26 @@ export type Wave = 'pulse12' | 'pulse25' | 'pulse50' | 'triangle' | 'saw' | 'noi
|
||||
export interface Track {
|
||||
wave: Wave
|
||||
gain: number
|
||||
/** One entry per step. '.' rest, '=' sustain previous, otherwise a note. */
|
||||
/**
|
||||
* One entry per step. '.' rest, '=' sustain previous, otherwise a note.
|
||||
* Slashes stack notes into a chord: "F4/A4/C5". On a noise track the
|
||||
* letter picks the drum: K kick, S snare, C clap, H closed hat, O open hat.
|
||||
*/
|
||||
notes: string[]
|
||||
/** Sweeping lowpass, the whole point of a funk bass. */
|
||||
filter?: { from: number; to: number; q?: number }
|
||||
/** Fraction of the note's length actually sounded; low values are stabs. */
|
||||
gate?: number
|
||||
/** Cents, for a fatter unison. */
|
||||
detune?: number
|
||||
}
|
||||
|
||||
export interface Tune {
|
||||
bpm: number
|
||||
stepsPerBeat: number
|
||||
tracks: Track[]
|
||||
/** 0 is straight, ~0.15 is a light funk shuffle. Delays every other step. */
|
||||
swing?: number
|
||||
}
|
||||
|
||||
/** Fourier series for a pulse wave of the given duty cycle. */
|
||||
@@ -112,6 +124,7 @@ export class Synth {
|
||||
at: number,
|
||||
dur: number,
|
||||
gain: number,
|
||||
opts: { filter?: Track['filter']; detune?: number } = {},
|
||||
) {
|
||||
const ctx = this.ensure()
|
||||
const osc = ctx.createOscillator()
|
||||
@@ -119,6 +132,7 @@ export class Synth {
|
||||
else if (wave === 'saw') osc.type = 'sawtooth'
|
||||
else osc.setPeriodicWave(this.waves[wave] ?? this.waves.pulse50!)
|
||||
osc.frequency.setValueAtTime(freq, at)
|
||||
if (opts.detune) osc.detune.setValueAtTime(opts.detune, at)
|
||||
|
||||
const env = ctx.createGain()
|
||||
const peak = Math.max(0.0001, gain)
|
||||
@@ -127,38 +141,78 @@ export class Synth {
|
||||
env.gain.setValueAtTime(peak, at + Math.max(0.02, dur * 0.6))
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
|
||||
|
||||
osc.connect(env).connect(dest)
|
||||
let node: AudioNode = osc
|
||||
if (opts.filter) {
|
||||
const lp = ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.Q.value = opts.filter.q ?? 6
|
||||
lp.frequency.setValueAtTime(opts.filter.from, at)
|
||||
lp.frequency.exponentialRampToValueAtTime(
|
||||
Math.max(60, opts.filter.to),
|
||||
at + Math.max(0.05, dur),
|
||||
)
|
||||
osc.connect(lp)
|
||||
node = lp
|
||||
}
|
||||
|
||||
node.connect(env).connect(dest)
|
||||
osc.start(at)
|
||||
osc.stop(at + dur + 0.02)
|
||||
}
|
||||
|
||||
private percussion(dest: AudioNode, kind: string, at: number, gain: number) {
|
||||
/** A small kit: pitched-sine kick, noise-and-tone snare, clap, two hats. */
|
||||
private drum(dest: AudioNode, kind: string, at: number, gain: number) {
|
||||
const ctx = this.ensure()
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = this.noiseBuffer
|
||||
|
||||
if (kind === 'K') {
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(130, at)
|
||||
osc.frequency.exponentialRampToValueAtTime(42, at + 0.11)
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(gain * 1.5, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.24)
|
||||
osc.connect(env).connect(dest)
|
||||
osc.start(at)
|
||||
osc.stop(at + 0.26)
|
||||
return
|
||||
}
|
||||
|
||||
const noise = ctx.createBufferSource()
|
||||
noise.buffer = this.noiseBuffer
|
||||
const filter = ctx.createBiquadFilter()
|
||||
const env = ctx.createGain()
|
||||
let dur = 0.05
|
||||
|
||||
let dur = 0.06
|
||||
if (kind === 'K') {
|
||||
filter.type = 'lowpass'
|
||||
filter.frequency.value = 220
|
||||
dur = 0.12
|
||||
} else if (kind === 'S') {
|
||||
if (kind === 'S' || kind === 'C') {
|
||||
filter.type = 'bandpass'
|
||||
filter.frequency.value = 1800
|
||||
dur = 0.12
|
||||
filter.frequency.value = kind === 'S' ? 1900 : 1300
|
||||
filter.Q.value = kind === 'S' ? 0.9 : 2.4
|
||||
dur = kind === 'S' ? 0.16 : 0.1
|
||||
if (kind === 'S') {
|
||||
// A little body under the crack.
|
||||
const tone = ctx.createOscillator()
|
||||
tone.type = 'triangle'
|
||||
tone.frequency.setValueAtTime(210, at)
|
||||
tone.frequency.exponentialRampToValueAtTime(150, at + 0.09)
|
||||
const tenv = ctx.createGain()
|
||||
tenv.gain.setValueAtTime(gain * 0.6, at)
|
||||
tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.1)
|
||||
tone.connect(tenv).connect(dest)
|
||||
tone.start(at)
|
||||
tone.stop(at + 0.12)
|
||||
}
|
||||
} else {
|
||||
filter.type = 'highpass'
|
||||
filter.frequency.value = 6000
|
||||
dur = 0.04
|
||||
filter.frequency.value = 7200
|
||||
dur = kind === 'O' ? 0.22 : 0.035
|
||||
}
|
||||
|
||||
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)
|
||||
noise.connect(filter).connect(env).connect(dest)
|
||||
noise.start(at)
|
||||
noise.stop(at + dur + 0.02)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- sequencer
|
||||
@@ -189,8 +243,12 @@ export class Synth {
|
||||
if (!ctx || !tune) return
|
||||
const stepDur = 60 / tune.bpm / tune.stepsPerBeat
|
||||
const length = Math.max(...tune.tracks.map((t) => t.notes.length))
|
||||
const swing = tune.swing ?? 0
|
||||
|
||||
while (this.nextStepTime < ctx.currentTime + 0.2) {
|
||||
// A shuffle pushes every other step late without moving the downbeats.
|
||||
const at = this.nextStepTime + (this.step % 2 === 1 ? swing * stepDur : 0)
|
||||
|
||||
for (const track of tune.tracks) {
|
||||
const note = track.notes[this.step % track.notes.length]
|
||||
if (!note || note === '.' || note === '=') continue
|
||||
@@ -201,13 +259,20 @@ export class Synth {
|
||||
if (track.notes[(this.step + i) % track.notes.length] === '=') held++
|
||||
else break
|
||||
}
|
||||
const dur = held * stepDur * 0.95
|
||||
const dur = held * stepDur * (track.gate ?? 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.drum(this.musicBus, note, at, track.gain)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const part of note.split('/')) {
|
||||
const f = noteToFreq(part)
|
||||
if (!f) continue
|
||||
this.voice(this.musicBus, track.wave, f, at, dur, track.gain, {
|
||||
filter: track.filter,
|
||||
detune: track.detune,
|
||||
})
|
||||
}
|
||||
}
|
||||
this.nextStepTime += stepDur
|
||||
@@ -230,6 +295,11 @@ export class Synth {
|
||||
this.seq([['E5', 0.05]], 'pulse12', 0.15)
|
||||
}
|
||||
|
||||
/** A quiet register blip, played once per few glasses as the day runs. */
|
||||
tick() {
|
||||
this.seq([['B5', 0.035]], 'pulse12', 0.09)
|
||||
}
|
||||
|
||||
select() {
|
||||
this.seq([['C5', 0.05], ['G5', 0.09]], 'pulse25', 0.18)
|
||||
}
|
||||
@@ -285,6 +355,55 @@ export class Synth {
|
||||
osc.stop(at + 0.7)
|
||||
}
|
||||
|
||||
/** The Atari's key-click beep. */
|
||||
beep() {
|
||||
this.seq([['A5', 0.06]], 'pulse50', 0.14)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cassette load: bandpassed hiss under a square wave flipping between two
|
||||
* tones, which is roughly what audio FSK sounded like coming off tape.
|
||||
*/
|
||||
tape(seconds: number): () => void {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.02
|
||||
const until = at + seconds
|
||||
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'square'
|
||||
for (let t = at, flip = 0; t < until; t += 0.018, flip++) {
|
||||
osc.frequency.setValueAtTime(flip % 2 === 0 ? 1300 : 2400, t)
|
||||
}
|
||||
const oscGain = ctx.createGain()
|
||||
oscGain.gain.value = 0.045
|
||||
|
||||
const hiss = ctx.createBufferSource()
|
||||
hiss.buffer = this.noiseBuffer
|
||||
hiss.loop = true
|
||||
const band = ctx.createBiquadFilter()
|
||||
band.type = 'bandpass'
|
||||
band.frequency.value = 1800
|
||||
band.Q.value = 0.7
|
||||
const hissGain = ctx.createGain()
|
||||
hissGain.gain.value = 0.05
|
||||
|
||||
osc.connect(oscGain).connect(this.sfxBus)
|
||||
hiss.connect(band).connect(hissGain).connect(this.sfxBus)
|
||||
osc.start(at)
|
||||
hiss.start(at)
|
||||
osc.stop(until)
|
||||
hiss.stop(until)
|
||||
|
||||
return () => {
|
||||
try {
|
||||
osc.stop()
|
||||
hiss.stop()
|
||||
} catch {
|
||||
// Already stopped; nothing to do.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fanfare() {
|
||||
this.seq(
|
||||
[['C5', 0.11], ['E5', 0.11], ['G5', 0.11], ['C6', 0.11], ['G5', 0.11], ['C6', 0.4]],
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { synth } from '../audio/synth'
|
||||
import { Btn } from './Crt'
|
||||
|
||||
const LOAD_SECONDS = 4.2
|
||||
|
||||
/**
|
||||
* Loading from cassette, the way the listing in the magazine arrived. It is
|
||||
* also where the audio gets unlocked: browsers want a gesture before they
|
||||
* will make a sound, and pressing PLAY is exactly that gesture.
|
||||
*/
|
||||
export function BootScreen({ onLoaded }: { onLoaded: () => void }) {
|
||||
const [stage, setStage] = useState<'ready' | 'loading' | 'done'>('ready')
|
||||
const [progress, setProgress] = useState(0)
|
||||
const stopTape = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => () => stopTape.current?.(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (stage !== 'loading') return
|
||||
const startedAt = performance.now()
|
||||
let raf = 0
|
||||
const frame = (now: number) => {
|
||||
const p = Math.min(1, (now - startedAt) / (LOAD_SECONDS * 1000))
|
||||
setProgress(p)
|
||||
if (p < 1) raf = requestAnimationFrame(frame)
|
||||
else {
|
||||
stopTape.current?.()
|
||||
setStage('done')
|
||||
synth.beep()
|
||||
window.setTimeout(onLoaded, 650)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [stage, onLoaded])
|
||||
|
||||
const play = () => {
|
||||
synth.ensure()
|
||||
synth.beep()
|
||||
stopTape.current = synth.tape(LOAD_SECONDS)
|
||||
setStage('loading')
|
||||
}
|
||||
|
||||
const skip = () => {
|
||||
stopTape.current?.()
|
||||
onLoaded()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="boot">
|
||||
{/* Loader stripes bleed off both edges, the way tape loaders did. */}
|
||||
{stage === 'loading' && <div className="boot-stripes" aria-hidden />}
|
||||
|
||||
<div className="boot-text">
|
||||
<div>ATARI 8K BASIC REV. B</div>
|
||||
<div> </div>
|
||||
<div>READY</div>
|
||||
<div>CLOAD</div>
|
||||
|
||||
{stage === 'ready' && (
|
||||
<>
|
||||
<div> </div>
|
||||
<div>PRESS PLAY ON RECORDER, THEN RETURN.</div>
|
||||
<div className="boot-cursor">█</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage !== 'ready' && (
|
||||
<>
|
||||
<div> </div>
|
||||
<div>
|
||||
LOADING "LEMONADE"
|
||||
{stage === 'loading' ? '.'.repeat(1 + Math.floor(progress * 12)) : ''}
|
||||
</div>
|
||||
{stage === 'done' && (
|
||||
<>
|
||||
<div> </div>
|
||||
<div>READY</div>
|
||||
<div className="boot-cursor">█</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="boot-controls">
|
||||
{stage === 'ready' ? (
|
||||
<Btn kind="primary" onClick={play}>
|
||||
► PRESS PLAY
|
||||
</Btn>
|
||||
) : (
|
||||
stage === 'loading' && (
|
||||
<Btn kind="ghost" onClick={skip}>
|
||||
SKIP THE LOAD
|
||||
</Btn>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WEATHER, costMessage } from '../game/constants'
|
||||
import { streetBusyness } from '../game/engine'
|
||||
import type { DayConditions } from '../game/types'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { Scene } from './Scene'
|
||||
@@ -15,7 +16,7 @@ export function BriefingScreen({
|
||||
return (
|
||||
<div className="stack">
|
||||
<Line className="center inv-line">DAY {conditions.day} IN LEMONSVILLE</Line>
|
||||
<Scene conditions={{ ...conditions, storm: false }} />
|
||||
<Scene conditions={{ ...conditions, storm: false }} traffic={streetBusyness(conditions)} />
|
||||
<Line className="center accent">
|
||||
WEATHER REPORT: {WEATHER[conditions.weather].label}
|
||||
</Line>
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { DayConditions } from '../game/types'
|
||||
|
||||
/**
|
||||
* Cel-shaded lemonade stand: flat colour, one hard shadow per form, one bold
|
||||
* outline, light coming from the upper right. Everything is plain geometry so
|
||||
* the whole scene stays legible at any size.
|
||||
*/
|
||||
|
||||
const INK = '#33234f'
|
||||
|
||||
interface Mood {
|
||||
skyTop: string
|
||||
skyBottom: string
|
||||
hillFar: string
|
||||
hillNear: string
|
||||
ground: string
|
||||
groundShade: string
|
||||
/** Painted over the whole scene to tie the palette together. */
|
||||
wash: string
|
||||
washOpacity: number
|
||||
shadow: number
|
||||
}
|
||||
|
||||
const MOODS: Record<string, Mood> = {
|
||||
sunny: {
|
||||
skyTop: '#3AA9F5',
|
||||
skyBottom: '#BFEAFF',
|
||||
hillFar: '#7FD1A6',
|
||||
hillNear: '#5CBF83',
|
||||
ground: '#86DE68',
|
||||
groundShade: '#4FA83D',
|
||||
wash: '#FFE9A8',
|
||||
washOpacity: 0.1,
|
||||
shadow: 0.28,
|
||||
},
|
||||
cloudy: {
|
||||
skyTop: '#7C93B8',
|
||||
skyBottom: '#C6D3E2',
|
||||
hillFar: '#8FB5A2',
|
||||
hillNear: '#6E9C82',
|
||||
ground: '#7FC46A',
|
||||
groundShade: '#4E8B54',
|
||||
wash: '#9FB3CE',
|
||||
washOpacity: 0.22,
|
||||
shadow: 0.12,
|
||||
},
|
||||
hot: {
|
||||
skyTop: '#F2792B',
|
||||
skyBottom: '#FFD68A',
|
||||
hillFar: '#D8A25C',
|
||||
hillNear: '#B77F42',
|
||||
ground: '#D9C25E',
|
||||
groundShade: '#A38B36',
|
||||
wash: '#FF9E3D',
|
||||
washOpacity: 0.2,
|
||||
shadow: 0.34,
|
||||
},
|
||||
storm: {
|
||||
skyTop: '#231B3D',
|
||||
skyBottom: '#4C4370',
|
||||
hillFar: '#3C4463',
|
||||
hillNear: '#2E3550',
|
||||
ground: '#4A6B52',
|
||||
groundShade: '#2F4838',
|
||||
wash: '#2A2350',
|
||||
washOpacity: 0.34,
|
||||
shadow: 0.08,
|
||||
},
|
||||
}
|
||||
|
||||
const SHIRTS = ['#FF5D8F', '#2EC4B6', '#FFB627', '#8E7DFF', '#FF7A29', '#4FC3F7']
|
||||
const SKINS = ['#FFCDA8', '#E0A87C', '#B87A54', '#7C4F32']
|
||||
const HAIR = ['#5A3B2E', '#1F1B24', '#C97B3C', '#8E6BA8']
|
||||
const MAX_WALKERS = 6
|
||||
|
||||
export function CelScene({
|
||||
conditions,
|
||||
price,
|
||||
traffic = 0,
|
||||
variant = 'full',
|
||||
}: {
|
||||
conditions: DayConditions
|
||||
price?: number
|
||||
/** How busy the street is, 0 to 1. Drives how many people walk on. */
|
||||
traffic?: number
|
||||
variant?: 'full' | 'strip'
|
||||
}) {
|
||||
const { weather, heatWave, streetCrew, storm } = conditions
|
||||
const key = storm ? 'storm' : weather
|
||||
const m = MOODS[key] ?? MOODS.sunny
|
||||
const daylight = !storm && weather !== 'cloudy'
|
||||
|
||||
const walkers = useMemo(() => {
|
||||
const busy = Math.max(0, Math.min(1, traffic))
|
||||
// In a downpour nobody stops; at most one soul hurries past.
|
||||
const count = storm ? (busy > 0 ? 1 : 0) : Math.round(busy * MAX_WALKERS)
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
i,
|
||||
// Alternating sides, and a queue spot short of the counter.
|
||||
fromLeft: i % 2 === 0,
|
||||
stop: i % 2 === 0 ? 168 - (i >> 1) * 36 : 472 + (i >> 1) * 36,
|
||||
delay: i * (storm ? 1.1 : 2.3),
|
||||
duration: storm ? 4.5 : 11 + (i % 3),
|
||||
shirt: SHIRTS[i % SHIRTS.length],
|
||||
skin: SKINS[(i * 3) % SKINS.length],
|
||||
hair: HAIR[(i * 5) % HAIR.length],
|
||||
}))
|
||||
}, [traffic, storm])
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`cel ${variant === 'strip' ? 'cel-strip' : ''} ${storm ? 'is-storm' : ''} ${heatWave ? 'is-heat' : ''}`}
|
||||
viewBox="0 0 640 300"
|
||||
role="img"
|
||||
aria-label={`${weather} day at the lemonade stand`}
|
||||
preserveAspectRatio={variant === 'strip' ? 'xMidYMax slice' : 'xMidYMid meet'}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="cel-sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={m.skyTop} />
|
||||
<stop offset="100%" stopColor={m.skyBottom} />
|
||||
</linearGradient>
|
||||
<clipPath id="cel-awning">
|
||||
<path d="M168 110 H472 V152 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 Z" />
|
||||
</clipPath>
|
||||
<clipPath id="cel-jug">
|
||||
<rect x="286" y="170" width="58" height="50" rx="9" />
|
||||
</clipPath>
|
||||
<clipPath id="cel-frame">
|
||||
<rect x="0" y="0" width="640" height="300" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g clipPath="url(#cel-frame)">
|
||||
<rect width="640" height="300" fill="url(#cel-sky)" />
|
||||
|
||||
{/* --- sky --------------------------------------------------- */}
|
||||
{daylight ? (
|
||||
<g className="cel-sun">
|
||||
<g className="cel-rays" style={{ transformOrigin: '536px 68px' }}>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x="532"
|
||||
y="8"
|
||||
width="8"
|
||||
height="26"
|
||||
rx="4"
|
||||
fill={heatWave ? '#FFF3C4' : '#FFE27A'}
|
||||
opacity="0.9"
|
||||
transform={`rotate(${i * 30} 536 68)`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<circle cx="536" cy="68" r={heatWave ? 46 : 38} fill="#FFD93D" />
|
||||
<path
|
||||
d={`M536 ${68 - (heatWave ? 46 : 38)} a${heatWave ? 46 : 38} ${heatWave ? 46 : 38} 0 0 0 0 ${(heatWave ? 46 : 38) * 2} a${heatWave ? 46 : 38} ${heatWave ? 46 : 38} 0 0 0 0 -${(heatWave ? 46 : 38) * 2} Z`}
|
||||
fill="#F5B62B"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<circle cx="536" cy="68" r={heatWave ? 46 : 38} fill="none" stroke={INK} strokeWidth="4" />
|
||||
</g>
|
||||
) : (
|
||||
<g className="cel-clouds">
|
||||
{[
|
||||
{ x: 120, y: 62, s: 1.1 },
|
||||
{ x: 380, y: 44, s: 0.85 },
|
||||
{ x: 540, y: 78, s: 1 },
|
||||
].map((c, i) => (
|
||||
<g key={i} transform={`translate(${c.x} ${c.y}) scale(${c.s})`}>
|
||||
<path
|
||||
d="M-62 18 a26 26 0 0 1 12 -48 a34 34 0 0 1 62 -12 a28 28 0 0 1 46 22 a22 22 0 0 1 -6 38 Z"
|
||||
fill={storm ? '#5A5480' : '#F3F7FF'}
|
||||
stroke={INK}
|
||||
strokeWidth="4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M-62 18 h114 a22 22 0 0 0 6 -18 q-60 14 -120 0 Z"
|
||||
fill={storm ? '#443E68' : '#D5E2F5'}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* --- land -------------------------------------------------- */}
|
||||
<path d="M0 214 q90 -46 190 -14 q120 38 240 -8 q120 -44 210 6 V300 H0 Z" fill={m.hillFar} />
|
||||
<path d="M0 236 q120 -32 250 -2 q140 32 260 -10 q80 -24 130 4 V300 H0 Z" fill={m.hillNear} />
|
||||
<g stroke={INK} strokeWidth="4" strokeLinejoin="round">
|
||||
{[52, 96, 566, 610].map((x, i) => (
|
||||
<g key={x}>
|
||||
<rect x={x - 4} y={228 - (i % 2) * 8} width="8" height="26" fill="#8A5A2B" />
|
||||
<circle cx={x} cy={216 - (i % 2) * 8} r={22 - (i % 2) * 3} fill={m.hillFar} />
|
||||
<path
|
||||
d={`M${x} ${194 - (i % 2) * 8} a${22 - (i % 2) * 3} ${22 - (i % 2) * 3} 0 0 1 0 ${(22 - (i % 2) * 3) * 2} Z`}
|
||||
fill={m.hillNear}
|
||||
stroke="none"
|
||||
opacity="0.65"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
<rect x="0" y="252" width="640" height="48" fill={m.ground} />
|
||||
<rect x="0" y="252" width="640" height="8" fill={m.groundShade} opacity="0.5" />
|
||||
<rect x="0" y="248" width="640" height="5" fill={INK} opacity="0.35" />
|
||||
|
||||
{/* --- the stand --------------------------------------------- */}
|
||||
<g stroke={INK} strokeWidth="4" strokeLinejoin="round">
|
||||
<ellipse cx="320" cy="286" rx="150" ry="13" fill={INK} opacity={m.shadow} stroke="none" />
|
||||
|
||||
<rect x="192" y="150" width="16" height="98" fill="#C98A4B" />
|
||||
<rect x="432" y="150" width="16" height="98" fill="#C98A4B" />
|
||||
<rect x="192" y="150" width="6" height="98" fill="#9A6435" stroke="none" />
|
||||
<rect x="432" y="150" width="6" height="98" fill="#9A6435" stroke="none" />
|
||||
|
||||
{/* counter */}
|
||||
<rect x="182" y="214" width="276" height="18" rx="5" fill="#E0A867" />
|
||||
<rect x="190" y="232" width="260" height="52" fill="#C98A4B" />
|
||||
<rect x="190" y="232" width="14" height="52" fill="#9A6435" stroke="none" />
|
||||
|
||||
{/* hand-painted sign board */}
|
||||
<rect x="212" y="238" width="216" height="40" rx="5" fill="#FFF6DC" />
|
||||
<rect x="212" y="266" width="216" height="12" rx="5" fill="#EBDCB4" stroke="none" />
|
||||
<text className="cel-sign" x="320" y="258" textAnchor="middle">
|
||||
LEMONADE
|
||||
</text>
|
||||
{price !== undefined && (
|
||||
<text className="cel-price" x="320" y="274" textAnchor="middle">
|
||||
{price}¢ A GLASS
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* jug of lemonade */}
|
||||
<g>
|
||||
<path d="M344 182 q22 6 0 26" fill="none" stroke={INK} strokeWidth="9" />
|
||||
<path d="M344 182 q22 6 0 26" fill="none" stroke="#DFF6FF" strokeWidth="4" />
|
||||
<rect x="286" y="170" width="58" height="50" rx="9" fill="#EAF9FF" />
|
||||
<g clipPath="url(#cel-jug)">
|
||||
<rect x="286" y="186" width="58" height="34" fill="#FFD93D" stroke="none" />
|
||||
<rect x="286" y="186" width="58" height="5" fill="#F5B62B" stroke="none" />
|
||||
<g fill="#FFFFFF" opacity="0.85" stroke="none">
|
||||
<rect x="296" y="192" width="11" height="11" rx="2" transform="rotate(-14 301 197)" />
|
||||
<rect x="318" y="200" width="10" height="10" rx="2" transform="rotate(12 323 205)" />
|
||||
</g>
|
||||
<rect x="286" y="170" width="12" height="50" fill="#FFFFFF" opacity="0.5" stroke="none" />
|
||||
</g>
|
||||
<rect x="286" y="170" width="58" height="50" rx="9" fill="none" />
|
||||
<rect x="282" y="164" width="66" height="10" rx="5" fill="#DFF6FF" />
|
||||
</g>
|
||||
|
||||
{/* stack of cups and a bowl of lemons */}
|
||||
<g>
|
||||
<path d="M226 214 l4 -22 h20 l4 22 Z" fill="#FFF6DC" />
|
||||
<path d="M226 200 h28" stroke={INK} strokeWidth="3" />
|
||||
<ellipse cx="392" cy="210" rx="30" ry="9" fill="#DFF6FF" />
|
||||
<circle cx="380" cy="203" r="10" fill="#FFE45E" />
|
||||
<circle cx="398" cy="200" r="11" fill="#FFE45E" />
|
||||
<path d="M390 194 a11 11 0 0 1 15 10" fill="none" stroke="#F5C518" strokeWidth="5" />
|
||||
</g>
|
||||
|
||||
{/* awning */}
|
||||
<g>
|
||||
<path
|
||||
d="M168 110 H472 V152 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 Z"
|
||||
fill="#FF6B6B"
|
||||
/>
|
||||
<g clipPath="url(#cel-awning)" stroke="none">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<rect key={i} x={168 + i * 38} y="106" width="19" height="70" fill="#FFF6DC" />
|
||||
))}
|
||||
<rect x="168" y="106" width="304" height="14" fill={INK} opacity="0.16" />
|
||||
</g>
|
||||
<path
|
||||
d="M168 110 H472 V152 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 Z"
|
||||
fill="none"
|
||||
/>
|
||||
<rect x="160" y="100" width="320" height="14" rx="7" fill="#E0A867" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* --- the street ------------------------------------------- */}
|
||||
{walkers.map((w) => (
|
||||
<g
|
||||
key={w.i}
|
||||
className={`cel-walker ${w.fromLeft ? 'from-left' : 'from-right'} ${storm ? 'is-hurrying' : ''}`}
|
||||
style={{
|
||||
// Custom properties feed the keyframes, so one animation
|
||||
// serves every figure on either side of the stand.
|
||||
['--stop' as string]: `${w.stop}px`,
|
||||
animationDelay: `${w.delay}s`,
|
||||
animationDuration: `${w.duration}s`,
|
||||
}}
|
||||
>
|
||||
<g className="cel-bob">
|
||||
<ellipse cx="0" cy="290" rx="20" ry="6" fill={INK} opacity={m.shadow * 0.7} />
|
||||
<g stroke={INK} strokeWidth="4" strokeLinejoin="round" strokeLinecap="round">
|
||||
<g className="cel-legs">
|
||||
<line className="leg-a" x1="-6" y1="272" x2="-11" y2="288" stroke={INK} />
|
||||
<line className="leg-b" x1="6" y1="272" x2="11" y2="288" stroke={INK} />
|
||||
</g>
|
||||
<rect x="-15" y="232" width="30" height="44" rx="13" fill={w.shirt} />
|
||||
{heatWave && !storm && (
|
||||
<g className="cel-fan">
|
||||
<rect x="16" y="238" width="16" height="5" rx="2" fill="#FFF6DC" />
|
||||
</g>
|
||||
)}
|
||||
<circle cx="0" cy="220" r="16" fill={w.skin} />
|
||||
<path d="M-16 217 a16 16 0 0 1 32 0 q-16 -10 -32 0 Z" fill={w.hair} />
|
||||
<g fill={INK} stroke="none">
|
||||
<circle cx={w.fromLeft ? 3 : -3} cy="222" r="2.2" />
|
||||
<circle cx={w.fromLeft ? 10 : -10} cy="222" r="2.2" />
|
||||
</g>
|
||||
<path
|
||||
d={`M${w.fromLeft ? 2 : -2} 230 q${w.fromLeft ? 5 : -5} 4 ${w.fromLeft ? 9 : -9} 0`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
{storm && (
|
||||
<g>
|
||||
<path d="M-26 196 a26 26 0 0 1 52 0 Z" fill="#2EC4B6" />
|
||||
<line x1="0" y1="196" x2="0" y2="228" />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* --- road works -------------------------------------------- */}
|
||||
{streetCrew && (
|
||||
<g stroke={INK} strokeWidth="4" strokeLinejoin="round">
|
||||
{[68, 572].map((x) => (
|
||||
<g key={x}>
|
||||
<path d={`M${x - 18} 288 L${x} 240 L${x + 18} 288 Z`} fill="#FF7A29" />
|
||||
<path d={`M${x - 11} 270 h22`} stroke="#FFF6DC" strokeWidth="7" />
|
||||
<rect x={x - 24} y="286" width="48" height="10" rx="3" fill="#FF7A29" />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* --- weather overlays -------------------------------------- */}
|
||||
{storm && (
|
||||
<>
|
||||
<g className="cel-rain" stroke="#CFE6FF" strokeWidth="3" strokeLinecap="round" opacity="0.75">
|
||||
{Array.from({ length: 40 }, (_, i) => {
|
||||
const x = (i * 71) % 660
|
||||
const y = (i * 53) % 300
|
||||
return <line key={i} x1={x} y1={y} x2={x - 7} y2={y + 22} />
|
||||
})}
|
||||
</g>
|
||||
<polygon
|
||||
className="cel-bolt"
|
||||
points="470,20 424,120 456,120 428,206 512,96 472,96 506,20"
|
||||
fill="#FFF3A8"
|
||||
stroke={INK}
|
||||
strokeWidth="4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect className="cel-flash" width="640" height="300" fill="#FFFFFF" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{heatWave && (
|
||||
<g className="cel-shimmer" stroke="#FFE9A8" strokeWidth="4" fill="none" opacity="0.5">
|
||||
{[262, 274, 286].map((y) => (
|
||||
<path key={y} d={`M-20 ${y} q22 -9 44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0`} />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
<rect width="640" height="300" fill={m.wash} opacity={m.washOpacity} />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MAX_SIGNS, SIGN_COST } from '../game/constants'
|
||||
import { dollars } from '../game/engine'
|
||||
import { dollars, streetBusyness } from '../game/engine'
|
||||
import type { DayConditions, Decision, Player } from '../game/types'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { Scene } from './Scene'
|
||||
@@ -48,8 +48,10 @@ export function DecideScreen({
|
||||
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."
|
||||
// Kept to one line each: the row below is always reserved, so a message
|
||||
// that wrapped would shift the button after all.
|
||||
if (signCost > player.assets) return 'NOT ENOUGH MONEY FOR THAT MANY SIGNS.'
|
||||
if (total > player.assets) return 'NOT ENOUGH MONEY FOR THAT MANY GLASSES.'
|
||||
if (d.glasses === 0) return 'YOU MUST MAKE AT LEAST ONE GLASS.'
|
||||
return null
|
||||
}, [signCost, total, player.assets, d.glasses])
|
||||
@@ -77,7 +79,11 @@ export function DecideScreen({
|
||||
<Line className="center inv-line">
|
||||
{playerCount > 1 ? `${player.name} - DAY ${conditions.day}` : `DAY ${conditions.day}`}
|
||||
</Line>
|
||||
<Scene conditions={{ ...conditions, storm: false }} price={d.price} />
|
||||
<Scene
|
||||
conditions={{ ...conditions, storm: false }}
|
||||
price={d.price}
|
||||
traffic={streetBusyness(conditions)}
|
||||
/>
|
||||
<Line>
|
||||
ASSETS <span className="money">{dollars(player.assets)}</span> · LEMONADE COSTS{' '}
|
||||
{conditions.costPerGlass}¢ A GLASS
|
||||
@@ -125,7 +131,8 @@ export function DecideScreen({
|
||||
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>}
|
||||
{/* Always present, so showing or clearing it never moves the button. */}
|
||||
<Line className={`warn hint ${error ? 'is-on' : ''}`}>{error ?? '\u00a0'}</Line>
|
||||
<div className="row center">
|
||||
<Btn kind="primary" onClick={submit} disabled={!!error}>
|
||||
SELL LEMONADE
|
||||
|
||||
@@ -2,9 +2,7 @@ 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')
|
||||
import { Wordmark } from './TitleScreen'
|
||||
|
||||
export function GameOverScreen({
|
||||
players,
|
||||
@@ -29,9 +27,7 @@ export function GameOverScreen({
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<pre className="banner small" aria-hidden>
|
||||
{BANNER.join('\n')}
|
||||
</pre>
|
||||
<Wordmark small />
|
||||
<Line className="center inv-line">THE SUMMER IS OVER</Line>
|
||||
<Line />
|
||||
<Line className="center">
|
||||
|
||||
@@ -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 PixelScene({ 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { WEATHER } from '../game/constants'
|
||||
import { dollars } from '../game/engine'
|
||||
import { dollars, soldBusyness } from '../game/engine'
|
||||
import type { DayConditions, DayResult, Player } from '../game/types'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { Scene } from './Scene'
|
||||
@@ -30,40 +30,14 @@ export function ReportScreen({
|
||||
onRetire: () => void
|
||||
onBlip: () => void
|
||||
}) {
|
||||
// App remounts this each day, so these start fresh without a reset effect.
|
||||
// App remounts this each day, so this starts 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,
|
||||
@@ -77,7 +51,12 @@ export function ReportScreen({
|
||||
<div className="stack">
|
||||
<Line className="center inv-line">$$ LEMONSVILLE DAILY FINANCIAL REPORT $$</Line>
|
||||
|
||||
<Line />
|
||||
<Scene
|
||||
conditions={conditions}
|
||||
price={r.decision.price}
|
||||
traffic={soldBusyness(r.glassesSold)}
|
||||
variant="strip"
|
||||
/>
|
||||
<Line className="center accent">
|
||||
DAY {conditions.day} — {player.name}
|
||||
</Line>
|
||||
@@ -95,6 +74,7 @@ export function ReportScreen({
|
||||
<Row label="PROFIT" value={dollars(r.profit)} strong />
|
||||
<Row label="ASSETS" value={dollars(r.assetsAfter)} strong />
|
||||
|
||||
|
||||
{player.bankrupt && (
|
||||
<>
|
||||
<Line />
|
||||
@@ -103,7 +83,6 @@ export function ReportScreen({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Line />
|
||||
<div className="row center">
|
||||
{!isLast && (
|
||||
<Btn
|
||||
|
||||
+25
-214
@@ -1,218 +1,29 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSkin } from '../skin'
|
||||
import type { DayConditions } from '../game/types'
|
||||
import { CelScene } from './CelScene'
|
||||
import { PixelScene } from './PixelScene'
|
||||
|
||||
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 })
|
||||
/**
|
||||
* The same day, drawn twice: chunky Atari rectangles for the 1979 skin,
|
||||
* cel-shaded vector art for the modern one.
|
||||
*/
|
||||
export function Scene({
|
||||
conditions,
|
||||
price,
|
||||
traffic,
|
||||
variant = 'full',
|
||||
}: {
|
||||
conditions: DayConditions
|
||||
price?: number
|
||||
/** How busy the street is, 0 to 1. Only the cel scene shows a crowd. */
|
||||
traffic?: number
|
||||
/** "strip" crops to the counter and the street, for screens short on room. */
|
||||
variant?: 'full' | 'strip'
|
||||
}) {
|
||||
const { skin } = useSkin()
|
||||
if (skin !== 'modern') {
|
||||
// The 1979 financial report was a page of text, and stays one.
|
||||
return variant === 'strip' ? null : <PixelScene conditions={conditions} price={price} />
|
||||
}
|
||||
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>
|
||||
)
|
||||
return <CelScene conditions={conditions} price={price} traffic={traffic} variant={variant} />
|
||||
}
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { useSkin } from '../skin'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { bannerRows } from './logo'
|
||||
|
||||
const BANNER = bannerRows('LEMONADE')
|
||||
|
||||
/** The block-letter banner is the 1979 article; modern gets a wordmark. */
|
||||
export function Wordmark({ small = false }: { small?: boolean }) {
|
||||
const { skin } = useSkin()
|
||||
if (skin === 'modern') {
|
||||
return (
|
||||
<div className={`wordmark ${small ? 'small' : ''}`}>
|
||||
<b>LEMONADE</b>
|
||||
<span>STAND</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<pre className={`banner ${small ? 'small' : ''}`} aria-label="LEMONADE">
|
||||
{BANNER.join('\n')}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitleScreen({
|
||||
onStart,
|
||||
onInstructions,
|
||||
@@ -14,15 +33,17 @@ export function TitleScreen({
|
||||
onScores: () => void
|
||||
seed: number
|
||||
}) {
|
||||
const { skin } = useSkin()
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<pre className="banner" aria-label="LEMONADE">
|
||||
{BANNER.join('\n')}
|
||||
</pre>
|
||||
<Line className="center accent">S T A N D</Line>
|
||||
<Wordmark />
|
||||
{skin === 'crt' && <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 className="center dim">
|
||||
{skin === 'crt' ? 'ATARI 8-BIT EDITION' : 'REMASTERED'} · SEED {seed}
|
||||
</Line>
|
||||
<Line />
|
||||
<Line className="center blink">PRESS START</Line>
|
||||
<Line />
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { soldBusyness } from '../game/engine'
|
||||
import type { DayConditions, DayResult, Player } from '../game/types'
|
||||
import { synth } from '../audio/synth'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { Scene } from './Scene'
|
||||
|
||||
const TRADING_MS = 2900
|
||||
const STORM_MS = 3400
|
||||
|
||||
/** Fast at first, settling at the end, the way a rush tails off. */
|
||||
const easeOut = (p: number) => 1 - Math.pow(1 - p, 2.2)
|
||||
|
||||
export function TradingScreen({
|
||||
results,
|
||||
players,
|
||||
conditions,
|
||||
onDone,
|
||||
}: {
|
||||
results: DayResult[]
|
||||
players: Player[]
|
||||
conditions: DayConditions
|
||||
onDone: () => void
|
||||
}) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const ticked = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const total = results.reduce((n, r) => n + r.glassesSold, 0)
|
||||
const span = conditions.storm ? STORM_MS : TRADING_MS
|
||||
const startedAt = performance.now()
|
||||
let raf = 0
|
||||
|
||||
if (conditions.storm) synth.thunder()
|
||||
|
||||
const frame = (now: number) => {
|
||||
const p = Math.min(1, (now - startedAt) / span)
|
||||
setProgress(p)
|
||||
|
||||
// A blip per few glasses, so a busy day sounds busy.
|
||||
if (!conditions.storm && total > 0) {
|
||||
const sold = Math.round(easeOut(p) * total)
|
||||
const step = Math.max(1, Math.ceil(total / 14))
|
||||
if (sold >= ticked.current + step) {
|
||||
ticked.current = sold
|
||||
synth.tick()
|
||||
}
|
||||
}
|
||||
|
||||
if (p < 1) raf = requestAnimationFrame(frame)
|
||||
else onDone()
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [results, conditions.storm, onDone])
|
||||
|
||||
const eased = easeOut(progress)
|
||||
const sold = (r: DayResult) => Math.round(eased * r.glassesSold)
|
||||
const soldTotal = results.reduce((n, r) => n + sold(r), 0)
|
||||
const madeTotal = results.reduce((n, r) => n + r.decision.glasses, 0)
|
||||
|
||||
if (conditions.storm) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<Line className="center inv-line">DAY {conditions.day} IN LEMONSVILLE</Line>
|
||||
<Scene conditions={conditions} traffic={0.2} />
|
||||
<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={onDone}>
|
||||
SEE THE DAMAGE
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<Line className="center inv-line">DAY {conditions.day} — OPEN FOR BUSINESS</Line>
|
||||
<Scene
|
||||
conditions={conditions}
|
||||
price={results[0].decision.price}
|
||||
traffic={soldBusyness(results[0].glassesSold)}
|
||||
/>
|
||||
|
||||
<Line className="center accent">
|
||||
{soldTotal} OF {madeTotal} GLASSES SOLD
|
||||
</Line>
|
||||
<div className="till" aria-hidden>
|
||||
<span style={{ width: `${Math.round(progress * 100)}%` }} />
|
||||
</div>
|
||||
|
||||
{results.length > 1 &&
|
||||
results.map((r) => {
|
||||
const player = players.find((p) => p.id === r.playerId)!
|
||||
return (
|
||||
<div className="report-row" key={r.playerId}>
|
||||
<span className="report-label">{player.name}</span>
|
||||
<span className="report-dots" aria-hidden />
|
||||
<span className="report-value">{sold(r)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<Line />
|
||||
<div className="row center">
|
||||
<Btn kind="ghost" onClick={onDone}>
|
||||
SKIP TO THE BOOKS
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -91,6 +91,21 @@ export function isBankrupt(assets: number, nextDay: number): boolean {
|
||||
return assets < costPerGlass(nextDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* How busy the street looks before anyone has priced anything - used to
|
||||
* decide how many people walk on. Not part of the simulation.
|
||||
*/
|
||||
export function streetBusyness(cond: DayConditions): number {
|
||||
if (cond.storm) return 0.18
|
||||
const base = WEATHER[cond.weather].traffic / 140
|
||||
const heat = cond.heatWave ? 1.35 : 1
|
||||
const crew = cond.streetCrew ? 0.2 : 1
|
||||
return Math.max(0, Math.min(1, base * heat * crew))
|
||||
}
|
||||
|
||||
/** How busy it actually was, from the glasses that crossed the counter. */
|
||||
export const soldBusyness = (sold: number) => Math.max(0, Math.min(1, sold / 55))
|
||||
|
||||
export const dollars = (cents: number): string => {
|
||||
const sign = cents < 0 ? '-' : ''
|
||||
const abs = Math.abs(cents)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const KEY = 'lemonade.highscores.v1'
|
||||
export const HIGH_SCORE_KEY = 'lemonade.highscores.v1'
|
||||
const KEY = HIGH_SCORE_KEY
|
||||
export const MAX_SCORES = 10
|
||||
|
||||
export interface Score {
|
||||
|
||||
+14
-4
@@ -20,8 +20,8 @@ export interface GameState {
|
||||
scoresReturn: Phase
|
||||
}
|
||||
|
||||
export const initialState = (seed: number): GameState => ({
|
||||
phase: 'title',
|
||||
export const initialState = (seed: number, phase: Phase = 'boot'): GameState => ({
|
||||
phase,
|
||||
seed,
|
||||
day: 0,
|
||||
players: [],
|
||||
@@ -36,6 +36,7 @@ export const initialState = (seed: number): GameState => ({
|
||||
})
|
||||
|
||||
export type Action =
|
||||
| { type: 'BOOTED' }
|
||||
| { type: 'SHOW_INTRO' }
|
||||
| { type: 'SHOW_SETUP' }
|
||||
| { type: 'START'; names: string[] }
|
||||
@@ -43,6 +44,7 @@ export type Action =
|
||||
| { type: 'OPEN_STAND' }
|
||||
| { type: 'SUBMIT'; playerId: number; decision: Decision }
|
||||
| { type: 'RESOLVE'; results: DayResult[] }
|
||||
| { type: 'SHOW_REPORT' }
|
||||
| { type: 'NEXT_DAY' }
|
||||
| { type: 'RETIRE' }
|
||||
| { type: 'SHOW_SCORES' }
|
||||
@@ -54,6 +56,9 @@ export const activePlayers = (s: GameState) => s.players.filter((p) => !p.bankru
|
||||
|
||||
export function reducer(state: GameState, action: Action): GameState {
|
||||
switch (action.type) {
|
||||
case 'BOOTED':
|
||||
return { ...state, phase: 'title' }
|
||||
|
||||
case 'SHOW_INTRO':
|
||||
return { ...state, phase: 'intro' }
|
||||
|
||||
@@ -108,7 +113,8 @@ export function reducer(state: GameState, action: Action): GameState {
|
||||
const nextDay = state.day + 1
|
||||
return {
|
||||
...state,
|
||||
phase: 'report',
|
||||
// The stand trades before the books are opened.
|
||||
phase: 'trading',
|
||||
results: action.results,
|
||||
history: [...state.history, ...action.results],
|
||||
streetCrewYesterday: state.conditions?.streetCrew ?? false,
|
||||
@@ -127,6 +133,9 @@ export function reducer(state: GameState, action: Action): GameState {
|
||||
}
|
||||
}
|
||||
|
||||
case 'SHOW_REPORT':
|
||||
return { ...state, phase: 'report' }
|
||||
|
||||
case 'NEXT_DAY': {
|
||||
if (activePlayers(state).length === 0) return { ...state, phase: 'gameover' }
|
||||
return { ...state, phase: 'briefing', day: state.day + 1, turn: 0 }
|
||||
@@ -144,7 +153,8 @@ export function reducer(state: GameState, action: Action): GameState {
|
||||
return { ...state, phase: state.scoresReturn }
|
||||
|
||||
case 'RESTART':
|
||||
return initialState(action.seed)
|
||||
// The tape only loads once a session; a new game starts at the title.
|
||||
return initialState(action.seed, 'title')
|
||||
|
||||
default:
|
||||
return state
|
||||
|
||||
@@ -39,12 +39,14 @@ export interface DayResult {
|
||||
}
|
||||
|
||||
export type Phase =
|
||||
| 'boot'
|
||||
| 'title'
|
||||
| 'intro'
|
||||
| 'setup'
|
||||
| 'briefing'
|
||||
| 'decide'
|
||||
| 'resolve'
|
||||
| 'trading'
|
||||
| 'report'
|
||||
| 'gameover'
|
||||
| 'scores'
|
||||
|
||||
+13
-2
@@ -36,14 +36,25 @@ body,
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(120% 80% at 50% 0%, #1a1c26 0%, #0b0c12 55%, #050609 100%);
|
||||
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);
|
||||
transition: background 400ms ease;
|
||||
}
|
||||
|
||||
/* The room the modern cabinet stands in: warmer, deeper, a little neon. */
|
||||
html[data-skin='modern'] body {
|
||||
background:
|
||||
radial-gradient(90% 60% at 15% 0%, rgba(255, 93, 143, 0.28) 0%, transparent 60%),
|
||||
radial-gradient(80% 60% at 90% 15%, rgba(46, 196, 182, 0.24) 0%, transparent 55%),
|
||||
radial-gradient(120% 90% at 50% 100%, #3a2168 0%, #1d1136 55%, #0e0820 100%);
|
||||
color: #fff6e6;
|
||||
font-family:
|
||||
ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
+4
-1
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { SkinProvider } from './SkinProvider.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<SkinProvider>
|
||||
<App />
|
||||
</SkinProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/**
|
||||
* Two skins over one game. "crt" is the 1979 machine, kept honest; "modern"
|
||||
* is the same logic with cel-shaded art and a funk band. The rules never
|
||||
* differ between them.
|
||||
*/
|
||||
export type Skin = 'crt' | 'modern'
|
||||
|
||||
export const SKIN_KEY = 'lemonade.skin.v1'
|
||||
|
||||
export function readSkin(): Skin {
|
||||
try {
|
||||
return window.localStorage.getItem(SKIN_KEY) === 'crt' ? 'crt' : 'modern'
|
||||
} catch {
|
||||
return 'modern'
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSkin(s: Skin): void {
|
||||
try {
|
||||
window.localStorage.setItem(SKIN_KEY, s)
|
||||
} catch {
|
||||
// A skin that does not stick is a small loss; carry on.
|
||||
}
|
||||
}
|
||||
|
||||
export interface SkinApi {
|
||||
skin: Skin
|
||||
setSkin: (s: Skin) => void
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
export const SkinContext = createContext<SkinApi>({
|
||||
skin: 'modern',
|
||||
setSkin: () => {},
|
||||
toggle: () => {},
|
||||
})
|
||||
|
||||
export const useSkin = () => useContext(SkinContext)
|
||||
Reference in New Issue
Block a user