Lemonade Stand: browser recreation of the Atari 8-bit BASIC game
Faithful day loop - weather report, cost of lemonade rising over the summer, glasses/signs/price decisions, thunderstorms, heat waves and street crews - with the $$ LEMONSVILLE DAILY FINANCIAL REPORT $$ laid out the way it printed. The simulation is kept out of React so a season can be run headlessly: a careful player compounds $2.00 into roughly $22 over twelve days, while an all-in player goes broke about seven times in ten. Weather scenes are pixel rectangles in SVG and the music is a small chiptune engine on the Web Audio API, so there are no binary assets. The screen is locked to a 4:3 tube and never scrolls.
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* A very small chiptune engine: pulse waves for voices, filtered white noise
|
||||
* for percussion, and a look-ahead scheduler so patterns stay in time even
|
||||
* when React is busy re-rendering.
|
||||
*/
|
||||
|
||||
const NOTE_INDEX: Record<string, number> = {
|
||||
C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5,
|
||||
'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11,
|
||||
}
|
||||
|
||||
/** "C#4" -> Hz. A4 = 440. */
|
||||
export function noteToFreq(note: string): number {
|
||||
const m = /^([A-G]#?)(-?\d)$/.exec(note)
|
||||
if (!m) return 0
|
||||
const semis = NOTE_INDEX[m[1]] + (Number(m[2]) + 1) * 12
|
||||
return 440 * Math.pow(2, (semis - 69) / 12)
|
||||
}
|
||||
|
||||
export type Wave = 'pulse12' | 'pulse25' | 'pulse50' | 'triangle' | 'saw' | 'noise'
|
||||
|
||||
export interface Track {
|
||||
wave: Wave
|
||||
gain: number
|
||||
/** One entry per step. '.' rest, '=' sustain previous, otherwise a note. */
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
export interface Tune {
|
||||
bpm: number
|
||||
stepsPerBeat: number
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
/** Fourier series for a pulse wave of the given duty cycle. */
|
||||
function pulseWave(ctx: AudioContext, duty: number, harmonics = 24): PeriodicWave {
|
||||
const real = new Float32Array(harmonics + 1)
|
||||
const imag = new Float32Array(harmonics + 1)
|
||||
for (let n = 1; n <= harmonics; n++) {
|
||||
imag[n] = (2 / (n * Math.PI)) * Math.sin(n * Math.PI * duty)
|
||||
}
|
||||
return ctx.createPeriodicWave(real, imag, { disableNormalization: false })
|
||||
}
|
||||
|
||||
export class Synth {
|
||||
private ctx: AudioContext | null = null
|
||||
private master!: GainNode
|
||||
private musicBus!: GainNode
|
||||
private sfxBus!: GainNode
|
||||
private waves: Partial<Record<Wave, PeriodicWave>> = {}
|
||||
private noiseBuffer!: AudioBuffer
|
||||
|
||||
private tune: Tune | null = null
|
||||
private step = 0
|
||||
private nextStepTime = 0
|
||||
private timer: number | null = null
|
||||
|
||||
musicOn = true
|
||||
sfxOn = true
|
||||
|
||||
/** Must be called from a user gesture the first time. */
|
||||
ensure(): AudioContext {
|
||||
if (this.ctx) {
|
||||
if (this.ctx.state === 'suspended') void this.ctx.resume()
|
||||
return this.ctx
|
||||
}
|
||||
const ctx = new AudioContext()
|
||||
this.ctx = ctx
|
||||
this.master = ctx.createGain()
|
||||
this.master.gain.value = 0.5
|
||||
this.master.connect(ctx.destination)
|
||||
|
||||
this.musicBus = ctx.createGain()
|
||||
this.musicBus.gain.value = 0.55
|
||||
this.musicBus.connect(this.master)
|
||||
|
||||
this.sfxBus = ctx.createGain()
|
||||
this.sfxBus.gain.value = 0.9
|
||||
this.sfxBus.connect(this.master)
|
||||
|
||||
this.waves.pulse12 = pulseWave(ctx, 0.125)
|
||||
this.waves.pulse25 = pulseWave(ctx, 0.25)
|
||||
this.waves.pulse50 = pulseWave(ctx, 0.5)
|
||||
|
||||
const len = Math.floor(ctx.sampleRate * 1.5)
|
||||
const buf = ctx.createBuffer(1, len, ctx.sampleRate)
|
||||
const data = buf.getChannelData(0)
|
||||
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
|
||||
this.noiseBuffer = buf
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
setMusic(on: boolean) {
|
||||
this.musicOn = on
|
||||
if (!this.ctx) return
|
||||
this.musicBus.gain.setTargetAtTime(on ? 0.55 : 0, this.ctx.currentTime, 0.05)
|
||||
}
|
||||
|
||||
setSfx(on: boolean) {
|
||||
this.sfxOn = on
|
||||
if (!this.ctx) return
|
||||
this.sfxBus.gain.setTargetAtTime(on ? 0.9 : 0, this.ctx.currentTime, 0.02)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- voices
|
||||
|
||||
private voice(
|
||||
dest: AudioNode,
|
||||
wave: Wave,
|
||||
freq: number,
|
||||
at: number,
|
||||
dur: number,
|
||||
gain: number,
|
||||
) {
|
||||
const ctx = this.ensure()
|
||||
const osc = ctx.createOscillator()
|
||||
if (wave === 'triangle') osc.type = 'triangle'
|
||||
else if (wave === 'saw') osc.type = 'sawtooth'
|
||||
else osc.setPeriodicWave(this.waves[wave] ?? this.waves.pulse50!)
|
||||
osc.frequency.setValueAtTime(freq, at)
|
||||
|
||||
const env = ctx.createGain()
|
||||
const peak = Math.max(0.0001, gain)
|
||||
env.gain.setValueAtTime(0.0001, at)
|
||||
env.gain.exponentialRampToValueAtTime(peak, at + 0.008)
|
||||
env.gain.setValueAtTime(peak, at + Math.max(0.02, dur * 0.6))
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
|
||||
|
||||
osc.connect(env).connect(dest)
|
||||
osc.start(at)
|
||||
osc.stop(at + dur + 0.02)
|
||||
}
|
||||
|
||||
private percussion(dest: AudioNode, kind: string, at: number, gain: number) {
|
||||
const ctx = this.ensure()
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = this.noiseBuffer
|
||||
const filter = ctx.createBiquadFilter()
|
||||
const env = ctx.createGain()
|
||||
|
||||
let dur = 0.06
|
||||
if (kind === 'K') {
|
||||
filter.type = 'lowpass'
|
||||
filter.frequency.value = 220
|
||||
dur = 0.12
|
||||
} else if (kind === 'S') {
|
||||
filter.type = 'bandpass'
|
||||
filter.frequency.value = 1800
|
||||
dur = 0.12
|
||||
} else {
|
||||
filter.type = 'highpass'
|
||||
filter.frequency.value = 6000
|
||||
dur = 0.04
|
||||
}
|
||||
|
||||
env.gain.setValueAtTime(gain, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
|
||||
src.connect(filter).connect(env).connect(dest)
|
||||
src.start(at)
|
||||
src.stop(at + dur + 0.02)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- sequencer
|
||||
|
||||
playTune(tune: Tune, restart = true) {
|
||||
this.ensure()
|
||||
if (this.tune === tune && this.timer !== null && !restart) return
|
||||
this.stopTune()
|
||||
this.tune = tune
|
||||
this.step = 0
|
||||
this.nextStepTime = this.ctx!.currentTime + 0.08
|
||||
this.timer = window.setInterval(() => this.schedule(), 25)
|
||||
}
|
||||
|
||||
stopTune() {
|
||||
if (this.timer !== null) window.clearInterval(this.timer)
|
||||
this.timer = null
|
||||
this.tune = null
|
||||
}
|
||||
|
||||
get playing() {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
private schedule() {
|
||||
const ctx = this.ctx
|
||||
const tune = this.tune
|
||||
if (!ctx || !tune) return
|
||||
const stepDur = 60 / tune.bpm / tune.stepsPerBeat
|
||||
const length = Math.max(...tune.tracks.map((t) => t.notes.length))
|
||||
|
||||
while (this.nextStepTime < ctx.currentTime + 0.2) {
|
||||
for (const track of tune.tracks) {
|
||||
const note = track.notes[this.step % track.notes.length]
|
||||
if (!note || note === '.' || note === '=') continue
|
||||
|
||||
// A note runs until the next step that is not a sustain marker.
|
||||
let held = 1
|
||||
for (let i = 1; i < length; i++) {
|
||||
if (track.notes[(this.step + i) % track.notes.length] === '=') held++
|
||||
else break
|
||||
}
|
||||
const dur = held * stepDur * 0.95
|
||||
|
||||
if (track.wave === 'noise') {
|
||||
this.percussion(this.musicBus, note, this.nextStepTime, track.gain)
|
||||
} else {
|
||||
const f = noteToFreq(note)
|
||||
if (f) this.voice(this.musicBus, track.wave, f, this.nextStepTime, dur, track.gain)
|
||||
}
|
||||
}
|
||||
this.nextStepTime += stepDur
|
||||
this.step = (this.step + 1) % length
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ sfx
|
||||
|
||||
private seq(notes: [string, number][], wave: Wave = 'pulse25', gain = 0.22) {
|
||||
const ctx = this.ensure()
|
||||
let t = ctx.currentTime + 0.01
|
||||
for (const [note, dur] of notes) {
|
||||
if (note !== '.') this.voice(this.sfxBus, wave, noteToFreq(note), t, dur, gain)
|
||||
t += dur
|
||||
}
|
||||
}
|
||||
|
||||
blip() {
|
||||
this.seq([['E5', 0.05]], 'pulse12', 0.15)
|
||||
}
|
||||
|
||||
select() {
|
||||
this.seq([['C5', 0.05], ['G5', 0.09]], 'pulse25', 0.18)
|
||||
}
|
||||
|
||||
reject() {
|
||||
this.seq([['A3', 0.09], ['E3', 0.16]], 'saw', 0.2)
|
||||
}
|
||||
|
||||
cash() {
|
||||
this.seq([['C5', 0.07], ['E5', 0.07], ['G5', 0.07], ['C6', 0.22]], 'pulse25', 0.2)
|
||||
}
|
||||
|
||||
sad() {
|
||||
this.seq([['G4', 0.12], ['F#4', 0.12], ['F4', 0.12], ['E4', 0.4]], 'triangle', 0.22)
|
||||
}
|
||||
|
||||
thunder() {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.01
|
||||
const src = ctx.createBufferSource()
|
||||
src.buffer = this.noiseBuffer
|
||||
src.loop = true
|
||||
const filter = ctx.createBiquadFilter()
|
||||
filter.type = 'lowpass'
|
||||
filter.frequency.setValueAtTime(1400, at)
|
||||
filter.frequency.exponentialRampToValueAtTime(120, at + 1.4)
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.0001, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.35, at + 0.05)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + 1.5)
|
||||
src.connect(filter).connect(env).connect(this.sfxBus)
|
||||
src.start(at)
|
||||
src.stop(at + 1.6)
|
||||
}
|
||||
|
||||
sunshine() {
|
||||
this.seq([['C5', 0.06], ['D5', 0.06], ['E5', 0.06], ['G5', 0.06], ['A5', 0.18]], 'pulse12', 0.16)
|
||||
}
|
||||
|
||||
heat() {
|
||||
const ctx = this.ensure()
|
||||
const at = ctx.currentTime + 0.01
|
||||
const osc = ctx.createOscillator()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(300, at)
|
||||
osc.frequency.exponentialRampToValueAtTime(900, at + 0.5)
|
||||
const env = ctx.createGain()
|
||||
env.gain.setValueAtTime(0.0001, at)
|
||||
env.gain.exponentialRampToValueAtTime(0.2, at + 0.1)
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.6)
|
||||
osc.connect(env).connect(this.sfxBus)
|
||||
osc.start(at)
|
||||
osc.stop(at + 0.7)
|
||||
}
|
||||
|
||||
fanfare() {
|
||||
this.seq(
|
||||
[['C5', 0.11], ['E5', 0.11], ['G5', 0.11], ['C6', 0.11], ['G5', 0.11], ['C6', 0.4]],
|
||||
'pulse25',
|
||||
0.2,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const synth = new Synth()
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Tune } from './synth'
|
||||
|
||||
const bar = (...notes: string[]) => notes
|
||||
|
||||
/**
|
||||
* Title theme - the sort of thing an ice cream van would play if it had a
|
||||
* POKEY chip. Four bars of C - F - G - C, twice around.
|
||||
*/
|
||||
export const TITLE_TUNE: Tune = {
|
||||
bpm: 132,
|
||||
stepsPerBeat: 2,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'pulse25',
|
||||
gain: 0.16,
|
||||
notes: [
|
||||
...bar('G4', '.', 'E4', '.', 'G4', '.', 'C5', '='),
|
||||
...bar('B4', '.', 'C5', '.', 'D5', '=', '=', '.'),
|
||||
...bar('A4', '.', 'F4', '.', 'A4', '.', 'C5', '='),
|
||||
...bar('D5', '.', 'C5', '.', 'B4', '=', '=', '.'),
|
||||
...bar('G4', '.', 'B4', '.', 'D5', '.', 'G5', '='),
|
||||
...bar('F5', '.', 'E5', '.', 'D5', '=', '=', '.'),
|
||||
...bar('E5', '.', 'C5', '.', 'G4', '.', 'E4', '.'),
|
||||
...bar('C4', '=', '=', '=', '=', '.', '.', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'pulse12',
|
||||
gain: 0.07,
|
||||
notes: [
|
||||
...bar('E5', '.', '.', '.', 'G5', '.', '.', '.'),
|
||||
...bar('.', '.', 'G5', '.', '.', '.', 'F5', '.'),
|
||||
...bar('F5', '.', '.', '.', 'A5', '.', '.', '.'),
|
||||
...bar('.', '.', 'A5', '.', '.', '.', 'G5', '.'),
|
||||
...bar('D5', '.', '.', '.', 'G5', '.', '.', '.'),
|
||||
...bar('.', '.', 'B5', '.', '.', '.', 'A5', '.'),
|
||||
...bar('G5', '.', '.', '.', 'E5', '.', '.', '.'),
|
||||
...bar('C5', '=', '=', '=', '.', '.', '.', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'triangle',
|
||||
gain: 0.3,
|
||||
notes: [
|
||||
...bar('C2', '.', 'G2', '.', 'C3', '.', 'G2', '.'),
|
||||
...bar('C2', '.', 'G2', '.', 'E2', '.', 'G2', '.'),
|
||||
...bar('F2', '.', 'C3', '.', 'F3', '.', 'C3', '.'),
|
||||
...bar('G2', '.', 'D3', '.', 'G3', '.', 'D3', '.'),
|
||||
...bar('G2', '.', 'D3', '.', 'G3', '.', 'D3', '.'),
|
||||
...bar('G2', '.', 'B2', '.', 'D3', '.', 'B2', '.'),
|
||||
...bar('C2', '.', 'G2', '.', 'C3', '.', 'G2', '.'),
|
||||
...bar('C2', '=', '=', '=', 'G2', '.', 'C2', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'noise',
|
||||
gain: 0.16,
|
||||
notes: [
|
||||
...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'),
|
||||
...bar('K', 'H', '.', 'H', 'S', 'H', 'K', 'H'),
|
||||
...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'),
|
||||
...bar('K', 'H', '.', 'H', 'S', 'H', 'K', 'H'),
|
||||
...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'),
|
||||
...bar('K', 'H', '.', 'H', 'S', 'H', 'K', 'H'),
|
||||
...bar('K', 'H', '.', 'H', 'S', 'H', '.', 'H'),
|
||||
...bar('K', '.', 'S', '.', 'K', 'S', 'S', 'S'),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/** Trading music: slower, sparser, meant to sit under the numbers. */
|
||||
export const DAY_TUNE: Tune = {
|
||||
bpm: 104,
|
||||
stepsPerBeat: 2,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'pulse12',
|
||||
gain: 0.1,
|
||||
notes: [
|
||||
...bar('C5', '.', 'E5', '.', 'G5', '.', 'E5', '.'),
|
||||
...bar('D5', '.', 'F5', '.', 'A5', '=', '.', '.'),
|
||||
...bar('B4', '.', 'D5', '.', 'G5', '.', 'D5', '.'),
|
||||
...bar('C5', '=', '=', '.', 'E5', '.', 'G4', '.'),
|
||||
...bar('A4', '.', 'C5', '.', 'E5', '.', 'C5', '.'),
|
||||
...bar('F4', '.', 'A4', '.', 'D5', '=', '.', '.'),
|
||||
...bar('G4', '.', 'B4', '.', 'D5', '.', 'F5', '.'),
|
||||
...bar('E5', '=', '=', '.', 'C5', '=', '.', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'triangle',
|
||||
gain: 0.26,
|
||||
notes: [
|
||||
...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'),
|
||||
...bar('D2', '.', '.', '.', 'A2', '.', '.', '.'),
|
||||
...bar('G2', '.', '.', '.', 'D3', '.', '.', '.'),
|
||||
...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'),
|
||||
...bar('A2', '.', '.', '.', 'E3', '.', '.', '.'),
|
||||
...bar('F2', '.', '.', '.', 'C3', '.', '.', '.'),
|
||||
...bar('G2', '.', '.', '.', 'D3', '.', '.', '.'),
|
||||
...bar('C2', '.', '.', '.', 'G2', '.', 'B2', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'noise',
|
||||
gain: 0.1,
|
||||
notes: [
|
||||
...bar('.', '.', 'H', '.', '.', '.', 'H', '.'),
|
||||
...bar('.', '.', 'H', '.', '.', '.', 'H', 'H'),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/** Plays once behind the closing standings. */
|
||||
export const END_TUNE: Tune = {
|
||||
bpm: 96,
|
||||
stepsPerBeat: 2,
|
||||
tracks: [
|
||||
{
|
||||
wave: 'pulse25',
|
||||
gain: 0.15,
|
||||
notes: [
|
||||
...bar('G4', '.', 'C5', '=', 'E5', '.', 'G5', '='),
|
||||
...bar('E5', '.', 'C5', '=', 'G4', '=', '=', '.'),
|
||||
...bar('A4', '.', 'D5', '=', 'F5', '.', 'A5', '='),
|
||||
...bar('G5', '.', 'E5', '=', 'C5', '=', '=', '.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
wave: 'triangle',
|
||||
gain: 0.28,
|
||||
notes: [
|
||||
...bar('C2', '.', '.', '.', 'C3', '.', '.', '.'),
|
||||
...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'),
|
||||
...bar('D2', '.', '.', '.', 'D3', '.', '.', '.'),
|
||||
...bar('C2', '.', '.', '.', 'G2', '.', 'C2', '.'),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user