Files
petal/web/src/effects/PetalFall.tsx
prosolis 8be852ddf2 Add a status-bar toggle to disable the falling petals
Some people find the drifting ambient petals distracting rather than
cozy, so make the whole PetalFall layer opt-out-able from a 🌸 toggle
sitting just left of the sound toggle in the status bar.

Mirrors the existing sound-mute pattern: a tiny localStorage-backed
pub/sub (effects/petals.ts) drives a PetalsToggle twin of SoundToggle.
PetalFall subscribes to the setting, stops animating and wipes the
canvas when off, and the choice persists across reloads. Works in both
day (petals) and night (stars) modes.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 17:27:00 -07:00

373 lines
13 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { isPetalsEnabled, onPetalsEnabledChange } from './petals'
// PetalFall is the cozy ambient layer: a fixed, non-interactive 2D canvas that
// drifts soft cherry-blossom petals down the page. Each petal is a real
// petal-shaped sprite (notched sakura silhouette with a gentle gradient and
// soft alpha), pre-rendered once per color and then blitted many times with
// rotation + sway. It sits behind the chrome, ignores pointer events, pauses
// when the tab is hidden, and renders nothing for prefers-reduced-motion.
// Saturated-but-soft blossom colors. Each petal fades from a light, near-white
// tip to its color at the base, so it reads as a real petal rather than a blob.
const PALETTE: { r: number; g: number; b: number }[] = [
{ r: 255, g: 150, b: 190 }, // rose pink
{ r: 255, g: 184, b: 205 }, // pale blossom
{ r: 255, g: 158, b: 130 }, // warm peach
{ r: 209, g: 160, b: 255 }, // lavender
{ r: 255, g: 122, b: 172 }, // deep rose
]
// Night-mode sparkle palette: soft starlight tones for the small twinkle accents
// scattered between the chunky cartoon stars (moonlit white, warm gold).
const STAR_PALETTE: { r: number; g: number; b: number }[] = [
{ r: 255, g: 255, b: 255 }, // white
{ r: 255, g: 240, b: 200 }, // warm gold
]
// Chunky cartoon-star colors — bold, candy-bright fills à la a Mario power star
// or a Kirby warp star. Each is drawn with a glossy gradient + a puffy outline.
const CARTOON_COLORS: { r: number; g: number; b: number }[] = [
{ r: 255, g: 201, b: 56 }, // golden power-star
{ r: 255, g: 150, b: 190 }, // bubblegum pink
{ r: 130, g: 195, b: 255 }, // sky blue
{ r: 150, g: 225, b: 190 }, // mint
{ r: 200, g: 175, b: 255 }, // lavender
]
const SPRITE_PX = 160 // off-screen render resolution per petal sprite
function rgba(c: { r: number; g: number; b: number }, a: number): string {
return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${a})`
}
function mixWhite(c: { r: number; g: number; b: number }, t: number) {
return { r: c.r + (255 - c.r) * t, g: c.g + (255 - c.g) * t, b: c.b + (255 - c.b) * t }
}
function scale(c: { r: number; g: number; b: number }, t: number) {
return { r: c.r * t, g: c.g * t, b: c.b * t }
}
// Draw one petal sprite: a notched sakura silhouette filled with a tip→base
// gradient and a faint central highlight, on a transparent canvas.
function makeSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement {
const s = SPRITE_PX
const cv = document.createElement('canvas')
cv.width = s
cv.height = s
const g = cv.getContext('2d')!
g.translate(s / 2, s / 2)
const hw = s * 0.3 // half width
const hh = s * 0.44 // half height
g.beginPath()
g.moveTo(0, hh) // pointed base
g.bezierCurveTo(hw, hh * 0.42, hw * 0.92, -hh * 0.52, hw * 0.2, -hh * 0.92) // right side → near tip
g.quadraticCurveTo(0, -hh * 0.72, -hw * 0.2, -hh * 0.92) // the sakura notch
g.bezierCurveTo(-hw * 0.92, -hh * 0.52, -hw, hh * 0.42, 0, hh) // left side → base
g.closePath()
const grad = g.createLinearGradient(0, -hh, 0, hh)
grad.addColorStop(0, rgba(mixWhite(color, 0.55), 0.78)) // light, soft tip
grad.addColorStop(0.45, rgba(color, 0.88))
grad.addColorStop(1, rgba(scale(color, 0.8), 0.95)) // deeper base
g.fillStyle = grad
g.fill()
// A soft white sheen down the centre for a delicate, glossy feel.
const sheen = g.createLinearGradient(-hw * 0.3, 0, hw * 0.3, 0)
sheen.addColorStop(0, 'rgba(255,255,255,0)')
sheen.addColorStop(0.5, 'rgba(255,255,255,0.28)')
sheen.addColorStop(1, 'rgba(255,255,255,0)')
g.fillStyle = sheen
g.fill()
return cv
}
// Draw one star sprite: a soft radial glow with a crisp four-point sparkle on
// top, filled brightest at the core so it reads as a twinkling star.
function makeStarSprite(color: { r: number; g: number; b: number }): HTMLCanvasElement {
const s = SPRITE_PX
const cv = document.createElement('canvas')
cv.width = s
cv.height = s
const g = cv.getContext('2d')!
g.translate(s / 2, s / 2)
// Soft halo behind the sparkle.
const glow = g.createRadialGradient(0, 0, 0, 0, 0, s * 0.5)
glow.addColorStop(0, rgba(mixWhite(color, 0.5), 0.5))
glow.addColorStop(0.35, rgba(color, 0.16))
glow.addColorStop(1, rgba(color, 0))
g.fillStyle = glow
g.beginPath()
g.arc(0, 0, s * 0.5, 0, Math.PI * 2)
g.fill()
// Four-point sparkle: alternate a long outer radius with a small inner one so
// the points pinch in sharply.
const R = s * 0.46
const r = s * 0.08
g.beginPath()
for (let i = 0; i < 8; i++) {
const ang = (i * Math.PI) / 4 - Math.PI / 2
const rad = i % 2 === 0 ? R : r
const x = Math.cos(ang) * rad
const y = Math.sin(ang) * rad
if (i === 0) g.moveTo(x, y)
else g.lineTo(x, y)
}
g.closePath()
const core = g.createRadialGradient(0, 0, 0, 0, 0, R)
core.addColorStop(0, rgba(mixWhite(color, 0.7), 0.98))
core.addColorStop(0.5, rgba(color, 0.9))
core.addColorStop(1, rgba(color, 0.2))
g.fillStyle = core
g.fill()
return cv
}
// Draw one chunky cartoon star — a fat five-point star with a glossy radial
// fill, a soft outer glow so it pops off the dark sky, a puffy rounded outline
// (round line joins → Kirby-ish soft points), and a little corner shine. Reads
// like a game power-up rather than a delicate sparkle.
function makeCartoonStar(base: { r: number; g: number; b: number }): HTMLCanvasElement {
const s = SPRITE_PX
const cv = document.createElement('canvas')
cv.width = s
cv.height = s
const g = cv.getContext('2d')!
g.translate(s / 2, s / 2)
const R = s * 0.4 // outer point radius
const r = R * 0.46 // inner radius — small ⇒ chunky, defined points
// Soft outer glow so the star separates from the night background.
const glow = g.createRadialGradient(0, 0, R * 0.3, 0, 0, R * 1.35)
glow.addColorStop(0, rgba(base, 0.35))
glow.addColorStop(1, rgba(base, 0))
g.fillStyle = glow
g.beginPath()
g.arc(0, 0, R * 1.35, 0, Math.PI * 2)
g.fill()
// Five-point star path (start at the top point).
const star = () => {
g.beginPath()
for (let i = 0; i < 10; i++) {
const ang = -Math.PI / 2 + (i * Math.PI) / 5
const rad = i % 2 === 0 ? R : r
const x = Math.cos(ang) * rad
const y = Math.sin(ang) * rad
if (i === 0) g.moveTo(x, y)
else g.lineTo(x, y)
}
g.closePath()
}
// Candy fill: bright near the top-left, deepening toward the lower edge.
const fill = g.createRadialGradient(-R * 0.25, -R * 0.3, R * 0.1, 0, 0, R * 1.1)
fill.addColorStop(0, rgba(mixWhite(base, 0.7), 1))
fill.addColorStop(0.5, rgba(base, 1))
fill.addColorStop(1, rgba(scale(base, 0.82), 1))
star()
g.fillStyle = fill
g.fill()
// Puffy outline in a deeper shade of the same hue (round joins soften the
// points so it feels hand-drawn, not spiky).
g.lineJoin = 'round'
g.lineCap = 'round'
g.lineWidth = s * 0.05
g.strokeStyle = rgba(scale(base, 0.5), 0.95)
star()
g.stroke()
// Glossy shine: a small soft white blob up in the top-left lobe.
const shine = g.createRadialGradient(-R * 0.22, -R * 0.32, 0, -R * 0.22, -R * 0.32, R * 0.3)
shine.addColorStop(0, 'rgba(255,255,255,0.85)')
shine.addColorStop(1, 'rgba(255,255,255,0)')
g.fillStyle = shine
g.beginPath()
g.ellipse(-R * 0.22, -R * 0.32, R * 0.26, R * 0.18, -0.5, 0, Math.PI * 2)
g.fill()
return cv
}
interface Petal {
baseX: number
y: number
size: number
vy: number // fall speed (px/s)
angle: number
spin: number // rad/s
swayAmp: number
swayFreq: number
swayPhase: number
alpha: number
sprite: number
}
export function PetalFall({ night = false }: { night?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [enabled, setEnabled] = useState(isPetalsEnabled)
// Let the status-bar toggle switch the ambient layer on/off live.
useEffect(() => onPetalsEnabledChange(setEnabled), [])
useEffect(() => {
if (!enabled) {
// User turned the ambient petals off — wipe any frame left on the canvas.
const el = canvasRef.current
el?.getContext('2d')?.clearRect(0, 0, el.width, el.height)
return
}
const reduce =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
if (reduce) return // respect users who don't want ambient motion
const el = canvasRef.current
if (!el) return
const c2d = el.getContext('2d')
if (!c2d) return
// Non-null aliases so the render closures keep the narrowed types.
const canvas = el
const ctx = c2d
// Night sky = chunky cartoon stars (the stars of the show) with a couple of
// small twinkle sparkles mixed in for depth. Random sprite pick weights it
// ~70% chunky stars.
const sprites = night
? [...CARTOON_COLORS.map(makeCartoonStar), ...STAR_PALETTE.map(makeStarSprite)]
: PALETTE.map(makeSprite)
const dpr = Math.min(window.devicePixelRatio || 1, 2)
let W = window.innerWidth
let H = window.innerHeight
// Scale petal count to the viewport so it stays sparse and gentle.
const count = Math.round(Math.min(46, Math.max(16, (W * H) / 46000)))
const rnd = (a: number, b: number) => a + Math.random() * (b - a)
// Stars are smaller, drift down a touch slower, sway less, and barely spin
// (they twinkle in place instead of tumbling like petals).
const spawn = (initial: boolean): Petal =>
night
? {
baseX: rnd(0, W),
y: initial ? rnd(-H, H) : rnd(-60, -20),
size: rnd(13, 32),
vy: rnd(14, 36),
angle: rnd(0, Math.PI * 2),
// Every star visibly spins — random direction, and a guaranteed
// minimum speed (a 5-point star is symmetric every 72°, so a slow
// rate reads as motionless). Varied so no two turn quite alike.
spin: (Math.random() < 0.5 ? -1 : 1) * rnd(0.5, 2.2),
swayAmp: 0, // stars fall straight down — no petal-like drift
swayFreq: rnd(0.4, 1.1), // still drives the twinkle shimmer below
swayPhase: rnd(0, Math.PI * 2),
alpha: rnd(0.55, 0.9),
sprite: Math.floor(Math.random() * sprites.length),
}
: {
baseX: rnd(0, W),
y: initial ? rnd(-H, H) : rnd(-60, -20),
size: rnd(16, 38),
vy: rnd(22, 52),
angle: rnd(0, Math.PI * 2),
spin: rnd(-0.9, 0.9),
swayAmp: rnd(14, 42),
swayFreq: rnd(0.3, 0.85),
swayPhase: rnd(0, Math.PI * 2),
alpha: rnd(0.5, 0.82),
sprite: Math.floor(Math.random() * sprites.length),
}
let petals = Array.from({ length: count }, () => spawn(true))
function resize() {
W = window.innerWidth
H = window.innerHeight
canvas.width = Math.floor(W * dpr)
canvas.height = Math.floor(H * dpr)
canvas.style.width = `${W}px`
canvas.style.height = `${H}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
window.addEventListener('resize', resize)
let raf = 0
let running = true
let last = performance.now()
function frame(now: number) {
if (!running) return
const dt = Math.min(0.05, (now - last) / 1000) // clamp big gaps (tab wake)
last = now
const t = now / 1000
ctx.clearRect(0, 0, W, H)
for (const p of petals) {
p.y += p.vy * dt
p.angle += p.spin * dt
const x = p.baseX + p.swayAmp * Math.sin(t * p.swayFreq + p.swayPhase)
// Recycle once fully past the bottom.
if (p.y - p.size > H) {
Object.assign(p, spawn(false))
continue
}
// Stars shimmer gently (shallow pulse so the chunky ones glow rather
// than blink); petals hold a steady alpha.
const alpha = night
? p.alpha * (0.8 + 0.2 * Math.sin(t * p.swayFreq * 2.2 + p.swayPhase))
: p.alpha
ctx.save()
ctx.translate(x, p.y)
ctx.rotate(p.angle)
ctx.globalAlpha = alpha
ctx.drawImage(sprites[p.sprite], -p.size / 2, -p.size / 2, p.size, p.size)
ctx.restore()
}
raf = requestAnimationFrame(frame)
}
const onVisibility = () => {
if (document.hidden) {
running = false
cancelAnimationFrame(raf)
} else if (!running) {
running = true
last = performance.now()
raf = requestAnimationFrame(frame)
}
}
document.addEventListener('visibilitychange', onVisibility)
raf = requestAnimationFrame(frame)
return () => {
running = false
cancelAnimationFrame(raf)
window.removeEventListener('resize', resize)
document.removeEventListener('visibilitychange', onVisibility)
petals = []
}
}, [night, enabled])
return (
<canvas
ref={canvasRef}
aria-hidden
className="petal-fall pointer-events-none fixed inset-0"
style={{ zIndex: 20 }}
/>
)
}