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
373 lines
13 KiB
TypeScript
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 }}
|
|
/>
|
|
)
|
|
}
|