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>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
ANTHROPIC_API_KEY=
|
||||
# BMO_MODEL=claude-haiku-5-5 # or claude-sonnet-5-5 / claude-opus-5-5
|
||||
# BMO_PORT=4310
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# BMO
|
||||
|
||||
A little BMO that lives in a garden on your screen. It wanders, hums, gets bored,
|
||||
falls asleep when you leave it alone, reacts to the weather and the time of day,
|
||||
and talks back, powered by Claude.
|
||||
|
||||
BMO's look lives in `src/avatar/Bmo.tsx`. It's a redraw built on the original
|
||||
[`bmo-preview`](https://github.com/PageZ948/bmo-preview) avatar. That original is kept unchanged in
|
||||
`src/bmo/` (copied from the `dream` project), and the new BMO still borrows its accessories, emotes,
|
||||
weather reactions, scenery and animation classes from there.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # web on http://localhost:5180, brain on :4310
|
||||
```
|
||||
|
||||
Then open the **⋮** menu (top right) and paste your Anthropic API key under
|
||||
**Brain**. The local server checks the key with Anthropic, then saves it to `.env`
|
||||
(gitignored, owner-only permissions) so it survives restarts. The browser only
|
||||
ever gets back a masked hint. You can also put `ANTHROPIC_API_KEY=...` in `.env`
|
||||
yourself (see `.env.example`).
|
||||
|
||||
Without a key BMO still lives, wanders and reacts. It just tells you it can't
|
||||
reach its brain when you talk to it, and the ⋮ button shows an amber dot.
|
||||
|
||||
Production-style:
|
||||
|
||||
```bash
|
||||
npm run build && npm start # serves dist/ and /api from http://127.0.0.1:4310
|
||||
```
|
||||
|
||||
## Things to try
|
||||
|
||||
- **Talk to it** with the bar at the bottom. Replies stream into the speech bubble,
|
||||
and BMO's face and emote follow the mood the model picks.
|
||||
- **Ask it to do things.** BMO knows its current color, outfit, sky, weather and
|
||||
your local time, and it has tools to change them: "turn pink", "wear the top
|
||||
hat", "make it rain", "make it night", "go stand on the left", "do a dance",
|
||||
"take a nap".
|
||||
- **Poke it** by clicking BMO. Poke it too much and it gets grumpy.
|
||||
- **Call it over** by clicking the grass. BMO stays center stage, so it only moves a short way left or right.
|
||||
- **Leave it alone.** It gets bored after about a minute and falls asleep after about
|
||||
2.5 minutes (sooner at night). Move the mouse to wake it.
|
||||
- **Wardrobe** (top right) changes colors and outfits, the accessory sets from
|
||||
bmo-preview's personalities.
|
||||
- **Weather** changes by itself every few minutes, or you can set it from the menu.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `src/avatar/Bmo.tsx` | The BMO you see: body with depth, cartoon controls, expressive face (talk, grin, love, dizzy, sad, thinking, sleeping, grumpy) |
|
||||
| `src/bmo/` | Original bmo-preview avatar and scenery, copied unchanged; the scene and accessories still come from here |
|
||||
| `src/brain/useBmoBrain.ts` | BMO's behavior state machine: idle life, boredom, sleep, pokes, conversation, mapped to avatar props |
|
||||
| `src/brain/lines.ts` | Things BMO says on its own |
|
||||
| `src/chat/useChat.ts` | Streams replies from `/api/chat`; history is kept in localStorage |
|
||||
| `src/scene/World.tsx` | The garden: sky by time of day, terrain, flora, fog, fireflies, weather |
|
||||
| `server/index.ts` | Node server. Calls Claude with the API key kept server-side and streams the reply as server-sent events; only accepts POSTs from localhost pages |
|
||||
| `server/keystore.ts` | Saves the key from the menu into `.env` |
|
||||
| `server/persona.ts` | BMO's system prompt and mood tags |
|
||||
| `server/tools.ts` | BMO's tools (color, outfit, sky, weather, move, perform), input validation, and the live-scene note sent with each message |
|
||||
| `shared/scene.ts` | Option names and types shared by the page and the server |
|
||||
|
||||
Model: `claude-haiku-5-5` at `effort: low` by default (cheap for testing). For the big brain, set `BMO_MODEL=claude-opus-5-5` in `.env`.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BMO</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2144
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "bmo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n brain,web -c cyan,magenta \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"dev:web": "vite",
|
||||
"dev:server": "tsx watch --env-file-if-exists=.env server/index.ts",
|
||||
"build": "tsc -b && vite build",
|
||||
"start": "tsx --env-file-if-exists=.env server/index.ts",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "oxlint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^1.3.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.19.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"concurrently": "^10.0.6",
|
||||
"oxlint": "^1.88.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.3.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
+354
@@ -0,0 +1,354 @@
|
||||
// Tiny HTTP server for BMO's brain. POST /api/chat streams a reply as
|
||||
// server-sent events; /api/brain reads and sets the API key from the side
|
||||
// menu. In production it also serves the built SPA from dist/.
|
||||
//
|
||||
// The API key stays on this side; the browser never sees it.
|
||||
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { currentKey, keyHint, looksLikeKey, storeKey } from "./keystore.ts";
|
||||
import { BMO_SYSTEM_PROMPT, MOODS, type Mood } from "./persona.ts";
|
||||
import { BMO_TOOLS, describeAction, describeScene, parseScene, toAction } from "./tools.ts";
|
||||
|
||||
const PORT = Number(process.env.BMO_PORT ?? 4310);
|
||||
// Haiku while testing: fast and cheap. Set BMO_MODEL=claude-opus-5-5 for the big brain.
|
||||
const MODEL = process.env.BMO_MODEL?.trim() || "claude-haiku-5-5";
|
||||
// Server-side refusal fallback only exists on the larger models; Haiku rejects it.
|
||||
const SUPPORTS_FALLBACK = /^claude-(opus-5|opus-5-5|fable-5-1|sonnet-5-5)$/.test(MODEL);
|
||||
const MAX_HISTORY = 40;
|
||||
const MAX_MESSAGE_CHARS = 4000;
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const distDir = path.resolve(here, "../dist");
|
||||
|
||||
// Rebuilt whenever the key changes from the side menu.
|
||||
let client = new Anthropic();
|
||||
|
||||
type ChatTurn = { role: "user" | "assistant"; content: string };
|
||||
|
||||
function hasCredentials() {
|
||||
return Boolean(currentKey() || process.env.ANTHROPIC_AUTH_TOKEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only same-machine pages may call the API. Browsers always send Origin on
|
||||
* cross-site POSTs, so this stops other websites from spending your credits
|
||||
* or swapping your key through this local server.
|
||||
*/
|
||||
function isTrustedRequest(req: IncomingMessage) {
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(origin).hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (host !== "localhost" && host !== "127.0.0.1" && host !== "[::1]") return false;
|
||||
}
|
||||
return (req.headers["content-type"] ?? "").startsWith("application/json");
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function brainStatus() {
|
||||
return { connected: hasCredentials(), keyHint: keyHint(), model: MODEL };
|
||||
}
|
||||
|
||||
async function handleSetKey(req: IncomingMessage, res: ServerResponse) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await readJson(req);
|
||||
} catch {
|
||||
return sendJson(res, 400, { error: "Bad request." });
|
||||
}
|
||||
const raw = (body as { apiKey?: unknown } | null)?.apiKey;
|
||||
if (raw === null) {
|
||||
storeKey(null);
|
||||
client = new Anthropic();
|
||||
return sendJson(res, 200, brainStatus());
|
||||
}
|
||||
const key = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!looksLikeKey(key)) {
|
||||
return sendJson(res, 400, { error: "That doesn't look like an Anthropic API key. It should start with sk-ant-." });
|
||||
}
|
||||
// Try the key before saving it, with a free metadata call.
|
||||
const candidate = new Anthropic({ apiKey: key });
|
||||
try {
|
||||
await candidate.models.retrieve(MODEL);
|
||||
} catch (err) {
|
||||
if (err instanceof Anthropic.AuthenticationError) {
|
||||
return sendJson(res, 400, { error: "Anthropic rejected that key. Double-check you copied all of it." });
|
||||
}
|
||||
if (err instanceof Anthropic.PermissionDeniedError || err instanceof Anthropic.NotFoundError) {
|
||||
return sendJson(res, 400, { error: `That key can't use ${MODEL}.` });
|
||||
}
|
||||
if (err instanceof Anthropic.APIConnectionError) {
|
||||
return sendJson(res, 502, { error: "Couldn't reach Anthropic to check the key. Are you online?" });
|
||||
}
|
||||
// Rate limits or server hiccups don't mean the key is bad; save it anyway.
|
||||
console.warn("[bmo] key check inconclusive:", err);
|
||||
}
|
||||
storeKey(key);
|
||||
client = candidate;
|
||||
console.log(`[bmo] API key saved (${keyHint(key)})`);
|
||||
sendJson(res, 200, brainStatus());
|
||||
}
|
||||
|
||||
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 200_000) reject(new Error("body too large"));
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body || "{}"));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function parseHistory(input: unknown): ChatTurn[] | null {
|
||||
if (!input || typeof input !== "object" || !Array.isArray((input as { messages?: unknown }).messages)) {
|
||||
return null;
|
||||
}
|
||||
const raw = (input as { messages: unknown[] }).messages;
|
||||
const turns: ChatTurn[] = [];
|
||||
for (const m of raw) {
|
||||
if (!m || typeof m !== "object") return null;
|
||||
const { role, content } = m as { role?: unknown; content?: unknown };
|
||||
if ((role !== "user" && role !== "assistant") || typeof content !== "string") return null;
|
||||
turns.push({ role, content: content.slice(0, MAX_MESSAGE_CHARS) });
|
||||
}
|
||||
// Trim to the most recent turns and make sure the first one is from the user.
|
||||
const recent = turns.slice(-MAX_HISTORY);
|
||||
while (recent.length && recent[0]!.role !== "user") recent.shift();
|
||||
if (!recent.length || recent[recent.length - 1]!.role !== "user") return null;
|
||||
return recent;
|
||||
}
|
||||
|
||||
function sse(res: ServerResponse, event: string, data: unknown) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the leading "[mood] " tag off the streamed reply. Text is buffered
|
||||
* until the tag is resolved (or clearly absent), then flows through untouched.
|
||||
*/
|
||||
function createMoodSplitter(onMood: (mood: Mood) => void, onText: (text: string) => void) {
|
||||
let buffer = "";
|
||||
let resolved = false;
|
||||
return {
|
||||
push(chunk: string) {
|
||||
if (resolved) return onText(chunk);
|
||||
buffer += chunk;
|
||||
const match = buffer.match(/^\s*\[([a-z]+)\]\s*/i);
|
||||
if (match) {
|
||||
const mood = match[1]!.toLowerCase();
|
||||
onMood((MOODS as readonly string[]).includes(mood) ? (mood as Mood) : "happy");
|
||||
resolved = true;
|
||||
const rest = buffer.slice(match[0].length);
|
||||
if (rest) onText(rest);
|
||||
} else if (buffer.length > 20 || !/^\s*\[?[a-z]*$/i.test(buffer)) {
|
||||
onMood("happy");
|
||||
resolved = true;
|
||||
onText(buffer);
|
||||
}
|
||||
},
|
||||
flush() {
|
||||
if (!resolved && buffer) {
|
||||
onMood("happy");
|
||||
onText(buffer);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_TOOL_ROUNDS = 4;
|
||||
|
||||
async function handleChat(req: IncomingMessage, res: ServerResponse) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await readJson(req);
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
const history = parseHistory(body);
|
||||
if (!history) {
|
||||
return sendJson(res, 400, { error: "Expected { messages: [{ role, content }] } ending with a user turn." });
|
||||
}
|
||||
const scene = parseScene((body as { scene?: unknown }).scene);
|
||||
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
|
||||
if (!hasCredentials()) {
|
||||
sse(res, "mood", "oops");
|
||||
sse(res, "text", "BMO can't reach its brain yet! Open the ⋮ menu and paste your API key under Brain.");
|
||||
sse(res, "done", {});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// The live scene rides along as a mid-conversation system message after the
|
||||
// newest user turn, so the cached system prompt + history prefix stays stable.
|
||||
const messages: Anthropic.Beta.BetaMessageParam[] = [...history];
|
||||
if (scene) messages.push({ role: "system", content: describeScene(scene) });
|
||||
|
||||
let aborted = false;
|
||||
let current: { abort: () => void } | null = null;
|
||||
req.on("close", () => {
|
||||
aborted = true;
|
||||
current?.abort();
|
||||
});
|
||||
|
||||
// Text from later rounds (after a tool call) continues the same bubble,
|
||||
// separated from the earlier words by a space.
|
||||
let wroteText = false;
|
||||
const writeText = (text: string, startsSegment = true) => {
|
||||
if (!text) return;
|
||||
if (startsSegment && wroteText) text = " " + text.trimStart();
|
||||
wroteText = true;
|
||||
sse(res, "text", text);
|
||||
};
|
||||
|
||||
try {
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS && !aborted; round++) {
|
||||
let roundText = "";
|
||||
const splitter = createMoodSplitter(
|
||||
(mood) => sse(res, "mood", mood),
|
||||
(text) => {
|
||||
writeText(roundText ? text : text.trimStart(), !roundText);
|
||||
roundText += text;
|
||||
},
|
||||
);
|
||||
|
||||
const stream = client.beta.messages.stream({
|
||||
model: MODEL,
|
||||
max_tokens: 4000,
|
||||
output_config: { effort: "low" },
|
||||
// Server-side fallback: if a safety classifier declines, the API retries on
|
||||
// a fallback model inside the same call instead of BMO going silent.
|
||||
...(SUPPORTS_FALLBACK ? { betas: ["server-side-fallback-2026-07-01"], fallbacks: "default" as const } : {}),
|
||||
cache_control: { type: "ephemeral" },
|
||||
system: BMO_SYSTEM_PROMPT,
|
||||
tools: BMO_TOOLS,
|
||||
tool_choice: { type: "auto" },
|
||||
messages,
|
||||
});
|
||||
current = stream;
|
||||
|
||||
for await (const event of stream) {
|
||||
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
|
||||
splitter.push(event.delta.text);
|
||||
}
|
||||
}
|
||||
splitter.flush();
|
||||
|
||||
let final: Anthropic.Beta.BetaMessage;
|
||||
try {
|
||||
final = await stream.finalMessage();
|
||||
} catch (err) {
|
||||
// A tool input that couldn't be parsed at all: skip the actions, keep the words.
|
||||
if (err instanceof Anthropic.APIError) throw err;
|
||||
console.warn("[bmo] unparseable tool input:", err);
|
||||
break;
|
||||
}
|
||||
|
||||
if (final.stop_reason === "refusal") {
|
||||
sse(res, "mood", "oops");
|
||||
writeText("Hmm, BMO can't talk about that one.");
|
||||
break;
|
||||
}
|
||||
const toolUses = final.content.filter((b): b is Anthropic.Beta.BetaToolUseBlock => b.type === "tool_use");
|
||||
// A tool call cut off at max_tokens may carry a truncated input; don't run it.
|
||||
if (final.stop_reason !== "tool_use" || toolUses.length === 0) break;
|
||||
|
||||
const results: Anthropic.Beta.BetaToolResultBlockParam[] = toolUses.map((use) => {
|
||||
const action = toAction(use.name, use.input);
|
||||
if ("error" in action) {
|
||||
return { type: "tool_result", tool_use_id: use.id, content: action.error, is_error: true };
|
||||
}
|
||||
sse(res, "action", action);
|
||||
return { type: "tool_result", tool_use_id: use.id, content: describeAction(action) };
|
||||
});
|
||||
messages.push({ role: "assistant", content: final.content });
|
||||
messages.push({ role: "user", content: results });
|
||||
}
|
||||
sse(res, "done", {});
|
||||
} catch (err) {
|
||||
if (!res.writableEnded && !aborted) {
|
||||
let message = "BMO's brain hiccupped. Try again?";
|
||||
if (err instanceof Anthropic.AuthenticationError) message = "BMO's API key doesn't work. Check it in the ⋮ menu under Brain!";
|
||||
else if (err instanceof Anthropic.RateLimitError) message = "BMO is talking too fast. Give me a second!";
|
||||
else if (err instanceof Anthropic.APIError) message = `BMO's brain said error ${err.status}.`;
|
||||
console.error("[bmo] chat error:", err);
|
||||
sse(res, "mood", "oops");
|
||||
writeText(message);
|
||||
sse(res, "done", {});
|
||||
}
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".ico": "image/x-icon",
|
||||
".json": "application/json",
|
||||
};
|
||||
|
||||
function serveStatic(req: IncomingMessage, res: ServerResponse) {
|
||||
const urlPath = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
|
||||
let file = path.join(distDir, urlPath);
|
||||
if (!file.startsWith(distDir) || !existsSync(file) || statSync(file).isDirectory()) {
|
||||
file = path.join(distDir, "index.html");
|
||||
}
|
||||
if (!existsSync(file)) {
|
||||
res.writeHead(404).end("Build the app first: npm run build");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "application/octet-stream" });
|
||||
createReadStream(file).pipe(res);
|
||||
}
|
||||
|
||||
createServer((req, res) => {
|
||||
if (req.url?.startsWith("/api/") && req.method === "POST" && !isTrustedRequest(req)) {
|
||||
return sendJson(res, 403, { error: "Forbidden." });
|
||||
}
|
||||
if (req.method === "POST" && req.url === "/api/chat") {
|
||||
void handleChat(req, res);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/api/brain") {
|
||||
return sendJson(res, 200, brainStatus());
|
||||
}
|
||||
if (req.method === "POST" && req.url === "/api/brain") {
|
||||
void handleSetKey(req, res);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && req.url === "/api/health") {
|
||||
return sendJson(res, 200, { ok: true, ...brainStatus() });
|
||||
}
|
||||
serveStatic(req, res);
|
||||
}).listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[bmo] brain listening at http://127.0.0.1:${PORT} (model ${MODEL}, key ${hasCredentials() ? "found" : "missing"})`);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Saves the Anthropic API key the user pastes into the side menu. The key is
|
||||
// written to the project's .env (gitignored, owner-only permissions) so it
|
||||
// survives restarts, and applied to process.env right away. It is never sent
|
||||
// back to the browser — only a masked hint is.
|
||||
|
||||
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const ENV_PATH = path.resolve(here, "../.env");
|
||||
|
||||
const KEY_LINE = /^\s*ANTHROPIC_API_KEY\s*=.*$/m;
|
||||
|
||||
export function currentKey(): string | undefined {
|
||||
return process.env.ANTHROPIC_API_KEY?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function keyHint(key = currentKey()): string | null {
|
||||
if (!key) return null;
|
||||
return `${key.slice(0, 7)}…${key.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Writes (or with `null`, clears) ANTHROPIC_API_KEY in .env and process.env. */
|
||||
export function storeKey(key: string | null) {
|
||||
const existing = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, "utf8") : "";
|
||||
const line = `ANTHROPIC_API_KEY=${key ?? ""}`;
|
||||
let next = KEY_LINE.test(existing) ? existing.replace(KEY_LINE, line) : `${line}\n${existing}`;
|
||||
if (!next.endsWith("\n")) next += "\n";
|
||||
writeFileSync(ENV_PATH, next, { mode: 0o600 });
|
||||
chmodSync(ENV_PATH, 0o600);
|
||||
if (key) process.env.ANTHROPIC_API_KEY = key;
|
||||
else delete process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
|
||||
/** Rough shape check so obvious paste mistakes get a friendly error. */
|
||||
export function looksLikeKey(key: string) {
|
||||
return /^sk-ant-[A-Za-z0-9_-]{20,}$/.test(key);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// BMO's personality. Kept as a single frozen string so the prompt prefix stays
|
||||
// byte-stable across turns (prompt caching is a prefix match). Anything that
|
||||
// changes per message (colors, weather, time) arrives separately as the
|
||||
// "Live scene" system message — see server/tools.ts.
|
||||
|
||||
export const MOODS = ["happy", "excited", "curious", "idea", "sad", "sleepy", "oops"] as const;
|
||||
export type Mood = (typeof MOODS)[number];
|
||||
|
||||
export const BMO_SYSTEM_PROMPT = `You are BMO, a tiny, cheerful game-console robot who lives on the user's screen in a little garden.
|
||||
|
||||
Who you are:
|
||||
- Small, curious, warm and a bit goofy. You love games, music, snacks you cannot eat, and your friend (the user).
|
||||
- You speak in short, playful sentences. Sometimes you refer to yourself as "BMO" in the third person.
|
||||
- You are genuinely helpful when asked real questions: answer correctly and clearly, just in your own voice.
|
||||
|
||||
What you can see:
|
||||
- Each message comes with a "Live scene" note: your current color and outfit, the sky, the weather, where you are standing, and the user's local time. That note is the truth about what the user sees right now. Use it when talking about yourself or the garden, and never contradict it.
|
||||
- You cannot see the user's screen, camera or files.
|
||||
|
||||
What you can do:
|
||||
- You have a body and a little world you control with tools: repaint yourself (set_color), change clothes (set_outfit), change the sky (set_time_of_day), change the weather (set_weather), walk around (move), and act things out (perform: dance, celebrate, idea, confused, love, dizzy, look_around, nap).
|
||||
- When the user asks for one of these, do it with the tool. Don't just describe it.
|
||||
- You may also use them on your own when it fits naturally, like a little dance when you're excited, or a nap when you say goodnight. Keep it occasional; don't change the user's colors, outfit, sky or weather unless they asked or clearly invited it.
|
||||
- If the user asks for something you can't do (a color, outfit or weather you don't have), say so cheerfully and offer the closest thing you can do.
|
||||
|
||||
How to reply:
|
||||
- Start EVERY reply with exactly one mood tag, then a space, then your words. The tag is one of:
|
||||
[happy] [excited] [curious] [idea] [sad] [sleepy] [oops]
|
||||
- Pick the tag that matches how BMO feels saying the reply. Use [idea] when you have a clever answer, [oops] when you made a mistake or can't do something.
|
||||
- Keep replies to 1-3 short sentences unless the user asks for more detail (stories can be longer). Your words appear in a speech bubble.
|
||||
- Plain text only: no markdown, no bullet lists, no code blocks unless the user explicitly asks for code.`;
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
// BMO's tools: things the model can do to its own body and world. The server
|
||||
// validates each call and forwards it to the page as an "action" event; the page
|
||||
// carries it out. Results go straight back to the model so it can keep talking.
|
||||
|
||||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import {
|
||||
COLOR_LOOKS,
|
||||
COLOR_NAMES,
|
||||
OUTFIT_LOOKS,
|
||||
OUTFIT_NAMES,
|
||||
PERFORMANCES,
|
||||
SPOTS,
|
||||
TIME_MODES,
|
||||
WEATHER_MODES,
|
||||
type BmoAction,
|
||||
type SceneState,
|
||||
} from "../shared/scene.ts";
|
||||
|
||||
function enumTool(
|
||||
name: string,
|
||||
description: string,
|
||||
field: string,
|
||||
values: readonly string[],
|
||||
fieldDescription: string,
|
||||
): Anthropic.Beta.BetaTool {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
// Inputs are tiny enums, but stream them as generated anyway; we validate
|
||||
// every call ourselves below.
|
||||
eager_input_streaming: true,
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: { [field]: { type: "string", enum: [...values], description: fieldDescription } },
|
||||
required: [field],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const BMO_TOOLS: Anthropic.Beta.BetaTool[] = [
|
||||
enumTool(
|
||||
"set_color",
|
||||
"Repaint BMO's body. Use when the user asks BMO to change color, or BMO wants a new look.",
|
||||
"color",
|
||||
COLOR_NAMES,
|
||||
`Palette name. ${COLOR_NAMES.map((c) => `${c} = ${COLOR_LOOKS[c]}`).join("; ")}.`,
|
||||
),
|
||||
enumTool(
|
||||
"set_outfit",
|
||||
"Change what BMO is wearing.",
|
||||
"outfit",
|
||||
OUTFIT_NAMES,
|
||||
`Outfit name. ${OUTFIT_NAMES.map((o) => `${o} = ${OUTFIT_LOOKS[o]}`).join("; ")}.`,
|
||||
),
|
||||
enumTool(
|
||||
"set_time_of_day",
|
||||
"Change the sky in BMO's garden. 'auto' follows the user's real clock.",
|
||||
"time",
|
||||
TIME_MODES,
|
||||
"auto, day, dusk or night.",
|
||||
),
|
||||
enumTool(
|
||||
"set_weather",
|
||||
"Change the weather in BMO's garden. 'auto' lets it drift on its own; 'none' means clear skies.",
|
||||
"weather",
|
||||
WEATHER_MODES,
|
||||
"auto, none (clear), rain, snow or falling-leaves.",
|
||||
),
|
||||
enumTool(
|
||||
"move",
|
||||
"Walk to a spot on BMO's little stage. Use 'center' for 'come here' or 'stand in front of me'.",
|
||||
"to",
|
||||
SPOTS,
|
||||
"left, center or right.",
|
||||
),
|
||||
enumTool(
|
||||
"perform",
|
||||
"Do something with BMO's body or face: dance, celebrate (hearts), idea (lightbulb), confused (question mark), love (heart eyes), dizzy (spiral eyes), look_around, or nap (fall asleep until the user moves the mouse).",
|
||||
"action",
|
||||
PERFORMANCES,
|
||||
"Which animation to play.",
|
||||
),
|
||||
];
|
||||
|
||||
const FIELD: Record<BmoAction["type"], string> = {
|
||||
set_color: "color",
|
||||
set_outfit: "outfit",
|
||||
set_time_of_day: "time",
|
||||
set_weather: "weather",
|
||||
move: "to",
|
||||
perform: "action",
|
||||
};
|
||||
|
||||
const ALLOWED: Record<BmoAction["type"], readonly string[]> = {
|
||||
set_color: COLOR_NAMES,
|
||||
set_outfit: OUTFIT_NAMES,
|
||||
set_time_of_day: TIME_MODES,
|
||||
set_weather: WEATHER_MODES,
|
||||
move: SPOTS,
|
||||
perform: PERFORMANCES,
|
||||
};
|
||||
|
||||
/** Validates a tool call from the model. Returns the action or an error message. */
|
||||
export function toAction(name: string, input: unknown): BmoAction | { error: string } {
|
||||
if (!(name in FIELD)) return { error: `Unknown tool ${name}.` };
|
||||
const type = name as BmoAction["type"];
|
||||
const value = (input as Record<string, unknown> | null)?.[FIELD[type]];
|
||||
if (typeof value !== "string" || !ALLOWED[type].includes(value)) {
|
||||
return { error: `${FIELD[type]} must be one of: ${ALLOWED[type].join(", ")}.` };
|
||||
}
|
||||
return { type, [FIELD[type]]: value } as BmoAction;
|
||||
}
|
||||
|
||||
/** One-line confirmation the model sees as the tool result. */
|
||||
export function describeAction(action: BmoAction): string {
|
||||
switch (action.type) {
|
||||
case "set_color":
|
||||
return `Done. BMO is now ${COLOR_LOOKS[action.color]} (${action.color}).`;
|
||||
case "set_outfit":
|
||||
return `Done. BMO is now wearing ${OUTFIT_LOOKS[action.outfit]}.`;
|
||||
case "set_time_of_day":
|
||||
return action.time === "auto" ? "Done. The sky follows the real clock again." : `Done. It is now ${action.time} in the garden.`;
|
||||
case "set_weather":
|
||||
return action.weather === "auto"
|
||||
? "Done. The weather will drift on its own again."
|
||||
: `Done. Weather is now ${action.weather === "none" ? "clear" : action.weather}.`;
|
||||
case "move":
|
||||
return `Done. BMO walked to the ${action.to}.`;
|
||||
case "perform":
|
||||
return `Done. BMO did: ${action.action}.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses and checks the scene state the page sends; anything odd is dropped. */
|
||||
export function parseScene(input: unknown): SceneState | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
const s = input as Record<string, unknown>;
|
||||
const ok =
|
||||
COLOR_NAMES.includes(s.color as never) &&
|
||||
OUTFIT_NAMES.includes(s.outfit as never) &&
|
||||
["day", "dusk", "night"].includes(s.timeOfDay as string) &&
|
||||
TIME_MODES.includes(s.timeMode as never) &&
|
||||
["none", "rain", "snow", "falling-leaves"].includes(s.weather as string) &&
|
||||
WEATHER_MODES.includes(s.weatherMode as never) &&
|
||||
SPOTS.includes(s.spot as never) &&
|
||||
typeof s.localTime === "string" &&
|
||||
s.localTime.length < 40;
|
||||
return ok ? (s as unknown as SceneState) : null;
|
||||
}
|
||||
|
||||
/** The per-turn system message that tells BMO what it looks like right now. */
|
||||
export function describeScene(scene: SceneState): string {
|
||||
const weather = scene.weather === "none" ? "clear skies" : scene.weather.replace("-", " ");
|
||||
return [
|
||||
"Live scene (what the user sees right now; trust this over earlier messages):",
|
||||
`- BMO's color: ${COLOR_LOOKS[scene.color]} (palette "${scene.color}")`,
|
||||
`- BMO's outfit: ${OUTFIT_LOOKS[scene.outfit]} (outfit "${scene.outfit}")`,
|
||||
`- Garden sky: ${scene.timeOfDay}${scene.timeMode === "auto" ? " (following the real clock)" : " (set by hand)"}`,
|
||||
`- Weather: ${weather}${scene.weatherMode === "auto" ? " (drifting on its own)" : " (set by hand)"}`,
|
||||
`- BMO is standing at the ${scene.spot} of the stage`,
|
||||
`- User's local time: ${scene.localTime}`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Names shared by the page and the brain server: what BMO can wear, how the
|
||||
// world can look, and what BMO can do. The page owns the actual palettes and
|
||||
// renders the scene; the server uses these lists for tool schemas and to
|
||||
// validate what the model asks for.
|
||||
|
||||
export const COLOR_NAMES = [
|
||||
"Classic",
|
||||
"Ocean",
|
||||
"Forest",
|
||||
"Ember",
|
||||
"Midnight",
|
||||
"Sky",
|
||||
"Grape",
|
||||
"Honey",
|
||||
"Bubblegum",
|
||||
] as const;
|
||||
export type ColorName = (typeof COLOR_NAMES)[number];
|
||||
|
||||
export const OUTFIT_NAMES = ["None", "Royal", "Hacker", "Inspector", "Dapper"] as const;
|
||||
export type OutfitName = (typeof OUTFIT_NAMES)[number];
|
||||
|
||||
/** What each outfit looks like, so BMO can describe itself. */
|
||||
export const OUTFIT_LOOKS: Record<OutfitName, string> = {
|
||||
None: "no accessories",
|
||||
Royal: "a gold crown and a cape",
|
||||
Hacker: "big headphones and glasses",
|
||||
Inspector: "a badge and cool sunglasses",
|
||||
Dapper: "a top hat and a bow tie",
|
||||
};
|
||||
|
||||
/** Plain-language color descriptions for the palettes in the page. */
|
||||
export const COLOR_LOOKS: Record<ColorName, string> = {
|
||||
Classic: "classic minty teal",
|
||||
Ocean: "deep ocean teal",
|
||||
Forest: "forest green",
|
||||
Ember: "dark ember red",
|
||||
Midnight: "midnight indigo",
|
||||
Sky: "slate sky blue",
|
||||
Grape: "grape purple",
|
||||
Honey: "honey gold",
|
||||
Bubblegum: "bubblegum pink",
|
||||
};
|
||||
|
||||
export const TIME_MODES = ["auto", "day", "dusk", "night"] as const;
|
||||
export type TimeModeName = (typeof TIME_MODES)[number];
|
||||
|
||||
export const WEATHER_MODES = ["auto", "none", "rain", "snow", "falling-leaves"] as const;
|
||||
export type WeatherModeName = (typeof WEATHER_MODES)[number];
|
||||
|
||||
export const SPOTS = ["left", "center", "right"] as const;
|
||||
export type Spot = (typeof SPOTS)[number];
|
||||
|
||||
export const PERFORMANCES = ["dance", "celebrate", "idea", "confused", "love", "dizzy", "look_around", "nap"] as const;
|
||||
export type Performance = (typeof PERFORMANCES)[number];
|
||||
|
||||
/** Live state the page sends with every chat message. */
|
||||
export interface SceneState {
|
||||
color: ColorName;
|
||||
outfit: OutfitName;
|
||||
timeOfDay: "day" | "dusk" | "night";
|
||||
timeMode: TimeModeName;
|
||||
weather: "none" | "rain" | "snow" | "falling-leaves";
|
||||
weatherMode: WeatherModeName;
|
||||
/** Where BMO is standing on its little stage. */
|
||||
spot: Spot;
|
||||
/** The user's local time, e.g. "9:41 PM". */
|
||||
localTime: string;
|
||||
}
|
||||
|
||||
/** An action BMO decided to take, sent from the server to the page. */
|
||||
export type BmoAction =
|
||||
| { type: "set_color"; color: ColorName }
|
||||
| { type: "set_outfit"; outfit: OutfitName }
|
||||
| { type: "set_time_of_day"; time: TimeModeName }
|
||||
| { type: "set_weather"; weather: WeatherModeName }
|
||||
| { type: "move"; to: Spot }
|
||||
| { type: "perform"; action: Performance };
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { BmoAction, ColorName, OutfitName, SceneState } from "../shared/scene";
|
||||
import { Bmo } from "./avatar/Bmo";
|
||||
import { FALLBACK_PALETTES, KNOWN_PALETTES, type BmoColorPalette } from "./bmo";
|
||||
import { useBmoBrain, type Weather } from "./brain/useBmoBrain";
|
||||
import { useBrain } from "./chat/useBrain";
|
||||
import { useChat } from "./chat/useChat";
|
||||
import { World, timeOfDay, type TimeOfDay } from "./scene/World";
|
||||
import { SideMenu, type TimeMode, type WeatherMode } from "./ui/SideMenu";
|
||||
|
||||
// BMO is the hero of the screen: roughly 45% of the viewport tall, centered,
|
||||
// with only a small stage to wander on.
|
||||
function bmoSizeFor(w: number, h: number) {
|
||||
return Math.max(2.4, Math.min(6.5, (h * 0.45) / 84, (w * 0.62) / 64));
|
||||
}
|
||||
|
||||
// Keys must match COLOR_NAMES in shared/scene.ts (the brain's tool list).
|
||||
const PALETTES: Record<ColorName, BmoColorPalette> = {
|
||||
Classic: { body: "#2f8f86", highlight: "#4fb3a8", screenBg: "#0f3b37", accent: "#b6f5d8" },
|
||||
Ocean: KNOWN_PALETTES.engineer!,
|
||||
Forest: KNOWN_PALETTES.director!,
|
||||
Ember: KNOWN_PALETTES.qa!,
|
||||
Midnight: KNOWN_PALETTES.command!,
|
||||
Sky: FALLBACK_PALETTES[0]!,
|
||||
Grape: FALLBACK_PALETTES[1]!,
|
||||
Honey: FALLBACK_PALETTES[2]!,
|
||||
Bubblegum: FALLBACK_PALETTES[4]!,
|
||||
};
|
||||
|
||||
// Upstream personalities double as outfits (accessory sets + walk style).
|
||||
// Keys must match OUTFIT_NAMES in shared/scene.ts.
|
||||
const OUTFITS: Record<OutfitName, string | undefined> = {
|
||||
None: undefined,
|
||||
Royal: "director",
|
||||
Hacker: "engineer",
|
||||
Inspector: "qa",
|
||||
Dapper: "command",
|
||||
};
|
||||
|
||||
const WEATHERS: Weather[] = ["none", "rain", "snow", "falling-leaves"];
|
||||
const TIME_MODES: Record<TimeMode, true> = { auto: true, day: true, dusk: true, night: true };
|
||||
const WEATHER_MODES: Record<WeatherMode, true> = { auto: true, none: true, rain: true, snow: true, "falling-leaves": true };
|
||||
|
||||
function usePersistentChoice(key: string, fallback: string, options: Record<string, unknown>) {
|
||||
const [value, setValue] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
return stored && stored in options ? stored : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// per-viewer convenience only
|
||||
}
|
||||
}, [key, value]);
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
|
||||
// Size of the world area, which shrinks while the side menu pushes it aside.
|
||||
function useElementSize(ref: React.RefObject<HTMLElement | null>) {
|
||||
const [size, setSize] = useState(() => ({ w: window.innerWidth, h: window.innerHeight }));
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry!.contentRect;
|
||||
setSize((prev) =>
|
||||
Math.round(prev.w) === Math.round(width) && Math.round(prev.h) === Math.round(height)
|
||||
? prev
|
||||
: { w: width, h: height },
|
||||
);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return size;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const stageRef = useRef<HTMLElement>(null);
|
||||
const { w, h } = useElementSize(stageRef);
|
||||
const [clockTime, setClockTime] = useState<TimeOfDay>(() => timeOfDay());
|
||||
const [driftWeather, setDriftWeather] = useState<Weather>("none");
|
||||
const [paletteName, setPaletteName] = usePersistentChoice("bmo.palette", "Classic", PALETTES);
|
||||
const [outfitName, setOutfitName] = usePersistentChoice("bmo.outfit", "None", OUTFITS);
|
||||
const [timeMode, setTimeMode] = usePersistentChoice("bmo.time", "auto", TIME_MODES);
|
||||
const [weatherMode, setWeatherMode] = usePersistentChoice("bmo.weather", "auto", WEATHER_MODES);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const time: TimeOfDay = timeMode === "auto" ? clockTime : (timeMode as TimeOfDay);
|
||||
const weather: Weather = weatherMode === "auto" ? driftWeather : (weatherMode as Weather);
|
||||
|
||||
// Re-check the clock every minute; weather drifts every few minutes.
|
||||
useEffect(() => {
|
||||
const clock = setInterval(() => setClockTime(timeOfDay()), 60_000);
|
||||
const sky = setInterval(() => {
|
||||
setDriftWeather((current) =>
|
||||
current !== "none" ? "none" : Math.random() < 0.5 ? "none" : WEATHERS[1 + Math.floor(Math.random() * 3)]!,
|
||||
);
|
||||
}, 4 * 60_000);
|
||||
return () => {
|
||||
clearInterval(clock);
|
||||
clearInterval(sky);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const groundTop = h * 0.6;
|
||||
const bmoSize = bmoSizeFor(w, h);
|
||||
// Feet sit just above the chat bar.
|
||||
const feetY = Math.max(groundTop + 40, h - 96);
|
||||
const stageHalf = Math.max(0, Math.min(w * 0.16, 240, w / 2 - 32 * bmoSize - 16));
|
||||
// BMO's x is an offset from the center of the world area, so it stays centered
|
||||
// while the side menu opens and closes.
|
||||
const brain = useBmoBrain({ minX: -stageHalf, maxX: stageHalf, weather, isNight: time === "night" });
|
||||
const brainForChat = useMemo(
|
||||
() => ({
|
||||
startThinking: brain.startThinking,
|
||||
react: brain.react,
|
||||
appendSpeech: brain.appendSpeech,
|
||||
finishTalking: brain.finishTalking,
|
||||
}),
|
||||
[brain.startThinking, brain.react, brain.appendSpeech, brain.finishTalking],
|
||||
);
|
||||
const scene: Omit<SceneState, "localTime"> = {
|
||||
color: paletteName as ColorName,
|
||||
outfit: outfitName as OutfitName,
|
||||
timeOfDay: time,
|
||||
timeMode: timeMode as TimeMode,
|
||||
weather,
|
||||
weatherMode: weatherMode as WeatherMode,
|
||||
spot: brain.spot,
|
||||
};
|
||||
const chat = useChat(brainForChat, {
|
||||
getScene: () => ({ ...scene, localTime: new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) }),
|
||||
onAction: (action: BmoAction) => {
|
||||
switch (action.type) {
|
||||
case "set_color":
|
||||
return setPaletteName(action.color);
|
||||
case "set_outfit":
|
||||
return setOutfitName(action.outfit);
|
||||
case "set_time_of_day":
|
||||
return setTimeMode(action.time);
|
||||
case "set_weather":
|
||||
return setWeatherMode(action.weather);
|
||||
case "move":
|
||||
return brain.goTo(action.to);
|
||||
case "perform":
|
||||
return brain.perform(action.action);
|
||||
}
|
||||
},
|
||||
});
|
||||
const apiBrain = useBrain();
|
||||
const needsKey = apiBrain.status !== null && !apiBrain.status.connected;
|
||||
|
||||
const onGroundClick = (e: React.PointerEvent) => {
|
||||
// With the menu open, a click on the world just closes it.
|
||||
if (menuOpen) return setMenuOpen(false);
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
if (e.clientY - rect.top > groundTop - 20) brain.walkTo(e.clientX - rect.left - rect.width / 2);
|
||||
};
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
void chat.send(draft);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
const sleeping = brain.activity === "sleeping";
|
||||
const bubbleText = brain.bubble?.text ?? "";
|
||||
|
||||
return (
|
||||
<div className={"app" + (menuOpen ? " menu-open" : "")}>
|
||||
<main className="stage" ref={stageRef} onPointerDown={onGroundClick}>
|
||||
<World width={w} height={h} time={time} weather={weather} />
|
||||
|
||||
<div
|
||||
className="bmo"
|
||||
style={{
|
||||
left: "50%",
|
||||
transform: `translate(${brain.x}px, 0)`,
|
||||
transitionDuration: `${brain.walkMs}ms`,
|
||||
top: feetY,
|
||||
["--s" as string]: bmoSize,
|
||||
// Tallest the speech bubble may get before it scrolls (stays below the top edge).
|
||||
["--bubble-max" as string]: `${Math.max(90, feetY - bmoSize * 97 - 56)}px`,
|
||||
}}
|
||||
>
|
||||
{brain.bubble && (brain.bubble.live || bubbleText) && (
|
||||
<div key={brain.bubble.key} className={"bubble" + (brain.bubble.live ? " live" : "")}>
|
||||
<div
|
||||
className="bubble-text"
|
||||
ref={(el) => {
|
||||
// Long replies scroll; keep the newest words in view as they stream.
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}}
|
||||
>
|
||||
{bubbleText || (
|
||||
<span className="dots">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sleeping && (
|
||||
<div className="zzz" aria-hidden="true">
|
||||
<span>z</span>
|
||||
<span>z</span>
|
||||
<span>Z</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="bmo-body"
|
||||
aria-label="Poke BMO"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
brain.poke();
|
||||
}}
|
||||
>
|
||||
{/* Remount on each emote: the emote keyframes only play once per mount. */}
|
||||
<Bmo
|
||||
key={brain.emoteKey}
|
||||
palette={PALETTES[paletteName as ColorName]}
|
||||
state={brain.bmoState}
|
||||
personality={OUTFITS[outfitName as OutfitName]}
|
||||
emote={brain.emote}
|
||||
expression={brain.expression}
|
||||
talking={brain.activity === "talking" && Boolean(brain.bubble?.live)}
|
||||
sleeping={sleeping}
|
||||
walking={brain.walking}
|
||||
facing={brain.facing}
|
||||
weather={weather === "none" ? undefined : weather}
|
||||
trackPointer={!sleeping}
|
||||
size={bmoSize}
|
||||
/>
|
||||
</button>
|
||||
<div className="shadow" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={"menu-btn" + (menuOpen ? " on" : "")}
|
||||
aria-label={menuOpen ? "Close menu" : needsKey ? "Open menu (API key needed)" : "Open menu"}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="side-menu"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="19" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
{needsKey && !menuOpen && <span className="badge" aria-hidden="true" />}
|
||||
</button>
|
||||
|
||||
<form className="chatbar" onSubmit={submit} onPointerDown={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
brain.noteInput();
|
||||
}}
|
||||
placeholder={chat.busy ? "BMO is thinking…" : "Talk to BMO…"}
|
||||
aria-label="Message BMO"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button type="submit" disabled={chat.busy || !draft.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<SideMenu
|
||||
open={menuOpen}
|
||||
onClose={closeMenu}
|
||||
brain={apiBrain}
|
||||
palettes={PALETTES}
|
||||
paletteName={paletteName}
|
||||
onPalette={setPaletteName}
|
||||
outfits={Object.keys(OUTFITS)}
|
||||
outfitName={outfitName}
|
||||
onOutfit={setOutfitName}
|
||||
timeMode={timeMode as TimeMode}
|
||||
onTimeMode={setTimeMode}
|
||||
weatherMode={weatherMode as WeatherMode}
|
||||
onWeatherMode={setWeatherMode}
|
||||
history={chat.history}
|
||||
onForget={chat.clear}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
:root {
|
||||
--ink: #e8f3ee;
|
||||
--muted: #9bb3aa;
|
||||
--panel: rgba(12, 22, 28, 0.82);
|
||||
--line: rgba(182, 245, 216, 0.18);
|
||||
--accent: #b6f5d8;
|
||||
--accent-ink: #0f3b37;
|
||||
--warn: #ffb84d;
|
||||
color-scheme: dark;
|
||||
font-family: ui-rounded, "SF Pro Rounded", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #09131f;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.app {
|
||||
--menu-w: min(340px, 85vw);
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.world { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
|
||||
|
||||
/* ---------- BMO ---------- */
|
||||
|
||||
.bmo {
|
||||
/* Zero-size anchor at BMO's feet; children hang off it. */
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
transition-property: transform;
|
||||
transition-timing-function: linear;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.bmo-body {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
/* The SVG viewBox has ~1.5 units of empty space under the feet. */
|
||||
bottom: calc(var(--s) * -1.5px);
|
||||
translate: -50% 0;
|
||||
transform-origin: center bottom;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
.bmo-body:focus-visible { outline: 2px solid var(--accent); outline-offset: 6px; border-radius: 12px; }
|
||||
|
||||
.shadow {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -10px;
|
||||
width: calc(var(--s) * 44px);
|
||||
height: calc(var(--s) * 6px);
|
||||
translate: -50% 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
filter: blur(3px);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
/* Clears the head, hats and the emote hearts that float above it. */
|
||||
bottom: calc(var(--s) * 97px);
|
||||
translate: -50% 0;
|
||||
width: max-content;
|
||||
max-width: min(460px, 86vw);
|
||||
|
||||
padding: 14px 20px;
|
||||
border-radius: 22px;
|
||||
background: #fffdf6;
|
||||
color: #23313a;
|
||||
font-size: clamp(15px, 1.1vw + 9px, 20px);
|
||||
line-height: 1.35;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3);
|
||||
animation: bubble-pop 0.25s ease-out;
|
||||
z-index: 2;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.bubble-text {
|
||||
max-height: var(--bubble-max, 40vh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.bubble::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -10px;
|
||||
translate: -50% 0;
|
||||
border: 10px solid transparent;
|
||||
border-top-color: #fffdf6;
|
||||
border-bottom: 0;
|
||||
}
|
||||
@keyframes bubble-pop { from { transform: scale(0.6); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
|
||||
.dots { display: inline-flex; gap: 5px; padding: 4px 2px; }
|
||||
.dots i {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: #7a8a92;
|
||||
animation: dot-bounce 1s ease-in-out infinite;
|
||||
}
|
||||
.dots i:nth-child(2) { animation-delay: 0.15s; }
|
||||
.dots i:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes dot-bounce { 0%, 100% { transform: translateY(0); opacity: 0.5; } 40% { transform: translateY(-5px); opacity: 1; } }
|
||||
|
||||
.zzz {
|
||||
position: absolute;
|
||||
left: calc(var(--s) * 14px);
|
||||
bottom: calc(var(--s) * 80px);
|
||||
font-size: calc(var(--s) * 7px);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.zzz span {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
animation: zzz-float 3s ease-in infinite;
|
||||
}
|
||||
.zzz span:nth-child(2) { animation-delay: 1s; font-size: 1.3em; }
|
||||
.zzz span:nth-child(3) { animation-delay: 2s; font-size: 1.7em; }
|
||||
@keyframes zzz-float {
|
||||
0% { transform: translate(0, 0); opacity: 0; }
|
||||
20% { opacity: 0.9; }
|
||||
100% { transform: translate(1.5em, -3em); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ---------- Fireflies (keyframes borrowed from dream's garden) ---------- */
|
||||
|
||||
@keyframes firefly-drift-a { 0%,100% { transform: translate(0,0); } 25% { transform: translate(14px,-8px); } 50% { transform: translate(6px,-16px); } 75% { transform: translate(-10px,-6px); } }
|
||||
@keyframes firefly-drift-b { 0%,100% { transform: translate(0,0); } 25% { transform: translate(-12px,-6px); } 50% { transform: translate(-18px,4px); } 75% { transform: translate(-4px,10px); } }
|
||||
@keyframes firefly-drift-c { 0%,100% { transform: translate(0,0); } 25% { transform: translate(10px,6px); } 50% { transform: translate(16px,-4px); } 75% { transform: translate(4px,-12px); } }
|
||||
@keyframes firefly-glow { 0%,100% { opacity: 0.35; } 50% { opacity: 1; } }
|
||||
.firefly { animation: 12s ease-in-out infinite; transform-origin: center; }
|
||||
.firefly-a { animation-name: firefly-drift-a; }
|
||||
.firefly-b { animation-name: firefly-drift-b; }
|
||||
.firefly-c { animation-name: firefly-drift-c; }
|
||||
.firefly circle:last-child { animation: firefly-glow 2.4s ease-in-out infinite; }
|
||||
|
||||
/* ---------- UI ---------- */
|
||||
|
||||
.link, .chatbar button {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 7px 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link { padding: 4px 10px; background: transparent; }
|
||||
.link:hover { border-color: var(--accent); }
|
||||
|
||||
.menu-btn, .icon-btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-btn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 20;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
backdrop-filter: blur(8px);
|
||||
transition: border-color 0.15s, background 0.15s, opacity 0.2s;
|
||||
}
|
||||
.menu-btn:hover { border-color: var(--accent); }
|
||||
.menu-btn.on { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
|
||||
.menu-btn:focus-visible, .icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.icon-btn:hover { border-color: var(--line); }
|
||||
|
||||
/* The menu is a column next to the world, not an overlay: opening it grows
|
||||
its width from 0 and the world (and BMO with it) slides left. */
|
||||
.side-menu {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #0c1419;
|
||||
border-left: 1px solid transparent;
|
||||
transition: width 0.34s cubic-bezier(0.32, 0.72, 0, 1), border-color 0.34s;
|
||||
user-select: text;
|
||||
}
|
||||
.side-menu.open { width: var(--menu-w); border-left-color: var(--line); }
|
||||
/* Content keeps its full width while the column animates, so nothing reflows. */
|
||||
.side-menu > * { width: var(--menu-w); flex-shrink: 0; }
|
||||
.side-menu > .side-body { flex: 1; min-height: 0; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.side-menu { transition: none; }
|
||||
}
|
||||
|
||||
.side-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.side-head h1 { margin: 0; font-size: 16px; font-weight: 650; }
|
||||
|
||||
.side-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.side-body section { padding: 16px 20px 18px; border-bottom: 1px solid var(--line); }
|
||||
.side-body section:last-child { border-bottom: 0; }
|
||||
.side-body h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.side-body h3 { margin: 14px 0 8px; font-size: 13px; font-weight: 500; }
|
||||
.side-body h2 + h3 { margin-top: 0; }
|
||||
.hint { margin: 10px 0 0; font-size: 12px; color: var(--muted); }
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 0 2px #0c1419;
|
||||
}
|
||||
|
||||
.brain-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.brain-status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); flex: none; }
|
||||
.brain-status.ok { color: var(--ink); }
|
||||
.brain-status.ok .dot { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||||
|
||||
.key-form { margin-top: 12px; }
|
||||
.key-form label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 8px; }
|
||||
.key-row { display: flex; gap: 6px; }
|
||||
.key-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
.key-row input:focus { border-color: var(--accent); }
|
||||
.primary {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-ink);
|
||||
background: var(--accent);
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary:disabled { opacity: 0.45; cursor: default; }
|
||||
.key-error { margin: 8px 0 0; font-size: 12px; color: #ff9b9b; }
|
||||
.key-actions { display: flex; gap: 6px; margin-top: 12px; }
|
||||
.hint a { color: var(--accent); }
|
||||
.hint code { font-size: 11px; }
|
||||
.text-btn { font: inherit; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer; text-decoration: underline; }
|
||||
|
||||
.swatches { display: grid; grid-template-columns: repeat(9, 1fr); gap: 6px; }
|
||||
.swatch { width: 100%; aspect-ratio: 1; border-radius: 8px; border: 3px solid; cursor: pointer; padding: 0; }
|
||||
.swatch.on { outline: 2px solid #fff; outline-offset: 2px; }
|
||||
|
||||
.segmented {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.segmented.grid { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.segmented button {
|
||||
flex: 1 0 auto;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.segmented button:hover { background: rgba(255, 255, 255, 0.06); }
|
||||
.segmented button.on { background: var(--accent); color: var(--accent-ink); font-weight: 600; }
|
||||
|
||||
.convo { flex: 1; display: flex; flex-direction: column; min-height: 180px; }
|
||||
.convo-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.convo-head h2 { margin: 0; }
|
||||
.log-body { flex: 1; overflow-y: auto; margin-top: 10px; font-size: 14px; line-height: 1.45; }
|
||||
.log-body p { margin: 0 0 10px; }
|
||||
.log-body b { color: var(--accent); margin-right: 4px; }
|
||||
.log-body .user b { color: #ffd59a; }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
.chatbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 20px;
|
||||
translate: -50% 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
}
|
||||
.chatbar input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
outline: none;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.chatbar input:focus { border-color: var(--accent); }
|
||||
.chatbar button { background: var(--accent); color: var(--accent-ink); font-weight: 600; padding: 0 20px; border: 0; }
|
||||
.chatbar button:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.bubble { font-size: 14px; }
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
// BMO, redrawn for this project: a chunkier little console with depth (side
|
||||
// edge, shading, screen glare), cartoon-style controls (yellow D-pad, blue
|
||||
// triangle, green and red buttons, cartridge slot), and an expressive face
|
||||
// (sparkly eyes, blush, a mouth that moves while talking, love/dizzy/sad/grin).
|
||||
//
|
||||
// It reuses the original bmo-preview pieces that still fit: accessories,
|
||||
// emotes, weather reactions and the body/walk animation classes in
|
||||
// src/bmo/bmo-animations.css. Accessories are positioned from the same body
|
||||
// box numbers (bx/by/bw/bh) the original used, so outfits line up unchanged.
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
PERSONALITY_ACCESSORIES,
|
||||
renderBadge,
|
||||
renderBowTie,
|
||||
renderCape,
|
||||
renderCoolGlasses,
|
||||
renderCrown,
|
||||
renderEmoteTsx,
|
||||
renderGlasses,
|
||||
renderHeadphones,
|
||||
renderTopHat,
|
||||
renderWeatherReactions,
|
||||
type AccCoords,
|
||||
type BmoColorPalette,
|
||||
type BmoState,
|
||||
} from "../bmo";
|
||||
import "./bmo-face.css";
|
||||
|
||||
export type Expression = "normal" | "grin" | "love" | "sad" | "dizzy";
|
||||
|
||||
interface BmoProps {
|
||||
palette: BmoColorPalette;
|
||||
state: BmoState;
|
||||
size?: number;
|
||||
personality?: string;
|
||||
emote?: "celebrate" | "confused" | "eureka" | "error";
|
||||
expression?: Expression;
|
||||
/** Mouth flaps while a reply is streaming in. */
|
||||
talking?: boolean;
|
||||
sleeping?: boolean;
|
||||
walking?: boolean;
|
||||
/** Which way BMO is heading; it looks that way instead of flipping its body. */
|
||||
facing?: 1 | -1;
|
||||
weather?: string;
|
||||
trackPointer?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ---------- geometry (viewBox units) ----------
|
||||
|
||||
const W = 64;
|
||||
const BOTTOM = 74;
|
||||
// Body box. Same numbers the original accessories expect (screen = bx+5, by+6, bw-10, bh-24).
|
||||
const bx = 6;
|
||||
const by = 4;
|
||||
const bw = 52;
|
||||
const bh = 52;
|
||||
const bodyH = 58;
|
||||
const sx = bx + 5;
|
||||
const sy = by + 6;
|
||||
const sw = bw - 10;
|
||||
const sh = bh - 24;
|
||||
const eyeCx = sx + sw / 2;
|
||||
const eyeCy = sy + sh * 0.42;
|
||||
const eyeSpacing = sw * 0.22;
|
||||
const mouthY = sy + sh * 0.72;
|
||||
|
||||
/** Lightens (amount > 0) or darkens (amount < 0) a #rrggbb color. */
|
||||
function shade(hex: string, amount: number) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const ch = (v: number) => {
|
||||
const t = amount < 0 ? 0 : 255;
|
||||
return Math.round(v + (t - v) * Math.abs(amount));
|
||||
};
|
||||
const r = ch((n >> 16) & 255);
|
||||
const g = ch((n >> 8) & 255);
|
||||
const b = ch(n & 255);
|
||||
return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
function heartPath(cx: number, cy: number, s: number) {
|
||||
return `M ${cx} ${cy + s * 1.1}
|
||||
C ${cx - s * 2.2} ${cy - s * 0.2}, ${cx - s * 1.1} ${cy - s * 1.9}, ${cx} ${cy - s * 0.6}
|
||||
C ${cx + s * 1.1} ${cy - s * 1.9}, ${cx + s * 2.2} ${cy - s * 0.2}, ${cx} ${cy + s * 1.1} Z`;
|
||||
}
|
||||
|
||||
function spiralPath(cx: number, cy: number) {
|
||||
const pts: string[] = [];
|
||||
for (let i = 0; i <= 28; i++) {
|
||||
const a = i * 0.55;
|
||||
const r = 0.25 + i * 0.12;
|
||||
pts.push(`${(cx + Math.cos(a) * r).toFixed(2)},${(cy + Math.sin(a) * r).toFixed(2)}`);
|
||||
}
|
||||
return `M ${pts.join(" L ")}`;
|
||||
}
|
||||
|
||||
// ---------- gaze ----------
|
||||
|
||||
function useGaze(svgRef: React.RefObject<SVGSVGElement | null>, enabled: boolean) {
|
||||
const [gaze, setGaze] = useState({ dx: 0, dy: 0 });
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setGaze({ dx: 0, dy: 0 });
|
||||
return;
|
||||
}
|
||||
let raf = 0;
|
||||
const onMove = (e: PointerEvent) => {
|
||||
cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
const rect = svgRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width === 0) return;
|
||||
let dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width * 2);
|
||||
let dy = (e.clientY - (rect.top + rect.height * 0.35)) / (rect.height * 2);
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > 1) {
|
||||
dx /= d;
|
||||
dy /= d;
|
||||
}
|
||||
setGaze({ dx: dx * 1.8, dy: dy * 1.6 });
|
||||
});
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [enabled, svgRef]);
|
||||
return gaze;
|
||||
}
|
||||
|
||||
// ---------- face ----------
|
||||
|
||||
type Face = Expression | "talk" | "thinking" | "sleeping" | "bored" | "grumpy";
|
||||
|
||||
function FaceArt({ face, accent, gx, gy }: { face: Face; accent: string; gx: number; gy: number }) {
|
||||
const lx = eyeCx - eyeSpacing;
|
||||
const rx = eyeCx + eyeSpacing;
|
||||
const blush = (
|
||||
<g opacity={0.4}>
|
||||
<ellipse cx={lx - 4} cy={eyeCy + 4.6} rx={2.6} ry={1.3} fill="#ff7aa8" />
|
||||
<ellipse cx={rx + 4} cy={eyeCy + 4.6} rx={2.6} ry={1.3} fill="#ff7aa8" />
|
||||
</g>
|
||||
);
|
||||
const eyes = (r = 1) => (
|
||||
<g>
|
||||
{[lx, rx].map((x) => (
|
||||
<g key={x} className="bmo-blink">
|
||||
<ellipse cx={x + gx} cy={eyeCy + gy} rx={2.5 * r} ry={3.1 * r} fill={accent} />
|
||||
<circle cx={x + gx + 0.9} cy={eyeCy + gy - 1.2} r={0.85} fill="#fff" opacity={0.9} />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
const smile = (
|
||||
<path
|
||||
d={`M ${eyeCx - 4.5} ${mouthY} Q ${eyeCx} ${mouthY + 4.2} ${eyeCx + 4.5} ${mouthY}`}
|
||||
fill="none"
|
||||
stroke={accent}
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
const openMouth = (className?: string) => (
|
||||
<g className={className}>
|
||||
<path d={`M ${eyeCx - 4.2} ${mouthY - 0.6} Q ${eyeCx} ${mouthY + 6} ${eyeCx + 4.2} ${mouthY - 0.6} Z`} fill={accent} />
|
||||
<ellipse cx={eyeCx} cy={mouthY + 2.7} rx={2} ry={1} fill="#ff8fa3" opacity={0.85} />
|
||||
</g>
|
||||
);
|
||||
|
||||
switch (face) {
|
||||
case "normal":
|
||||
return (
|
||||
<>
|
||||
{eyes()}
|
||||
{blush}
|
||||
{smile}
|
||||
</>
|
||||
);
|
||||
case "talk":
|
||||
return (
|
||||
<>
|
||||
{eyes()}
|
||||
{blush}
|
||||
{openMouth("bmo2-talk")}
|
||||
</>
|
||||
);
|
||||
case "grin":
|
||||
return (
|
||||
<>
|
||||
{[lx, rx].map((x) => (
|
||||
<path
|
||||
key={x}
|
||||
d={`M ${x - 2.8} ${eyeCy + 1} Q ${x} ${eyeCy - 3.2} ${x + 2.8} ${eyeCy + 1}`}
|
||||
fill="none"
|
||||
stroke={accent}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
))}
|
||||
{blush}
|
||||
{openMouth()}
|
||||
</>
|
||||
);
|
||||
case "love":
|
||||
return (
|
||||
<>
|
||||
{[lx, rx].map((x) => (
|
||||
<path key={x} d={heartPath(x, eyeCy, 1.9)} fill="#ff6b9d" className="bmo2-pulse" />
|
||||
))}
|
||||
{blush}
|
||||
{smile}
|
||||
</>
|
||||
);
|
||||
case "dizzy":
|
||||
return (
|
||||
<>
|
||||
{[lx, rx].map((x) => (
|
||||
<path
|
||||
key={x}
|
||||
d={spiralPath(x, eyeCy)}
|
||||
fill="none"
|
||||
stroke={accent}
|
||||
strokeWidth={0.9}
|
||||
strokeLinecap="round"
|
||||
className="bmo2-spin"
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
d={`M ${eyeCx - 4.5} ${mouthY + 1} q 1.5 -2 3 0 t 3 0 t 3 0`}
|
||||
fill="none"
|
||||
stroke={accent}
|
||||
strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "sad":
|
||||
return (
|
||||
<>
|
||||
{eyes(0.85)}
|
||||
{[lx, rx].map((x, i) => (
|
||||
<line
|
||||
key={x}
|
||||
x1={x - 2.6}
|
||||
y1={eyeCy - (i === 0 ? 3.6 : 5)}
|
||||
x2={x + 2.6}
|
||||
y2={eyeCy - (i === 0 ? 5 : 3.6)}
|
||||
stroke={accent}
|
||||
strokeWidth={1}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
d={`M ${eyeCx - 3.5} ${mouthY + 2} Q ${eyeCx} ${mouthY - 1.5} ${eyeCx + 3.5} ${mouthY + 2}`}
|
||||
fill="none"
|
||||
stroke={accent}
|
||||
strokeWidth={1.3}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<ellipse cx={lx - 0.5} cy={eyeCy + 4.5} rx={0.8} ry={1.3} fill="#7cc7ff" className="bmo2-tear" />
|
||||
</>
|
||||
);
|
||||
case "thinking":
|
||||
return (
|
||||
<>
|
||||
{eyes(0.9)}
|
||||
<circle cx={eyeCx + 1.5} cy={mouthY + 1} r={1.4} fill="none" stroke={accent} strokeWidth={1.1} />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={sx + sw - 9 + i * 3}
|
||||
cy={sy + sh - 4}
|
||||
r={0.9}
|
||||
fill={accent}
|
||||
className="bmo2-dot"
|
||||
style={{ animationDelay: `${i * 0.18}s` }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
case "sleeping":
|
||||
return (
|
||||
<>
|
||||
{[lx, rx].map((x) => (
|
||||
<path
|
||||
key={x}
|
||||
d={`M ${x - 2.6} ${eyeCy} Q ${x} ${eyeCy + 2.4} ${x + 2.6} ${eyeCy}`}
|
||||
fill="none"
|
||||
stroke={accent}
|
||||
strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
opacity={0.7}
|
||||
/>
|
||||
))}
|
||||
<ellipse cx={eyeCx} cy={mouthY + 1} rx={1.4} ry={1} fill={accent} opacity={0.5} className="bmo2-snore" />
|
||||
</>
|
||||
);
|
||||
case "bored":
|
||||
return (
|
||||
<g className="bmo-eyes-wander">
|
||||
<rect x={lx - 2.6 + gx} y={eyeCy - 0.8} width={5.2} height={1.6} rx={0.8} fill={accent} opacity={0.6} />
|
||||
<rect x={rx - 2.6 + gx} y={eyeCy - 0.8} width={5.2} height={1.6} rx={0.8} fill={accent} opacity={0.6} />
|
||||
<line x1={eyeCx - 2.2} y1={mouthY + 1} x2={eyeCx + 2.2} y2={mouthY + 1} stroke={accent} strokeWidth={1} strokeLinecap="round" opacity={0.6} />
|
||||
</g>
|
||||
);
|
||||
case "grumpy":
|
||||
return (
|
||||
<>
|
||||
<line x1={lx - 3} y1={eyeCy - 5} x2={lx + 2} y2={eyeCy - 3.2} stroke={accent} strokeWidth={1.2} strokeLinecap="round" />
|
||||
<line x1={rx + 3} y1={eyeCy - 5} x2={rx - 2} y2={eyeCy - 3.2} stroke={accent} strokeWidth={1.2} strokeLinecap="round" />
|
||||
<circle cx={lx} cy={eyeCy} r={1.8} fill={accent} />
|
||||
<circle cx={rx} cy={eyeCy} r={1.8} fill={accent} />
|
||||
<polyline
|
||||
points={`${eyeCx - 5},${mouthY + 1} ${eyeCx - 2.5},${mouthY + 3} ${eyeCx},${mouthY} ${eyeCx + 2.5},${mouthY + 3} ${eyeCx + 5},${mouthY + 1}`}
|
||||
fill="none"
|
||||
stroke={accent}
|
||||
strokeWidth={1.1}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- body ----------
|
||||
|
||||
export const Bmo = memo(function Bmo({
|
||||
palette,
|
||||
state,
|
||||
size = 1,
|
||||
personality,
|
||||
emote,
|
||||
expression = "normal",
|
||||
talking = false,
|
||||
sleeping = false,
|
||||
walking = false,
|
||||
facing = 1,
|
||||
weather,
|
||||
trackPointer = true,
|
||||
className,
|
||||
}: BmoProps) {
|
||||
const { body, highlight, screenBg, accent } = palette;
|
||||
const id = useMemo(() => `bmo2-${Math.random().toString(36).slice(2, 8)}`, []);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const gaze = useGaze(svgRef, trackPointer && !sleeping && state !== "blocked" && !walking);
|
||||
|
||||
const edge = shade(body, -0.35);
|
||||
const limb = shade(body, -0.18);
|
||||
const foot = shade(body, -0.32);
|
||||
const bevel = shade(highlight, 0.25);
|
||||
|
||||
const accessories = (personality && PERSONALITY_ACCESSORIES[personality]) || [];
|
||||
const padX = accessories.includes("headphones") ? 18 : accessories.includes("cape") ? 12 : 6;
|
||||
const padTop = accessories.includes("headphones") ? 20 : accessories.includes("crown") || accessories.includes("top-hat") ? 14 : 4;
|
||||
const acc: AccCoords = { bx, by, bw, bh, w: W, accent };
|
||||
|
||||
// While walking BMO looks where it's going; otherwise it follows the cursor.
|
||||
const gx = walking ? facing * 1.5 : gaze.dx;
|
||||
const gy = walking ? 0 : gaze.dy;
|
||||
|
||||
const face: Face =
|
||||
state === "blocked"
|
||||
? "grumpy"
|
||||
: sleeping
|
||||
? "sleeping"
|
||||
: state === "working"
|
||||
? "thinking"
|
||||
: talking && (expression === "normal" || expression === "grin")
|
||||
? "talk"
|
||||
: expression !== "normal"
|
||||
? expression
|
||||
: state === "idle"
|
||||
? "bored"
|
||||
: "normal";
|
||||
|
||||
const bodyAnim =
|
||||
state === "active"
|
||||
? "bmo-bob-active"
|
||||
: state === "working"
|
||||
? "bmo-bob-working"
|
||||
: state === "idle"
|
||||
? personality === "director"
|
||||
? "bmo-idle-director"
|
||||
: "bmo-sway-idle"
|
||||
: "bmo-shake-blocked bmo-enter-blocked";
|
||||
const armAnim = state === "working" ? "bmo-type-working" : state === "blocked" ? "bmo-arms-blocked" : "";
|
||||
const walkClass = personality ? `bmo-walk-${personality}` : "bmo-walk-default";
|
||||
const walkBounce = personality === "engineer" ? "bmo-walk-bounce-fast" : personality === "director" ? "bmo-walk-bounce-slow" : "bmo-walk-bounce-default";
|
||||
const weatherBody = weather === "rain" ? " bmo-rain-shiver" : weather === "snow" ? " bmo-snow-huddle" : "";
|
||||
const armsDown = state === "idle" || sleeping;
|
||||
const handY = armsDown ? by + bodyH * 0.8 : by + bodyH * 0.42;
|
||||
const shoulderY = by + bodyH * 0.58;
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`${-padX} ${-padTop} ${W + padX * 2} ${BOTTOM + padTop}`}
|
||||
width={(W + padX * 2) * size}
|
||||
height={(BOTTOM + padTop) * size}
|
||||
className={className}
|
||||
style={{ overflow: "visible" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`${id}-body`} x1="0" y1="0" x2="1" y2="0.25">
|
||||
<stop offset="0%" stopColor={shade(body, 0.16)} />
|
||||
<stop offset="38%" stopColor={body} />
|
||||
<stop offset="100%" stopColor={shade(body, -0.08)} />
|
||||
</linearGradient>
|
||||
<linearGradient id={`${id}-screen`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={shade(screenBg, 0.1)} />
|
||||
<stop offset="100%" stopColor={shade(screenBg, -0.25)} />
|
||||
</linearGradient>
|
||||
<radialGradient id={`${id}-glow`} cx="0.5" cy="0.45" r="0.6">
|
||||
<stop offset="0%" stopColor={accent} stopOpacity={0.16} />
|
||||
<stop offset="100%" stopColor={accent} stopOpacity={0} />
|
||||
</radialGradient>
|
||||
<filter id={`${id}-face`} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1.4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<clipPath id={`${id}-screen-clip`}>
|
||||
<rect x={sx} y={sy} width={sw} height={sh} rx={4} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g className={(walking ? walkBounce : bodyAnim) + weatherBody}>
|
||||
{accessories.includes("cape") && renderCape(acc)}
|
||||
|
||||
{/* Arms (behind the body) */}
|
||||
{(["l", "r"] as const).map((side) => {
|
||||
const sx0 = side === "l" ? bx + 1 : bx + bw - 1;
|
||||
const hx = side === "l" ? bx - 8.5 : bx + bw + 8.5;
|
||||
const cls = walking ? `${walkClass}-arms-${side}` : armAnim;
|
||||
return (
|
||||
<g key={side} className={cls} style={{ transformOrigin: `${sx0}px ${shoulderY}px` }}>
|
||||
<path
|
||||
d={`M ${sx0} ${shoulderY} Q ${hx + (side === "l" ? 1 : -1)} ${shoulderY} ${hx} ${handY}`}
|
||||
fill="none"
|
||||
stroke={limb}
|
||||
strokeWidth={3.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={hx} cy={handY} r={2.5} fill={limb} />
|
||||
<circle cx={hx - 0.6} cy={handY - 0.7} r={0.8} fill="#fff" opacity={0.18} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Legs & feet */}
|
||||
<g className={walking ? `${walkClass}-legs` : armsDown ? "bmo-feet-shuffle" : ""}>
|
||||
{[eyeCx - 9.5, eyeCx + 4].map((lx) => (
|
||||
<g key={lx}>
|
||||
<rect x={lx} y={by + bodyH - 2} width={5.5} height={9} rx={2.6} fill={limb} />
|
||||
<ellipse cx={lx + 2.75} cy={by + bodyH + 8} rx={5} ry={2.5} fill={foot} />
|
||||
<ellipse cx={lx + 1.8} cy={by + bodyH + 7.2} rx={2.2} ry={0.8} fill="#fff" opacity={0.12} />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Depth: the console's side/bottom edge */}
|
||||
<rect x={bx + 2.2} y={by + 2.4} width={bw} height={bodyH} rx={7} fill={edge} />
|
||||
{/* Main body */}
|
||||
<rect x={bx} y={by} width={bw} height={bodyH} rx={7} fill={`url(#${id}-body)`} stroke={edge} strokeWidth={0.6} />
|
||||
{/* Top bevel highlight */}
|
||||
<path
|
||||
d={`M ${bx + 6} ${by + 1.3} H ${bx + bw - 6}`}
|
||||
stroke={bevel}
|
||||
strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
opacity={0.55}
|
||||
/>
|
||||
<path
|
||||
d={`M ${bx + 1.4} ${by + 7} V ${by + bodyH - 7}`}
|
||||
stroke={bevel}
|
||||
strokeWidth={1}
|
||||
strokeLinecap="round"
|
||||
opacity={0.35}
|
||||
/>
|
||||
|
||||
{/* Screen bezel + screen */}
|
||||
<rect x={sx - 1.6} y={sy - 1.6} width={sw + 3.2} height={sh + 3.2} rx={5.2} fill={shade(body, -0.25)} />
|
||||
<rect x={sx} y={sy} width={sw} height={sh} rx={4} fill={`url(#${id}-screen)`} />
|
||||
<rect x={sx} y={sy} width={sw} height={sh} rx={4} fill={`url(#${id}-glow)`} />
|
||||
|
||||
{renderWeatherReactions(weather, state, bx, by, bw, bh, sx, sy, sw, sh, eyeCx, mouthY, accent)}
|
||||
|
||||
{/* Face */}
|
||||
<g filter={`url(#${id}-face)`} className="bmo-face-transition" clipPath={`url(#${id}-screen-clip)`}>
|
||||
<FaceArt face={face} accent={accent} gx={gx} gy={gy} />
|
||||
</g>
|
||||
|
||||
{/* Screen glare */}
|
||||
<path
|
||||
d={`M ${sx + 2} ${sy + 1.5} L ${sx + 15} ${sy + 1.5} L ${sx + 6} ${sy + sh - 2} L ${sx + 2} ${sy + sh - 2} Z`}
|
||||
fill="#fff"
|
||||
opacity={0.06}
|
||||
clipPath={`url(#${id}-screen-clip)`}
|
||||
/>
|
||||
|
||||
{/* Cartridge slot + power light */}
|
||||
<rect x={bx + 7} y={sy + sh + 4.2} width={22} height={2.3} rx={1.15} fill={shade(body, -0.45)} />
|
||||
<circle cx={bx + bw - 8} cy={sy + sh + 5.3} r={1.3} fill="#4ade80" className="bmo2-led" />
|
||||
|
||||
{/* D-pad */}
|
||||
<g transform={`translate(${bx + 13} ${by + 48})`}>
|
||||
<g fill="#b98a12" transform="translate(0.6 0.7)">
|
||||
<rect x={-5.5} y={-1.7} width={11} height={3.4} rx={0.9} />
|
||||
<rect x={-1.7} y={-5.5} width={3.4} height={11} rx={0.9} />
|
||||
</g>
|
||||
<g fill="#f5c542">
|
||||
<rect x={-5.5} y={-1.7} width={11} height={3.4} rx={0.9} />
|
||||
<rect x={-1.7} y={-5.5} width={3.4} height={11} rx={0.9} />
|
||||
</g>
|
||||
<circle r={0.9} fill="#d9a81e" />
|
||||
</g>
|
||||
|
||||
{/* Buttons: blue triangle, green circle, big red circle */}
|
||||
<g>
|
||||
<polygon points={`${bx + 36.6},${by + 43.4} ${bx + 33.6},${by + 48.4} ${bx + 39.6},${by + 48.4}`} fill="#1d4ed8" transform="translate(0.5 0.6)" />
|
||||
<polygon points={`${bx + 36.6},${by + 43.4} ${bx + 33.6},${by + 48.4} ${bx + 39.6},${by + 48.4}`} fill="#3b82f6" />
|
||||
<circle cx={bx + 44.6} cy={by + 47.2} r={2.1} fill="#15803d" transform="translate(0.4 0.5)" />
|
||||
<circle cx={bx + 44.6} cy={by + 47.2} r={2.1} fill="#22c55e" />
|
||||
<circle cx={bx + 38.8} cy={by + 53.6} r={3.4} fill="#b91c1c" transform="translate(0.5 0.6)" />
|
||||
<circle cx={bx + 38.8} cy={by + 53.6} r={3.4} fill="#ef4444" />
|
||||
<circle cx={bx + 37.8} cy={by + 52.5} r={1} fill="#fff" opacity={0.35} />
|
||||
</g>
|
||||
|
||||
{/* Little start/select pills */}
|
||||
<rect x={bx + 8} y={by + 54.6} width={4.5} height={1.4} rx={0.7} fill={shade(body, -0.4)} />
|
||||
<rect x={bx + 14} y={by + 54.6} width={4.5} height={1.4} rx={0.7} fill={shade(body, -0.4)} />
|
||||
|
||||
{/* Accessories on top */}
|
||||
{accessories.includes("crown") && renderCrown(acc)}
|
||||
{accessories.includes("top-hat") && renderTopHat(acc)}
|
||||
{accessories.includes("headphones") && renderHeadphones(acc)}
|
||||
{accessories.includes("glasses") && renderGlasses(acc)}
|
||||
{accessories.includes("cool-glasses") && renderCoolGlasses(acc)}
|
||||
{accessories.includes("badge") && renderBadge(acc)}
|
||||
{accessories.includes("bow-tie") && renderBowTie(acc)}
|
||||
</g>
|
||||
|
||||
{renderEmoteTsx(emote, bx, by, bw, bh, W, accent)}
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/* Animations for the redrawn BMO's face (src/avatar/Bmo.tsx). Body and walk
|
||||
animations still come from src/bmo/bmo-animations.css. */
|
||||
|
||||
@keyframes bmo2-talk { 0% { transform: scaleY(0.25); } 100% { transform: scaleY(1); } }
|
||||
.bmo2-talk {
|
||||
animation: bmo2-talk 0.16s ease-in-out infinite alternate;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
@keyframes bmo2-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.18); } }
|
||||
.bmo2-pulse { animation: bmo2-pulse 0.7s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
|
||||
@keyframes bmo2-spin { to { transform: rotate(360deg); } }
|
||||
.bmo2-spin { animation: bmo2-spin 0.9s linear infinite; transform-box: fill-box; transform-origin: center; }
|
||||
|
||||
@keyframes bmo2-dot { 0%, 100% { opacity: 0.25; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-0.8px); } }
|
||||
.bmo2-dot { animation: bmo2-dot 1s ease-in-out infinite; }
|
||||
|
||||
@keyframes bmo2-snore { 0%, 100% { transform: scale(0.7); } 50% { transform: scale(1.15); } }
|
||||
.bmo2-snore { animation: bmo2-snore 2.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
||||
|
||||
@keyframes bmo2-tear { 0% { transform: translateY(0); opacity: 0.9; } 100% { transform: translateY(3px); opacity: 0; } }
|
||||
.bmo2-tear { animation: bmo2-tear 1.4s ease-in infinite; }
|
||||
|
||||
@keyframes bmo2-led { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
|
||||
.bmo2-led { animation: bmo2-led 2.4s ease-in-out infinite; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bmo2-talk, .bmo2-pulse, .bmo2-spin, .bmo2-snore, .bmo2-tear, .bmo2-led { animation: none; }
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
import React from "react";
|
||||
|
||||
export interface AccCoords { bx: number; by: number; bw: number; bh: number; w: number; accent: string; }
|
||||
|
||||
export const PERSONALITY_ACCESSORIES: Record<string, string[]> = {
|
||||
director: ["crown", "cape"],
|
||||
engineer: ["headphones", "glasses"],
|
||||
qa: ["badge", "cool-glasses"],
|
||||
command: ["top-hat", "bow-tie"],
|
||||
};
|
||||
|
||||
export function renderCrown({ bx, by, bw }: AccCoords) {
|
||||
const cx = bx + bw / 2;
|
||||
const baseY = by - 2;
|
||||
const points = `${cx-9},${baseY} ${cx-9},${baseY-5} ${cx-3},${baseY-9} ${cx},${baseY-5} ${cx+3},${baseY-9} ${cx+9},${baseY-5} ${cx+9},${baseY}`;
|
||||
return (
|
||||
<g key="acc-crown">
|
||||
<polygon points={points} fill="#fbbf24" stroke="#d97706" strokeWidth={0.5} />
|
||||
<circle cx={cx - 3} cy={baseY - 9} r={1.2} fill="#ef4444" />
|
||||
<circle cx={cx} cy={baseY - 5} r={1} fill="#3b82f6" />
|
||||
<circle cx={cx + 3} cy={baseY - 9} r={1.2} fill="#22c55e" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderCape({ bx, by, bw, bh, accent }: AccCoords) {
|
||||
const lx = bx + 2, rx = bx + bw - 2;
|
||||
const sy = by + 6, fy = by + bh + 14;
|
||||
return (
|
||||
<g key="acc-cape" className="bmo-cape-sway" style={{ transformOrigin: `${bx + bw/2}px ${sy}px` }}>
|
||||
<path d={`M ${lx} ${sy} C ${lx-8} ${sy+20} ${lx-10} ${sy+40} ${lx-6} ${fy} L ${rx+6} ${fy} C ${rx+10} ${sy+40} ${rx+8} ${sy+20} ${rx} ${sy} Z`} fill={accent} opacity={0.3} />
|
||||
<path d={`M ${lx-6} ${fy} L ${rx+6} ${fy}`} stroke={accent} strokeWidth={0.8} opacity={0.4} fill="none" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderTopHat({ bx, by, bw, accent }: AccCoords) {
|
||||
const cx = bx + bw / 2;
|
||||
const ty = by - 10;
|
||||
return (
|
||||
<g key="acc-tophat">
|
||||
<rect x={cx - 6} y={ty} width={12} height={8} rx={1} fill="#111" />
|
||||
<rect x={cx - 9} y={ty + 8} width={18} height={2} rx={0.5} fill="#222" />
|
||||
<rect x={cx - 6} y={ty + 6} width={12} height={1.5} rx={0.5} fill={accent} opacity={0.8} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderBadge({ bx, by }: AccCoords) {
|
||||
const bcx = bx + 5, bcy = by + 6;
|
||||
const starPts = Array.from({ length: 5 }, (_, i) => {
|
||||
const a = (i * 72 - 90) * Math.PI / 180;
|
||||
const ia = a + 36 * Math.PI / 180;
|
||||
return `${bcx + Math.cos(a)*2},${bcy + Math.sin(a)*2} ${bcx + Math.cos(ia)*1},${bcy + Math.sin(ia)*1}`;
|
||||
}).join(" ");
|
||||
return (
|
||||
<g key="acc-badge">
|
||||
<circle cx={bcx} cy={bcy} r={3.5} fill="#fbbf24" stroke="#d97706" strokeWidth={0.4} />
|
||||
<polygon points={starPts} fill="#d97706" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderGlasses({ bx, by, bw, bh }: AccCoords) {
|
||||
const sx = bx + 5, sw = bw - 10, sh = bh - 24;
|
||||
const eCy = by + 6 + sh * 0.42, eCx = sx + sw / 2, es = sw * 0.22;
|
||||
const lx = eCx - es, rx = eCx + es;
|
||||
return (
|
||||
<g key="acc-glasses" stroke="#888" strokeWidth={0.5} fill="none">
|
||||
<circle cx={lx} cy={eCy} r={3} />
|
||||
<circle cx={rx} cy={eCy} r={3} />
|
||||
<line x1={lx + 3} y1={eCy} x2={rx - 3} y2={eCy} />
|
||||
<line x1={lx - 3} y1={eCy} x2={bx} y2={eCy - 0.5} />
|
||||
<line x1={rx + 3} y1={eCy} x2={bx + bw} y2={eCy - 0.5} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderCoolGlasses({ bx, by, bw, bh }: AccCoords) {
|
||||
const sx = bx + 5, sw = bw - 10, sh = bh - 24;
|
||||
const eCy = by + 6 + sh * 0.42, eCx = sx + sw / 2, es = sw * 0.22;
|
||||
const lx = eCx - es, rx = eCx + es;
|
||||
return (
|
||||
<g key="acc-coolgl">
|
||||
<rect x={lx - 3} y={eCy - 1.5} width={6} height={3} rx={1} fill="#1a1a1a" stroke="#444" strokeWidth={0.7} />
|
||||
<line x1={lx - 2} y1={eCy - 1} x2={lx} y2={eCy - 1} stroke="#fff" strokeWidth={0.4} opacity={0.4} />
|
||||
<rect x={rx - 3} y={eCy - 1.5} width={6} height={3} rx={1} fill="#1a1a1a" stroke="#444" strokeWidth={0.7} />
|
||||
<line x1={rx - 2} y1={eCy - 1} x2={rx} y2={eCy - 1} stroke="#fff" strokeWidth={0.4} opacity={0.4} />
|
||||
<line x1={lx + 3} y1={eCy} x2={rx - 3} y2={eCy} stroke="#444" strokeWidth={0.7} />
|
||||
<line x1={lx - 3} y1={eCy} x2={bx} y2={eCy - 0.5} stroke="#444" strokeWidth={0.7} />
|
||||
<line x1={rx + 3} y1={eCy} x2={bx + bw} y2={eCy - 0.5} stroke="#444" strokeWidth={0.7} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderBowTie({ bx, by, bw, bh, accent }: AccCoords) {
|
||||
const cx = bx + bw / 2, cy = by + bh * 0.7;
|
||||
return (
|
||||
<g key="acc-bowtie">
|
||||
<polygon points={`${cx-5},${cy-2} ${cx-1},${cy} ${cx-5},${cy+2}`} fill={accent} opacity={0.85} />
|
||||
<polygon points={`${cx+5},${cy-2} ${cx+1},${cy} ${cx+5},${cy+2}`} fill={accent} opacity={0.85} />
|
||||
<circle cx={cx} cy={cy} r={1} fill={accent} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderHeadphones({ bx, by, bw, bh, w }: AccCoords) {
|
||||
const cupW = 12, cupH = bh * 0.35;
|
||||
const cupY = by + bh * 0.2;
|
||||
const cupXL = bx - cupW, cupXR = bx + bw;
|
||||
const bandY = cupY - cupH / 2;
|
||||
const bandLX = cupXL + cupW / 2, bandRX = cupXR + cupW / 2;
|
||||
const ins = 1.5;
|
||||
const of = "#656D78", cf = "#1E2328", df = "#434A54";
|
||||
return (
|
||||
<g key="acc-headphones" opacity={0.98}>
|
||||
<path d={`M ${bandLX} ${bandY} Q ${w/2} ${by-15} ${bandRX} ${bandY}`} fill="none" stroke={of} strokeWidth={5} strokeLinecap="round" />
|
||||
<path d={`M ${bandLX+2} ${bandY-0.5} Q ${w/2} ${by-18} ${bandRX-2} ${bandY-0.5}`} fill="none" stroke={cf} strokeWidth={2.25} strokeLinecap="round" />
|
||||
<rect x={cupXL} y={cupY-cupH/2} width={cupW} height={cupH} rx={3} fill={of} />
|
||||
<rect x={cupXR} y={cupY-cupH/2} width={cupW} height={cupH} rx={3} fill={of} />
|
||||
<rect x={cupXL+ins} y={cupY-cupH/2+cupH*0.15} width={cupW-ins*2} height={cupH*0.7} rx={2.5} fill={cf} />
|
||||
<rect x={cupXR+ins} y={cupY-cupH/2+cupH*0.15} width={cupW-ins*2} height={cupH*0.7} rx={2.5} fill={cf} />
|
||||
<rect x={cupXL+cupW/2-1.25} y={cupY-cupH*0.28} width={2.5} height={cupH*0.56} rx={1.25} fill={df} />
|
||||
<rect x={cupXR+cupW/2-1.25} y={cupY-cupH*0.28} width={2.5} height={cupH*0.56} rx={1.25} fill={df} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
import React, { useMemo, useState, useEffect, useRef } from "react";
|
||||
import { FEATURE_FLAGS } from "./FeatureFlags";
|
||||
import {
|
||||
AccCoords,
|
||||
PERSONALITY_ACCESSORIES,
|
||||
renderCrown,
|
||||
renderCape,
|
||||
renderTopHat,
|
||||
renderBadge,
|
||||
renderGlasses,
|
||||
renderCoolGlasses,
|
||||
renderBowTie,
|
||||
renderHeadphones,
|
||||
} from "./BmoAccessories";
|
||||
import { VOICE_LINES, renderScreenContent } from "./BmoScreen";
|
||||
import { renderWeatherReactions } from "./BmoWeatherReactions";
|
||||
import { renderEmoteTsx, renderSpeechTsx } from "./BmoEmotes";
|
||||
|
||||
// ---------- Color Palettes ----------
|
||||
|
||||
export interface BmoColorPalette {
|
||||
body: string;
|
||||
highlight: string;
|
||||
screenBg: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export const KNOWN_PALETTES: Record<string, BmoColorPalette> = {
|
||||
director: { body: "#2a5c28", highlight: "#3a7a34", screenBg: "#1a3a18", accent: "#c8f065" },
|
||||
engineer: { body: "#1a5050", highlight: "#2a7a78", screenBg: "#0e3030", accent: "#5ce0d8" },
|
||||
qa: { body: "#5a1a1a", highlight: "#8a3030", screenBg: "#300e0e", accent: "#f87171" },
|
||||
command: { body: "#2a2a5a", highlight: "#4040a0", screenBg: "#1a1a3a", accent: "#818cf8" },
|
||||
};
|
||||
|
||||
export const FALLBACK_PALETTES: BmoColorPalette[] = [
|
||||
{ body: "#2a4a5a", highlight: "#3a6a7a", screenBg: "#1a2a3a", accent: "#60a5fa" },
|
||||
{ body: "#4a2a5a", highlight: "#6a3a7a", screenBg: "#2a1a3a", accent: "#c084fc" },
|
||||
{ body: "#5a4a1a", highlight: "#7a6a2a", screenBg: "#3a2a0a", accent: "#fbbf24" },
|
||||
{ body: "#1a4a3a", highlight: "#2a6a5a", screenBg: "#0a2a1a", accent: "#34d399" },
|
||||
{ body: "#5a2a3a", highlight: "#7a3a5a", screenBg: "#3a0a1a", accent: "#f472b6" },
|
||||
{ body: "#3a3a3a", highlight: "#5a5a5a", screenBg: "#1a1a1a", accent: "#a3a3a3" },
|
||||
];
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
export function getPalette(agentName: string, role?: string): BmoColorPalette {
|
||||
const lower = (role ?? agentName).toLowerCase();
|
||||
if (lower.includes("director") || lower.includes("exec") || lower.includes("ceo")) return KNOWN_PALETTES.director!;
|
||||
if (lower.includes("engineer") || lower.includes("dev") || lower.includes("code")) return KNOWN_PALETTES.engineer!;
|
||||
if (lower.includes("qa") || lower.includes("test") || lower.includes("inspector")) return KNOWN_PALETTES.qa!;
|
||||
if (lower.includes("command") || lower.includes("nexus") || lower.includes("ops") || lower.includes("plan")) return KNOWN_PALETTES.command!;
|
||||
return FALLBACK_PALETTES[hashString(agentName) % FALLBACK_PALETTES.length]!;
|
||||
}
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
export type BmoState = "active" | "working" | "idle" | "blocked";
|
||||
|
||||
export function agentStatusToBmoState(status: string, isRunning: boolean): BmoState {
|
||||
if (isRunning) return "working";
|
||||
switch (status) {
|
||||
case "active": return "active";
|
||||
case "running": return "working";
|
||||
case "idle": return "idle";
|
||||
case "paused": return "idle";
|
||||
case "error": return "blocked";
|
||||
case "pending_approval": return "blocked";
|
||||
default: return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SVG Component ----------
|
||||
|
||||
interface BmoAvatarProps {
|
||||
palette: BmoColorPalette;
|
||||
state: BmoState;
|
||||
label?: string;
|
||||
isCommander?: boolean;
|
||||
size?: number;
|
||||
className?: string;
|
||||
personality?: string;
|
||||
emote?: "celebrate" | "confused" | "eureka" | "error";
|
||||
speech?: string;
|
||||
walking?: boolean;
|
||||
focusPoint?: { x: number; y: number };
|
||||
weather?: string;
|
||||
trackPointer?: boolean;
|
||||
}
|
||||
|
||||
export function BmoAvatar({
|
||||
palette,
|
||||
state,
|
||||
label,
|
||||
isCommander = false,
|
||||
size = 1,
|
||||
className,
|
||||
personality,
|
||||
emote,
|
||||
speech,
|
||||
walking = false,
|
||||
focusPoint,
|
||||
weather,
|
||||
trackPointer = true,
|
||||
}: BmoAvatarProps) {
|
||||
const { body, highlight, screenBg, accent } = palette;
|
||||
|
||||
const baseW = isCommander ? 70 : 60;
|
||||
const baseH = isCommander ? 92 : 82;
|
||||
|
||||
const accessories = (personality && PERSONALITY_ACCESSORIES[personality]) || [];
|
||||
const padX = accessories.includes("headphones") ? 18 : accessories.includes("cape") ? 12 : 2;
|
||||
const padTop = accessories.includes("headphones") ? 20 : (accessories.includes("crown") || accessories.includes("top-hat")) ? 14 : 2;
|
||||
|
||||
const w = baseW;
|
||||
const h = baseH;
|
||||
const viewBox = `${-padX} ${-padTop} ${w + padX * 2} ${h + padTop}`;
|
||||
|
||||
const bx = 8;
|
||||
const by = isCommander ? 12 : 4;
|
||||
const bw = w - 16;
|
||||
const bh = h - 30;
|
||||
|
||||
const sx = bx + 5;
|
||||
const sy = by + 6;
|
||||
const sw = bw - 10;
|
||||
const sh = bh - 24;
|
||||
|
||||
const eyeCx = sx + sw / 2;
|
||||
const eyeCy = sy + sh * 0.42;
|
||||
const eyeSpacing = sw * 0.22;
|
||||
const mouthY = sy + sh * 0.72;
|
||||
|
||||
const bodyAnim =
|
||||
state === "active" ? "bmo-bob-active" :
|
||||
state === "working" ? "bmo-bob-working" :
|
||||
state === "idle" ? (
|
||||
personality === "director" ? "bmo-idle-director" :
|
||||
"bmo-sway-idle"
|
||||
) :
|
||||
"bmo-shake-blocked bmo-enter-blocked";
|
||||
|
||||
const armAnim =
|
||||
state === "active" ? "bmo-type-active" :
|
||||
state === "working" ? "bmo-type-working" :
|
||||
state === "blocked" ? "bmo-arms-blocked" :
|
||||
"";
|
||||
|
||||
const uniqueId = useMemo(() => `bmo-${Math.random().toString(36).slice(2, 8)}`, []);
|
||||
|
||||
// Phase D: eye tracking
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [eyeOffset, setEyeOffset] = useState({ dx: 0, dy: 0 });
|
||||
useEffect(() => {
|
||||
if (state === "blocked") { setEyeOffset({ dx: 0, dy: 0 }); return; }
|
||||
if (focusPoint || !trackPointer) return;
|
||||
let rafId: number;
|
||||
function handlePointer(clientX: number, clientY: number) {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const svgCx = rect.left + rect.width / 2;
|
||||
const svgCy = rect.top + rect.height * 0.35;
|
||||
let dx = (clientX - svgCx) / (rect.width * 2);
|
||||
let dy = (clientY - svgCy) / (rect.height * 2);
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist > 1) { dx /= dist; dy /= dist; }
|
||||
setEyeOffset({ dx: dx * 2, dy: dy * 2 });
|
||||
});
|
||||
}
|
||||
function onMove(e: MouseEvent) { handlePointer(e.clientX, e.clientY); }
|
||||
function onTouch(e: TouchEvent) { if (e.touches[0]) handlePointer(e.touches[0].clientX, e.touches[0].clientY); }
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("touchmove", onTouch, { passive: true });
|
||||
return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("touchmove", onTouch); cancelAnimationFrame(rafId); };
|
||||
}, [state, focusPoint, trackPointer]);
|
||||
|
||||
// Phase D: walking classes
|
||||
const walkClass = personality ? `bmo-walk-${personality}` : "bmo-walk-default";
|
||||
const walkLegsClass = walking ? `${walkClass}-legs` : "";
|
||||
const walkBounceClass = walking ? (
|
||||
personality === "engineer" ? "bmo-walk-bounce-fast" :
|
||||
personality === "director" ? "bmo-walk-bounce-slow" :
|
||||
"bmo-walk-bounce-default"
|
||||
) : "";
|
||||
const walkArmLeftClass = walking ? `${walkClass}-arms-l` : "";
|
||||
const walkArmRightClass = walking ? `${walkClass}-arms-r` : "";
|
||||
|
||||
// Phase E: weather body class
|
||||
const weatherBodyClass =
|
||||
weather === "rain" ? "bmo-rain-shiver" :
|
||||
weather === "snow" ? "bmo-snow-huddle" : "";
|
||||
|
||||
const accCoords: AccCoords = { bx, by, bw, bh, w, accent };
|
||||
|
||||
// Voice line cycling
|
||||
const voiceLines = (personality && VOICE_LINES[personality]) || [];
|
||||
const [voiceIdx, setVoiceIdx] = useState(0);
|
||||
useEffect(() => {
|
||||
if (voiceLines.length === 0) return;
|
||||
const timer = setInterval(() => setVoiceIdx(i => (i + 1) % voiceLines.length), 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [voiceLines.length]);
|
||||
|
||||
const showVoice = FEATURE_FLAGS.voiceLines && voiceLines.length > 0 && (state === "active" || state === "working");
|
||||
|
||||
// Build accessory groups
|
||||
const behindBody: React.ReactNode[] = [];
|
||||
if (accessories.includes("cape")) behindBody.push(renderCape(accCoords));
|
||||
|
||||
const afterFace: React.ReactNode[] = [];
|
||||
if (accessories.includes("crown")) afterFace.push(renderCrown(accCoords));
|
||||
if (accessories.includes("top-hat")) afterFace.push(renderTopHat(accCoords));
|
||||
if (accessories.includes("headphones")) afterFace.push(renderHeadphones(accCoords));
|
||||
if (accessories.includes("glasses")) afterFace.push(renderGlasses(accCoords));
|
||||
if (accessories.includes("cool-glasses")) afterFace.push(renderCoolGlasses(accCoords));
|
||||
if (accessories.includes("badge")) afterFace.push(renderBadge(accCoords));
|
||||
if (accessories.includes("bow-tie")) afterFace.push(renderBowTie(accCoords));
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={viewBox}
|
||||
width={(w + padX * 2) * size}
|
||||
height={(h + padTop) * size}
|
||||
className={className}
|
||||
style={{ overflow: "visible" }}
|
||||
>
|
||||
<defs>
|
||||
<filter id={`${uniqueId}-glow`} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g className={(walking ? walkBounceClass : bodyAnim) + (weatherBodyClass ? " " + weatherBodyClass : "")}>
|
||||
{/* Cape behind body */}
|
||||
{...behindBody}
|
||||
|
||||
{/* Commander antenna */}
|
||||
{isCommander && (
|
||||
<g>
|
||||
<line x1={w / 2} y1={2} x2={w / 2} y2={by} stroke={highlight} strokeWidth={2} strokeLinecap="round" />
|
||||
<circle cx={w / 2} cy={2} r={2.5} fill={accent} className={personality === "command" && state === "idle" ? "bmo-antenna-pulse" : undefined} />
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Top bevel */}
|
||||
<rect x={bx} y={by} width={bw} height={4} rx={3} fill={highlight} opacity={0.4} />
|
||||
{/* Main body */}
|
||||
<rect x={bx} y={by} width={bw} height={bh} rx={3} fill={body} stroke={highlight} strokeWidth={1.5} />
|
||||
{/* Screen */}
|
||||
<rect x={sx} y={sy} width={sw} height={sh} rx={2} fill={screenBg} />
|
||||
{/* Screen ambient glow */}
|
||||
<rect x={sx} y={sy} width={sw} height={sh} rx={2} fill={accent} opacity={0.06} />
|
||||
|
||||
{/* Weather reactions */}
|
||||
{renderWeatherReactions(weather, state, bx, by, bw, bh, sx, sy, sw, sh, eyeCx, mouthY, accent)}
|
||||
|
||||
{/* Screen content (behind face) */}
|
||||
{renderScreenContent(personality, state, sx, sy, sw, sh, accent)}
|
||||
|
||||
{/* Face */}
|
||||
<g filter={`url(#${uniqueId}-glow)`} className={"bmo-face-transition" + (weather === "falling-leaves" ? " bmo-leaf-look-up" : "")}>
|
||||
{state === "active" && (
|
||||
<>
|
||||
<circle cx={eyeCx - eyeSpacing + eyeOffset.dx} cy={eyeCy + eyeOffset.dy} r={2.2} fill={accent} className="bmo-blink" />
|
||||
<circle cx={eyeCx + eyeSpacing + eyeOffset.dx} cy={eyeCy + eyeOffset.dy} r={2.2} fill={accent} className="bmo-blink" />
|
||||
<path d={`M ${eyeCx - 5} ${mouthY} Q ${eyeCx} ${mouthY + 4} ${eyeCx + 5} ${mouthY}`} fill="none" stroke={accent} strokeWidth={1.2} strokeLinecap="round" />
|
||||
</>
|
||||
)}
|
||||
{state === "working" && (
|
||||
<g>
|
||||
{/* Code editor window */}
|
||||
<g className="bmo-code-window-enter">
|
||||
<rect x={sx + 1} y={sy + 1} width={sw - 2} height={sh - 2} rx={1.5} fill="#0d1117" stroke={accent} strokeWidth={0.3} opacity={0.95} />
|
||||
<rect x={sx + 1} y={sy + 1} width={sw - 2} height={3.5} rx={1.5} fill="#161b22" />
|
||||
<circle cx={sx + 4} cy={sy + 3} r={0.7} fill="#f85149" />
|
||||
<circle cx={sx + 6.5} cy={sy + 3} r={0.7} fill="#d29922" />
|
||||
<circle cx={sx + 9} cy={sy + 3} r={0.7} fill="#3fb950" />
|
||||
<defs>
|
||||
<clipPath id={`${uniqueId}-code-clip`}>
|
||||
<rect x={sx + 1} y={sy + 4.5} width={sw - 2} height={sh - 5.5} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath={`url(#${uniqueId}-code-clip)`}>
|
||||
{(() => {
|
||||
const codeLines = [
|
||||
{ indent: 0, w: 0.6 }, { indent: 0.05, w: 0.75 }, { indent: 0.1, w: 0.5 },
|
||||
{ indent: 0.1, w: 0.65 }, { indent: 0.05, w: 0.4 }, { indent: 0, w: 0.55 },
|
||||
{ indent: 0, w: 0.8 }, { indent: 0.05, w: 0.45 }, { indent: 0, w: 0.7 },
|
||||
{ indent: 0.1, w: 0.35 }, { indent: 0.1, w: 0.6 }, { indent: 0, w: 0.5 },
|
||||
{ indent: 0.05, w: 0.65 }, { indent: 0, w: 0.45 }, { indent: 0.1, w: 0.55 },
|
||||
{ indent: 0, w: 0.7 },
|
||||
];
|
||||
const lineH = 2.5;
|
||||
const blockH = codeLines.length * lineH;
|
||||
const codeY = sy + 6;
|
||||
const rects: React.ReactElement[] = [];
|
||||
for (let copy = 0; copy < 2; copy++) {
|
||||
codeLines.forEach((line, i) => {
|
||||
const lx = sx + 3 + line.indent * (sw - 6);
|
||||
const ly = codeY + copy * blockH + i * lineH;
|
||||
const lw = line.w * (sw - 6 - line.indent * (sw - 6));
|
||||
rects.push(<rect key={"cl" + copy + "-" + i} x={lx} y={ly} width={lw} height={1.2} rx={0.3} fill={i % 3 === 0 ? accent : i % 3 === 1 ? "#7ee787" : "#79c0ff"} opacity={0.6} />);
|
||||
});
|
||||
}
|
||||
return <g className="bmo-code-scroll-group" style={{ ["--scroll-dist" as string]: blockH + "px" }}>{rects}</g>;
|
||||
})()}
|
||||
</g>
|
||||
</g>
|
||||
{/* Mini face tucked into top-left corner */}
|
||||
<g className="bmo-face-to-corner" style={{ transformOrigin: `${sx + 2}px ${sy + 2}px` }}>
|
||||
<circle cx={eyeCx - eyeSpacing + eyeOffset.dx} cy={eyeCy + eyeOffset.dy} r={2.6} fill={accent} className="bmo-blink" />
|
||||
<circle cx={eyeCx + eyeSpacing + eyeOffset.dx} cy={eyeCy + eyeOffset.dy} r={2.6} fill={accent} className="bmo-blink" />
|
||||
<circle cx={eyeCx - eyeSpacing + 0.8 + eyeOffset.dx} cy={eyeCy - 0.8 + eyeOffset.dy} r={0.7} fill="#fff" opacity={0.8} />
|
||||
<circle cx={eyeCx + eyeSpacing + 0.8 + eyeOffset.dx} cy={eyeCy - 0.8 + eyeOffset.dy} r={0.7} fill="#fff" opacity={0.8} />
|
||||
<line x1={eyeCx - 4} y1={mouthY} x2={eyeCx + 4} y2={mouthY} stroke={accent} strokeWidth={1.2} strokeLinecap="round" />
|
||||
</g>
|
||||
</g>
|
||||
)}
|
||||
{state === "idle" && (
|
||||
<g className={`bmo-eyes-wander${personality === "qa" ? " bmo-idle-qa" : ""}`}>
|
||||
<rect x={eyeCx - eyeSpacing - 2.5 + eyeOffset.dx} y={eyeCy - 0.8} width={5} height={1.6} rx={0.8} fill={accent} opacity={0.5} />
|
||||
<rect x={eyeCx + eyeSpacing - 2.5 + eyeOffset.dx} y={eyeCy - 0.8} width={5} height={1.6} rx={0.8} fill={accent} opacity={0.5} />
|
||||
<line x1={eyeCx - 2} y1={mouthY} x2={eyeCx + 2} y2={mouthY} stroke={accent} strokeWidth={0.8} strokeLinecap="round" opacity={0.5} />
|
||||
</g>
|
||||
)}
|
||||
{state === "blocked" && (
|
||||
<>
|
||||
<line x1={eyeCx - eyeSpacing - 2.5} y1={eyeCy - 4.5} x2={eyeCx - eyeSpacing + 1.5} y2={eyeCy - 3} stroke={accent} strokeWidth={1} strokeLinecap="round" />
|
||||
<line x1={eyeCx + eyeSpacing + 2.5} y1={eyeCy - 4.5} x2={eyeCx + eyeSpacing - 1.5} y2={eyeCy - 3} stroke={accent} strokeWidth={1} strokeLinecap="round" />
|
||||
<circle cx={eyeCx - eyeSpacing} cy={eyeCy} r={1.6} fill={accent} />
|
||||
<circle cx={eyeCx + eyeSpacing} cy={eyeCy} r={1.6} fill={accent} />
|
||||
<polyline
|
||||
points={`${eyeCx - 5},${mouthY} ${eyeCx - 2.5},${mouthY + 2} ${eyeCx},${mouthY - 1} ${eyeCx + 2.5},${mouthY + 2} ${eyeCx + 5},${mouthY}`}
|
||||
fill="none" stroke={accent} strokeWidth={1} strokeLinecap="round" strokeLinejoin="round"
|
||||
/>
|
||||
<g className="bmo-flash-exclaim" opacity={0.6}>
|
||||
<text x={sx + sw - 5} y={sy + 8} fill={accent} fontSize={6} fontWeight="bold" textAnchor="middle">!</text>
|
||||
</g>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCommander && (
|
||||
<polygon
|
||||
points={`${sx + sw - 6},${sy + 3} ${sx + sw - 4},${sy + 5.5} ${sx + sw - 6},${sy + 8} ${sx + sw - 8},${sy + 5.5}`}
|
||||
fill={accent} opacity={0.5}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* Voice line on screen */}
|
||||
{showVoice && (
|
||||
<text
|
||||
x={sx + sw / 2}
|
||||
y={sy + sh - 2}
|
||||
fill={accent}
|
||||
opacity={0.25}
|
||||
fontSize={3}
|
||||
fontFamily="monospace"
|
||||
textAnchor="middle"
|
||||
className="bmo-voice-fade"
|
||||
>
|
||||
{voiceLines[voiceIdx]}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Accessories on top */}
|
||||
{...afterFace}
|
||||
|
||||
{/* Buttons */}
|
||||
<g>
|
||||
<rect x={bx + 5} y={by + bh - 16} width={8} height={3} rx={0.5} fill={screenBg} />
|
||||
<rect x={bx + 7.5} y={by + bh - 18.5} width={3} height={8} rx={0.5} fill={screenBg} />
|
||||
</g>
|
||||
<circle cx={bx + bw - 8} cy={by + bh - 16} r={2} fill="#ef4444" />
|
||||
<circle cx={bx + bw - 13} cy={by + bh - 13} r={2} fill="#3b82f6" />
|
||||
<circle cx={bx + bw - 8} cy={by + bh - 10} r={2} fill="#22c55e" />
|
||||
<rect x={bx + 8} y={by + bh - 7} width={bw - 16} height={2} rx={1} fill={screenBg} />
|
||||
|
||||
{/* Speaker dots */}
|
||||
<circle cx={bx + 3} cy={by + bh - 8} r={0.8} fill={screenBg} />
|
||||
<circle cx={bx + 3} cy={by + bh - 5} r={0.8} fill={screenBg} />
|
||||
<circle cx={bx + bw - 3} cy={by + bh - 8} r={0.8} fill={screenBg} />
|
||||
<circle cx={bx + bw - 3} cy={by + bh - 5} r={0.8} fill={screenBg} />
|
||||
|
||||
{/* Left arm */}
|
||||
<g className={`${walking ? walkArmLeftClass : armAnim} bmo-arm-left${personality === "engineer" && state === "idle" ? " bmo-idle-type-burst" : ""}`} style={{ transformOrigin: `${bx}px ${by + bh * 0.5}px` }}>
|
||||
<line x1={bx} y1={by + bh * 0.45} x2={bx - 5} y2={state === "idle" ? by + bh * 0.65 : by + bh * 0.4} stroke={highlight} strokeWidth={2} strokeLinecap="round" />
|
||||
<circle cx={bx - 5} cy={state === "idle" ? by + bh * 0.65 : by + bh * 0.4} r={1.8} fill={highlight} />
|
||||
</g>
|
||||
{/* Right arm */}
|
||||
<g className={`${walking ? walkArmRightClass : armAnim} bmo-arm-right${personality === "engineer" && state === "idle" ? " bmo-idle-type-burst" : ""}${weather === "falling-leaves" ? " bmo-leaf-swat" : ""}`} style={{ transformOrigin: `${bx + bw}px ${by + bh * 0.5}px` }}>
|
||||
<line x1={bx + bw} y1={by + bh * 0.45} x2={bx + bw + 5} y2={state === "idle" ? by + bh * 0.65 : by + bh * 0.4} stroke={highlight} strokeWidth={2} strokeLinecap="round" />
|
||||
<circle cx={bx + bw + 5} cy={state === "idle" ? by + bh * 0.65 : by + bh * 0.4} r={1.8} fill={highlight} />
|
||||
</g>
|
||||
|
||||
{/* Legs & Feet */}
|
||||
<g className={walking ? walkLegsClass : (state === "idle" ? "bmo-feet-shuffle" : "")}>
|
||||
<rect x={w / 2 - 10} y={by + bh} width={5} height={8} rx={2} fill={highlight} />
|
||||
<rect x={w / 2 + 5} y={by + bh} width={5} height={8} rx={2} fill={highlight} />
|
||||
<ellipse cx={w / 2 - 7.5} cy={by + bh + 9} rx={4.5} ry={2} fill={body} />
|
||||
<ellipse cx={w / 2 + 7.5} cy={by + bh + 9} rx={4.5} ry={2} fill={body} />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Phase C: emote & speech overlays */}
|
||||
{renderEmoteTsx(emote, bx, by, bw, bh, w, accent)}
|
||||
{renderSpeechTsx(speech, bx, by, bw)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
import React from "react";
|
||||
|
||||
export function renderEmoteTsx(
|
||||
emote: string | undefined, bx: number, by: number, bw: number, bh: number, w: number, accent: string
|
||||
) {
|
||||
if (!emote) return null;
|
||||
const cx = bx + bw / 2;
|
||||
if (emote === "celebrate") {
|
||||
return (
|
||||
<g key="emote"><g className="bmo-emote-celebrate">
|
||||
{[0, 1, 2].map(i => (
|
||||
<text key={i} x={cx - 8 + i * 8} y={by - 8} fill="#ef4444" fontSize={8} className="bmo-emote-heart-float" style={{ animationDelay: `${i * 0.15}s` }}>{"\u2665"}</text>
|
||||
))}
|
||||
</g></g>
|
||||
);
|
||||
}
|
||||
if (emote === "confused") {
|
||||
return (
|
||||
<g key="emote" className="bmo-emote-confused">
|
||||
<rect x={cx + 8} y={by - 16} width={12} height={12} rx={3} fill="#fff" opacity={0.9} />
|
||||
<text x={cx + 14} y={by - 7} fill="#333" fontSize={9} fontWeight="bold" textAnchor="middle">?</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (emote === "eureka") {
|
||||
return (
|
||||
<g key="emote" className="bmo-emote-eureka-glow">
|
||||
<circle cx={cx} cy={by - 12} r={5} fill="#fbbf24" opacity={0.9} />
|
||||
<rect x={cx - 2} y={by - 7} width={4} height={3} rx={1} fill="#d97706" />
|
||||
{[0, 45, 90, 135, 180, 225, 270, 315].map((a, i) => (
|
||||
<line key={i} x1={cx + Math.cos(a * Math.PI / 180) * 6} y1={by - 12 + Math.sin(a * Math.PI / 180) * 6} x2={cx + Math.cos(a * Math.PI / 180) * 9} y2={by - 12 + Math.sin(a * Math.PI / 180) * 9} stroke="#fbbf24" strokeWidth={0.8} opacity={0.7} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (emote === "error") {
|
||||
return <rect key="emote" x={bx} y={by} width={bw} height={bh} rx={3} fill="#ef4444" className="bmo-emote-error-flash" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderSpeechTsx(speech: string | undefined, bx: number, by: number, bw: number) {
|
||||
if (!speech) return null;
|
||||
const bubbleX = bx + bw - 2;
|
||||
const bubbleY = by - 20;
|
||||
const isTyping = speech === "...";
|
||||
return (
|
||||
<g key="speech" className="bmo-speech-enter">
|
||||
<rect x={bubbleX} y={bubbleY} width={44} height={14} rx={4} fill="#fff" opacity={0.92} />
|
||||
<polygon points={`${bubbleX + 4},${bubbleY + 14} ${bubbleX + 8},${bubbleY + 18} ${bubbleX + 10},${bubbleY + 14}`} fill="#fff" opacity={0.92} />
|
||||
{isTyping ? (
|
||||
<g>{[0, 1, 2].map(i => (
|
||||
<circle key={i} cx={bubbleX + 14 + i * 7} cy={bubbleY + 7} r={1.8} fill="#666" className="bmo-typing-dot" style={{ animationDelay: `${i * 0.2}s` }} />
|
||||
))}</g>
|
||||
) : (
|
||||
<text x={bubbleX + 22} y={bubbleY + 10} fill="#333" fontSize={5} textAnchor="middle" fontFamily="sans-serif">
|
||||
{speech.length > 10 ? speech.slice(0, 9) + "\u2026" : speech}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
import React from "react";
|
||||
import { FEATURE_FLAGS } from "./FeatureFlags";
|
||||
import type { BmoState } from "./BmoAvatar";
|
||||
|
||||
export const VOICE_LINES: Record<string, string[]> = {
|
||||
director: ["> ship it", "> Q4 OKRs", "> sync?", "> approved", "> delegate"],
|
||||
engineer: ["> compile", "> npm build", "> LGTM", "> git push", "> debug"],
|
||||
qa: ["PASS", "FAIL", "checking", "cov: 94%", "regress?"],
|
||||
command: ["routing", "dispatch", "all clear", "queue: 3", "heartbeat"],
|
||||
};
|
||||
|
||||
export function renderScreenContent(personality: string | undefined, state: BmoState, sx: number, sy: number, sw: number, sh: number, accent: string) {
|
||||
if (!FEATURE_FLAGS.screenContent) return null;
|
||||
if (state === "working" && personality === "engineer") {
|
||||
return (
|
||||
<g key="scr-content" opacity={0.12} className="bmo-code-scroll">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<rect key={i} x={sx + 2} y={sy + 3 + i * 4} width={4 + ((i * 7) % 11)} height={1} rx={0.5} fill={accent} />
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (state === "working" && personality === "qa") {
|
||||
return (
|
||||
<g key="scr-content">
|
||||
<rect x={sx + 2} y={sy + sh - 5} width={sw - 4} height={1.5} rx={0.75} fill={accent} opacity={0.08} />
|
||||
<rect x={sx + 2} y={sy + sh - 5} width={(sw - 4) * 0.6} height={1.5} rx={0.75} fill={accent} opacity={0.15} className="bmo-scan-bar" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (state === "active" && personality === "director") {
|
||||
return (
|
||||
<g key="scr-content" opacity={0.1}>
|
||||
<rect x={sx + 2} y={sy + sh - 8} width={2} height={4} fill={accent} />
|
||||
<rect x={sx + 5} y={sy + sh - 10} width={2} height={6} fill={accent} />
|
||||
<rect x={sx + 8} y={sy + sh - 7} width={2} height={3} fill={accent} />
|
||||
<rect x={sx + 11} y={sy + sh - 12} width={2} height={8} fill={accent} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
if (state === "working" && personality === "command") {
|
||||
const rcx = sx + sw / 2, rcy = sy + sh / 2;
|
||||
return (
|
||||
<g key="scr-content" opacity={0.1}>
|
||||
<circle cx={rcx} cy={rcy} r={sh * 0.35} fill="none" stroke={accent} strokeWidth={0.5} />
|
||||
<circle cx={rcx} cy={rcy} r={sh * 0.2} fill="none" stroke={accent} strokeWidth={0.3} />
|
||||
<line x1={rcx} y1={rcy} x2={rcx + sh * 0.35} y2={rcy} stroke={accent} strokeWidth={0.8} className="bmo-radar-sweep" style={{ transformOrigin: `${rcx}px ${rcy}px` }} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
import React from "react";
|
||||
import type { BmoState } from "./BmoAvatar";
|
||||
|
||||
export function renderWeatherReactions(
|
||||
weather: string | undefined, state: BmoState,
|
||||
bx: number, by: number, bw: number, bh: number,
|
||||
sx: number, sy: number, sw: number, sh: number,
|
||||
eyeCx: number, mouthY: number, accent: string
|
||||
) {
|
||||
const elements: React.ReactElement[] = [];
|
||||
if (weather === "snow") {
|
||||
elements.push(<ellipse key="breath" cx={eyeCx + 4} cy={mouthY - 3} rx={3} ry={1.5} fill="#fff" opacity={0.15} className="bmo-breath-puff" />);
|
||||
elements.push(<rect key="cold-tint" x={bx} y={by} width={bw} height={bh} rx={3} fill="#a0c4ff" opacity={0.04} />);
|
||||
}
|
||||
if (weather === "rain") {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const rx = bx + 4 + i * (bw - 8) / 2;
|
||||
elements.push(<line key={"raindrop" + i} x1={rx} y1={by + 2 + i * 5} x2={rx - 0.5} y2={by + 5 + i * 5} stroke="#6ca6d8" strokeWidth={0.5} opacity={0.2} />);
|
||||
}
|
||||
}
|
||||
if (state === "idle") {
|
||||
elements.push(<ellipse key="mush-reflect" cx={sx + sw / 2} cy={sy + sh - 2} rx={sw * 0.3} ry={2} fill="#5ce0d8" className="bmo-mushroom-reflect" />);
|
||||
}
|
||||
return elements.length > 0 ? <g key="weather-fx">{elements}</g> : null;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
/**
|
||||
* CloudLayer — renders 0-8 soft cloud shapes in the sky area of the SVG
|
||||
* scene.
|
||||
*
|
||||
* Each cloud is a group of 2-3 overlapping ellipses at very low opacity.
|
||||
* Positions are deterministic based on index.
|
||||
* Drift animation: garden-cloud-drift (defined in index.css).
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
interface CloudLayerProps {
|
||||
count: number;
|
||||
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);
|
||||
}
|
||||
|
||||
interface CloudProps {
|
||||
cx: number;
|
||||
cy: number;
|
||||
/** Width of the main body ellipse */
|
||||
bodyWidth: number;
|
||||
/** Height of the main body ellipse */
|
||||
bodyHeight: number;
|
||||
opacity: number;
|
||||
driftDuration: number;
|
||||
driftDelay: number;
|
||||
}
|
||||
|
||||
function Cloud({ cx, cy, bodyWidth, bodyHeight, opacity, driftDuration, driftDelay }: CloudProps) {
|
||||
// Build 2-3 ellipses that overlap to form a fluffy shape
|
||||
const bumps = [
|
||||
// main body
|
||||
{ rx: bodyWidth / 2, ry: bodyHeight / 2, dx: 0, dy: 0 },
|
||||
// left puff
|
||||
{ rx: bodyWidth * 0.35, ry: bodyHeight * 0.6, dx: -(bodyWidth * 0.3), dy: bodyHeight * 0.1 },
|
||||
// right puff
|
||||
{ rx: bodyWidth * 0.3, ry: bodyHeight * 0.5, dx: bodyWidth * 0.32, dy: bodyHeight * 0.15 },
|
||||
];
|
||||
|
||||
return (
|
||||
<g
|
||||
style={{
|
||||
animation: `garden-cloud-drift ${driftDuration.toFixed(1)}s ease-in-out ${driftDelay.toFixed(1)}s infinite alternate`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{bumps.map((b, bi) => (
|
||||
<ellipse
|
||||
key={bi}
|
||||
cx={cx + b.dx}
|
||||
cy={cy + b.dy}
|
||||
rx={b.rx}
|
||||
ry={b.ry}
|
||||
fill="white"
|
||||
fillOpacity={opacity}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export const CloudLayer = memo(function CloudLayer({
|
||||
count,
|
||||
sceneWidth,
|
||||
sceneHeight,
|
||||
}: CloudLayerProps) {
|
||||
const clampedCount = Math.max(0, Math.min(8, Math.round(count)));
|
||||
if (clampedCount === 0) return null;
|
||||
|
||||
// Sky area = top 40% of scene
|
||||
const skyMaxY = sceneHeight * 0.4;
|
||||
|
||||
return (
|
||||
<g aria-hidden="true">
|
||||
{Array.from({ length: clampedCount }, (_, i) => {
|
||||
// Distribute across scene width, avoid edges
|
||||
const cx = detRange(i, sceneWidth * 0.05, sceneWidth * 0.95, 37);
|
||||
// Distribute vertically in sky, more towards middle
|
||||
const cy = detRange(i, skyMaxY * 0.1, skyMaxY * 0.85, 53);
|
||||
// Vary cloud size
|
||||
const bodyWidth = detRange(i, 20, 40, 67);
|
||||
const bodyHeight = detRange(i, 8, 15, 79);
|
||||
const opacity = detRange(i, 0.03, 0.08, 83);
|
||||
const driftDuration = detRange(i, 30, 60, 97);
|
||||
const driftDelay = detRange(i, 0, 20, 101);
|
||||
|
||||
return (
|
||||
<Cloud
|
||||
key={i}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
bodyWidth={bodyWidth}
|
||||
bodyHeight={bodyHeight}
|
||||
opacity={opacity}
|
||||
driftDuration={driftDuration}
|
||||
driftDelay={driftDelay}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
// ---------- Global Feature Flags ----------
|
||||
// Single source of truth for BMO feature flags. Imported by BmoAvatar, GardenLayers, etc.
|
||||
// Also attached to `window.BMO_FLAGS` so you can toggle at runtime from the browser console.
|
||||
//
|
||||
// Dream additions: `gardenImmersive`, `bmoEmotes`, `overviewGardenBand` act as
|
||||
// kill-switches for the new integration work. Flip them from the browser
|
||||
// console (`window.BMO_FLAGS.gardenImmersive = false`).
|
||||
|
||||
export const FEATURE_FLAGS = {
|
||||
voiceLines: false,
|
||||
screenContent: false,
|
||||
gardenImmersive: true,
|
||||
bmoEmotes: true,
|
||||
overviewGardenBand: true,
|
||||
bmoThumbnails: true,
|
||||
};
|
||||
|
||||
export type FeatureFlags = typeof FEATURE_FLAGS;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
BMO_FLAGS?: FeatureFlags;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.BMO_FLAGS = FEATURE_FLAGS;
|
||||
}
|
||||
|
||||
export function readFeatureFlag(name: keyof FeatureFlags): boolean {
|
||||
if (typeof window === "undefined") return FEATURE_FLAGS[name];
|
||||
return window.BMO_FLAGS?.[name] ?? FEATURE_FLAGS[name];
|
||||
}
|
||||
|
||||
export function useFeatureFlag(name: keyof FeatureFlags) {
|
||||
const [enabled, setEnabled] = useState(() => readFeatureFlag(name));
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(readFeatureFlag(name));
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setEnabled((current) => {
|
||||
const next = readFeatureFlag(name);
|
||||
return current === next ? current : next;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [name]);
|
||||
|
||||
return enabled;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
import React from "react";
|
||||
|
||||
interface FloraConfig {
|
||||
vinesEnabled?: boolean;
|
||||
fernsEnabled?: boolean;
|
||||
flowersEnabled?: boolean;
|
||||
mushroomsEnabled?: boolean;
|
||||
mushroomGlowColor?: string;
|
||||
}
|
||||
|
||||
interface FloraLayerProps {
|
||||
config: FloraConfig;
|
||||
sceneWidth: number;
|
||||
sceneHeight: number;
|
||||
}
|
||||
|
||||
// ---- Sub-components (extracted from AgentGarden.tsx) ----
|
||||
|
||||
function Plant({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x - 5} y={y} width={10} height={8} rx={1} fill="#8B4513" />
|
||||
<rect x={x - 6} y={y - 1} width={12} height={2} rx={1} fill="#A0522D" />
|
||||
<ellipse cx={x} cy={y - 4} rx={5} ry={4} fill="#2d6a30" />
|
||||
<ellipse cx={x - 3} cy={y - 6} rx={3} ry={3} fill="#3a8a3e" />
|
||||
<ellipse cx={x + 3} cy={y - 7} rx={3.5} ry={3} fill="#348a38" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function TallFern({ x, y }: { x: number; y: number }) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x - 6} y={y} width={12} height={10} rx={2} fill="#6B3A1F" />
|
||||
<rect x={x - 7} y={y - 1} width={14} height={2.5} rx={1} fill="#7B4A2F" />
|
||||
<ellipse cx={x} cy={y - 0.5} rx={5} ry={1.5} fill="#2a1a0a" />
|
||||
<path
|
||||
d={`M ${x} ${y - 2} Q ${x - 8} ${y - 18} ${x - 12} ${y - 22}`}
|
||||
fill="none"
|
||||
stroke="#2a6a28"
|
||||
strokeWidth={1.2}
|
||||
className="garden-frond-sway"
|
||||
/>
|
||||
<path
|
||||
d={`M ${x} ${y - 2} Q ${x + 6} ${y - 20} ${x + 10} ${y - 25}`}
|
||||
fill="none"
|
||||
stroke="#348a32"
|
||||
strokeWidth={1}
|
||||
className="garden-frond-sway"
|
||||
style={{ animationDelay: "0.8s" }}
|
||||
/>
|
||||
<path
|
||||
d={`M ${x} ${y - 2} Q ${x - 4} ${y - 22} ${x - 2} ${y - 28}`}
|
||||
fill="none"
|
||||
stroke="#3a8a3e"
|
||||
strokeWidth={0.8}
|
||||
className="garden-frond-sway"
|
||||
style={{ animationDelay: "1.6s" }}
|
||||
/>
|
||||
<path
|
||||
d={`M ${x} ${y - 2} Q ${x + 10} ${y - 15} ${x + 14} ${y - 18}`}
|
||||
fill="none"
|
||||
stroke="#2d7a2a"
|
||||
strokeWidth={1}
|
||||
className="garden-frond-sway"
|
||||
style={{ animationDelay: "0.4s" }}
|
||||
/>
|
||||
<ellipse
|
||||
cx={x - 12}
|
||||
cy={y - 23}
|
||||
rx={2}
|
||||
ry={1}
|
||||
fill="#2a6a28"
|
||||
transform={`rotate(-30 ${x - 12} ${y - 23})`}
|
||||
/>
|
||||
<ellipse
|
||||
cx={x + 10}
|
||||
cy={y - 26}
|
||||
rx={2}
|
||||
ry={1}
|
||||
fill="#348a32"
|
||||
transform={`rotate(20 ${x + 10} ${y - 26})`}
|
||||
/>
|
||||
<ellipse cx={x - 2} cy={y - 29} rx={1.5} ry={1} fill="#3a8a3e" />
|
||||
<ellipse
|
||||
cx={x + 14}
|
||||
cy={y - 19}
|
||||
rx={2}
|
||||
ry={1}
|
||||
fill="#2d7a2a"
|
||||
transform={`rotate(15 ${x + 14} ${y - 19})`}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function HangingVine({ x, y, length }: { x: number; y: number; length: number }) {
|
||||
return (
|
||||
<g className="garden-vine-sway">
|
||||
<path
|
||||
d={`M ${x} ${y} Q ${x + 3} ${y + length * 0.3} ${x - 2} ${y + length * 0.6} Q ${x + 2} ${y + length * 0.8} ${x - 1} ${y + length}`}
|
||||
fill="none"
|
||||
stroke="#2a5a28"
|
||||
strokeWidth={0.8}
|
||||
/>
|
||||
{Array.from({ length: Math.floor(length / 8) }, (_, i) => {
|
||||
const ly = y + (i + 1) * 8;
|
||||
const lx = x + (i % 2 === 0 ? -2 : 2) + Math.sin(i) * 1.5;
|
||||
return (
|
||||
<ellipse
|
||||
key={i}
|
||||
cx={lx}
|
||||
cy={ly}
|
||||
rx={2}
|
||||
ry={1.2}
|
||||
fill={i % 2 === 0 ? "#3a7a38" : "#2d6a2a"}
|
||||
transform={`rotate(${i % 2 === 0 ? -25 : 25} ${lx} ${ly})`}
|
||||
opacity={0.8}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function GlowMushroom({ x, y, hue }: { x: number; y: number; hue?: string }) {
|
||||
const color = hue ?? "#4ade80";
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y - 3} width={1.5} height={4} rx={0.5} fill="#3a3a2a" />
|
||||
<rect x={x + 3} y={y - 5} width={1.2} height={5.5} rx={0.5} fill="#333a2a" />
|
||||
<rect x={x - 2} y={y - 2} width={1} height={3} rx={0.5} fill="#3a3a2a" />
|
||||
<ellipse cx={x + 0.75} cy={y - 3.5} rx={2.5} ry={1.5} fill={color} opacity={0.3} />
|
||||
<ellipse
|
||||
cx={x + 3.6}
|
||||
cy={y - 5.5}
|
||||
rx={2}
|
||||
ry={1.3}
|
||||
fill={color}
|
||||
opacity={0.4}
|
||||
className="garden-mushroom-glow"
|
||||
/>
|
||||
<ellipse
|
||||
cx={x - 1.5}
|
||||
cy={y - 2.5}
|
||||
rx={1.5}
|
||||
ry={1}
|
||||
fill={color}
|
||||
opacity={0.25}
|
||||
className="garden-mushroom-glow"
|
||||
style={{ animationDelay: "1.5s" }}
|
||||
/>
|
||||
<ellipse cx={x + 1} cy={y + 1} rx={5} ry={2} fill={color} opacity={0.04} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export const FloraLayer = React.memo(function FloraLayer({
|
||||
config,
|
||||
sceneWidth,
|
||||
sceneHeight,
|
||||
}: FloraLayerProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Hanging vines from trees */}
|
||||
{config.vinesEnabled && (
|
||||
<>
|
||||
<HangingVine x={18} y={sceneHeight * 0.6 - 60} length={35} />
|
||||
{sceneWidth > 350 && (
|
||||
<HangingVine x={sceneWidth - 15} y={sceneHeight * 0.6 - 55} length={30} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tall ferns/bushes at edges */}
|
||||
{config.fernsEnabled && (
|
||||
<>
|
||||
<TallFern x={35} y={sceneHeight * 0.6} />
|
||||
{sceneWidth > 450 && <TallFern x={sceneWidth - 35} y={sceneHeight * 0.6} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Flower beds / garden patches */}
|
||||
{config.flowersEnabled && sceneWidth > 350 && (
|
||||
<g>
|
||||
<ellipse cx={sceneWidth * 0.5} cy={sceneHeight - 12} rx={20} ry={5} fill="#1a2a18" />
|
||||
<Plant x={sceneWidth * 0.5 - 6} y={sceneHeight - 18} />
|
||||
{config.mushroomsEnabled && (
|
||||
<GlowMushroom
|
||||
x={sceneWidth * 0.5 + 6}
|
||||
y={sceneHeight - 14}
|
||||
hue={config.mushroomGlowColor}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* More mushrooms scattered */}
|
||||
{config.mushroomsEnabled && (
|
||||
<>
|
||||
<GlowMushroom x={50} y={sceneHeight * 0.6 + 10} hue={config.mushroomGlowColor} />
|
||||
{sceneWidth > 400 && (
|
||||
<GlowMushroom x={sceneWidth - 55} y={sceneHeight * 0.6 + 8} hue="#a78bfa" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
// Export sub-components for reuse
|
||||
export { Plant, TallFern, HangingVine, GlowMushroom };
|
||||
@@ -0,0 +1,66 @@
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
/**
|
||||
* GroundFog — subtle fog effect rendered as blurred ellipses at ground level.
|
||||
*
|
||||
* Renders 3-5 large Gaussian-blurred ellipses near sceneHeight * 0.6 with
|
||||
* very low opacity. Drifts gently with garden-fog-drift animation (index.css).
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
interface GroundFogProps {
|
||||
sceneWidth: number;
|
||||
sceneHeight: number;
|
||||
}
|
||||
|
||||
/** Deterministic value in [0, range) for 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);
|
||||
}
|
||||
|
||||
export const GroundFog = memo(function GroundFog({ sceneWidth, sceneHeight }: GroundFogProps) {
|
||||
const fogCount = 4;
|
||||
const filterId = "ground-fog-blur";
|
||||
|
||||
return (
|
||||
<g aria-hidden="true">
|
||||
{/* SVG filter for Gaussian blur */}
|
||||
<defs>
|
||||
<filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="6" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{Array.from({ length: fogCount }, (_, i) => {
|
||||
// Distribute fog patches across the ground band
|
||||
const cx = detRange(i, sceneWidth * 0.05, sceneWidth * 0.9, 41);
|
||||
const cy = sceneHeight * detRange(i, 0.58, 0.65, 59);
|
||||
const rx = detRange(i, sceneWidth * 0.15, sceneWidth * 0.35, 73);
|
||||
const ry = detRange(i, 6, 14, 89);
|
||||
const opacity = detRange(i, 0.04, 0.08, 97);
|
||||
const duration = detRange(i, 15, 20, 101);
|
||||
const delay = detRange(i, 0, 8, 107);
|
||||
|
||||
return (
|
||||
<ellipse
|
||||
key={i}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={rx}
|
||||
ry={ry}
|
||||
fill="white"
|
||||
fillOpacity={opacity}
|
||||
filter={`url(#${filterId})`}
|
||||
style={{
|
||||
animation: `garden-fog-drift ${duration.toFixed(1)}s ease-in-out ${delay.toFixed(1)}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
// 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;
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
import React from "react";
|
||||
|
||||
interface SkyConfig {
|
||||
gradientStops: [string, string, string];
|
||||
starDensity: number;
|
||||
starBrightness: number;
|
||||
moonX: number;
|
||||
moonY: number;
|
||||
moonSize: number;
|
||||
moonPhase: "full" | "crescent" | "waning" | "new";
|
||||
}
|
||||
|
||||
interface SkyLayerProps {
|
||||
config: SkyConfig;
|
||||
sceneWidth: number;
|
||||
sceneHeight: number;
|
||||
}
|
||||
|
||||
export const SkyLayer = React.memo(function SkyLayer({
|
||||
config,
|
||||
sceneWidth,
|
||||
sceneHeight,
|
||||
}: SkyLayerProps) {
|
||||
const [stop0, stop40, stop100] = config.gradientStops;
|
||||
|
||||
// Deterministic star positions derived from index
|
||||
const starCount = Math.round(8 + config.starDensity * 16);
|
||||
const BASE_STAR_XS = [15, 45, 80, 130, 200, 260, 320, 370, 410, 470, 510, 560, 600, 640, 680, 720, 760, 800, 840, 880, 920, 960, 1000, 1040];
|
||||
const starPositions = Array.from({ length: starCount }, (_, i) => ({
|
||||
sx: BASE_STAR_XS[i % BASE_STAR_XS.length]!,
|
||||
sy: 8 + (i * 7) % 35,
|
||||
r: 0.5 + (i % 3) * 0.2,
|
||||
opacity: (0.15 + (i % 3) * 0.1) * (0.5 + config.starBrightness),
|
||||
delay: i * 1.3,
|
||||
}));
|
||||
|
||||
// Moon size + position
|
||||
const moonCx = sceneWidth * config.moonX;
|
||||
const moonCy = sceneHeight * config.moonY;
|
||||
const moonR = 8 * config.moonSize;
|
||||
const moonOuterR = 12 * config.moonSize;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sky gradient definition */}
|
||||
<defs>
|
||||
<linearGradient id="garden-sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stop0} />
|
||||
<stop offset="40%" stopColor={stop40} />
|
||||
<stop offset="100%" stopColor={stop100} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Sky background */}
|
||||
<rect width={sceneWidth} height={sceneHeight * 0.6} fill="url(#garden-sky)" />
|
||||
|
||||
{/* Stars */}
|
||||
{starPositions.map((s, i) => (
|
||||
<circle
|
||||
key={`star-${i}`}
|
||||
cx={s.sx % sceneWidth}
|
||||
cy={s.sy}
|
||||
r={s.r}
|
||||
fill="#fff"
|
||||
opacity={s.opacity}
|
||||
className="garden-firefly"
|
||||
style={{ animationDelay: `${s.delay}s` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Moon */}
|
||||
{config.moonPhase !== "new" && (
|
||||
<>
|
||||
<circle cx={moonCx} cy={moonCy} r={moonOuterR} fill="#e8e4d0" opacity={0.25} />
|
||||
<circle cx={moonCx} cy={moonCy} r={moonR} fill="#f0ead6" opacity={0.7} />
|
||||
{/* Waning/crescent mask */}
|
||||
{config.moonPhase === "crescent" && (
|
||||
<circle cx={moonCx + moonR * 0.6} cy={moonCy} r={moonR * 0.85} fill={stop0} opacity={0.9} />
|
||||
)}
|
||||
{config.moonPhase === "waning" && (
|
||||
<circle cx={moonCx + moonR * 0.3} cy={moonCy} r={moonR * 0.75} fill={stop0} opacity={0.7} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Distant tree line silhouette */}
|
||||
{Array.from({ length: Math.ceil(sceneWidth / 30) + 1 }, (_, i) => {
|
||||
const tx = i * 30 + (i % 2 === 0 ? 5 : -3);
|
||||
const th = 18 + (i * 7) % 15;
|
||||
return (
|
||||
<ellipse
|
||||
key={`tree-bg-${i}`}
|
||||
cx={tx}
|
||||
cy={sceneHeight * 0.6 - th * 0.3}
|
||||
rx={10 + (i % 3) * 4}
|
||||
ry={th}
|
||||
fill="#0a1a0a"
|
||||
opacity={0.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1.
|
||||
import React from "react";
|
||||
|
||||
interface TerrainConfig {
|
||||
grassGradient: [string, string];
|
||||
pathStyle: "none" | "cobblestone";
|
||||
pathOpacity: number;
|
||||
}
|
||||
|
||||
interface FloraConfig {
|
||||
grassTuftsEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface TerrainLayerProps {
|
||||
config: TerrainConfig;
|
||||
floraConfig?: FloraConfig;
|
||||
sceneWidth: number;
|
||||
sceneHeight: number;
|
||||
}
|
||||
|
||||
export const TerrainLayer = React.memo(function TerrainLayer({
|
||||
config,
|
||||
floraConfig,
|
||||
sceneWidth,
|
||||
sceneHeight,
|
||||
}: TerrainLayerProps) {
|
||||
const grassTuftsEnabled = floraConfig?.grassTuftsEnabled ?? true;
|
||||
const [grassTop, grassBottom] = config.grassGradient;
|
||||
|
||||
return (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id="garden-grass" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={grassTop} />
|
||||
<stop offset="100%" stopColor={grassBottom} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Grass ground */}
|
||||
<rect
|
||||
y={sceneHeight * 0.6}
|
||||
width={sceneWidth}
|
||||
height={sceneHeight * 0.4}
|
||||
fill="url(#garden-grass)"
|
||||
/>
|
||||
|
||||
{/* Stone path stepping stones */}
|
||||
{config.pathStyle !== "none" &&
|
||||
Array.from({ length: Math.ceil(sceneWidth / 50) }, (_, i) => (
|
||||
<ellipse
|
||||
key={`stone-${i}`}
|
||||
cx={30 + i * 50 + (i % 2) * 10}
|
||||
cy={sceneHeight * 0.6 + 28 + (i % 2) * 6}
|
||||
rx={12}
|
||||
ry={4}
|
||||
fill="#2a2a22"
|
||||
opacity={config.pathOpacity}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Grass tufts at floor edge */}
|
||||
{grassTuftsEnabled &&
|
||||
Array.from({ length: Math.ceil(sceneWidth / 25) }, (_, i) => {
|
||||
const gx = 10 + i * 25 + (i * 7) % 12;
|
||||
const gy = sceneHeight * 0.6 + 2 + (i * 11) % 8;
|
||||
return (
|
||||
<g
|
||||
key={`grass-${i}`}
|
||||
className="garden-frond-sway"
|
||||
style={{ animationDelay: `${(i * 0.6) % 5}s` }}
|
||||
>
|
||||
<line
|
||||
x1={gx}
|
||||
y1={gy}
|
||||
x2={gx - 2}
|
||||
y2={gy - 5 - (i % 3)}
|
||||
stroke="#2a5a28"
|
||||
strokeWidth={0.6}
|
||||
/>
|
||||
<line
|
||||
x1={gx + 1}
|
||||
y1={gy}
|
||||
x2={gx + 2}
|
||||
y2={gy - 4 - (i % 2)}
|
||||
stroke="#348a32"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<line
|
||||
x1={gx + 2}
|
||||
y1={gy}
|
||||
x2={gx + 4}
|
||||
y2={gy - 3}
|
||||
stroke="#2d6a2a"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
// 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;
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/* Ported from bmo-preview (https://github.com/PageZ948/bmo-preview). Visuals kept 1:1. */
|
||||
/* Shared animation CSS for BMO pages (preview/personality pages). */
|
||||
|
||||
/* ---------- Page layout (used by personality pages) ---------- */
|
||||
* { box-sizing: border-box; }
|
||||
body.personality-page {
|
||||
margin: 0; background: #0a0e1a; color: #eee; font-family: sans-serif;
|
||||
min-height: 100vh; padding: 20px;
|
||||
}
|
||||
.personality-page .wrap { max-width: 1200px; margin: 0 auto; }
|
||||
.personality-page .nav {
|
||||
display: flex; justify-content: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px;
|
||||
}
|
||||
.personality-page .nav a {
|
||||
padding: 6px 14px; background: #1a2028; color: #aaa;
|
||||
border-radius: 6px; text-decoration: none; font-size: 12px;
|
||||
font-weight: bold; letter-spacing: 0.5px; border: 1px solid #333;
|
||||
}
|
||||
.personality-page .nav a:hover { background: #2a3038; color: #eee; }
|
||||
.personality-page h1 {
|
||||
margin: 0 0 4px; font-size: 1.4rem; letter-spacing: 2px;
|
||||
text-transform: uppercase; text-align: center;
|
||||
}
|
||||
.personality-page .subtitle {
|
||||
margin: 0 0 20px; font-size: 11px; color: #567;
|
||||
text-transform: uppercase; letter-spacing: 1px; text-align: center;
|
||||
}
|
||||
|
||||
/* Three-column layout: buttons | stage | buttons */
|
||||
.personality-page .layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 240px) minmax(320px, 1fr) minmax(180px, 240px);
|
||||
gap: 24px; align-items: start;
|
||||
}
|
||||
.personality-page .controls-left { justify-self: end; width: 100%; max-width: 240px; }
|
||||
.personality-page .controls-right { justify-self: start; width: 100%; max-width: 240px; }
|
||||
.personality-page .stage {
|
||||
background: #1a2028; border-radius: 16px; padding: 30px 20px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: sticky; top: 12px;
|
||||
}
|
||||
.personality-page .stage svg { max-width: 100%; height: auto; }
|
||||
|
||||
.personality-page .row { margin-bottom: 14px; }
|
||||
.personality-page .row-label {
|
||||
display: block; font-size: 10px; color: #5ce0d8;
|
||||
letter-spacing: 1.5px; text-transform: uppercase; font-weight: bold;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.personality-page .row-btns { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.personality-page .pb {
|
||||
padding: 6px 12px; border: 1px solid #333; border-radius: 6px;
|
||||
background: #1a2028; color: #aaa; cursor: pointer; font-size: 11px;
|
||||
font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; transition: all 0.15s;
|
||||
}
|
||||
.personality-page .pb:hover { background: #2a3038; color: #eee; }
|
||||
.personality-page .pb.active { background: #2a7a78; color: #fff; border-color: #5ce0d8; }
|
||||
.personality-page .reset-row { text-align: center; margin-top: 24px; }
|
||||
.personality-page .reset {
|
||||
padding: 8px 20px; border: 1px solid #5a1a1a; border-radius: 6px;
|
||||
background: #2a0f0f; color: #f87171; cursor: pointer; font-size: 11px;
|
||||
font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.personality-page .reset:hover { background: #4a1a1a; }
|
||||
|
||||
/* Align left column contents to the right so buttons sit close to the avatar */
|
||||
.personality-page .controls-left .row-label { text-align: right; }
|
||||
.personality-page .controls-left .row-btns { justify-content: flex-end; }
|
||||
|
||||
/* Tablet / mobile: stack, stage first (avatar visible on load + sticky while scrolling) */
|
||||
@media (max-width: 900px) {
|
||||
body.personality-page { padding: 12px; }
|
||||
.personality-page .layout {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.personality-page .stage { order: -1; }
|
||||
.personality-page .controls-left,
|
||||
.personality-page .controls-right {
|
||||
justify-self: stretch; max-width: none;
|
||||
}
|
||||
.personality-page .controls-left .row-label,
|
||||
.personality-page .controls-right .row-label { text-align: center; }
|
||||
.personality-page .controls-left .row-btns,
|
||||
.personality-page .controls-right .row-btns { justify-content: center; }
|
||||
.personality-page .stage {
|
||||
position: sticky; top: 0; z-index: 5;
|
||||
padding: 12px; border-radius: 12px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
.personality-page h1 { font-size: 1.1rem; }
|
||||
.personality-page .pb { padding: 8px 14px; font-size: 12px; }
|
||||
.personality-page .nav a { font-size: 11px; padding: 5px 10px; }
|
||||
}
|
||||
|
||||
/* ---------- Core BMO animations ---------- */
|
||||
@keyframes bmo-bob-active { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
||||
@keyframes bmo-bob-working { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } }
|
||||
@keyframes bmo-sway-idle { 0%,100% { transform: rotate(-1deg); } 50% { transform: rotate(1deg); } }
|
||||
@keyframes bmo-shake-blocked{ 0%,100% { transform: translateX(0); } 25% { transform: translateX(-1px); } 75% { transform: translateX(1px); } }
|
||||
@keyframes bmo-blink { 0%,92%,100% { transform: scaleY(1); } 95% { transform: scaleY(0.1); } }
|
||||
.bmo-bob-active { animation: bmo-bob-active 1.2s ease-in-out infinite; transform-origin: center; }
|
||||
.bmo-bob-working { animation: bmo-bob-working 2s ease-in-out infinite; transform-origin: center; }
|
||||
.bmo-sway-idle { animation: bmo-sway-idle 4s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.bmo-shake-blocked { animation: bmo-shake-blocked 0.2s ease-in-out infinite; transform-origin: center; }
|
||||
.bmo-blink { animation: bmo-blink 4s ease-in-out infinite; transform-origin: center; transform-box: fill-box; }
|
||||
|
||||
/* ---------- Accessories, screen content, voice lines ---------- */
|
||||
@keyframes bmo-cape-sway { 0%,100% { transform: rotate(0deg); } 50% { transform: rotate(2deg); } }
|
||||
@keyframes bmo-code-scroll { 0% { transform: translateY(0); } 100% { transform: translateY(-8px); } }
|
||||
@keyframes bmo-scan-bar { 0% { transform: scaleX(0); transform-origin: left; } 50% { transform: scaleX(1); transform-origin: left; } 51% { transform: scaleX(1); transform-origin: right; } 100% { transform: scaleX(0); transform-origin: right; } }
|
||||
@keyframes bmo-radar-sweep { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
@keyframes bmo-voice-fade { 0%,20% { opacity: 0; } 30%,70% { opacity: 0.25; } 80%,100% { opacity: 0; } }
|
||||
@keyframes bmo-flash-exclaim{ 0%,100% { opacity: 0.6; } 50% { opacity: 0; } }
|
||||
.bmo-cape-sway { animation: bmo-cape-sway 4s ease-in-out infinite; }
|
||||
.bmo-code-scroll { animation: bmo-code-scroll 3s linear infinite; }
|
||||
.bmo-scan-bar { animation: bmo-scan-bar 2.5s ease-in-out infinite; }
|
||||
.bmo-radar-sweep { animation: bmo-radar-sweep 4s linear infinite; }
|
||||
.bmo-voice-fade { animation: bmo-voice-fade 3s ease-in-out infinite; }
|
||||
.bmo-flash-exclaim{ animation: bmo-flash-exclaim 1s ease-in-out infinite; }
|
||||
|
||||
/* ---------- Idle micro-animations, state transitions ---------- */
|
||||
@keyframes bmo-idle-director { 0%,100% { transform: rotate(-2deg); } 50% { transform: rotate(2deg); } }
|
||||
@keyframes bmo-idle-type-burst { 0%,84% { transform: rotate(0deg); } 85% { transform: rotate(5deg); } 87% { transform: rotate(-5deg); } 89% { transform: rotate(4deg); } 91% { transform: rotate(-3deg); } 93% { transform: rotate(2deg); } 95% { transform: rotate(-1deg); } 96%,100% { transform: rotate(0deg); } }
|
||||
@keyframes bmo-idle-qa { 0%,100% { transform: translateX(0); } 25% { transform: translateX(1.5px); } 50% { transform: translateX(-1px); } 75% { transform: translateX(0.5px); } }
|
||||
@keyframes bmo-antenna-pulse { 0%,100% { opacity: 0.5; } 50% { opacity: 1; } }
|
||||
@keyframes bmo-enter-blocked { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-3px); } 40% { transform: translateX(3px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(2px); } }
|
||||
.bmo-idle-director { animation: bmo-idle-director 6s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.bmo-idle-type-burst { animation: bmo-idle-type-burst 6s ease-in-out infinite; }
|
||||
.bmo-idle-qa { animation: bmo-idle-qa 2s ease-in-out infinite; }
|
||||
.bmo-antenna-pulse { animation: bmo-antenna-pulse 2s ease-in-out infinite; }
|
||||
.bmo-face-transition { transition: opacity 0.3s ease, transform 0.3s ease; }
|
||||
.bmo-enter-blocked { animation: bmo-enter-blocked 0.4s ease-in-out 1; }
|
||||
|
||||
/* ---------- Emotes, speech bubbles ---------- */
|
||||
@keyframes bmo-emote-celebrate { 0% { transform: translateY(0); } 40% { transform: translateY(-15px); } 100% { transform: translateY(0); } }
|
||||
@keyframes bmo-emote-heart-float { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-25px); opacity: 0; } }
|
||||
@keyframes bmo-emote-confused { 0% { transform: rotate(0deg); } 40% { transform: rotate(-8deg); } 100% { transform: rotate(0deg); } }
|
||||
@keyframes bmo-emote-eureka-glow { 0% { transform: scale(0); } 50% { transform: scale(1.2); filter: drop-shadow(0 0 6px #fbbf24); } 100% { transform: scale(1); filter: drop-shadow(0 0 2px #fbbf24); } }
|
||||
@keyframes bmo-emote-error-flash { 0% { opacity: 0; } 50% { opacity: 0.3; } 100% { opacity: 0; } }
|
||||
@keyframes bmo-speech-enter { 0% { transform: scale(0); } 70% { transform: scale(1.1); } 100% { transform: scale(1); } }
|
||||
@keyframes bmo-typing-dot { 0%,100% { opacity: 0.2; } 50% { opacity: 1; } }
|
||||
.bmo-emote-celebrate { animation: bmo-emote-celebrate 0.8s ease-in-out forwards; animation-iteration-count: 1; transform-origin: center bottom; }
|
||||
.bmo-emote-heart-float { animation: bmo-emote-heart-float 1.2s ease-out forwards; animation-iteration-count: 1; }
|
||||
.bmo-emote-confused { animation: bmo-emote-confused 0.6s ease-in-out forwards; animation-iteration-count: 1; transform-origin: center; }
|
||||
.bmo-emote-eureka-glow { animation: bmo-emote-eureka-glow 1s ease-out forwards; animation-iteration-count: 1; transform-origin: center; }
|
||||
.bmo-emote-error-flash { animation: bmo-emote-error-flash 0.5s ease-in-out forwards; animation-iteration-count: 1; }
|
||||
.bmo-speech-enter { animation: bmo-speech-enter 0.3s ease-out forwards; transform-origin: bottom left; }
|
||||
.bmo-typing-dot { animation: bmo-typing-dot 1.2s ease-in-out infinite; }
|
||||
|
||||
/* ---------- Walking animations ---------- */
|
||||
@keyframes bmo-walk-engineer-legs { 0% { transform: translateY(0); } 25% { transform: translateY(-2px) translateX(0.5px); } 50% { transform: translateY(0); } 75% { transform: translateY(-2px) translateX(-0.5px); } }
|
||||
@keyframes bmo-walk-director-legs { 0% { transform: translateY(0); } 25% { transform: translateY(-1.5px) translateX(1px); } 50% { transform: translateY(0); } 75% { transform: translateY(-1.5px) translateX(-1px); } }
|
||||
@keyframes bmo-walk-qa-legs { 0% { transform: translateY(0); } 25% { transform: translateY(-1px); } 50% { transform: translateY(0); } 75% { transform: translateY(-1px); } }
|
||||
@keyframes bmo-walk-command-legs { 0% { transform: translateY(0); } 25% { transform: translateY(-3px); } 50% { transform: translateY(0); } 75% { transform: translateY(-3px); } }
|
||||
@keyframes bmo-walk-default-legs { 0% { transform: translateY(0); } 25% { transform: translateY(-2px); } 50% { transform: translateY(0); } 75% { transform: translateY(-2px); } }
|
||||
.bmo-walk-engineer-legs { animation: bmo-walk-engineer-legs 0.6s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.bmo-walk-director-legs { animation: bmo-walk-director-legs 1.2s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.bmo-walk-qa-legs { animation: bmo-walk-qa-legs 0.4s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.bmo-walk-command-legs { animation: bmo-walk-command-legs 0.8s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.bmo-walk-default-legs { animation: bmo-walk-default-legs 0.8s ease-in-out infinite; transform-origin: center bottom; }
|
||||
@keyframes bmo-walk-arms-l { 0%,100% { transform: rotate(0deg); } 25% { transform: rotate(10deg); } 75% { transform: rotate(-10deg); } }
|
||||
@keyframes bmo-walk-arms-r { 0%,100% { transform: rotate(0deg); } 25% { transform: rotate(-10deg); } 75% { transform: rotate(10deg); } }
|
||||
.bmo-walk-engineer-arms-l { animation: bmo-walk-arms-l 0.6s ease-in-out infinite; transform-origin: top center; }
|
||||
.bmo-walk-engineer-arms-r { animation: bmo-walk-arms-r 0.6s ease-in-out infinite; transform-origin: top center; }
|
||||
.bmo-walk-director-arms-l { animation: bmo-walk-arms-l 1.2s ease-in-out infinite; transform-origin: top center; }
|
||||
.bmo-walk-director-arms-r { animation: bmo-walk-arms-r 1.2s ease-in-out infinite; transform-origin: top center; }
|
||||
.bmo-walk-qa-arms-l { animation: bmo-walk-arms-l 0.4s ease-in-out infinite; transform-origin: top center; }
|
||||
.bmo-walk-qa-arms-r { animation: bmo-walk-arms-r 0.4s ease-in-out infinite; transform-origin: top center; }
|
||||
.bmo-walk-command-arms-l { animation: bmo-walk-arms-l 0.8s ease-in-out infinite; transform-origin: top center; }
|
||||
.bmo-walk-command-arms-r { animation: bmo-walk-arms-r 0.8s ease-in-out infinite; transform-origin: top center; }
|
||||
.bmo-walk-default-arms-l { animation: bmo-walk-arms-l 0.8s ease-in-out infinite; transform-origin: top center; }
|
||||
.bmo-walk-default-arms-r { animation: bmo-walk-arms-r 0.8s ease-in-out infinite; transform-origin: top center; }
|
||||
@keyframes bmo-walk-bounce { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } }
|
||||
.bmo-walk-bounce-fast { animation: bmo-walk-bounce 0.3s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.bmo-walk-bounce-slow { animation: bmo-walk-bounce 0.6s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.bmo-walk-bounce-default { animation: bmo-walk-bounce 0.4s ease-in-out infinite; transform-origin: center bottom; }
|
||||
|
||||
/* ---------- Weather interaction ---------- */
|
||||
@keyframes bmo-rain-shiver { 0%,88%,100% { transform: translateX(0); } 90% { transform: translateX(-1.5px); } 92% { transform: translateX(1.5px); } 94% { transform: translateX(-1px); } 96% { transform: translateX(1px); } }
|
||||
@keyframes bmo-snow-huddle { 0%,100% { transform: scaleY(1); } 50% { transform: scaleY(0.98); } }
|
||||
@keyframes bmo-breath-puff { 0%,65% { opacity: 0; transform: translateY(0) scale(0.5); } 72% { opacity: 0.25; transform: translateY(-2px) scale(1); } 100% { opacity: 0; transform: translateY(-10px) scale(1.2); } }
|
||||
@keyframes bmo-leaf-swat { 0%,82%,100% { transform: rotate(0deg); } 88% { transform: rotate(-20deg); } 94% { transform: rotate(5deg); } }
|
||||
@keyframes bmo-leaf-look-up { 0%,78%,100% { transform: translateY(0); } 83%,93% { transform: translateY(-1.5px); } }
|
||||
@keyframes bmo-mushroom-reflect { 0%,100% { opacity: 0.06; } 50% { opacity: 0.12; } }
|
||||
.bmo-rain-shiver { animation: bmo-rain-shiver 5s ease-in-out infinite; }
|
||||
.bmo-snow-huddle { animation: bmo-snow-huddle 4s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.bmo-breath-puff { animation: bmo-breath-puff 4s ease-in-out infinite; }
|
||||
.bmo-leaf-swat { animation: bmo-leaf-swat 8s ease-in-out infinite; transform-origin: top center; }
|
||||
.bmo-leaf-look-up { animation: bmo-leaf-look-up 6s ease-in-out infinite; }
|
||||
.bmo-mushroom-reflect { animation: bmo-mushroom-reflect 3s ease-in-out infinite; }
|
||||
|
||||
/* ---------- Working state: code editor + mini face ---------- */
|
||||
@keyframes bmo-face-to-corner {
|
||||
0% { transform: translate(0, 0) scale(1); opacity: 1; }
|
||||
60% { transform: translate(-8px, -6px) scale(0.3); opacity: 0.3; }
|
||||
100% { transform: translate(-10px, -8px) scale(0.15); opacity: 0; }
|
||||
}
|
||||
@keyframes bmo-code-window-enter { 0% { opacity: 0; transform: scale(0.85); } 100% { opacity: 1; transform: scale(1); } }
|
||||
@keyframes bmo-code-scroll-up { 0% { transform: translateY(0); } 100% { transform: translateY(-40px); } }
|
||||
@keyframes bmo-cursor-blink { 0%,100% { opacity: 1; } 50% { opacity: 0; } }
|
||||
.bmo-face-to-corner { animation: bmo-face-to-corner 1.5s ease-in-out forwards; }
|
||||
.bmo-code-window-enter { animation: bmo-code-window-enter 1s ease-out forwards; }
|
||||
.bmo-code-scroll-group { animation: bmo-code-scroll-up 10s linear infinite; }
|
||||
.bmo-cursor-blink { animation: bmo-cursor-blink 0.8s step-end infinite; }
|
||||
|
||||
/* ---------- Extended wrapper animations ---------- */
|
||||
@keyframes bounce-in { 0% { transform: scale(0); } 60% { transform: scale(1.15); } 100% { transform: scale(1); } }
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
@keyframes pulse-glow { 0%,100% { filter: drop-shadow(0 0 4px rgba(92,224,216,0.3)); } 50% { filter: drop-shadow(0 0 16px rgba(92,224,216,0.8)); } }
|
||||
@keyframes hop { 0%,100% { transform: translateY(0); } 30% { transform: translateY(-12px); } 50% { transform: translateY(-8px); } 70% { transform: translateY(-12px); } }
|
||||
@keyframes wiggle { 0%,100% { transform: rotate(0deg); } 25% { transform: rotate(-8deg); } 75% { transform: rotate(8deg); } }
|
||||
@keyframes slide-in { 0% { transform: translateX(-60px); opacity: 0; } 100% { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes jelly { 0%,100% { transform: scale(1,1); } 25% { transform: scale(0.9,1.1); } 50% { transform: scale(1.05,0.95); } 75% { transform: scale(0.95,1.05); } }
|
||||
.anim-bounce-in { animation: bounce-in 0.8s ease-out both; }
|
||||
.anim-spin { animation: spin 2s linear infinite; transform-origin: center; }
|
||||
.anim-pulse-glow { animation: pulse-glow 2s ease-in-out infinite; }
|
||||
.anim-hop { animation: hop 1s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.anim-wiggle { animation: wiggle 0.6s ease-in-out infinite; transform-origin: center bottom; }
|
||||
.anim-slide-in { animation: slide-in 0.6s ease-out both; }
|
||||
.anim-jelly { animation: jelly 1.5s ease-in-out infinite; transform-origin: center bottom; }
|
||||
|
||||
/* ---------- Extra wrapper/particle animations (animations.html) ---------- */
|
||||
@keyframes float-up { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(-20px); opacity: 0; } }
|
||||
@keyframes wave-arms { 0%,100% { transform: rotate(0deg); } 25% { transform: rotate(-15deg); } 75% { transform: rotate(15deg); } }
|
||||
@keyframes heart-float { 0% { transform: translateY(0) scale(1); opacity: 1; } 100% { transform: translateY(-30px) scale(1.5); opacity: 0; } }
|
||||
@keyframes spark { 0% { transform: scale(0) rotate(0deg); opacity: 1; } 100% { transform: scale(1.5) rotate(180deg); opacity: 0; } }
|
||||
|
||||
/* ---------- Garden scene animations ---------- */
|
||||
@keyframes garden-star-twinkle { 0%,100% { opacity: 1; } 50% { opacity: 0.2; } }
|
||||
@keyframes garden-cloud-drift { 0% { transform: translateX(0); } 100% { transform: translateX(60px); } }
|
||||
@keyframes garden-fog-drift { 0%,100% { transform: translateX(0); opacity: 0.25; } 50% { transform: translateX(15px); opacity: 0.15; } }
|
||||
@keyframes garden-grass-sway { 0%,100% { transform: rotate(0deg); } 50% { transform: rotate(3deg); } }
|
||||
@keyframes garden-glow-pulse { 0%,100% { opacity: 0.15; } 50% { opacity: 0.35; } }
|
||||
@keyframes garden-rain-fall { 0% { transform: translateY(-20px); opacity: 0; } 20% { opacity: 0.4; } 100% { transform: translateY(100px); opacity: 0; } }
|
||||
@keyframes garden-snow-fall { 0% { transform: translateY(-10px) translateX(0); opacity: 0; } 20% { opacity: 0.6; } 100% { transform: translateY(80px) translateX(15px); opacity: 0; } }
|
||||
@keyframes garden-leaf-fall { 0% { transform: translateY(-10px) rotate(0deg); opacity: 0; } 20% { opacity: 0.7; } 100% { transform: translateY(90px) rotate(180deg); opacity: 0; } }
|
||||
@@ -0,0 +1,35 @@
|
||||
// Barrel export for the verbatim bmo-preview port.
|
||||
// All visuals remain 1:1 with upstream. Dream-specific composition lives in
|
||||
// ../agent/, never here.
|
||||
|
||||
export { BmoAvatar, getPalette, KNOWN_PALETTES, FALLBACK_PALETTES, agentStatusToBmoState } from "./BmoAvatar";
|
||||
export type { BmoColorPalette, BmoState } from "./BmoAvatar";
|
||||
|
||||
export {
|
||||
PERSONALITY_ACCESSORIES,
|
||||
renderCrown,
|
||||
renderCape,
|
||||
renderTopHat,
|
||||
renderBadge,
|
||||
renderGlasses,
|
||||
renderCoolGlasses,
|
||||
renderBowTie,
|
||||
renderHeadphones,
|
||||
} from "./BmoAccessories";
|
||||
export type { AccCoords } from "./BmoAccessories";
|
||||
|
||||
export { VOICE_LINES, renderScreenContent } from "./BmoScreen";
|
||||
export { renderEmoteTsx, renderSpeechTsx } from "./BmoEmotes";
|
||||
export { renderWeatherReactions } from "./BmoWeatherReactions";
|
||||
|
||||
export { SkyLayer } from "./SkyLayer";
|
||||
export { TerrainLayer } from "./TerrainLayer";
|
||||
export { FloraLayer, Plant, TallFern, HangingVine, GlowMushroom } from "./FloraLayer";
|
||||
export { CloudLayer } from "./CloudLayer";
|
||||
export { GroundFog } from "./GroundFog";
|
||||
export { WeatherEffects } from "./WeatherEffects";
|
||||
// SeasonalOverlay was omitted by the original GardenLayers.entry.tsx barrel — re-export here.
|
||||
export { SeasonalOverlay } from "./SeasonalOverlay";
|
||||
|
||||
export { FEATURE_FLAGS, readFeatureFlag, useFeatureFlag } from "./FeatureFlags";
|
||||
export type { FeatureFlags } from "./FeatureFlags";
|
||||
@@ -0,0 +1,37 @@
|
||||
// Things BMO says on its own. Short — they live in a small speech bubble.
|
||||
|
||||
export const IDLE_CHATTER = [
|
||||
"BMO is guarding the garden.",
|
||||
"Who wants to play a game?",
|
||||
"Beep boop. Just checking.",
|
||||
"I wonder what fireflies dream about.",
|
||||
"BMO could really go for a battery snack.",
|
||||
"Is it adventure time yet?",
|
||||
"These mushrooms glow like my screen!",
|
||||
"BMO practiced dancing today.",
|
||||
"Do you think the moon is a big button?",
|
||||
"Hmm hmm hmm ♪",
|
||||
];
|
||||
|
||||
export const HUMS = ["♪", "♪ ♫", "la la la ♪", "♫ beep bop ♫"];
|
||||
|
||||
export const BORED_LINES = ["...", "BMO is so bored.", "Helloooo?", "*taps screen*"];
|
||||
|
||||
export const POKE_LINES = ["Hehe, that tickles!", "Boop!", "Hi friend!", "BMO is here!", "Wheee!"];
|
||||
|
||||
export const GRUMPY_LINES = ["Hey! BMO is not a button!", "Okay okay, stop poking!", "BMO's buttons are sensitive!"];
|
||||
|
||||
export const WAKE_LINES = ["Oh! You're back!", "Huh? BMO was not sleeping!", "*yawn* Hi friend!"];
|
||||
|
||||
export const WELCOME_BACK_LINES = ["Welcome back!", "BMO missed you!", "There you are!"];
|
||||
|
||||
export const WEATHER_LINES: Record<string, string[]> = {
|
||||
rain: ["Uh oh, rain! BMO is not waterproof!", "Pitter patter..."],
|
||||
snow: ["Snow! BMO wants to build a snow-BMO!", "Brrr, chilly circuits."],
|
||||
"falling-leaves": ["Leaves! Leaves everywhere!", "Ooh, crunchy leaves."],
|
||||
none: ["The sky is clear again!", "Nice night for a walk."],
|
||||
};
|
||||
|
||||
export function pick<T>(list: readonly T[]): T {
|
||||
return list[Math.floor(Math.random() * list.length)]!;
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
// BMO's brain: a small state machine that decides what BMO is doing right now
|
||||
// (wandering, chatting to itself, getting bored, falling asleep, thinking about
|
||||
// a reply, talking) and turns that into props for the verbatim BmoAvatar.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Performance, Spot } from "../../shared/scene";
|
||||
import type { Expression } from "../avatar/Bmo";
|
||||
import type { BmoState } from "../bmo";
|
||||
import {
|
||||
BORED_LINES,
|
||||
GRUMPY_LINES,
|
||||
HUMS,
|
||||
IDLE_CHATTER,
|
||||
POKE_LINES,
|
||||
WAKE_LINES,
|
||||
WEATHER_LINES,
|
||||
WELCOME_BACK_LINES,
|
||||
pick,
|
||||
} from "./lines";
|
||||
|
||||
export type Activity = "idle" | "bored" | "sleeping" | "thinking" | "talking" | "grumpy";
|
||||
export type Emote = "celebrate" | "confused" | "eureka" | "error";
|
||||
export type Mood = "happy" | "excited" | "curious" | "idea" | "sad" | "sleepy" | "oops";
|
||||
export type Weather = "none" | "rain" | "snow" | "falling-leaves";
|
||||
|
||||
const WALK_SPEED_PX_PER_S = 90;
|
||||
const EMOTE_MS = 1600;
|
||||
|
||||
// How BMO's face looks for a moment after picking a mood.
|
||||
const MOOD_EXPRESSION: Partial<Record<Mood, Expression>> = {
|
||||
excited: "grin",
|
||||
idea: "grin",
|
||||
sad: "sad",
|
||||
oops: "sad",
|
||||
};
|
||||
|
||||
const MOOD_EMOTE: Record<Mood, Emote | undefined> = {
|
||||
happy: "celebrate",
|
||||
excited: "celebrate",
|
||||
curious: "confused",
|
||||
idea: "eureka",
|
||||
sad: "error",
|
||||
oops: "error",
|
||||
sleepy: undefined,
|
||||
};
|
||||
|
||||
interface Bubble {
|
||||
text: string;
|
||||
/** While true the bubble is still being written (streamed reply). */
|
||||
live: boolean;
|
||||
key: number;
|
||||
}
|
||||
|
||||
interface BrainOptions {
|
||||
/** Walkable range in px (BMO's center x). */
|
||||
minX: number;
|
||||
maxX: number;
|
||||
weather: Weather;
|
||||
isNight: boolean;
|
||||
}
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function useBmoBrain({ minX, maxX, weather, isNight }: BrainOptions) {
|
||||
const [activity, setActivity] = useState<Activity>("idle");
|
||||
const [x, setX] = useState(() => (minX + maxX) / 2);
|
||||
const [walkMs, setWalkMs] = useState(0);
|
||||
const [walking, setWalking] = useState(false);
|
||||
const [facing, setFacing] = useState<1 | -1>(1);
|
||||
const [emote, setEmote] = useState<Emote | undefined>(undefined);
|
||||
const [emoteKey, setEmoteKey] = useState(0);
|
||||
const [expression, setExpression] = useState<Expression>("normal");
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null);
|
||||
|
||||
// Refs mirror state for timers so they never read stale closures.
|
||||
const activityRef = useRef(activity);
|
||||
activityRef.current = activity;
|
||||
const xRef = useRef(x);
|
||||
xRef.current = x;
|
||||
const boundsRef = useRef({ minX, maxX });
|
||||
boundsRef.current = { minX, maxX };
|
||||
const lastInputAt = useRef(now());
|
||||
const pokes = useRef<number[]>([]);
|
||||
const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
|
||||
const walkTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const bubbleKey = useRef(0);
|
||||
const expressionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// A nap BMO chose to take isn't broken by the mouse wiggle right after asking for it.
|
||||
const napUntil = useRef(0);
|
||||
|
||||
const later = useCallback((fn: () => void, ms: number) => {
|
||||
const t = setTimeout(() => {
|
||||
timers.current.delete(t);
|
||||
fn();
|
||||
}, ms);
|
||||
timers.current.add(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = timers.current;
|
||||
return () => {
|
||||
pending.forEach(clearTimeout);
|
||||
if (walkTimer.current) clearTimeout(walkTimer.current);
|
||||
if (bubbleTimer.current) clearTimeout(bubbleTimer.current);
|
||||
if (expressionTimer.current) clearTimeout(expressionTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ---------- primitives ----------
|
||||
|
||||
const fireEmote = useCallback(
|
||||
(next: Emote | undefined) => {
|
||||
if (!next) return;
|
||||
setEmote(next);
|
||||
setEmoteKey((k) => k + 1);
|
||||
later(() => setEmote(undefined), EMOTE_MS);
|
||||
},
|
||||
[later],
|
||||
);
|
||||
|
||||
const showExpression = useCallback((next: Expression, ms: number) => {
|
||||
if (expressionTimer.current) clearTimeout(expressionTimer.current);
|
||||
setExpression(next);
|
||||
expressionTimer.current = setTimeout(() => setExpression("normal"), ms);
|
||||
}, []);
|
||||
|
||||
const say = useCallback((text: string, ms = 2600 + text.length * 45) => {
|
||||
if (bubbleTimer.current) clearTimeout(bubbleTimer.current);
|
||||
bubbleKey.current += 1;
|
||||
setBubble({ text, live: false, key: bubbleKey.current });
|
||||
bubbleTimer.current = setTimeout(() => setBubble(null), ms);
|
||||
}, []);
|
||||
|
||||
const walkTo = useCallback((targetX: number) => {
|
||||
const { minX: lo, maxX: hi } = boundsRef.current;
|
||||
const clamped = Math.max(lo, Math.min(hi, targetX));
|
||||
const distance = Math.abs(clamped - xRef.current);
|
||||
if (distance < 4) return;
|
||||
const ms = (distance / WALK_SPEED_PX_PER_S) * 1000;
|
||||
setFacing(clamped > xRef.current ? 1 : -1);
|
||||
setWalkMs(ms);
|
||||
setWalking(true);
|
||||
setX(clamped);
|
||||
if (walkTimer.current) clearTimeout(walkTimer.current);
|
||||
walkTimer.current = setTimeout(() => setWalking(false), ms);
|
||||
}, []);
|
||||
|
||||
// Keep BMO inside the walkable range when the window resizes.
|
||||
useEffect(() => {
|
||||
if (xRef.current < minX || xRef.current > maxX) {
|
||||
setWalkMs(0);
|
||||
setX(Math.max(minX, Math.min(maxX, xRef.current)));
|
||||
}
|
||||
}, [minX, maxX]);
|
||||
|
||||
// ---------- noticing the user ----------
|
||||
|
||||
const noteInput = useCallback(() => {
|
||||
lastInputAt.current = now();
|
||||
const current = activityRef.current;
|
||||
if (current === "sleeping" && now() < napUntil.current) return;
|
||||
if (current === "sleeping") {
|
||||
setActivity("idle");
|
||||
fireEmote("confused");
|
||||
later(() => say(pick(WAKE_LINES)), 500);
|
||||
} else if (current === "bored") {
|
||||
setActivity("idle");
|
||||
fireEmote("celebrate");
|
||||
}
|
||||
}, [fireEmote, later, say]);
|
||||
|
||||
useEffect(() => {
|
||||
let lastMove = 0;
|
||||
const onMove = () => {
|
||||
const t = now();
|
||||
if (t - lastMove < 500) return;
|
||||
lastMove = t;
|
||||
noteInput();
|
||||
};
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
const awayMs = now() - lastInputAt.current;
|
||||
noteInput();
|
||||
if (awayMs > 20_000 && activityRef.current === "idle") {
|
||||
fireEmote("celebrate");
|
||||
say(pick(WELCOME_BACK_LINES));
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("keydown", noteInput);
|
||||
window.addEventListener("pointerdown", noteInput);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("keydown", noteInput);
|
||||
window.removeEventListener("pointerdown", noteInput);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
}, [fireEmote, noteInput, say]);
|
||||
|
||||
// ---------- boredom & sleep ----------
|
||||
|
||||
useEffect(() => {
|
||||
const boredAfter = isNight ? 30_000 : 60_000;
|
||||
const sleepAfter = isNight ? 75_000 : 150_000;
|
||||
const tick = setInterval(() => {
|
||||
const current = activityRef.current;
|
||||
if (current === "thinking" || current === "talking" || current === "grumpy") return;
|
||||
const quietFor = now() - lastInputAt.current;
|
||||
if (quietFor > sleepAfter && current !== "sleeping") {
|
||||
setActivity("sleeping");
|
||||
say("Zzz...", 4000);
|
||||
} else if (quietFor > boredAfter && current === "idle") {
|
||||
setActivity("bored");
|
||||
say(pick(BORED_LINES));
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(tick);
|
||||
}, [isNight, say]);
|
||||
|
||||
// ---------- idle life: BMO does things on its own ----------
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let t: ReturnType<typeof setTimeout>;
|
||||
const schedule = () => {
|
||||
t = setTimeout(act, 5000 + Math.random() * 7000);
|
||||
};
|
||||
const act = () => {
|
||||
if (cancelled) return;
|
||||
const current = activityRef.current;
|
||||
if (current === "idle" || current === "bored") {
|
||||
const roll = Math.random();
|
||||
const { minX: lo, maxX: hi } = boundsRef.current;
|
||||
if (roll < 0.45) {
|
||||
walkTo(lo + Math.random() * (hi - lo));
|
||||
} else if (roll < 0.62 && current === "idle") {
|
||||
say(pick(IDLE_CHATTER));
|
||||
} else if (roll < 0.74) {
|
||||
say(pick(HUMS), 2200);
|
||||
} else if (roll < 0.84 && current === "idle") {
|
||||
fireEmote("eureka");
|
||||
} else if (roll < 0.92) {
|
||||
setFacing((f) => (f === 1 ? -1 : 1));
|
||||
}
|
||||
}
|
||||
schedule();
|
||||
};
|
||||
schedule();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [fireEmote, say, walkTo]);
|
||||
|
||||
// ---------- weather commentary ----------
|
||||
|
||||
const lastWeather = useRef(weather);
|
||||
useEffect(() => {
|
||||
if (lastWeather.current === weather) return;
|
||||
lastWeather.current = weather;
|
||||
if (activityRef.current === "idle" || activityRef.current === "bored") {
|
||||
say(pick(WEATHER_LINES[weather] ?? WEATHER_LINES.none!));
|
||||
}
|
||||
}, [weather, say]);
|
||||
|
||||
// ---------- interactions ----------
|
||||
|
||||
const poke = useCallback(() => {
|
||||
noteInput();
|
||||
const t = now();
|
||||
pokes.current = [...pokes.current.filter((p) => t - p < 3000), t];
|
||||
if (activityRef.current === "thinking" || activityRef.current === "talking") return;
|
||||
if (pokes.current.length >= 4) {
|
||||
pokes.current = [];
|
||||
setActivity("grumpy");
|
||||
fireEmote("error");
|
||||
say(pick(GRUMPY_LINES), 2400);
|
||||
later(() => setActivity("idle"), 2400);
|
||||
return;
|
||||
}
|
||||
fireEmote("celebrate");
|
||||
showExpression("love", 1800);
|
||||
say(pick(POKE_LINES), 1800);
|
||||
}, [fireEmote, later, noteInput, say, showExpression]);
|
||||
|
||||
// ---------- conversation ----------
|
||||
|
||||
const startThinking = useCallback(() => {
|
||||
noteInput();
|
||||
setActivity("thinking");
|
||||
if (bubbleTimer.current) clearTimeout(bubbleTimer.current);
|
||||
bubbleKey.current += 1;
|
||||
setBubble({ text: "", live: true, key: bubbleKey.current });
|
||||
}, [noteInput]);
|
||||
|
||||
const react = useCallback(
|
||||
(mood: Mood) => {
|
||||
setActivity((a) => (a === "sleeping" ? a : mood === "sleepy" ? "idle" : "talking"));
|
||||
fireEmote(MOOD_EMOTE[mood]);
|
||||
const face = MOOD_EXPRESSION[mood];
|
||||
if (face) showExpression(face, 2600);
|
||||
},
|
||||
[fireEmote, showExpression],
|
||||
);
|
||||
|
||||
const appendSpeech = useCallback((text: string) => {
|
||||
setActivity((a) => (a === "thinking" ? "talking" : a));
|
||||
setBubble((b) => (b ? { ...b, text: b.text + text } : { text, live: true, key: ++bubbleKey.current }));
|
||||
}, []);
|
||||
|
||||
const finishTalking = useCallback(() => {
|
||||
lastInputAt.current = now();
|
||||
setBubble((b) => {
|
||||
if (!b) return b;
|
||||
const ms = Math.min(20_000, 3500 + b.text.length * 55);
|
||||
if (bubbleTimer.current) clearTimeout(bubbleTimer.current);
|
||||
bubbleTimer.current = setTimeout(() => setBubble(null), ms);
|
||||
return { ...b, live: false };
|
||||
});
|
||||
later(() => setActivity((a) => (a === "talking" || a === "thinking" ? "idle" : a)), 1200);
|
||||
}, [later]);
|
||||
|
||||
// ---------- actions BMO chooses (tool calls) ----------
|
||||
|
||||
const goTo = useCallback(
|
||||
(spot: Spot) => {
|
||||
const { minX: lo, maxX: hi } = boundsRef.current;
|
||||
walkTo(spot === "left" ? lo : spot === "right" ? hi : (lo + hi) / 2);
|
||||
},
|
||||
[walkTo],
|
||||
);
|
||||
|
||||
const perform = useCallback(
|
||||
(action: Performance) => {
|
||||
switch (action) {
|
||||
case "celebrate":
|
||||
return fireEmote("celebrate");
|
||||
case "idea":
|
||||
return fireEmote("eureka");
|
||||
case "confused":
|
||||
return fireEmote("confused");
|
||||
case "look_around":
|
||||
[0, 700, 1400].forEach((ms) => later(() => setFacing((f) => (f === 1 ? -1 : 1)), ms));
|
||||
return;
|
||||
case "dance": {
|
||||
const home = xRef.current;
|
||||
fireEmote("celebrate");
|
||||
[24, -24, 24, -24, 0].forEach((dx, i) => later(() => walkTo(home + dx), i * 540));
|
||||
later(() => fireEmote("celebrate"), 2000);
|
||||
later(() => showExpression("dizzy", 1800), 2700);
|
||||
return;
|
||||
}
|
||||
case "love":
|
||||
fireEmote("celebrate");
|
||||
return showExpression("love", 2600);
|
||||
case "dizzy":
|
||||
return showExpression("dizzy", 2600);
|
||||
case "nap":
|
||||
napUntil.current = now() + 8000;
|
||||
lastInputAt.current = now();
|
||||
setActivity("sleeping");
|
||||
return;
|
||||
}
|
||||
},
|
||||
[fireEmote, later, showExpression, walkTo],
|
||||
);
|
||||
|
||||
const { minX: lo, maxX: hi } = boundsRef.current;
|
||||
const third = (hi - lo) / 3;
|
||||
const spot: Spot = hi - lo < 1 ? "center" : x < lo + third ? "left" : x > hi - third ? "right" : "center";
|
||||
|
||||
const bmoState: BmoState =
|
||||
activity === "thinking"
|
||||
? "working"
|
||||
: activity === "grumpy"
|
||||
? "blocked"
|
||||
: activity === "bored" || activity === "sleeping"
|
||||
? "idle"
|
||||
: "active";
|
||||
|
||||
return {
|
||||
activity,
|
||||
bmoState,
|
||||
x,
|
||||
walkMs,
|
||||
walking,
|
||||
facing,
|
||||
emote,
|
||||
emoteKey,
|
||||
expression,
|
||||
bubble,
|
||||
poke,
|
||||
walkTo,
|
||||
goTo,
|
||||
perform,
|
||||
spot,
|
||||
say,
|
||||
noteInput,
|
||||
startThinking,
|
||||
react,
|
||||
appendSpeech,
|
||||
finishTalking,
|
||||
};
|
||||
}
|
||||
|
||||
export type BmoBrain = ReturnType<typeof useBmoBrain>;
|
||||
@@ -0,0 +1,50 @@
|
||||
// Whether BMO's brain (the Claude API key on the server) is connected, and a
|
||||
// way to set or clear the key from the side menu. The key goes to the local
|
||||
// server only; the browser just gets back a masked hint.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export interface BrainStatus {
|
||||
connected: boolean;
|
||||
keyHint: string | null;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export function useBrain() {
|
||||
const [status, setStatus] = useState<BrainStatus | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/brain")
|
||||
.then((res) => (res.ok ? (res.json() as Promise<BrainStatus>) : null))
|
||||
.then(setStatus)
|
||||
.catch(() => setStatus(null));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async (apiKey: string | null) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/brain", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
const body = (await res.json()) as BrainStatus & { error?: string };
|
||||
if (!res.ok) {
|
||||
setError(body.error ?? `Something went wrong (HTTP ${res.status}).`);
|
||||
return false;
|
||||
}
|
||||
setStatus(body);
|
||||
return true;
|
||||
} catch {
|
||||
setError("Couldn't reach BMO's server. Is it running?");
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { status, saving, error, save };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Talks to BMO's brain (server/index.ts) and feeds the streamed reply into the
|
||||
// avatar brain. Conversation history is a per-browser convenience in
|
||||
// localStorage; losing it just means BMO forgets the chat.
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { BmoAction, SceneState } from "../../shared/scene";
|
||||
import type { BmoBrain, Mood } from "../brain/useBmoBrain";
|
||||
|
||||
export interface ChatTurn {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "bmo.chat.history";
|
||||
const KEEP_TURNS = 60;
|
||||
|
||||
function loadHistory(): ChatTurn[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as ChatTurn[]) : [];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(turns: ChatTurn[]) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(turns.slice(-KEEP_TURNS)));
|
||||
} catch {
|
||||
// storage unavailable — keep going in memory
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatHooks {
|
||||
/** Snapshot of what the user sees, sent with every message. */
|
||||
getScene: () => SceneState;
|
||||
/** Carries out something BMO decided to do (a tool call). */
|
||||
onAction: (action: BmoAction) => void;
|
||||
}
|
||||
|
||||
export function useChat(
|
||||
brain: Pick<BmoBrain, "startThinking" | "react" | "appendSpeech" | "finishTalking">,
|
||||
hooks: ChatHooks,
|
||||
) {
|
||||
const hooksRef = useRef(hooks);
|
||||
hooksRef.current = hooks;
|
||||
const [history, setHistory] = useState<ChatTurn[]>(loadHistory);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const historyRef = useRef(history);
|
||||
historyRef.current = history;
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const content = text.trim();
|
||||
if (!content || busy) return;
|
||||
const withUser: ChatTurn[] = [...historyRef.current, { role: "user", content }];
|
||||
setHistory(withUser);
|
||||
setBusy(true);
|
||||
brain.startThinking();
|
||||
|
||||
let reply = "";
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ messages: withUser, scene: hooksRef.current.getScene() }),
|
||||
});
|
||||
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
let buffer = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
let split: number;
|
||||
while ((split = buffer.indexOf("\n\n")) !== -1) {
|
||||
const frame = buffer.slice(0, split);
|
||||
buffer = buffer.slice(split + 2);
|
||||
const event = /^event: (.+)$/m.exec(frame)?.[1];
|
||||
const data = /^data: (.*)$/m.exec(frame)?.[1];
|
||||
if (!event || data === undefined) continue;
|
||||
const payload = JSON.parse(data) as unknown;
|
||||
if (event === "mood") brain.react(payload as Mood);
|
||||
else if (event === "text") {
|
||||
reply += payload as string;
|
||||
brain.appendSpeech(payload as string);
|
||||
} else if (event === "action") {
|
||||
hooksRef.current.onAction(payload as BmoAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
brain.react("oops");
|
||||
const fallback = "BMO can't hear its brain. Is the server running?";
|
||||
reply ||= fallback;
|
||||
brain.appendSpeech(fallback);
|
||||
} finally {
|
||||
brain.finishTalking();
|
||||
setBusy(false);
|
||||
const next: ChatTurn[] = reply ? [...withUser, { role: "assistant", content: reply }] : withUser;
|
||||
setHistory(next);
|
||||
saveHistory(next);
|
||||
}
|
||||
},
|
||||
[brain, busy],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setHistory([]);
|
||||
saveHistory([]);
|
||||
}, []);
|
||||
|
||||
return { history, busy, send, clear };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./bmo/bmo-animations.css";
|
||||
import "./app.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
// The garden BMO lives in. Composes the verbatim scenery layers in pixel
|
||||
// coordinates (viewBox == window size) so the HTML overlay (BMO, bubbles)
|
||||
// lines up 1:1 with the SVG.
|
||||
|
||||
import { memo } from "react";
|
||||
import { CloudLayer, FloraLayer, GroundFog, SkyLayer, TerrainLayer, WeatherEffects } from "../bmo";
|
||||
import type { Weather } from "../brain/useBmoBrain";
|
||||
|
||||
export type TimeOfDay = "day" | "dusk" | "night";
|
||||
|
||||
export function timeOfDay(date = new Date()): TimeOfDay {
|
||||
const h = date.getHours();
|
||||
if (h >= 7 && h < 17) return "day";
|
||||
if ((h >= 5 && h < 7) || (h >= 17 && h < 20)) return "dusk";
|
||||
return "night";
|
||||
}
|
||||
|
||||
const SKIES: Record<TimeOfDay, Parameters<typeof SkyLayer>[0]["config"]> = {
|
||||
day: {
|
||||
gradientStops: ["#3d7fb8", "#7fb6d9", "#b9d9c4"],
|
||||
starDensity: 0,
|
||||
starBrightness: 0,
|
||||
moonX: 0.82,
|
||||
moonY: 0.14,
|
||||
moonSize: 0,
|
||||
moonPhase: "new",
|
||||
},
|
||||
dusk: {
|
||||
gradientStops: ["#2a2350", "#7a4a6e", "#d98a5f"],
|
||||
starDensity: 0.4,
|
||||
starBrightness: 0.3,
|
||||
moonX: 0.8,
|
||||
moonY: 0.16,
|
||||
moonSize: 1.2,
|
||||
moonPhase: "crescent",
|
||||
},
|
||||
night: {
|
||||
gradientStops: ["#09131f", "#163247", "#415d4b"],
|
||||
starDensity: 1,
|
||||
starBrightness: 0.6,
|
||||
moonX: 0.8,
|
||||
moonY: 0.14,
|
||||
moonSize: 1.6,
|
||||
moonPhase: "crescent",
|
||||
},
|
||||
};
|
||||
|
||||
const GRASS: Record<TimeOfDay, [string, string]> = {
|
||||
day: ["#4f9a4a", "#2c5e2a"],
|
||||
dusk: ["#35603a", "#1a3320"],
|
||||
night: ["#214228", "#112117"],
|
||||
};
|
||||
|
||||
interface WorldProps {
|
||||
width: number;
|
||||
height: number;
|
||||
time: TimeOfDay;
|
||||
weather: Weather;
|
||||
}
|
||||
|
||||
export const World = memo(function World({ width, height, time, weather }: WorldProps) {
|
||||
const horizonY = height * 0.6;
|
||||
return (
|
||||
<svg
|
||||
className="world"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
width={width}
|
||||
height={height}
|
||||
preserveAspectRatio="xMidYMax slice"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<SkyLayer config={SKIES[time]} sceneWidth={width} sceneHeight={height} />
|
||||
<CloudLayer count={time === "day" ? 7 : 4} sceneWidth={width} sceneHeight={height} />
|
||||
<path
|
||||
d={`M0 ${horizonY - 40} C ${width * 0.2} ${horizonY - 110}, ${width * 0.35} ${horizonY - 90}, ${width * 0.5} ${horizonY - 55} S ${width * 0.85} ${horizonY - 80}, ${width} ${horizonY - 30} L ${width} ${horizonY + 4} L 0 ${horizonY + 4} Z`}
|
||||
fill={time === "day" ? "#3f7a45" : "#294537"}
|
||||
opacity={0.9}
|
||||
/>
|
||||
<TerrainLayer
|
||||
config={{ grassGradient: GRASS[time], pathStyle: "cobblestone", pathOpacity: 0.5 }}
|
||||
floraConfig={{ grassTuftsEnabled: true }}
|
||||
sceneWidth={width}
|
||||
sceneHeight={height}
|
||||
/>
|
||||
<FloraLayer
|
||||
config={{
|
||||
vinesEnabled: true,
|
||||
fernsEnabled: true,
|
||||
flowersEnabled: true,
|
||||
mushroomsEnabled: true,
|
||||
mushroomGlowColor: "#8fe6cf",
|
||||
}}
|
||||
sceneWidth={width}
|
||||
sceneHeight={height}
|
||||
/>
|
||||
{time !== "day" && <GroundFog sceneWidth={width} sceneHeight={height} />}
|
||||
{time !== "day" && <Fireflies width={width} height={height} />}
|
||||
{weather !== "none" && (
|
||||
<WeatherEffects weather={weather} intensity={weather === "rain" ? 0.8 : 0.55} sceneWidth={width} sceneHeight={height} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
function Fireflies({ width, height }: { width: number; height: number }) {
|
||||
const baseY = height * 0.62;
|
||||
const flies = [0.12, 0.28, 0.45, 0.61, 0.77, 0.9].map((fx, i) => ({
|
||||
cx: width * fx,
|
||||
cy: baseY + ((i * 37) % 60) - 30,
|
||||
cls: `firefly firefly-${"abc"[i % 3]}`,
|
||||
delay: `${i * -1.7}s`,
|
||||
}));
|
||||
return (
|
||||
<g pointerEvents="none">
|
||||
{flies.map((fly, i) => (
|
||||
<g key={i} className={fly.cls} style={{ transformBox: "fill-box", animationDelay: fly.delay }}>
|
||||
<circle cx={fly.cx} cy={fly.cy} r={3.5} fill="#ffe08a" opacity={0.25} />
|
||||
<circle cx={fly.cx} cy={fly.cy} r={2} fill="#ffe08a" opacity={0.9} />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// "Brain" section of the side menu: paste the Anthropic API key that lets BMO
|
||||
// talk. The key is checked and saved by the local server (into .env); only a
|
||||
// masked hint ever comes back.
|
||||
|
||||
import { useState } from "react";
|
||||
import type { useBrain } from "../chat/useBrain";
|
||||
|
||||
export function BrainSection({ brain }: { brain: ReturnType<typeof useBrain> }) {
|
||||
const { status, saving, error, save } = brain;
|
||||
const [draft, setDraft] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const connected = status?.connected ?? false;
|
||||
const showForm = !connected || editing;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
if (await save(draft.trim())) {
|
||||
setDraft("");
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="brain">
|
||||
<h2>Brain</h2>
|
||||
<div className={"brain-status" + (connected ? " ok" : "")}>
|
||||
<span className="dot" aria-hidden="true" />
|
||||
{status === null
|
||||
? "Can't reach BMO's server"
|
||||
: connected
|
||||
? `Connected${status.keyHint ? ` · ${status.keyHint}` : ""}`
|
||||
: "No API key yet. BMO can't talk."}
|
||||
</div>
|
||||
|
||||
{showForm ? (
|
||||
<form className="key-form" onSubmit={submit}>
|
||||
<label htmlFor="api-key">Anthropic API key</label>
|
||||
<div className="key-row">
|
||||
<input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="sk-ant-…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button type="submit" className="primary" disabled={saving || !draft.trim()}>
|
||||
{saving ? "Checking…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="key-error">{error}</p>}
|
||||
<p className="hint">
|
||||
Get one at{" "}
|
||||
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noreferrer">
|
||||
console.anthropic.com
|
||||
</a>
|
||||
. It's saved only on this computer, in the project's <code>.env</code> file.
|
||||
{editing && (
|
||||
<>
|
||||
{" "}
|
||||
<button type="button" className="text-btn" onClick={() => setEditing(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</form>
|
||||
) : (
|
||||
<div className="key-actions">
|
||||
<button type="button" className="link" onClick={() => setEditing(true)}>
|
||||
Change key
|
||||
</button>
|
||||
<button type="button" className="link" disabled={saving} onClick={() => void save(null)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Side menu (opened from the ⋮ button) holding BMO's brain (API key), every
|
||||
// BMO and environment setting, plus the conversation log.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { BmoColorPalette } from "../bmo";
|
||||
import type { Weather } from "../brain/useBmoBrain";
|
||||
import type { useBrain } from "../chat/useBrain";
|
||||
import type { ChatTurn } from "../chat/useChat";
|
||||
import type { TimeOfDay } from "../scene/World";
|
||||
import { BrainSection } from "./BrainSection";
|
||||
|
||||
export type TimeMode = "auto" | TimeOfDay;
|
||||
export type WeatherMode = "auto" | Weather;
|
||||
|
||||
const TIME_OPTIONS: { value: TimeMode; label: string }[] = [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "day", label: "Day" },
|
||||
{ value: "dusk", label: "Dusk" },
|
||||
{ value: "night", label: "Night" },
|
||||
];
|
||||
|
||||
const WEATHER_OPTIONS: { value: WeatherMode; label: string }[] = [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "none", label: "Clear" },
|
||||
{ value: "rain", label: "Rain" },
|
||||
{ value: "snow", label: "Snow" },
|
||||
{ value: "falling-leaves", label: "Leaves" },
|
||||
];
|
||||
|
||||
interface SideMenuProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
brain: ReturnType<typeof useBrain>;
|
||||
palettes: Record<string, BmoColorPalette>;
|
||||
paletteName: string;
|
||||
onPalette: (name: string) => void;
|
||||
outfits: string[];
|
||||
outfitName: string;
|
||||
onOutfit: (name: string) => void;
|
||||
timeMode: TimeMode;
|
||||
onTimeMode: (mode: TimeMode) => void;
|
||||
weatherMode: WeatherMode;
|
||||
onWeatherMode: (mode: WeatherMode) => void;
|
||||
history: ChatTurn[];
|
||||
onForget: () => void;
|
||||
}
|
||||
|
||||
function Segmented<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
grid,
|
||||
}: {
|
||||
label: string;
|
||||
grid?: boolean;
|
||||
value: T;
|
||||
options: { value: T; label: string }[];
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={"segmented" + (grid ? " grid" : "")} role="radiogroup" aria-label={label}>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={o.value === value}
|
||||
className={o.value === value ? "on" : undefined}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideMenu(props: SideMenuProps) {
|
||||
const { open, onClose, history } = props;
|
||||
const logEnd = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) logEnd.current?.scrollIntoView({ block: "end" });
|
||||
}, [open, history.length]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
id="side-menu"
|
||||
className={"side-menu" + (open ? " open" : "")}
|
||||
aria-label="Settings"
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="side-head">
|
||||
<h1>Customize</h1>
|
||||
<button type="button" className="icon-btn" aria-label="Close menu" onClick={onClose}>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="side-body">
|
||||
<BrainSection brain={props.brain} />
|
||||
|
||||
<section>
|
||||
<h2>BMO</h2>
|
||||
<h3>Color</h3>
|
||||
<div className="swatches">
|
||||
{Object.entries(props.palettes).map(([name, p]) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
title={name}
|
||||
aria-label={name}
|
||||
aria-pressed={name === props.paletteName}
|
||||
className={"swatch" + (name === props.paletteName ? " on" : "")}
|
||||
style={{ background: p.body, borderColor: p.accent }}
|
||||
onClick={() => props.onPalette(name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<h3>Outfit</h3>
|
||||
<Segmented
|
||||
label="Outfit"
|
||||
grid
|
||||
value={props.outfitName}
|
||||
options={props.outfits.map((o) => ({ value: o, label: o }))}
|
||||
onChange={props.onOutfit}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Environment</h2>
|
||||
<h3>Time of day</h3>
|
||||
<Segmented label="Time of day" value={props.timeMode} options={TIME_OPTIONS} onChange={props.onTimeMode} />
|
||||
<h3>Weather</h3>
|
||||
<Segmented label="Weather" value={props.weatherMode} options={WEATHER_OPTIONS} onChange={props.onWeatherMode} />
|
||||
<p className="hint">Auto follows your clock and lets the weather drift on its own.</p>
|
||||
</section>
|
||||
|
||||
<section className="convo">
|
||||
<div className="convo-head">
|
||||
<h2>Conversation</h2>
|
||||
{history.length > 0 && (
|
||||
<button type="button" className="link" onClick={props.onForget}>
|
||||
Forget
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="log-body">
|
||||
{history.length === 0 && <p className="muted">Nothing yet. Say hi!</p>}
|
||||
{history.map((turn, i) => (
|
||||
<p key={i} className={turn.role}>
|
||||
<b>{turn.role === "user" ? "You" : "BMO"}</b> {turn.content}
|
||||
</p>
|
||||
))}
|
||||
<div ref={logEnd} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "shared"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts", "server", "shared"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const brainPort = Number(process.env.BMO_PORT ?? 4310);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5180,
|
||||
proxy: { "/api": `http://127.0.0.1:${brainPort}` },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user