From 7fcb8637718d5b36aa0fb6f569fc68ffef225b4b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 8 Sep 2026 15:59:52 -0700 Subject: [PATCH] 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. --- README.md | 67 +++- src/App.css | 507 ++++++++++++++++++++++++++---- src/App.tsx | 81 ++++- src/SkinProvider.tsx | 32 ++ src/audio/funk.ts | 176 +++++++++++ src/audio/synth.ts | 165 ++++++++-- src/components/BootScreen.tsx | 102 ++++++ src/components/BriefingScreen.tsx | 3 +- src/components/CelScene.tsx | 379 ++++++++++++++++++++++ src/components/DecideScreen.tsx | 17 +- src/components/GameOverScreen.tsx | 8 +- src/components/PixelScene.tsx | 218 +++++++++++++ src/components/ReportScreen.tsx | 39 +-- src/components/Scene.tsx | 239 ++------------ src/components/TitleScreen.tsx | 31 +- src/components/TradingScreen.tsx | 117 +++++++ src/game/engine.ts | 15 + src/game/highscores.ts | 3 +- src/game/reducer.ts | 18 +- src/game/types.ts | 2 + src/index.css | 15 +- src/main.tsx | 5 +- src/skin.ts | 40 +++ 23 files changed, 1909 insertions(+), 370 deletions(-) create mode 100644 src/SkinProvider.tsx create mode 100644 src/audio/funk.ts create mode 100644 src/components/BootScreen.tsx create mode 100644 src/components/CelScene.tsx create mode 100644 src/components/PixelScene.tsx create mode 100644 src/components/TradingScreen.tsx create mode 100644 src/skin.ts diff --git a/README.md b/README.md index e07e7df..a068c6b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/App.css b/src/App.css index 8d50566..08a1b10 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/App.tsx b/src/App.tsx index 5031b1b..be100a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(loadScores) const [freshScores, setFreshScores] = useState([]) @@ -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'} + { + wake() + synth.select() + toggleSkin() + }} + title={skin === 'crt' ? 'Switch to the remaster' : 'Switch to the 1979 machine'} + > + {skin === 'crt' ? 'REMASTER' : '1979'} + NEW GAME @@ -193,6 +236,8 @@ export default function App() { } > + {state.phase === 'boot' && } + {status && (
DAY {String(status.day).padStart(2, '0')} @@ -237,6 +282,16 @@ export default function App() { /> )} + {state.phase === 'trading' && state.conditions && ( + + )} + {state.phase === 'report' && state.conditions && ( (readSkin) + + const setSkin = useCallback((s: Skin) => { + setSkinState(s) + writeSkin(s) + }, []) + + // The attribute lives on 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( + () => ({ skin, setSkin, toggle: () => setSkin(skin === 'crt' ? 'modern' : 'crt') }), + [skin, setSkin], + ) + + return {children} +} diff --git a/src/audio/funk.ts b/src/audio/funk.ts new file mode 100644 index 0000000..e3eead5 --- /dev/null +++ b/src/audio/funk.ts @@ -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]), + ], +} diff --git a/src/audio/synth.ts b/src/audio/synth.ts index 1125d23..36d3876 100644 --- a/src/audio/synth.ts +++ b/src/audio/synth.ts @@ -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]], diff --git a/src/components/BootScreen.tsx b/src/components/BootScreen.tsx new file mode 100644 index 0000000..17c8e10 --- /dev/null +++ b/src/components/BootScreen.tsx @@ -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 ( +
+ {/* Loader stripes bleed off both edges, the way tape loaders did. */} + {stage === 'loading' &&
} + +
+
ATARI 8K BASIC  REV. B
+
 
+
READY
+
CLOAD
+ + {stage === 'ready' && ( + <> +
 
+
PRESS PLAY ON RECORDER, THEN RETURN.
+
+ + )} + + {stage !== 'ready' && ( + <> +
 
+
+ LOADING "LEMONADE" + {stage === 'loading' ? '.'.repeat(1 + Math.floor(progress * 12)) : ''} +
+ {stage === 'done' && ( + <> +
 
+
READY
+
+ + )} + + )} +
+ +
+ {stage === 'ready' ? ( + + ► PRESS PLAY + + ) : ( + stage === 'loading' && ( + + SKIP THE LOAD + + ) + )} +
+
+ ) +} diff --git a/src/components/BriefingScreen.tsx b/src/components/BriefingScreen.tsx index 4b99653..4c0dc39 100644 --- a/src/components/BriefingScreen.tsx +++ b/src/components/BriefingScreen.tsx @@ -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 (
DAY {conditions.day} IN LEMONSVILLE - + WEATHER REPORT: {WEATHER[conditions.weather].label} diff --git a/src/components/CelScene.tsx b/src/components/CelScene.tsx new file mode 100644 index 0000000..accc3a9 --- /dev/null +++ b/src/components/CelScene.tsx @@ -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 = { + 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 ( + + + + + + + + + + + + + + + + + + + + + {/* --- sky --------------------------------------------------- */} + {daylight ? ( + + + {Array.from({ length: 12 }, (_, i) => ( + + ))} + + + + + + ) : ( + + {[ + { x: 120, y: 62, s: 1.1 }, + { x: 380, y: 44, s: 0.85 }, + { x: 540, y: 78, s: 1 }, + ].map((c, i) => ( + + + + + ))} + + )} + + {/* --- land -------------------------------------------------- */} + + + + {[52, 96, 566, 610].map((x, i) => ( + + + + + + ))} + + + + + + {/* --- the stand --------------------------------------------- */} + + + + + + + + + {/* counter */} + + + + + {/* hand-painted sign board */} + + + + LEMONADE + + {price !== undefined && ( + + {price}¢ A GLASS + + )} + + {/* jug of lemonade */} + + + + + + + + + + + + + + + + + + {/* stack of cups and a bowl of lemons */} + + + + + + + + + + {/* awning */} + + + + {Array.from({ length: 8 }, (_, i) => ( + + ))} + + + + + + + + {/* --- the street ------------------------------------------- */} + {walkers.map((w) => ( + + + + + + + + + + {heatWave && !storm && ( + + + + )} + + + + + + + + {storm && ( + + + + + )} + + + + ))} + + {/* --- road works -------------------------------------------- */} + {streetCrew && ( + + {[68, 572].map((x) => ( + + + + + + ))} + + )} + + {/* --- weather overlays -------------------------------------- */} + {storm && ( + <> + + {Array.from({ length: 40 }, (_, i) => { + const x = (i * 71) % 660 + const y = (i * 53) % 300 + return + })} + + + + + )} + + {heatWave && ( + + {[262, 274, 286].map((y) => ( + + ))} + + )} + + + + + ) +} diff --git a/src/components/DecideScreen.tsx b/src/components/DecideScreen.tsx index 2edd5bc..545c134 100644 --- a/src/components/DecideScreen.tsx +++ b/src/components/DecideScreen.tsx @@ -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({ {playerCount > 1 ? `${player.name} - DAY ${conditions.day}` : `DAY ${conditions.day}`} - + ASSETS {dollars(player.assets)} · LEMONADE COSTS{' '} {conditions.costPerGlass}¢ A GLASS @@ -125,7 +131,8 @@ export function DecideScreen({ TODAY'S OUTLAY {dollars(total)} · LEFT IN TIN{' '} {dollars(left)} - {error && {error}} + {/* Always present, so showing or clearing it never moves the button. */} + {error ?? '\u00a0'}
SELL LEMONADE diff --git a/src/components/GameOverScreen.tsx b/src/components/GameOverScreen.tsx index fbf5a3a..b9b19f5 100644 --- a/src/components/GameOverScreen.tsx +++ b/src/components/GameOverScreen.tsx @@ -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 (
-
-        {BANNER.join('\n')}
-      
+ THE SUMMER IS OVER diff --git a/src/components/PixelScene.tsx b/src/components/PixelScene.tsx new file mode 100644 index 0000000..829bc0b --- /dev/null +++ b/src/components/PixelScene.tsx @@ -0,0 +1,218 @@ +import { useMemo } from 'react' +import type { DayConditions } from '../game/types' + +const PALETTE: Record = { + 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 ( + + + + + + + + + + {heatWave && ( + + {[13, 15, 17].map((y) => ( + + ))} + + )} + + {rects.map((r, i) => ( + + ))} + + {storm && ( + <> + + {Array.from({ length: 26 }, (_, i) => ( + + ))} + + + + )} + + + LEMONADE + + {price !== undefined && ( + + {price}¢ A GLASS + + )} + + ) +} diff --git a/src/components/ReportScreen.tsx b/src/components/ReportScreen.tsx index 2e24685..f919493 100644 --- a/src/components/ReportScreen.tsx +++ b/src/components/ReportScreen.tsx @@ -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 ( -
- DAY {conditions.day} IN LEMONSVILLE - - A THUNDERSTORM HIT LEMONSVILLE EARLIER - TODAY, JUST AS THE STANDS WERE BEING - SET UP. EVERYTHING WAS RUINED!! - -
- { - onBlip() - setStormSeen(true) - }} - > - SEE THE DAMAGE - -
-
- ) - } - const events = [ WEATHER[conditions.weather].label, conditions.heatWave ? 'HEAT WAVE' : null, @@ -77,7 +51,12 @@ export function ReportScreen({
$$ LEMONSVILLE DAILY FINANCIAL REPORT $$ - + DAY {conditions.day} — {player.name} @@ -95,6 +74,7 @@ export function ReportScreen({ + {player.bankrupt && ( <> @@ -103,7 +83,6 @@ export function ReportScreen({ )} -
{!isLast && ( = { - 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 : } - 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 ( - - - - - - - - - - {heatWave && ( - - {[13, 15, 17].map((y) => ( - - ))} - - )} - - {rects.map((r, i) => ( - - ))} - - {storm && ( - <> - - {Array.from({ length: 26 }, (_, i) => ( - - ))} - - - - )} - - - LEMONADE - - {price !== undefined && ( - - {price}¢ A GLASS - - )} - - ) + return } diff --git a/src/components/TitleScreen.tsx b/src/components/TitleScreen.tsx index ea07c0f..78d505f 100644 --- a/src/components/TitleScreen.tsx +++ b/src/components/TitleScreen.tsx @@ -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 ( +
+ LEMONADE + STAND +
+ ) + } + return ( +
+      {BANNER.join('\n')}
+    
+ ) +} + export function TitleScreen({ onStart, onInstructions, @@ -14,15 +33,17 @@ export function TitleScreen({ onScores: () => void seed: number }) { + const { skin } = useSkin() + return (
-
-        {BANNER.join('\n')}
-      
- S T A N D + + {skin === 'crt' && S T A N D} LEMONSVILLE, CALIFORNIA - ATARI 8-BIT EDITION · SEED {seed} + + {skin === 'crt' ? 'ATARI 8-BIT EDITION' : 'REMASTERED'} · SEED {seed} + PRESS START diff --git a/src/components/TradingScreen.tsx b/src/components/TradingScreen.tsx new file mode 100644 index 0000000..ebb5343 --- /dev/null +++ b/src/components/TradingScreen.tsx @@ -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 ( +
+ DAY {conditions.day} IN LEMONSVILLE + + A THUNDERSTORM HIT LEMONSVILLE EARLIER + TODAY, JUST AS THE STANDS WERE BEING + SET UP. EVERYTHING WAS RUINED!! + +
+ + SEE THE DAMAGE + +
+
+ ) + } + + return ( +
+ DAY {conditions.day} — OPEN FOR BUSINESS + + + + {soldTotal} OF {madeTotal} GLASSES SOLD + +
+ +
+ + {results.length > 1 && + results.map((r) => { + const player = players.find((p) => p.id === r.playerId)! + return ( +
+ {player.name} + + {sold(r)} +
+ ) + })} + + +
+ + SKIP TO THE BOOKS + +
+
+ ) +} diff --git a/src/game/engine.ts b/src/game/engine.ts index 5af621f..3edd9c0 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -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) diff --git a/src/game/highscores.ts b/src/game/highscores.ts index 286a87e..3149a29 100644 --- a/src/game/highscores.ts +++ b/src/game/highscores.ts @@ -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 { diff --git a/src/game/reducer.ts b/src/game/reducer.ts index bb7997a..d007edb 100644 --- a/src/game/reducer.ts +++ b/src/game/reducer.ts @@ -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 diff --git a/src/game/types.ts b/src/game/types.ts index 7fedb63..3996a33 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -39,12 +39,14 @@ export interface DayResult { } export type Phase = + | 'boot' | 'title' | 'intro' | 'setup' | 'briefing' | 'decide' | 'resolve' + | 'trading' | 'report' | 'gameover' | 'scores' diff --git a/src/index.css b/src/index.css index 83252b8..4716d96 100644 --- a/src/index.css +++ b/src/index.css @@ -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) { diff --git a/src/main.tsx b/src/main.tsx index bef5202..bbff60a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - + + + , ) diff --git a/src/skin.ts b/src/skin.ts new file mode 100644 index 0000000..20e8b08 --- /dev/null +++ b/src/skin.ts @@ -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({ + skin: 'modern', + setSkin: () => {}, + toggle: () => {}, +}) + +export const useSkin = () => useContext(SkinContext)