Remaster: cel-shaded skin, a crowd that tracks trade, and a funk band
Two skins over one game. 1979 is the machine as it was; the remaster is the same rules in cel-shaded vector art with a modern palette. The simulation is untouched - only the paint differs - and the toggle sits under the set. The street is now driven by the game. Before prices are in, the number of people walking on comes from the forecast; afterwards it comes from the glasses that actually sold, so the report shows the crowd you earned. A heat wave has them fanning themselves; a downpour leaves one figure hurrying past under an umbrella. Selling no longer snaps straight to the books. The stand trades for a few seconds first, tally climbing and till filling, which is where that crowd animation pays off. Skippable. The synth grew a kit (pitched-sine kick, snare with body, hats), a sweeping resonant lowpass for the bass, chords and a shuffle - enough for a funk band. The chiptunes stay for the 1979 skin. Boot is now a cassette load, which doubles as the gesture browsers require before audio will play. Fixes found on the way: the picture scaled about its centre while flex auto-margins collapsed to zero on overflow, which pushed the report's buttons off the bottom; and the validation line moved the button when it appeared, so it is now reserved and faded. Sibling tabs in one browser also keep their score tables in step.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { synth } from '../audio/synth'
|
||||
import { Btn } from './Crt'
|
||||
|
||||
const LOAD_SECONDS = 4.2
|
||||
|
||||
/**
|
||||
* Loading from cassette, the way the listing in the magazine arrived. It is
|
||||
* also where the audio gets unlocked: browsers want a gesture before they
|
||||
* will make a sound, and pressing PLAY is exactly that gesture.
|
||||
*/
|
||||
export function BootScreen({ onLoaded }: { onLoaded: () => void }) {
|
||||
const [stage, setStage] = useState<'ready' | 'loading' | 'done'>('ready')
|
||||
const [progress, setProgress] = useState(0)
|
||||
const stopTape = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => () => stopTape.current?.(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (stage !== 'loading') return
|
||||
const startedAt = performance.now()
|
||||
let raf = 0
|
||||
const frame = (now: number) => {
|
||||
const p = Math.min(1, (now - startedAt) / (LOAD_SECONDS * 1000))
|
||||
setProgress(p)
|
||||
if (p < 1) raf = requestAnimationFrame(frame)
|
||||
else {
|
||||
stopTape.current?.()
|
||||
setStage('done')
|
||||
synth.beep()
|
||||
window.setTimeout(onLoaded, 650)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [stage, onLoaded])
|
||||
|
||||
const play = () => {
|
||||
synth.ensure()
|
||||
synth.beep()
|
||||
stopTape.current = synth.tape(LOAD_SECONDS)
|
||||
setStage('loading')
|
||||
}
|
||||
|
||||
const skip = () => {
|
||||
stopTape.current?.()
|
||||
onLoaded()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="boot">
|
||||
{/* Loader stripes bleed off both edges, the way tape loaders did. */}
|
||||
{stage === 'loading' && <div className="boot-stripes" aria-hidden />}
|
||||
|
||||
<div className="boot-text">
|
||||
<div>ATARI 8K BASIC REV. B</div>
|
||||
<div> </div>
|
||||
<div>READY</div>
|
||||
<div>CLOAD</div>
|
||||
|
||||
{stage === 'ready' && (
|
||||
<>
|
||||
<div> </div>
|
||||
<div>PRESS PLAY ON RECORDER, THEN RETURN.</div>
|
||||
<div className="boot-cursor">█</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage !== 'ready' && (
|
||||
<>
|
||||
<div> </div>
|
||||
<div>
|
||||
LOADING "LEMONADE"
|
||||
{stage === 'loading' ? '.'.repeat(1 + Math.floor(progress * 12)) : ''}
|
||||
</div>
|
||||
{stage === 'done' && (
|
||||
<>
|
||||
<div> </div>
|
||||
<div>READY</div>
|
||||
<div className="boot-cursor">█</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="boot-controls">
|
||||
{stage === 'ready' ? (
|
||||
<Btn kind="primary" onClick={play}>
|
||||
► PRESS PLAY
|
||||
</Btn>
|
||||
) : (
|
||||
stage === 'loading' && (
|
||||
<Btn kind="ghost" onClick={skip}>
|
||||
SKIP THE LOAD
|
||||
</Btn>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WEATHER, costMessage } from '../game/constants'
|
||||
import { streetBusyness } from '../game/engine'
|
||||
import type { DayConditions } from '../game/types'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { Scene } from './Scene'
|
||||
@@ -15,7 +16,7 @@ export function BriefingScreen({
|
||||
return (
|
||||
<div className="stack">
|
||||
<Line className="center inv-line">DAY {conditions.day} IN LEMONSVILLE</Line>
|
||||
<Scene conditions={{ ...conditions, storm: false }} />
|
||||
<Scene conditions={{ ...conditions, storm: false }} traffic={streetBusyness(conditions)} />
|
||||
<Line className="center accent">
|
||||
WEATHER REPORT: {WEATHER[conditions.weather].label}
|
||||
</Line>
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { DayConditions } from '../game/types'
|
||||
|
||||
/**
|
||||
* Cel-shaded lemonade stand: flat colour, one hard shadow per form, one bold
|
||||
* outline, light coming from the upper right. Everything is plain geometry so
|
||||
* the whole scene stays legible at any size.
|
||||
*/
|
||||
|
||||
const INK = '#33234f'
|
||||
|
||||
interface Mood {
|
||||
skyTop: string
|
||||
skyBottom: string
|
||||
hillFar: string
|
||||
hillNear: string
|
||||
ground: string
|
||||
groundShade: string
|
||||
/** Painted over the whole scene to tie the palette together. */
|
||||
wash: string
|
||||
washOpacity: number
|
||||
shadow: number
|
||||
}
|
||||
|
||||
const MOODS: Record<string, Mood> = {
|
||||
sunny: {
|
||||
skyTop: '#3AA9F5',
|
||||
skyBottom: '#BFEAFF',
|
||||
hillFar: '#7FD1A6',
|
||||
hillNear: '#5CBF83',
|
||||
ground: '#86DE68',
|
||||
groundShade: '#4FA83D',
|
||||
wash: '#FFE9A8',
|
||||
washOpacity: 0.1,
|
||||
shadow: 0.28,
|
||||
},
|
||||
cloudy: {
|
||||
skyTop: '#7C93B8',
|
||||
skyBottom: '#C6D3E2',
|
||||
hillFar: '#8FB5A2',
|
||||
hillNear: '#6E9C82',
|
||||
ground: '#7FC46A',
|
||||
groundShade: '#4E8B54',
|
||||
wash: '#9FB3CE',
|
||||
washOpacity: 0.22,
|
||||
shadow: 0.12,
|
||||
},
|
||||
hot: {
|
||||
skyTop: '#F2792B',
|
||||
skyBottom: '#FFD68A',
|
||||
hillFar: '#D8A25C',
|
||||
hillNear: '#B77F42',
|
||||
ground: '#D9C25E',
|
||||
groundShade: '#A38B36',
|
||||
wash: '#FF9E3D',
|
||||
washOpacity: 0.2,
|
||||
shadow: 0.34,
|
||||
},
|
||||
storm: {
|
||||
skyTop: '#231B3D',
|
||||
skyBottom: '#4C4370',
|
||||
hillFar: '#3C4463',
|
||||
hillNear: '#2E3550',
|
||||
ground: '#4A6B52',
|
||||
groundShade: '#2F4838',
|
||||
wash: '#2A2350',
|
||||
washOpacity: 0.34,
|
||||
shadow: 0.08,
|
||||
},
|
||||
}
|
||||
|
||||
const SHIRTS = ['#FF5D8F', '#2EC4B6', '#FFB627', '#8E7DFF', '#FF7A29', '#4FC3F7']
|
||||
const SKINS = ['#FFCDA8', '#E0A87C', '#B87A54', '#7C4F32']
|
||||
const HAIR = ['#5A3B2E', '#1F1B24', '#C97B3C', '#8E6BA8']
|
||||
const MAX_WALKERS = 6
|
||||
|
||||
export function CelScene({
|
||||
conditions,
|
||||
price,
|
||||
traffic = 0,
|
||||
variant = 'full',
|
||||
}: {
|
||||
conditions: DayConditions
|
||||
price?: number
|
||||
/** How busy the street is, 0 to 1. Drives how many people walk on. */
|
||||
traffic?: number
|
||||
variant?: 'full' | 'strip'
|
||||
}) {
|
||||
const { weather, heatWave, streetCrew, storm } = conditions
|
||||
const key = storm ? 'storm' : weather
|
||||
const m = MOODS[key] ?? MOODS.sunny
|
||||
const daylight = !storm && weather !== 'cloudy'
|
||||
|
||||
const walkers = useMemo(() => {
|
||||
const busy = Math.max(0, Math.min(1, traffic))
|
||||
// In a downpour nobody stops; at most one soul hurries past.
|
||||
const count = storm ? (busy > 0 ? 1 : 0) : Math.round(busy * MAX_WALKERS)
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
i,
|
||||
// Alternating sides, and a queue spot short of the counter.
|
||||
fromLeft: i % 2 === 0,
|
||||
stop: i % 2 === 0 ? 168 - (i >> 1) * 36 : 472 + (i >> 1) * 36,
|
||||
delay: i * (storm ? 1.1 : 2.3),
|
||||
duration: storm ? 4.5 : 11 + (i % 3),
|
||||
shirt: SHIRTS[i % SHIRTS.length],
|
||||
skin: SKINS[(i * 3) % SKINS.length],
|
||||
hair: HAIR[(i * 5) % HAIR.length],
|
||||
}))
|
||||
}, [traffic, storm])
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`cel ${variant === 'strip' ? 'cel-strip' : ''} ${storm ? 'is-storm' : ''} ${heatWave ? 'is-heat' : ''}`}
|
||||
viewBox="0 0 640 300"
|
||||
role="img"
|
||||
aria-label={`${weather} day at the lemonade stand`}
|
||||
preserveAspectRatio={variant === 'strip' ? 'xMidYMax slice' : 'xMidYMid meet'}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="cel-sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={m.skyTop} />
|
||||
<stop offset="100%" stopColor={m.skyBottom} />
|
||||
</linearGradient>
|
||||
<clipPath id="cel-awning">
|
||||
<path d="M168 110 H472 V152 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 Z" />
|
||||
</clipPath>
|
||||
<clipPath id="cel-jug">
|
||||
<rect x="286" y="170" width="58" height="50" rx="9" />
|
||||
</clipPath>
|
||||
<clipPath id="cel-frame">
|
||||
<rect x="0" y="0" width="640" height="300" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g clipPath="url(#cel-frame)">
|
||||
<rect width="640" height="300" fill="url(#cel-sky)" />
|
||||
|
||||
{/* --- sky --------------------------------------------------- */}
|
||||
{daylight ? (
|
||||
<g className="cel-sun">
|
||||
<g className="cel-rays" style={{ transformOrigin: '536px 68px' }}>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x="532"
|
||||
y="8"
|
||||
width="8"
|
||||
height="26"
|
||||
rx="4"
|
||||
fill={heatWave ? '#FFF3C4' : '#FFE27A'}
|
||||
opacity="0.9"
|
||||
transform={`rotate(${i * 30} 536 68)`}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<circle cx="536" cy="68" r={heatWave ? 46 : 38} fill="#FFD93D" />
|
||||
<path
|
||||
d={`M536 ${68 - (heatWave ? 46 : 38)} a${heatWave ? 46 : 38} ${heatWave ? 46 : 38} 0 0 0 0 ${(heatWave ? 46 : 38) * 2} a${heatWave ? 46 : 38} ${heatWave ? 46 : 38} 0 0 0 0 -${(heatWave ? 46 : 38) * 2} Z`}
|
||||
fill="#F5B62B"
|
||||
opacity="0.55"
|
||||
/>
|
||||
<circle cx="536" cy="68" r={heatWave ? 46 : 38} fill="none" stroke={INK} strokeWidth="4" />
|
||||
</g>
|
||||
) : (
|
||||
<g className="cel-clouds">
|
||||
{[
|
||||
{ x: 120, y: 62, s: 1.1 },
|
||||
{ x: 380, y: 44, s: 0.85 },
|
||||
{ x: 540, y: 78, s: 1 },
|
||||
].map((c, i) => (
|
||||
<g key={i} transform={`translate(${c.x} ${c.y}) scale(${c.s})`}>
|
||||
<path
|
||||
d="M-62 18 a26 26 0 0 1 12 -48 a34 34 0 0 1 62 -12 a28 28 0 0 1 46 22 a22 22 0 0 1 -6 38 Z"
|
||||
fill={storm ? '#5A5480' : '#F3F7FF'}
|
||||
stroke={INK}
|
||||
strokeWidth="4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M-62 18 h114 a22 22 0 0 0 6 -18 q-60 14 -120 0 Z"
|
||||
fill={storm ? '#443E68' : '#D5E2F5'}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* --- land -------------------------------------------------- */}
|
||||
<path d="M0 214 q90 -46 190 -14 q120 38 240 -8 q120 -44 210 6 V300 H0 Z" fill={m.hillFar} />
|
||||
<path d="M0 236 q120 -32 250 -2 q140 32 260 -10 q80 -24 130 4 V300 H0 Z" fill={m.hillNear} />
|
||||
<g stroke={INK} strokeWidth="4" strokeLinejoin="round">
|
||||
{[52, 96, 566, 610].map((x, i) => (
|
||||
<g key={x}>
|
||||
<rect x={x - 4} y={228 - (i % 2) * 8} width="8" height="26" fill="#8A5A2B" />
|
||||
<circle cx={x} cy={216 - (i % 2) * 8} r={22 - (i % 2) * 3} fill={m.hillFar} />
|
||||
<path
|
||||
d={`M${x} ${194 - (i % 2) * 8} a${22 - (i % 2) * 3} ${22 - (i % 2) * 3} 0 0 1 0 ${(22 - (i % 2) * 3) * 2} Z`}
|
||||
fill={m.hillNear}
|
||||
stroke="none"
|
||||
opacity="0.65"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
<rect x="0" y="252" width="640" height="48" fill={m.ground} />
|
||||
<rect x="0" y="252" width="640" height="8" fill={m.groundShade} opacity="0.5" />
|
||||
<rect x="0" y="248" width="640" height="5" fill={INK} opacity="0.35" />
|
||||
|
||||
{/* --- the stand --------------------------------------------- */}
|
||||
<g stroke={INK} strokeWidth="4" strokeLinejoin="round">
|
||||
<ellipse cx="320" cy="286" rx="150" ry="13" fill={INK} opacity={m.shadow} stroke="none" />
|
||||
|
||||
<rect x="192" y="150" width="16" height="98" fill="#C98A4B" />
|
||||
<rect x="432" y="150" width="16" height="98" fill="#C98A4B" />
|
||||
<rect x="192" y="150" width="6" height="98" fill="#9A6435" stroke="none" />
|
||||
<rect x="432" y="150" width="6" height="98" fill="#9A6435" stroke="none" />
|
||||
|
||||
{/* counter */}
|
||||
<rect x="182" y="214" width="276" height="18" rx="5" fill="#E0A867" />
|
||||
<rect x="190" y="232" width="260" height="52" fill="#C98A4B" />
|
||||
<rect x="190" y="232" width="14" height="52" fill="#9A6435" stroke="none" />
|
||||
|
||||
{/* hand-painted sign board */}
|
||||
<rect x="212" y="238" width="216" height="40" rx="5" fill="#FFF6DC" />
|
||||
<rect x="212" y="266" width="216" height="12" rx="5" fill="#EBDCB4" stroke="none" />
|
||||
<text className="cel-sign" x="320" y="258" textAnchor="middle">
|
||||
LEMONADE
|
||||
</text>
|
||||
{price !== undefined && (
|
||||
<text className="cel-price" x="320" y="274" textAnchor="middle">
|
||||
{price}¢ A GLASS
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* jug of lemonade */}
|
||||
<g>
|
||||
<path d="M344 182 q22 6 0 26" fill="none" stroke={INK} strokeWidth="9" />
|
||||
<path d="M344 182 q22 6 0 26" fill="none" stroke="#DFF6FF" strokeWidth="4" />
|
||||
<rect x="286" y="170" width="58" height="50" rx="9" fill="#EAF9FF" />
|
||||
<g clipPath="url(#cel-jug)">
|
||||
<rect x="286" y="186" width="58" height="34" fill="#FFD93D" stroke="none" />
|
||||
<rect x="286" y="186" width="58" height="5" fill="#F5B62B" stroke="none" />
|
||||
<g fill="#FFFFFF" opacity="0.85" stroke="none">
|
||||
<rect x="296" y="192" width="11" height="11" rx="2" transform="rotate(-14 301 197)" />
|
||||
<rect x="318" y="200" width="10" height="10" rx="2" transform="rotate(12 323 205)" />
|
||||
</g>
|
||||
<rect x="286" y="170" width="12" height="50" fill="#FFFFFF" opacity="0.5" stroke="none" />
|
||||
</g>
|
||||
<rect x="286" y="170" width="58" height="50" rx="9" fill="none" />
|
||||
<rect x="282" y="164" width="66" height="10" rx="5" fill="#DFF6FF" />
|
||||
</g>
|
||||
|
||||
{/* stack of cups and a bowl of lemons */}
|
||||
<g>
|
||||
<path d="M226 214 l4 -22 h20 l4 22 Z" fill="#FFF6DC" />
|
||||
<path d="M226 200 h28" stroke={INK} strokeWidth="3" />
|
||||
<ellipse cx="392" cy="210" rx="30" ry="9" fill="#DFF6FF" />
|
||||
<circle cx="380" cy="203" r="10" fill="#FFE45E" />
|
||||
<circle cx="398" cy="200" r="11" fill="#FFE45E" />
|
||||
<path d="M390 194 a11 11 0 0 1 15 10" fill="none" stroke="#F5C518" strokeWidth="5" />
|
||||
</g>
|
||||
|
||||
{/* awning */}
|
||||
<g>
|
||||
<path
|
||||
d="M168 110 H472 V152 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 Z"
|
||||
fill="#FF6B6B"
|
||||
/>
|
||||
<g clipPath="url(#cel-awning)" stroke="none">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<rect key={i} x={168 + i * 38} y="106" width="19" height="70" fill="#FFF6DC" />
|
||||
))}
|
||||
<rect x="168" y="106" width="304" height="14" fill={INK} opacity="0.16" />
|
||||
</g>
|
||||
<path
|
||||
d="M168 110 H472 V152 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 q-19 20 -38 0 Z"
|
||||
fill="none"
|
||||
/>
|
||||
<rect x="160" y="100" width="320" height="14" rx="7" fill="#E0A867" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* --- the street ------------------------------------------- */}
|
||||
{walkers.map((w) => (
|
||||
<g
|
||||
key={w.i}
|
||||
className={`cel-walker ${w.fromLeft ? 'from-left' : 'from-right'} ${storm ? 'is-hurrying' : ''}`}
|
||||
style={{
|
||||
// Custom properties feed the keyframes, so one animation
|
||||
// serves every figure on either side of the stand.
|
||||
['--stop' as string]: `${w.stop}px`,
|
||||
animationDelay: `${w.delay}s`,
|
||||
animationDuration: `${w.duration}s`,
|
||||
}}
|
||||
>
|
||||
<g className="cel-bob">
|
||||
<ellipse cx="0" cy="290" rx="20" ry="6" fill={INK} opacity={m.shadow * 0.7} />
|
||||
<g stroke={INK} strokeWidth="4" strokeLinejoin="round" strokeLinecap="round">
|
||||
<g className="cel-legs">
|
||||
<line className="leg-a" x1="-6" y1="272" x2="-11" y2="288" stroke={INK} />
|
||||
<line className="leg-b" x1="6" y1="272" x2="11" y2="288" stroke={INK} />
|
||||
</g>
|
||||
<rect x="-15" y="232" width="30" height="44" rx="13" fill={w.shirt} />
|
||||
{heatWave && !storm && (
|
||||
<g className="cel-fan">
|
||||
<rect x="16" y="238" width="16" height="5" rx="2" fill="#FFF6DC" />
|
||||
</g>
|
||||
)}
|
||||
<circle cx="0" cy="220" r="16" fill={w.skin} />
|
||||
<path d="M-16 217 a16 16 0 0 1 32 0 q-16 -10 -32 0 Z" fill={w.hair} />
|
||||
<g fill={INK} stroke="none">
|
||||
<circle cx={w.fromLeft ? 3 : -3} cy="222" r="2.2" />
|
||||
<circle cx={w.fromLeft ? 10 : -10} cy="222" r="2.2" />
|
||||
</g>
|
||||
<path
|
||||
d={`M${w.fromLeft ? 2 : -2} 230 q${w.fromLeft ? 5 : -5} 4 ${w.fromLeft ? 9 : -9} 0`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
{storm && (
|
||||
<g>
|
||||
<path d="M-26 196 a26 26 0 0 1 52 0 Z" fill="#2EC4B6" />
|
||||
<line x1="0" y1="196" x2="0" y2="228" />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* --- road works -------------------------------------------- */}
|
||||
{streetCrew && (
|
||||
<g stroke={INK} strokeWidth="4" strokeLinejoin="round">
|
||||
{[68, 572].map((x) => (
|
||||
<g key={x}>
|
||||
<path d={`M${x - 18} 288 L${x} 240 L${x + 18} 288 Z`} fill="#FF7A29" />
|
||||
<path d={`M${x - 11} 270 h22`} stroke="#FFF6DC" strokeWidth="7" />
|
||||
<rect x={x - 24} y="286" width="48" height="10" rx="3" fill="#FF7A29" />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* --- weather overlays -------------------------------------- */}
|
||||
{storm && (
|
||||
<>
|
||||
<g className="cel-rain" stroke="#CFE6FF" strokeWidth="3" strokeLinecap="round" opacity="0.75">
|
||||
{Array.from({ length: 40 }, (_, i) => {
|
||||
const x = (i * 71) % 660
|
||||
const y = (i * 53) % 300
|
||||
return <line key={i} x1={x} y1={y} x2={x - 7} y2={y + 22} />
|
||||
})}
|
||||
</g>
|
||||
<polygon
|
||||
className="cel-bolt"
|
||||
points="470,20 424,120 456,120 428,206 512,96 472,96 506,20"
|
||||
fill="#FFF3A8"
|
||||
stroke={INK}
|
||||
strokeWidth="4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect className="cel-flash" width="640" height="300" fill="#FFFFFF" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{heatWave && (
|
||||
<g className="cel-shimmer" stroke="#FFE9A8" strokeWidth="4" fill="none" opacity="0.5">
|
||||
{[262, 274, 286].map((y) => (
|
||||
<path key={y} d={`M-20 ${y} q22 -9 44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0 t44 0`} />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
<rect width="640" height="300" fill={m.wash} opacity={m.washOpacity} />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MAX_SIGNS, SIGN_COST } from '../game/constants'
|
||||
import { dollars } from '../game/engine'
|
||||
import { dollars, streetBusyness } from '../game/engine'
|
||||
import type { DayConditions, Decision, Player } from '../game/types'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { Scene } from './Scene'
|
||||
@@ -48,8 +48,10 @@ export function DecideScreen({
|
||||
const left = player.assets - total
|
||||
|
||||
const error = useMemo(() => {
|
||||
if (signCost > player.assets) return 'YOU CANNOT AFFORD THAT MANY SIGNS.'
|
||||
if (total > player.assets) return "YOU DON'T HAVE ENOUGH MONEY TO MAKE THAT MANY GLASSES."
|
||||
// Kept to one line each: the row below is always reserved, so a message
|
||||
// that wrapped would shift the button after all.
|
||||
if (signCost > player.assets) return 'NOT ENOUGH MONEY FOR THAT MANY SIGNS.'
|
||||
if (total > player.assets) return 'NOT ENOUGH MONEY FOR THAT MANY GLASSES.'
|
||||
if (d.glasses === 0) return 'YOU MUST MAKE AT LEAST ONE GLASS.'
|
||||
return null
|
||||
}, [signCost, total, player.assets, d.glasses])
|
||||
@@ -77,7 +79,11 @@ export function DecideScreen({
|
||||
<Line className="center inv-line">
|
||||
{playerCount > 1 ? `${player.name} - DAY ${conditions.day}` : `DAY ${conditions.day}`}
|
||||
</Line>
|
||||
<Scene conditions={{ ...conditions, storm: false }} price={d.price} />
|
||||
<Scene
|
||||
conditions={{ ...conditions, storm: false }}
|
||||
price={d.price}
|
||||
traffic={streetBusyness(conditions)}
|
||||
/>
|
||||
<Line>
|
||||
ASSETS <span className="money">{dollars(player.assets)}</span> · LEMONADE COSTS{' '}
|
||||
{conditions.costPerGlass}¢ A GLASS
|
||||
@@ -125,7 +131,8 @@ export function DecideScreen({
|
||||
TODAY'S OUTLAY <span className="money">{dollars(total)}</span> · LEFT IN TIN{' '}
|
||||
<span className={left < 0 ? 'warn' : 'money'}>{dollars(left)}</span>
|
||||
</Line>
|
||||
{error && <Line className="warn">{error}</Line>}
|
||||
{/* Always present, so showing or clearing it never moves the button. */}
|
||||
<Line className={`warn hint ${error ? 'is-on' : ''}`}>{error ?? '\u00a0'}</Line>
|
||||
<div className="row center">
|
||||
<Btn kind="primary" onClick={submit} disabled={!!error}>
|
||||
SELL LEMONADE
|
||||
|
||||
@@ -2,9 +2,7 @@ import { STARTING_ASSETS } from '../game/constants'
|
||||
import { dollars } from '../game/engine'
|
||||
import type { DayResult, Player } from '../game/types'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { bannerRows } from './logo'
|
||||
|
||||
const BANNER = bannerRows('LEMONADE')
|
||||
import { Wordmark } from './TitleScreen'
|
||||
|
||||
export function GameOverScreen({
|
||||
players,
|
||||
@@ -29,9 +27,7 @@ export function GameOverScreen({
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<pre className="banner small" aria-hidden>
|
||||
{BANNER.join('\n')}
|
||||
</pre>
|
||||
<Wordmark small />
|
||||
<Line className="center inv-line">THE SUMMER IS OVER</Line>
|
||||
<Line />
|
||||
<Line className="center">
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { DayConditions } from '../game/types'
|
||||
|
||||
const PALETTE: Record<string, string> = {
|
||||
r: '#d24b3e', // awning red
|
||||
w: '#f2f6ff', // awning white
|
||||
n: '#8a5a2b', // timber
|
||||
N: '#5e3c1c', // timber shadow
|
||||
y: '#e0a92b', // lemon shade
|
||||
Y: '#f7de5a', // lemon highlight
|
||||
g: '#9aa6bd', // cloud grey
|
||||
G: '#5f6b85', // storm grey
|
||||
k: '#20263a',
|
||||
c: '#7fd4ff',
|
||||
o: '#f08a24',
|
||||
e: '#5fae4a',
|
||||
}
|
||||
|
||||
const CELL = 8
|
||||
const COLS = 40
|
||||
const ROWS = 22
|
||||
|
||||
/** Body of the stand, 28 cells wide. The awning above it is generated. */
|
||||
const STAND = [
|
||||
' N yyyyy N ',
|
||||
' N yYYYYYy N ',
|
||||
' N yYYYYYyy N ',
|
||||
' N yYYYYYy N ',
|
||||
' N yyyyy N ',
|
||||
' nnnnnnnnnnnnnnnnnnnnn ',
|
||||
' nwwwwwwwwwwwwwwwwwwwn ',
|
||||
' nwwwwwwwwwwwwwwwwwwwn ',
|
||||
' nwwwwwwwwwwwwwwwwwwwn ',
|
||||
' nwwwwwwwwwwwwwwwwwwwn ',
|
||||
' N N ',
|
||||
' N N ',
|
||||
]
|
||||
|
||||
const STAND_X = 6
|
||||
const AWNING_Y = 5
|
||||
const STAND_Y = 8
|
||||
/** Each awning row is symmetric about the counter, and one wider than the last. */
|
||||
const AWNING_ROWS: [number, number][] = [
|
||||
[5, 21],
|
||||
[4, 22],
|
||||
[3, 23],
|
||||
]
|
||||
|
||||
interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
fill: string
|
||||
}
|
||||
|
||||
/** Turn a character grid into horizontal runs so the SVG stays small. */
|
||||
function runs(rows: string[], ox: number, oy: number): Rect[] {
|
||||
const out: Rect[] = []
|
||||
rows.forEach((row, ry) => {
|
||||
let x = 0
|
||||
while (x < row.length) {
|
||||
const ch = row[x]
|
||||
if (ch === ' ' || !PALETTE[ch]) {
|
||||
x++
|
||||
continue
|
||||
}
|
||||
let w = 1
|
||||
while (row[x + w] === ch) w++
|
||||
out.push({ x: ox + x, y: oy + ry, w, fill: PALETTE[ch] })
|
||||
x += w
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** A filled circle, quantised to the pixel grid. */
|
||||
function disc(cx: number, cy: number, r: number, fill: string): Rect[] {
|
||||
const out: Rect[] = []
|
||||
for (let y = -r; y <= r; y++) {
|
||||
const half = Math.floor(Math.sqrt(Math.max(0, r * r - y * y)))
|
||||
out.push({ x: cx - half, y: cy + y, w: half * 2 + 1, fill })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Red and white stripes that line up vertically from row to row. */
|
||||
function awning(): Rect[] {
|
||||
const out: Rect[] = []
|
||||
AWNING_ROWS.forEach(([from, to], row) => {
|
||||
for (let col = from; col <= to; col++) {
|
||||
out.push({
|
||||
x: STAND_X + col,
|
||||
y: AWNING_Y + row,
|
||||
w: 1,
|
||||
fill: (col >> 1) % 2 === 0 ? PALETTE.r : PALETTE.w,
|
||||
})
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function cloud(cx: number, cy: number, fill: string): Rect[] {
|
||||
return [
|
||||
...disc(cx, cy, 2, fill),
|
||||
...disc(cx - 3, cy + 1, 2, fill),
|
||||
...disc(cx + 3, cy + 1, 2, fill),
|
||||
{ x: cx - 6, y: cy + 2, w: 13, fill },
|
||||
{ x: cx - 5, y: cy + 3, w: 11, fill },
|
||||
]
|
||||
}
|
||||
|
||||
export function PixelScene({ conditions, price }: { conditions: DayConditions; price?: number }) {
|
||||
const { weather, heatWave, streetCrew, storm } = conditions
|
||||
|
||||
const sky = useMemo(() => {
|
||||
if (storm) return ['#2b3350', '#3d4468']
|
||||
if (weather === 'cloudy') return ['#5b6b93', '#8fa0c4']
|
||||
if (weather === 'hot') return heatWave ? ['#c9541f', '#f0a13a'] : ['#e07a24', '#f5c164']
|
||||
return ['#2f7fd8', '#8fd0f5']
|
||||
}, [weather, heatWave, storm])
|
||||
|
||||
const rects: Rect[] = []
|
||||
|
||||
if (storm) {
|
||||
rects.push(...cloud(10, 4, PALETTE.G), ...cloud(24, 3, PALETTE.G), ...cloud(33, 5, PALETTE.G))
|
||||
} else if (weather === 'cloudy') {
|
||||
rects.push(...cloud(9, 4, PALETTE.g), ...cloud(26, 3, PALETTE.g), ...cloud(34, 6, PALETTE.g))
|
||||
} else {
|
||||
const sunX = weather === 'hot' ? 32 : 33
|
||||
const sunR = weather === 'hot' ? 5 : 3
|
||||
rects.push(...disc(sunX, 5, sunR, heatWave ? '#fff3b0' : PALETTE.Y))
|
||||
if (weather === 'sunny') rects.push(...cloud(9, 3, PALETTE.w))
|
||||
}
|
||||
|
||||
rects.push(...awning(), ...runs(STAND, STAND_X, STAND_Y))
|
||||
|
||||
// Grass line under the stand.
|
||||
rects.push({ x: 0, y: ROWS - 1, w: COLS, fill: PALETTE.e })
|
||||
rects.push({ x: 0, y: ROWS - 2, w: COLS, fill: '#6fc258' })
|
||||
|
||||
if (streetCrew) {
|
||||
// A pair of road cones and a barrier where the customers used to walk.
|
||||
for (const cx of [2, 36]) {
|
||||
rects.push(
|
||||
{ x: cx, y: ROWS - 5, w: 1, fill: PALETTE.o },
|
||||
{ x: cx - 1, y: ROWS - 4, w: 3, fill: PALETTE.o },
|
||||
{ x: cx - 1, y: ROWS - 3, w: 3, fill: PALETTE.o },
|
||||
{ x: cx - 2, y: ROWS - 2, w: 5, fill: '#3a3f52' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`scene ${storm ? 'is-storm' : ''} ${heatWave ? 'is-heat' : ''}`}
|
||||
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
|
||||
role="img"
|
||||
aria-label={`${weather} day at the lemonade stand`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={sky[0]} />
|
||||
<stop offset="100%" stopColor={sky[1]} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width={COLS * CELL} height={ROWS * CELL} fill="url(#sky)" />
|
||||
|
||||
{heatWave && (
|
||||
<g className="shimmer" opacity="0.35">
|
||||
{[13, 15, 17].map((y) => (
|
||||
<rect key={y} x={0} y={y * CELL} width={COLS * CELL} height={CELL / 2} fill="#ffd9a0" />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{rects.map((r, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={r.x * CELL}
|
||||
y={r.y * CELL}
|
||||
width={r.w * CELL}
|
||||
height={CELL}
|
||||
fill={r.fill}
|
||||
shapeRendering="crispEdges"
|
||||
/>
|
||||
))}
|
||||
|
||||
{storm && (
|
||||
<>
|
||||
<g className="rain">
|
||||
{Array.from({ length: 26 }, (_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={((i * 37) % (COLS * CELL))}
|
||||
y={((i * 53) % 120) + 40}
|
||||
width={2}
|
||||
height={10}
|
||||
fill="#bcd8ff"
|
||||
opacity="0.75"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<polygon className="bolt" points="212,40 188,96 208,96 192,148 232,84 210,84 228,40" fill="#fff3a8" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<text className="stand-sign" x={156} y={price === undefined ? 132 : 125} textAnchor="middle">
|
||||
LEMONADE
|
||||
</text>
|
||||
{price !== undefined && (
|
||||
<text className="stand-price" x={156} y={137} textAnchor="middle">
|
||||
{price}¢ A GLASS
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { WEATHER } from '../game/constants'
|
||||
import { dollars } from '../game/engine'
|
||||
import { dollars, soldBusyness } from '../game/engine'
|
||||
import type { DayConditions, DayResult, Player } from '../game/types'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { Scene } from './Scene'
|
||||
@@ -30,40 +30,14 @@ export function ReportScreen({
|
||||
onRetire: () => void
|
||||
onBlip: () => void
|
||||
}) {
|
||||
// App remounts this each day, so these start fresh without a reset effect.
|
||||
// App remounts this each day, so this starts fresh without a reset effect.
|
||||
const [index, setIndex] = useState(0)
|
||||
// The storm gets its own screen before the books are opened.
|
||||
const [stormSeen, setStormSeen] = useState(false)
|
||||
|
||||
const r = results[index]
|
||||
const player = players.find((p) => p.id === r.playerId)!
|
||||
const isLast = index === results.length - 1
|
||||
const everyoneBroke = players.every((p) => p.bankrupt)
|
||||
|
||||
if (conditions.storm && !stormSeen) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<Line className="center inv-line">DAY {conditions.day} IN LEMONSVILLE</Line>
|
||||
<Scene conditions={conditions} />
|
||||
<Line className="warn">A THUNDERSTORM HIT LEMONSVILLE EARLIER</Line>
|
||||
<Line className="warn">TODAY, JUST AS THE STANDS WERE BEING</Line>
|
||||
<Line className="warn">SET UP. EVERYTHING WAS RUINED!!</Line>
|
||||
<Line />
|
||||
<div className="row center">
|
||||
<Btn
|
||||
kind="primary"
|
||||
onClick={() => {
|
||||
onBlip()
|
||||
setStormSeen(true)
|
||||
}}
|
||||
>
|
||||
SEE THE DAMAGE
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const events = [
|
||||
WEATHER[conditions.weather].label,
|
||||
conditions.heatWave ? 'HEAT WAVE' : null,
|
||||
@@ -77,7 +51,12 @@ export function ReportScreen({
|
||||
<div className="stack">
|
||||
<Line className="center inv-line">$$ LEMONSVILLE DAILY FINANCIAL REPORT $$</Line>
|
||||
|
||||
<Line />
|
||||
<Scene
|
||||
conditions={conditions}
|
||||
price={r.decision.price}
|
||||
traffic={soldBusyness(r.glassesSold)}
|
||||
variant="strip"
|
||||
/>
|
||||
<Line className="center accent">
|
||||
DAY {conditions.day} — {player.name}
|
||||
</Line>
|
||||
@@ -95,6 +74,7 @@ export function ReportScreen({
|
||||
<Row label="PROFIT" value={dollars(r.profit)} strong />
|
||||
<Row label="ASSETS" value={dollars(r.assetsAfter)} strong />
|
||||
|
||||
|
||||
{player.bankrupt && (
|
||||
<>
|
||||
<Line />
|
||||
@@ -103,7 +83,6 @@ export function ReportScreen({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Line />
|
||||
<div className="row center">
|
||||
{!isLast && (
|
||||
<Btn
|
||||
|
||||
+25
-214
@@ -1,218 +1,29 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSkin } from '../skin'
|
||||
import type { DayConditions } from '../game/types'
|
||||
import { CelScene } from './CelScene'
|
||||
import { PixelScene } from './PixelScene'
|
||||
|
||||
const PALETTE: Record<string, string> = {
|
||||
r: '#d24b3e', // awning red
|
||||
w: '#f2f6ff', // awning white
|
||||
n: '#8a5a2b', // timber
|
||||
N: '#5e3c1c', // timber shadow
|
||||
y: '#e0a92b', // lemon shade
|
||||
Y: '#f7de5a', // lemon highlight
|
||||
g: '#9aa6bd', // cloud grey
|
||||
G: '#5f6b85', // storm grey
|
||||
k: '#20263a',
|
||||
c: '#7fd4ff',
|
||||
o: '#f08a24',
|
||||
e: '#5fae4a',
|
||||
}
|
||||
|
||||
const CELL = 8
|
||||
const COLS = 40
|
||||
const ROWS = 22
|
||||
|
||||
/** Body of the stand, 28 cells wide. The awning above it is generated. */
|
||||
const STAND = [
|
||||
' N yyyyy N ',
|
||||
' N yYYYYYy N ',
|
||||
' N yYYYYYyy N ',
|
||||
' N yYYYYYy N ',
|
||||
' N yyyyy N ',
|
||||
' nnnnnnnnnnnnnnnnnnnnn ',
|
||||
' nwwwwwwwwwwwwwwwwwwwn ',
|
||||
' nwwwwwwwwwwwwwwwwwwwn ',
|
||||
' nwwwwwwwwwwwwwwwwwwwn ',
|
||||
' nwwwwwwwwwwwwwwwwwwwn ',
|
||||
' N N ',
|
||||
' N N ',
|
||||
]
|
||||
|
||||
const STAND_X = 6
|
||||
const AWNING_Y = 5
|
||||
const STAND_Y = 8
|
||||
/** Each awning row is symmetric about the counter, and one wider than the last. */
|
||||
const AWNING_ROWS: [number, number][] = [
|
||||
[5, 21],
|
||||
[4, 22],
|
||||
[3, 23],
|
||||
]
|
||||
|
||||
interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
fill: string
|
||||
}
|
||||
|
||||
/** Turn a character grid into horizontal runs so the SVG stays small. */
|
||||
function runs(rows: string[], ox: number, oy: number): Rect[] {
|
||||
const out: Rect[] = []
|
||||
rows.forEach((row, ry) => {
|
||||
let x = 0
|
||||
while (x < row.length) {
|
||||
const ch = row[x]
|
||||
if (ch === ' ' || !PALETTE[ch]) {
|
||||
x++
|
||||
continue
|
||||
}
|
||||
let w = 1
|
||||
while (row[x + w] === ch) w++
|
||||
out.push({ x: ox + x, y: oy + ry, w, fill: PALETTE[ch] })
|
||||
x += w
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** A filled circle, quantised to the pixel grid. */
|
||||
function disc(cx: number, cy: number, r: number, fill: string): Rect[] {
|
||||
const out: Rect[] = []
|
||||
for (let y = -r; y <= r; y++) {
|
||||
const half = Math.floor(Math.sqrt(Math.max(0, r * r - y * y)))
|
||||
out.push({ x: cx - half, y: cy + y, w: half * 2 + 1, fill })
|
||||
/**
|
||||
* The same day, drawn twice: chunky Atari rectangles for the 1979 skin,
|
||||
* cel-shaded vector art for the modern one.
|
||||
*/
|
||||
export function Scene({
|
||||
conditions,
|
||||
price,
|
||||
traffic,
|
||||
variant = 'full',
|
||||
}: {
|
||||
conditions: DayConditions
|
||||
price?: number
|
||||
/** How busy the street is, 0 to 1. Only the cel scene shows a crowd. */
|
||||
traffic?: number
|
||||
/** "strip" crops to the counter and the street, for screens short on room. */
|
||||
variant?: 'full' | 'strip'
|
||||
}) {
|
||||
const { skin } = useSkin()
|
||||
if (skin !== 'modern') {
|
||||
// The 1979 financial report was a page of text, and stays one.
|
||||
return variant === 'strip' ? null : <PixelScene conditions={conditions} price={price} />
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Red and white stripes that line up vertically from row to row. */
|
||||
function awning(): Rect[] {
|
||||
const out: Rect[] = []
|
||||
AWNING_ROWS.forEach(([from, to], row) => {
|
||||
for (let col = from; col <= to; col++) {
|
||||
out.push({
|
||||
x: STAND_X + col,
|
||||
y: AWNING_Y + row,
|
||||
w: 1,
|
||||
fill: (col >> 1) % 2 === 0 ? PALETTE.r : PALETTE.w,
|
||||
})
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function cloud(cx: number, cy: number, fill: string): Rect[] {
|
||||
return [
|
||||
...disc(cx, cy, 2, fill),
|
||||
...disc(cx - 3, cy + 1, 2, fill),
|
||||
...disc(cx + 3, cy + 1, 2, fill),
|
||||
{ x: cx - 6, y: cy + 2, w: 13, fill },
|
||||
{ x: cx - 5, y: cy + 3, w: 11, fill },
|
||||
]
|
||||
}
|
||||
|
||||
export function Scene({ conditions, price }: { conditions: DayConditions; price?: number }) {
|
||||
const { weather, heatWave, streetCrew, storm } = conditions
|
||||
|
||||
const sky = useMemo(() => {
|
||||
if (storm) return ['#2b3350', '#3d4468']
|
||||
if (weather === 'cloudy') return ['#5b6b93', '#8fa0c4']
|
||||
if (weather === 'hot') return heatWave ? ['#c9541f', '#f0a13a'] : ['#e07a24', '#f5c164']
|
||||
return ['#2f7fd8', '#8fd0f5']
|
||||
}, [weather, heatWave, storm])
|
||||
|
||||
const rects: Rect[] = []
|
||||
|
||||
if (storm) {
|
||||
rects.push(...cloud(10, 4, PALETTE.G), ...cloud(24, 3, PALETTE.G), ...cloud(33, 5, PALETTE.G))
|
||||
} else if (weather === 'cloudy') {
|
||||
rects.push(...cloud(9, 4, PALETTE.g), ...cloud(26, 3, PALETTE.g), ...cloud(34, 6, PALETTE.g))
|
||||
} else {
|
||||
const sunX = weather === 'hot' ? 32 : 33
|
||||
const sunR = weather === 'hot' ? 5 : 3
|
||||
rects.push(...disc(sunX, 5, sunR, heatWave ? '#fff3b0' : PALETTE.Y))
|
||||
if (weather === 'sunny') rects.push(...cloud(9, 3, PALETTE.w))
|
||||
}
|
||||
|
||||
rects.push(...awning(), ...runs(STAND, STAND_X, STAND_Y))
|
||||
|
||||
// Grass line under the stand.
|
||||
rects.push({ x: 0, y: ROWS - 1, w: COLS, fill: PALETTE.e })
|
||||
rects.push({ x: 0, y: ROWS - 2, w: COLS, fill: '#6fc258' })
|
||||
|
||||
if (streetCrew) {
|
||||
// A pair of road cones and a barrier where the customers used to walk.
|
||||
for (const cx of [2, 36]) {
|
||||
rects.push(
|
||||
{ x: cx, y: ROWS - 5, w: 1, fill: PALETTE.o },
|
||||
{ x: cx - 1, y: ROWS - 4, w: 3, fill: PALETTE.o },
|
||||
{ x: cx - 1, y: ROWS - 3, w: 3, fill: PALETTE.o },
|
||||
{ x: cx - 2, y: ROWS - 2, w: 5, fill: '#3a3f52' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`scene ${storm ? 'is-storm' : ''} ${heatWave ? 'is-heat' : ''}`}
|
||||
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
|
||||
role="img"
|
||||
aria-label={`${weather} day at the lemonade stand`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={sky[0]} />
|
||||
<stop offset="100%" stopColor={sky[1]} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width={COLS * CELL} height={ROWS * CELL} fill="url(#sky)" />
|
||||
|
||||
{heatWave && (
|
||||
<g className="shimmer" opacity="0.35">
|
||||
{[13, 15, 17].map((y) => (
|
||||
<rect key={y} x={0} y={y * CELL} width={COLS * CELL} height={CELL / 2} fill="#ffd9a0" />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{rects.map((r, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={r.x * CELL}
|
||||
y={r.y * CELL}
|
||||
width={r.w * CELL}
|
||||
height={CELL}
|
||||
fill={r.fill}
|
||||
shapeRendering="crispEdges"
|
||||
/>
|
||||
))}
|
||||
|
||||
{storm && (
|
||||
<>
|
||||
<g className="rain">
|
||||
{Array.from({ length: 26 }, (_, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={((i * 37) % (COLS * CELL))}
|
||||
y={((i * 53) % 120) + 40}
|
||||
width={2}
|
||||
height={10}
|
||||
fill="#bcd8ff"
|
||||
opacity="0.75"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<polygon className="bolt" points="212,40 188,96 208,96 192,148 232,84 210,84 228,40" fill="#fff3a8" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<text className="stand-sign" x={156} y={price === undefined ? 132 : 125} textAnchor="middle">
|
||||
LEMONADE
|
||||
</text>
|
||||
{price !== undefined && (
|
||||
<text className="stand-price" x={156} y={137} textAnchor="middle">
|
||||
{price}¢ A GLASS
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
return <CelScene conditions={conditions} price={price} traffic={traffic} variant={variant} />
|
||||
}
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { useSkin } from '../skin'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { bannerRows } from './logo'
|
||||
|
||||
const BANNER = bannerRows('LEMONADE')
|
||||
|
||||
/** The block-letter banner is the 1979 article; modern gets a wordmark. */
|
||||
export function Wordmark({ small = false }: { small?: boolean }) {
|
||||
const { skin } = useSkin()
|
||||
if (skin === 'modern') {
|
||||
return (
|
||||
<div className={`wordmark ${small ? 'small' : ''}`}>
|
||||
<b>LEMONADE</b>
|
||||
<span>STAND</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<pre className={`banner ${small ? 'small' : ''}`} aria-label="LEMONADE">
|
||||
{BANNER.join('\n')}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitleScreen({
|
||||
onStart,
|
||||
onInstructions,
|
||||
@@ -14,15 +33,17 @@ export function TitleScreen({
|
||||
onScores: () => void
|
||||
seed: number
|
||||
}) {
|
||||
const { skin } = useSkin()
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<pre className="banner" aria-label="LEMONADE">
|
||||
{BANNER.join('\n')}
|
||||
</pre>
|
||||
<Line className="center accent">S T A N D</Line>
|
||||
<Wordmark />
|
||||
{skin === 'crt' && <Line className="center accent">S T A N D</Line>}
|
||||
<Line />
|
||||
<Line className="center">LEMONSVILLE, CALIFORNIA</Line>
|
||||
<Line className="center dim">ATARI 8-BIT EDITION · SEED {seed}</Line>
|
||||
<Line className="center dim">
|
||||
{skin === 'crt' ? 'ATARI 8-BIT EDITION' : 'REMASTERED'} · SEED {seed}
|
||||
</Line>
|
||||
<Line />
|
||||
<Line className="center blink">PRESS START</Line>
|
||||
<Line />
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { soldBusyness } from '../game/engine'
|
||||
import type { DayConditions, DayResult, Player } from '../game/types'
|
||||
import { synth } from '../audio/synth'
|
||||
import { Btn, Line } from './Crt'
|
||||
import { Scene } from './Scene'
|
||||
|
||||
const TRADING_MS = 2900
|
||||
const STORM_MS = 3400
|
||||
|
||||
/** Fast at first, settling at the end, the way a rush tails off. */
|
||||
const easeOut = (p: number) => 1 - Math.pow(1 - p, 2.2)
|
||||
|
||||
export function TradingScreen({
|
||||
results,
|
||||
players,
|
||||
conditions,
|
||||
onDone,
|
||||
}: {
|
||||
results: DayResult[]
|
||||
players: Player[]
|
||||
conditions: DayConditions
|
||||
onDone: () => void
|
||||
}) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const ticked = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const total = results.reduce((n, r) => n + r.glassesSold, 0)
|
||||
const span = conditions.storm ? STORM_MS : TRADING_MS
|
||||
const startedAt = performance.now()
|
||||
let raf = 0
|
||||
|
||||
if (conditions.storm) synth.thunder()
|
||||
|
||||
const frame = (now: number) => {
|
||||
const p = Math.min(1, (now - startedAt) / span)
|
||||
setProgress(p)
|
||||
|
||||
// A blip per few glasses, so a busy day sounds busy.
|
||||
if (!conditions.storm && total > 0) {
|
||||
const sold = Math.round(easeOut(p) * total)
|
||||
const step = Math.max(1, Math.ceil(total / 14))
|
||||
if (sold >= ticked.current + step) {
|
||||
ticked.current = sold
|
||||
synth.tick()
|
||||
}
|
||||
}
|
||||
|
||||
if (p < 1) raf = requestAnimationFrame(frame)
|
||||
else onDone()
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [results, conditions.storm, onDone])
|
||||
|
||||
const eased = easeOut(progress)
|
||||
const sold = (r: DayResult) => Math.round(eased * r.glassesSold)
|
||||
const soldTotal = results.reduce((n, r) => n + sold(r), 0)
|
||||
const madeTotal = results.reduce((n, r) => n + r.decision.glasses, 0)
|
||||
|
||||
if (conditions.storm) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<Line className="center inv-line">DAY {conditions.day} IN LEMONSVILLE</Line>
|
||||
<Scene conditions={conditions} traffic={0.2} />
|
||||
<Line className="warn">A THUNDERSTORM HIT LEMONSVILLE EARLIER</Line>
|
||||
<Line className="warn">TODAY, JUST AS THE STANDS WERE BEING</Line>
|
||||
<Line className="warn">SET UP. EVERYTHING WAS RUINED!!</Line>
|
||||
<Line />
|
||||
<div className="row center">
|
||||
<Btn kind="primary" onClick={onDone}>
|
||||
SEE THE DAMAGE
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<Line className="center inv-line">DAY {conditions.day} — OPEN FOR BUSINESS</Line>
|
||||
<Scene
|
||||
conditions={conditions}
|
||||
price={results[0].decision.price}
|
||||
traffic={soldBusyness(results[0].glassesSold)}
|
||||
/>
|
||||
|
||||
<Line className="center accent">
|
||||
{soldTotal} OF {madeTotal} GLASSES SOLD
|
||||
</Line>
|
||||
<div className="till" aria-hidden>
|
||||
<span style={{ width: `${Math.round(progress * 100)}%` }} />
|
||||
</div>
|
||||
|
||||
{results.length > 1 &&
|
||||
results.map((r) => {
|
||||
const player = players.find((p) => p.id === r.playerId)!
|
||||
return (
|
||||
<div className="report-row" key={r.playerId}>
|
||||
<span className="report-label">{player.name}</span>
|
||||
<span className="report-dots" aria-hidden />
|
||||
<span className="report-value">{sold(r)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<Line />
|
||||
<div className="row center">
|
||||
<Btn kind="ghost" onClick={onDone}>
|
||||
SKIP TO THE BOOKS
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user