Files
BMO/src/bmo/SeasonalOverlay.tsx
T
peji 0f027e863b BMO: a living desktop buddy with a Claude brain
Standalone React + Vite app where BMO lives in a little garden:
- Redrawn avatar (src/avatar) with depth, cartoon controls and expressive
  faces; original bmo-preview avatar and scenery kept in src/bmo
- Idle life: wandering, chatter, boredom, sleep, pokes, weather and
  time-of-day reactions
- Chat via a local Node server streaming Claude replies (Haiku by default),
  with live scene awareness and tools to change color, outfit, sky,
  weather, move and perform
- Side menu for API key, appearance, environment and chat log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:05:50 -04:00

218 lines
5.6 KiB
TypeScript

// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
/**
* SeasonalOverlay — renders ground-level seasonal decorations as an SVG group.
*
* Supported overlays: autumn-leaves, snow, cherry-blossoms.
* Positions are deterministic (index-based). No Math.random().
* Cherry blossom petals in the air use a gentle drift animation
* (garden-pollen-drift) — same keyframe, different colors.
*/
import { memo } from "react";
interface SeasonalOverlayProps {
overlay: string;
sceneWidth: number;
sceneHeight: number;
}
/** Deterministic value in [0, range) for a given index + seed. */
function det(i: number, range: number, seed = 37): number {
return ((i * seed + i * i * 13) % 1000) / 1000 * range;
}
function detRange(i: number, min: number, max: number, seed = 37): number {
return min + det(i, max - min, seed);
}
// ---- Autumn leaves ----
const AUTUMN_COLORS = ["#f59e0b", "#ea580c", "#dc2626", "#92400e", "#d97706", "#b45309"];
function leafPath(): string {
return "M0,-3 C1.5,-1.5 3,0 1.5,3 C0,4.5 -1.5,3 0,0 C-1.5,-1.5 -3,0 0,-3Z";
}
function AutumnLeaves({
count,
sceneWidth,
sceneHeight,
}: {
count: number;
sceneWidth: number;
sceneHeight: number;
}) {
const groundMinY = sceneHeight * 0.60;
const groundMaxY = sceneHeight * 0.70;
return (
<>
{Array.from({ length: count }, (_, i) => {
const x = detRange(i, 10, sceneWidth - 10, 41);
const y = detRange(i, groundMinY, groundMaxY, 59);
const rotate = detRange(i, -45, 45, 73);
const scale = detRange(i, 0.6, 1.2, 89);
const color = AUTUMN_COLORS[i % AUTUMN_COLORS.length];
return (
<path
key={i}
d={leafPath()}
fill={color}
fillOpacity={0.65}
transform={`translate(${x.toFixed(1)},${y.toFixed(1)}) rotate(${rotate.toFixed(1)}) scale(${scale.toFixed(2)})`}
/>
);
})}
</>
);
}
// ---- Snow ----
function SnowOverlay({
sceneWidth,
sceneHeight,
}: {
sceneWidth: number;
sceneHeight: number;
}) {
// Wavy ground cover path
const groundY = sceneHeight * 0.63;
const amplitude = 3;
const segments = 12;
const segW = sceneWidth / segments;
let d = `M0,${groundY}`;
for (let i = 0; i <= segments; i++) {
const x = i * segW;
const y = groundY + (i % 2 === 0 ? -amplitude : amplitude);
if (i === 0) d += ` L${x},${y}`;
else d += ` Q${x - segW / 2},${y + amplitude * 0.5} ${x},${y}`;
}
d += ` L${sceneWidth},${sceneHeight} L0,${sceneHeight} Z`;
// Small snow pile ellipses near ground features
const pilePositions = Array.from({ length: 8 }, (_, i) => ({
cx: detRange(i, 15, sceneWidth - 15, 43),
cy: detRange(i, sceneHeight * 0.60, sceneHeight * 0.67, 61),
rx: detRange(i, 6, 14, 73),
ry: detRange(i, 2, 5, 89),
}));
return (
<>
{/* Wavy ground cover */}
<path d={d} fill="white" fillOpacity={0.18} />
{/* Snow piles */}
{pilePositions.map((p, i) => (
<ellipse
key={i}
cx={p.cx}
cy={p.cy}
rx={p.rx}
ry={p.ry}
fill="white"
fillOpacity={0.22}
/>
))}
</>
);
}
// ---- Cherry blossoms ----
const BLOSSOM_COLORS = ["#fbb6ce", "#f9a8d4", "#f472b6", "#fbcfe8"];
function CherryBlossoms({
count,
sceneWidth,
sceneHeight,
}: {
count: number;
sceneWidth: number;
sceneHeight: number;
}) {
const airCount = Math.floor(count * 0.6);
const groundCount = count - airCount;
return (
<>
{/* Air petals — animated drift */}
{Array.from({ length: airCount }, (_, i) => {
const cx = detRange(i, 5, sceneWidth - 5, 43);
const cy = detRange(i, sceneHeight * 0.1, sceneHeight * 0.55, 59);
const r = detRange(i, 1, 2.5, 71);
const color = BLOSSOM_COLORS[i % BLOSSOM_COLORS.length];
const duration = detRange(i, 5, 10, 83);
const delay = detRange(i, 0, 7, 97);
return (
<circle
key={`air-${i}`}
cx={cx}
cy={cy}
r={r}
fill={color}
fillOpacity={0.55}
style={{
animation: `garden-pollen-drift ${duration.toFixed(2)}s ease-in-out ${delay.toFixed(2)}s infinite`,
}}
/>
);
})}
{/* Ground petals — static */}
{Array.from({ length: groundCount }, (_, i) => {
const cx = detRange(i, 10, sceneWidth - 10, 101);
const cy = detRange(i, sceneHeight * 0.60, sceneHeight * 0.68, 113);
const r = detRange(i, 0.8, 2, 127);
const color = BLOSSOM_COLORS[i % BLOSSOM_COLORS.length];
return (
<circle
key={`gnd-${i}`}
cx={cx}
cy={cy}
r={r}
fill={color}
fillOpacity={0.4}
/>
);
})}
</>
);
}
// ---- Main component ----
export const SeasonalOverlay = memo(function SeasonalOverlay({
overlay,
sceneWidth,
sceneHeight,
}: SeasonalOverlayProps) {
if (overlay === "none" || !overlay) return null;
if (overlay === "autumn-leaves") {
return (
<g aria-hidden="true">
<AutumnLeaves count={12} sceneWidth={sceneWidth} sceneHeight={sceneHeight} />
</g>
);
}
if (overlay === "snow") {
return (
<g aria-hidden="true">
<SnowOverlay sceneWidth={sceneWidth} sceneHeight={sceneHeight} />
</g>
);
}
if (overlay === "cherry-blossoms") {
return (
<g aria-hidden="true">
<CherryBlossoms count={18} sceneWidth={sceneWidth} sceneHeight={sceneHeight} />
</g>
);
}
return null;
});