0f027e863b
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>
240 lines
5.9 KiB
TypeScript
240 lines
5.9 KiB
TypeScript
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
|
/**
|
|
* WeatherEffects — SVG group that renders animated weather particles.
|
|
*
|
|
* Supports: rain, snow, falling-leaves, pollen.
|
|
* Particle count scales with `intensity` (0-1).
|
|
* Positions are deterministic (index-based) — no Math.random().
|
|
*
|
|
* All animations must be defined in index.css as garden-rain-fall,
|
|
* garden-snow-fall, garden-leaf-fall, and garden-pollen-drift.
|
|
*/
|
|
|
|
import { memo } from "react";
|
|
|
|
interface WeatherEffectsProps {
|
|
weather: string;
|
|
intensity: number;
|
|
sceneWidth: number;
|
|
sceneHeight: number;
|
|
}
|
|
|
|
// ---- Deterministic helpers ----
|
|
|
|
/** Returns a value in [0, range) based on the index using a simple hash. */
|
|
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);
|
|
}
|
|
|
|
// ---- Rain ----
|
|
|
|
function RainParticles({
|
|
count,
|
|
sceneWidth,
|
|
sceneHeight,
|
|
}: {
|
|
count: number;
|
|
sceneWidth: number;
|
|
sceneHeight: number;
|
|
}) {
|
|
return (
|
|
<>
|
|
{Array.from({ length: count }, (_, i) => {
|
|
const x = detRange(i, 0, sceneWidth, 37);
|
|
const length = detRange(i, 4, 8, 53);
|
|
const duration = detRange(i, 1, 2, 71);
|
|
const delay = detRange(i, 0, 2, 89);
|
|
const sw = detRange(i, 0.5, 1, 43);
|
|
return (
|
|
<line
|
|
key={i}
|
|
x1={x}
|
|
y1={0}
|
|
x2={x}
|
|
y2={length}
|
|
stroke="rgba(100,160,255,0.3)"
|
|
strokeWidth={sw}
|
|
style={{
|
|
animation: `garden-rain-fall ${duration.toFixed(2)}s linear ${delay.toFixed(2)}s infinite`,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---- Snow ----
|
|
|
|
function SnowParticles({
|
|
count,
|
|
sceneWidth,
|
|
}: {
|
|
count: number;
|
|
sceneWidth: number;
|
|
}) {
|
|
return (
|
|
<>
|
|
{Array.from({ length: count }, (_, i) => {
|
|
const cx = detRange(i, 0, sceneWidth, 41);
|
|
const cy = detRange(i, 0, 20, 59);
|
|
const r = detRange(i, 0.5, 1.5, 67);
|
|
const opacity = detRange(i, 0.3, 0.6, 79);
|
|
const duration = detRange(i, 4, 8, 97);
|
|
const delay = detRange(i, 0, 5, 113);
|
|
return (
|
|
<circle
|
|
key={i}
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill="white"
|
|
fillOpacity={opacity}
|
|
style={{
|
|
animation: `garden-snow-fall ${duration.toFixed(2)}s ease-in-out ${delay.toFixed(2)}s infinite`,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---- Falling leaves ----
|
|
|
|
/** A tiny leaf silhouette path centered at 0,0 (~4px across). */
|
|
function leafPath(): string {
|
|
return "M0,-2 C1,-1 2,0 1,2 C0,3 -1,2 0,0 C-1,-1 -2,0 0,-2Z";
|
|
}
|
|
|
|
const LEAF_COLORS = ["#f59e0b", "#ea580c", "#dc2626", "#92400e", "#d97706"];
|
|
|
|
function FallingLeaves({
|
|
count,
|
|
sceneWidth,
|
|
}: {
|
|
count: number;
|
|
sceneWidth: number;
|
|
}) {
|
|
return (
|
|
<>
|
|
{Array.from({ length: count }, (_, i) => {
|
|
const x = detRange(i, 0, sceneWidth, 43);
|
|
const y = detRange(i, -20, 0, 61);
|
|
const color = LEAF_COLORS[i % LEAF_COLORS.length];
|
|
const duration = detRange(i, 5, 10, 83);
|
|
const delay = detRange(i, 0, 6, 101);
|
|
const scale = detRange(i, 0.8, 1.5, 107);
|
|
return (
|
|
<path
|
|
key={i}
|
|
d={leafPath()}
|
|
fill={color}
|
|
fillOpacity={0.7}
|
|
transform={`translate(${x},${y}) scale(${scale.toFixed(2)})`}
|
|
style={{
|
|
animation: `garden-leaf-fall ${duration.toFixed(2)}s ease-in-out ${delay.toFixed(2)}s infinite`,
|
|
transformOrigin: `${x}px ${y}px`,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---- Pollen ----
|
|
|
|
const POLLEN_COLORS = ["#fde047", "#a3e635", "#bef264", "#fef08a"];
|
|
|
|
function PollenParticles({
|
|
count,
|
|
sceneWidth,
|
|
sceneHeight,
|
|
}: {
|
|
count: number;
|
|
sceneWidth: number;
|
|
sceneHeight: number;
|
|
}) {
|
|
return (
|
|
<>
|
|
{Array.from({ length: count }, (_, i) => {
|
|
const cx = detRange(i, 0, sceneWidth, 47);
|
|
const cy = detRange(i, sceneHeight * 0.3, sceneHeight * 0.8, 71);
|
|
const r = detRange(i, 0.3, 0.8, 89);
|
|
const color = POLLEN_COLORS[i % POLLEN_COLORS.length];
|
|
const duration = detRange(i, 6, 12, 97);
|
|
const delay = detRange(i, 0, 8, 113);
|
|
return (
|
|
<circle
|
|
key={i}
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill={color}
|
|
fillOpacity={0.4}
|
|
style={{
|
|
animation: `garden-pollen-drift ${duration.toFixed(2)}s ease-in-out ${delay.toFixed(2)}s infinite`,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---- Main component ----
|
|
|
|
export const WeatherEffects = memo(function WeatherEffects({
|
|
weather,
|
|
intensity,
|
|
sceneWidth,
|
|
sceneHeight,
|
|
}: WeatherEffectsProps) {
|
|
if (weather === "none" || intensity <= 0) return null;
|
|
|
|
const clampedIntensity = Math.max(0, Math.min(1, intensity));
|
|
|
|
if (weather === "rain") {
|
|
const count = Math.round(20 + clampedIntensity * 40);
|
|
return (
|
|
<g aria-hidden="true">
|
|
<RainParticles count={count} sceneWidth={sceneWidth} sceneHeight={sceneHeight} />
|
|
</g>
|
|
);
|
|
}
|
|
|
|
if (weather === "snow") {
|
|
const count = Math.round(15 + clampedIntensity * 25);
|
|
return (
|
|
<g aria-hidden="true">
|
|
<SnowParticles count={count} sceneWidth={sceneWidth} />
|
|
</g>
|
|
);
|
|
}
|
|
|
|
if (weather === "falling-leaves") {
|
|
const count = Math.round(8 + clampedIntensity * 12);
|
|
return (
|
|
<g aria-hidden="true">
|
|
<FallingLeaves count={count} sceneWidth={sceneWidth} />
|
|
</g>
|
|
);
|
|
}
|
|
|
|
if (weather === "pollen") {
|
|
const count = Math.round(10 + clampedIntensity * 15);
|
|
return (
|
|
<g aria-hidden="true">
|
|
<PollenParticles count={count} sceneWidth={sceneWidth} sceneHeight={sceneHeight} />
|
|
</g>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
});
|