@codexstar/pi-pompom 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # pi-lumo
2
+
3
+ A 3D raymarched virtual pet companion for [Pi CLI](https://github.com/mariozechner/pi-coding-agent).
4
+
5
+ Lumo is an interactive terminal creature that lives above your editor — it walks, sleeps, chases fireflies, plays fetch, and reacts to your commands.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install @codexstar/pi-lumo
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Lumo appears automatically above the editor. Interact with single-key commands when the editor is empty:
16
+
17
+ | Key | Action |
18
+ |-----|--------|
19
+ | `⌥p` | Pet |
20
+ | `⌥f` | Feed |
21
+ | `⌥b` | Ball |
22
+ | `⌥m` | Music |
23
+ | `⌥c` | Color |
24
+ | `⌥s` | Sleep |
25
+ | `⌥w` | Wake |
26
+ | `⌥d` | Flip |
27
+ | `⌥o` | Hide |
28
+
29
+ Or use the `/lumo` command:
30
+
31
+ ```
32
+ /lumo on — show companion
33
+ /lumo off — hide companion
34
+ /lumo pet — pet Lumo
35
+ /lumo feed — drop food
36
+ /lumo ball — throw a ball
37
+ /lumo music — sing a song
38
+ /lumo color — cycle color theme
39
+ /lumo sleep — take a nap
40
+ /lumo wake — wake up
41
+ /lumo flip — do a flip
42
+ /lumo hide — wander offscreen
43
+ ```
44
+
45
+ ## Features
46
+
47
+ - Full 3D raymarched creature with physics and lighting
48
+ - Natural blinking, breathing, and idle animations
49
+ - Day/night sky cycle based on system time
50
+ - Particle effects (sparkles, music notes, rain, sleep Zs)
51
+ - Speech bubbles with contextual messages
52
+ - Firefly companion that Lumo chases
53
+ - Hunger and energy needs system
54
+ - 4 color themes (Cloud, Cotton Candy, Mint Drop, Sunset Gold)
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,250 @@
1
+ /**
2
+ * pi-pompom — Pompom Companion Extension for Pi CLI.
3
+ *
4
+ * A 3D raymarched virtual pet that lives above the editor.
5
+ * Interactive commands: pet, feed, ball, music, color, sleep, wake, flip, hide.
6
+ *
7
+ * Install as a standalone extension — no dependencies on pi-voice.
8
+ */
9
+
10
+ import type {
11
+ ExtensionAPI,
12
+ ExtensionContext,
13
+ } from "@mariozechner/pi-coding-agent";
14
+
15
+ import { renderPompom, resetPompom, pompomSetTalking, pompomKeypress, pompomStatus } from "./pompom";
16
+
17
+ export default function (pi: ExtensionAPI) {
18
+ let ctx: ExtensionContext | null = null;
19
+ let companionTimer: ReturnType<typeof setInterval> | null = null;
20
+ let companionActive = false;
21
+ let lastRenderTime = Date.now();
22
+ let terminalInputUnsub: (() => void) | null = null;
23
+ let enabled = true;
24
+
25
+ function showCompanion() {
26
+ if (companionActive) return;
27
+ if (!ctx?.hasUI) return;
28
+ companionActive = true;
29
+ lastRenderTime = Date.now();
30
+
31
+ const renderCompanion = (width: number): string[] => {
32
+ const now = Date.now();
33
+ const dt = Math.min(0.1, (now - lastRenderTime) / 1000);
34
+ lastRenderTime = now;
35
+ return renderPompom(Math.max(40, width), 0, dt);
36
+ };
37
+
38
+ ctx.ui.setWidget("pompom-companion", (_tui, _theme) => ({
39
+ invalidate() {},
40
+ render: renderCompanion,
41
+ }), { placement: "aboveEditor" });
42
+
43
+ companionTimer = setInterval(() => {
44
+ if (!ctx?.hasUI) return;
45
+ ctx.ui.setWidget("pompom-companion", (_tui, _theme) => ({
46
+ invalidate() {},
47
+ render: renderCompanion,
48
+ }), { placement: "aboveEditor" });
49
+ }, 150);
50
+ }
51
+
52
+ function hideCompanion() {
53
+ companionActive = false;
54
+ if (companionTimer) {
55
+ clearInterval(companionTimer);
56
+ companionTimer = null;
57
+ }
58
+ pompomSetTalking(false);
59
+ if (ctx?.hasUI) {
60
+ ctx.ui.setWidget("pompom-companion", undefined);
61
+ }
62
+ }
63
+
64
+ // macOS Option+key produces Unicode characters when macos-option-as-alt is off.
65
+ // Windows/Linux don't use this — they send ESC prefix for Alt+key (handled below).
66
+ const optionUnicodeMap: Record<string, string> = {
67
+ "π": "p", // Option+p → Pet
68
+ "ƒ": "f", // Option+f → Feed
69
+ "∫": "b", // Option+b → Ball
70
+ "µ": "m", // Option+m → Music
71
+ "ç": "c", // Option+c → Color
72
+ "∂": "d", // Option+d → Flip
73
+ "ß": "s", // Option+s → Sleep
74
+ "∑": "w", // Option+w → Wake
75
+ "ø": "o", // Option+o → Hide
76
+ "≈": "x", // Option+x → Dance
77
+ "†": "t", // Option+t → Treat
78
+ "˙": "h", // Option+h → Hug
79
+ };
80
+
81
+ function setupKeyHandler() {
82
+ if (!ctx?.hasUI) return;
83
+ if (terminalInputUnsub) { terminalInputUnsub(); terminalInputUnsub = null; }
84
+
85
+ terminalInputUnsub = ctx.ui.onTerminalInput((data: string) => {
86
+ if (!enabled || !companionActive) return undefined;
87
+
88
+ // 1. Ghostty keybind prefix \x1d + letter (macOS with Ghostty config)
89
+ if (data.length === 2 && data[0] === "\x1d" && "pfbmcdswoxth".includes(data[1])) {
90
+ pompomKeypress(data[1]);
91
+ return { consume: true };
92
+ }
93
+
94
+ // 2. ESC prefix — Alt+key on Windows/Linux, Option-as-Meta on macOS
95
+ // This is the primary method on Windows Terminal, CMD, PowerShell, WSL
96
+ if (data.length === 2 && data[0] === "\x1b" && "pfbmcdswoxth".includes(data[1])) {
97
+ pompomKeypress(data[1]);
98
+ return { consume: true };
99
+ }
100
+
101
+ // 3. macOS Unicode chars (Option key without macos-option-as-alt)
102
+ const mapped = optionUnicodeMap[data];
103
+ if (mapped) {
104
+ pompomKeypress(mapped);
105
+ return { consume: true };
106
+ }
107
+
108
+ // 4. Kitty keyboard protocol (Ghostty, Kitty, WezTerm, Windows Terminal 1.22+)
109
+ // Format: \x1b[<codepoint>;{1+modifier}u — Alt modifier bit = 2
110
+ const kittyMatch = data.match(/^\x1b\[(\d+);(\d+)u$/);
111
+ if (kittyMatch) {
112
+ const mod = parseInt(kittyMatch[2]);
113
+ if ((mod - 1) & 2) {
114
+ const char = String.fromCharCode(parseInt(kittyMatch[1]));
115
+ if ("pfbmcdswoxth".includes(char)) {
116
+ pompomKeypress(char);
117
+ return { consume: true };
118
+ }
119
+ }
120
+ }
121
+
122
+ return undefined;
123
+ });
124
+ }
125
+
126
+ // ─── Lifecycle ───────────────────────────────────────────────────────────
127
+
128
+ pi.on("session_start", async (_event, startCtx) => {
129
+ ctx = startCtx;
130
+ if (enabled) {
131
+ showCompanion();
132
+ setupKeyHandler();
133
+ }
134
+ });
135
+
136
+ pi.on("session_shutdown", async () => {
137
+ hideCompanion();
138
+ resetPompom();
139
+ if (terminalInputUnsub) { terminalInputUnsub(); terminalInputUnsub = null; }
140
+ });
141
+
142
+ pi.on("session_switch", async (_event, switchCtx) => {
143
+ hideCompanion();
144
+ resetPompom();
145
+ ctx = switchCtx;
146
+ if (enabled) {
147
+ showCompanion();
148
+ setupKeyHandler();
149
+ }
150
+ });
151
+
152
+ // ─── /pompom command ─────────────────────────────────────────────────────
153
+
154
+ const pompomCommands: Record<string, string> = {
155
+ pet: "p", feed: "f", ball: "b", music: "m", color: "c", theme: "c",
156
+ sleep: "s", wake: "w", flip: "d", hide: "o",
157
+ dance: "x", treat: "t", hug: "h",
158
+ };
159
+
160
+ pi.registerCommand("pompom", {
161
+ description: "Pompom companion — /pompom help for commands",
162
+ handler: async (args, cmdCtx) => {
163
+ ctx = cmdCtx;
164
+ const sub = (args || "").trim().toLowerCase();
165
+
166
+ if (sub === "on") {
167
+ enabled = true;
168
+ showCompanion();
169
+ setupKeyHandler();
170
+ cmdCtx.ui.notify("Pompom companion enabled 🐾", "info");
171
+ return;
172
+ }
173
+
174
+ if (sub === "off") {
175
+ enabled = false;
176
+ hideCompanion();
177
+ resetPompom();
178
+ if (terminalInputUnsub) { terminalInputUnsub(); terminalInputUnsub = null; }
179
+ cmdCtx.ui.notify("Pompom companion hidden.", "info");
180
+ return;
181
+ }
182
+
183
+ if (sub === "help" || sub === "?") {
184
+ const m = process.platform === "darwin" ? "⌥" : "Alt+";
185
+ cmdCtx.ui.notify(
186
+ `🐾 Pompom Commands\n` +
187
+ ` /pompom on|off Toggle companion\n` +
188
+ ` /pompom pet Pet Pompom ${m}p\n` +
189
+ ` /pompom feed Drop food ${m}f\n` +
190
+ ` /pompom treat Special treat ${m}t\n` +
191
+ ` /pompom hug Give a hug ${m}h\n` +
192
+ ` /pompom ball Throw a ball ${m}b\n` +
193
+ ` /pompom dance Dance! ${m}x\n` +
194
+ ` /pompom music Sing a song ${m}m\n` +
195
+ ` /pompom flip Do a flip ${m}d\n` +
196
+ ` /pompom sleep Nap time ${m}s\n` +
197
+ ` /pompom wake Wake up ${m}w\n` +
198
+ ` /pompom theme Cycle color ${m}c\n` +
199
+ ` /pompom hide Wander off ${m}o\n` +
200
+ ` /pompom status Check mood & stats`, "info"
201
+ );
202
+ return;
203
+ }
204
+
205
+ if (sub === "status") {
206
+ if (!companionActive) {
207
+ cmdCtx.ui.notify("Pompom is not active. Use /pompom on first.", "info");
208
+ return;
209
+ }
210
+ const s = pompomStatus();
211
+ const bar = (v: number) => "█".repeat(Math.round(v / 10)) + "░".repeat(10 - Math.round(v / 10));
212
+ cmdCtx.ui.notify(
213
+ `🐾 Pompom Status\n` +
214
+ ` Mood: ${s.mood}\n` +
215
+ ` Hunger: ${bar(s.hunger)} ${s.hunger}%\n` +
216
+ ` Energy: ${bar(s.energy)} ${s.energy}%\n` +
217
+ ` Theme: ${s.theme}`, "info"
218
+ );
219
+ return;
220
+ }
221
+
222
+ if (pompomCommands[sub]) {
223
+ if (!companionActive) {
224
+ enabled = true;
225
+ showCompanion();
226
+ setupKeyHandler();
227
+ }
228
+ pompomKeypress(pompomCommands[sub]);
229
+ return;
230
+ }
231
+
232
+ // No args or unknown: toggle if active, else show help
233
+ if (sub === "") {
234
+ if (companionActive) {
235
+ enabled = false;
236
+ hideCompanion();
237
+ resetPompom();
238
+ cmdCtx.ui.notify("Pompom companion hidden.", "info");
239
+ } else {
240
+ enabled = true;
241
+ showCompanion();
242
+ setupKeyHandler();
243
+ cmdCtx.ui.notify("Pompom companion enabled 🐾 — /pompom help for commands", "info");
244
+ }
245
+ } else {
246
+ cmdCtx.ui.notify(`Unknown command: ${sub}. Try /pompom help`, "warning");
247
+ }
248
+ },
249
+ });
250
+ }
@@ -0,0 +1,867 @@
1
+ /**
2
+ * Pompom Companion — 3D raymarched virtual pet for Pi CLI.
3
+ *
4
+ * A full 3D raymarched creature with physics, particles, speech bubbles,
5
+ * moods, and interactive commands. Driven by audio level for mouth animation.
6
+ */
7
+
8
+ // ─── Rendering Config ────────────────────────────────────────────────────────
9
+ // Widget dimensions — set once, used by renderPompom
10
+ let W = 50;
11
+ let H = 14; // character rows (each = 2 logical pixels via half-block)
12
+ const VIEW_OFFSET_Y = 0.18; // shift camera down so ground is visible in compact mode
13
+
14
+ const PHYSICS_DT = 0.016; // 60fps physics sub-stepping
15
+
16
+ // ─── Pet State ───────────────────────────────────────────────────────────────
17
+ type State = "idle" | "walk" | "flip" | "sleep" | "excited" | "chasing" | "fetching" | "singing" | "offscreen" | "peek" | "dance";
18
+
19
+ const idleSpeech = [
20
+ "What are we building? 🤔", "This is fun! ✨", "Boop! 🐾",
21
+ "I love it here! 💕", "Need a break? ☕", "Pom pom pom! 🎈",
22
+ "You're doing great! 🌈", "*wiggles ears*", "Hmm... 🌟",
23
+ "Hey! Look at me! 👋", "Tra la la~ 🎵", "*happy bounce*",
24
+ ];
25
+ let currentState: State = "idle";
26
+
27
+ let time = 0;
28
+ let blinkFade = 0;
29
+ let actionTimer = 0;
30
+ let speechTimer = 0;
31
+ let speechText = "";
32
+
33
+ // Needs
34
+ let hunger = 100;
35
+ let energy = 100;
36
+ let lastNeedsTick = 0;
37
+
38
+ // Themes
39
+ const themes = [
40
+ { name: "Cloud", r: 245, g: 250, b: 255 },
41
+ { name: "Cotton Candy", r: 255, g: 210, b: 230 },
42
+ { name: "Mint Drop", r: 200, g: 255, b: 220 },
43
+ { name: "Sunset Gold", r: 255, g: 225, b: 180 },
44
+ ];
45
+ let activeTheme = 0;
46
+
47
+ // Physical position
48
+ let posX = 0, posY = 0.15, posZ = 0;
49
+ let lookX = 0, lookY = 0;
50
+ let isWalking = false, isFlipping = false, flipPhase = 0;
51
+ let targetX = 0;
52
+ let bounceY = 0;
53
+ let isSleeping = false;
54
+ let breathe = 0;
55
+
56
+ // Audio-driven talking
57
+ let isTalking = false;
58
+ let talkAudioLevel = 0;
59
+
60
+ // Interactables
61
+ let ffX = 0, ffY = 0, ffZ = 0;
62
+ interface Food { x: number; y: number; vy: number; }
63
+ const foods: Food[] = [];
64
+ let ballX = -10, ballY = -10, ballZ = 0, ballVx = 0, ballVy = 0, ballVz = 0, hasBall = false;
65
+
66
+ interface Particle {
67
+ x: number; y: number; vx: number; vy: number;
68
+ char: string; r: number; g: number; b: number; life: number; type: string;
69
+ }
70
+ const particles: Particle[] = [];
71
+
72
+ let screenChars: string[][] = [];
73
+ let screenColors: string[][] = [];
74
+
75
+ function allocBuffers() {
76
+ screenChars = Array.from({ length: H }, () => Array(W).fill(" "));
77
+ screenColors = Array.from({ length: H }, () => Array(W).fill(""));
78
+ }
79
+ allocBuffers();
80
+
81
+ interface RenderObj {
82
+ id: string; mat: number;
83
+ x: number; y: number; z: number;
84
+ r?: number; rx?: number; ry?: number; rot?: number;
85
+ s?: number; c?: number;
86
+ }
87
+
88
+ function say(text: string, duration = 4.0) {
89
+ speechText = text;
90
+ speechTimer = duration;
91
+ }
92
+
93
+ function project2D(x: number, y: number): [number, number] {
94
+ const effectDim = Math.max(40, Math.min(W, H * 2.8));
95
+ const scale = 2.0 / effectDim;
96
+ const cx = (x / scale) + (W / 2.0);
97
+ const cy = ((y - VIEW_OFFSET_Y) / scale + H) / 2.0;
98
+ return [Math.floor(cx), Math.floor(cy)];
99
+ }
100
+
101
+ function getStringWidth(str: string): number {
102
+ let w = 0;
103
+ for (const char of str) {
104
+ w += (char.match(/[\u2600-\u26FF\u2700-\u27BF\uE000-\uF8FF\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}]/u)) ? 2 : 1;
105
+ }
106
+ return w;
107
+ }
108
+
109
+ function drawSpeechBubble(text: string, bx: number, by: number) {
110
+ // Strip multi-width chars (emoji) — the cell grid requires 1-wide characters only
111
+ let safe = "";
112
+ for (const ch of text) {
113
+ if (getStringWidth(ch) <= 1) safe += ch;
114
+ }
115
+ text = safe;
116
+ if (text.length > W - 10) text = text.substring(0, W - 13) + "...";
117
+ const pad = 2, width = text.length + pad * 2;
118
+ const startX = Math.floor(bx - width / 2), startY = Math.floor(by - 3);
119
+ if (startY < 0 || startY >= H) return;
120
+
121
+ const top = "╭" + "─".repeat(Math.max(0, width - 2)) + "╮";
122
+ const mid = "│ " + text + " │";
123
+ let tailPos = Math.floor(width / 2);
124
+ if (startX < 0) tailPos = 2;
125
+ if (startX + width > W) tailPos = width - 3;
126
+ const bot = "╰" + "─".repeat(Math.max(0, tailPos - 1)) + "v" + "─".repeat(Math.max(0, width - tailPos - 2)) + "╯";
127
+
128
+ const drawLine = (ly: number, str: string) => {
129
+ if (ly >= 0 && ly < H) {
130
+ const chars = [...str]; // iterate by codepoint, not code unit
131
+ for (let i = 0; i < chars.length; i++) {
132
+ const lx = startX + i;
133
+ if (lx >= 0 && lx < W) {
134
+ screenChars[ly][lx] = chars[i];
135
+ screenColors[ly][lx] = "\x1b[38;5;234m\x1b[48;5;255m";
136
+ }
137
+ }
138
+ }
139
+ };
140
+ drawLine(startY, top); drawLine(startY + 1, mid); drawLine(startY + 2, bot);
141
+ }
142
+
143
+ function fbm(x: number, y: number): number {
144
+ return Math.sin(x * 15 + time * 2) * Math.sin(y * 15 + time * 1.5) * 0.04 +
145
+ Math.sin(x * 30 - time) * Math.cos(y * 30) * 0.02;
146
+ }
147
+
148
+ function getWeatherAndTime() {
149
+ const h = new Date().getHours();
150
+ let rTop = 0, gTop = 0, bTop = 0, rBot = 0, gBot = 0, bBot = 0;
151
+ if (h >= 6 && h < 17) { rTop = 40; gTop = 120; bTop = 255; rBot = 180; gBot = 220; bBot = 255; }
152
+ else if (h >= 17 && h < 19) { rTop = 140; gTop = 50; bTop = 120; rBot = 255; gBot = 160; bBot = 100; }
153
+ else { rTop = 10; gTop = 10; bTop = 20; rBot = 25; gBot = 20; bBot = 40; }
154
+ return { rTop, gTop, bTop, rBot, gBot, bBot, isNight: h >= 19 || h < 6 };
155
+ }
156
+
157
+ function getObjHit(px: number, py: number, objects: RenderObj[]) {
158
+ let hitObj: RenderObj | null = null;
159
+ let hitNx = 0, hitNy = 0, hitNz = 1;
160
+ let hitU = 0, hitV = 0;
161
+
162
+ for (const obj of objects) {
163
+ let dx = px - obj.x;
164
+ let dy = py - obj.y;
165
+ const maxR = Math.max(obj.rx || obj.r || 1.0, obj.ry || obj.r || 1.0);
166
+ if (Math.abs(dx) > maxR + 0.35 || Math.abs(dy) > maxR + 0.35) continue;
167
+
168
+ if (obj.s !== undefined && obj.c !== undefined) {
169
+ const nx = dx * obj.c + dy * obj.s;
170
+ const ny = -dx * obj.s + dy * obj.c;
171
+ dx = nx; dy = ny;
172
+ }
173
+
174
+ const rx = obj.rx || obj.r || 1;
175
+ const ry = obj.ry || obj.r || 1;
176
+ let dist = Math.sqrt((dx * dx) / (rx * rx) + (dy * dy) / (ry * ry));
177
+
178
+ let fluff = 0;
179
+ if (obj.id === "body") {
180
+ fluff = fbm(dx, dy);
181
+ const faceDist = Math.sqrt(dx * dx + dy * dy);
182
+ const faceMask = Math.max(0, 1.0 - faceDist * 4.0);
183
+ fluff *= (1.0 - faceMask);
184
+ if (isSleeping) fluff *= 0.3;
185
+ } else if (obj.id === "tail") {
186
+ fluff = Math.sin(Math.atan2(dy, dx) * 5 + time * 3) * 0.2;
187
+ } else if (obj.id === "pillow") {
188
+ fluff = Math.sin(dx * 5) * Math.cos(dy * 10) * 0.1;
189
+ }
190
+
191
+ if (dist < 1.0 + fluff) {
192
+ hitObj = obj;
193
+ hitNx = dx / rx; hitNy = dy / ry;
194
+ const nlen = Math.sqrt(hitNx * hitNx + hitNy * hitNy);
195
+ if (nlen > 1.0) { hitNx /= nlen; hitNy /= nlen; }
196
+ hitNz = Math.sqrt(Math.max(0, 1.0 - hitNx * hitNx - hitNy * hitNy));
197
+ hitU = hitNx; hitV = hitNy;
198
+ if (obj.s !== undefined && obj.c !== undefined) {
199
+ const nnx = hitNx * obj.c - hitNy * obj.s;
200
+ const nny = hitNx * obj.s + hitNy * obj.c;
201
+ hitNx = nnx; hitNy = nny;
202
+ }
203
+ }
204
+ }
205
+ return { hitObj, hitNx, hitNy, hitNz, hitU, hitV };
206
+ }
207
+
208
+ function shadeObject(hit: ReturnType<typeof getObjHit>, px: number, py: number, objects: RenderObj[]): [number, number, number] {
209
+ const { hitObj, hitNx, hitNy, hitNz, hitU, hitV } = hit;
210
+ if (!hitObj) return [-1, -1, -1];
211
+
212
+ let r = 255, g = 255, b = 255, gloss = 0;
213
+ const th = themes[activeTheme];
214
+
215
+ if (hitObj.mat === 1) {
216
+ r = th.r; g = th.g; b = th.b;
217
+ if (hitNy > 0.15) {
218
+ const belly = Math.min(1.0, (hitNy - 0.15) * 1.5);
219
+ r = r * (1 - belly) + 255 * belly; g = g * (1 - belly) + 250 * belly; b = b * (1 - belly) + 245 * belly;
220
+ }
221
+ if (hitObj.id === "body" && hitNy < -0.3) {
222
+ const spot = Math.sin(hitNx * 10) * Math.cos(hitNy * 8);
223
+ if (spot > 0.6) { r = Math.max(0, r - 40); g = Math.max(0, g - 20); b = Math.max(0, b - 10); }
224
+ }
225
+ if (hitObj.id === "body") {
226
+ let bdx = px - hitObj.x, bdy = py - hitObj.y;
227
+ if (isFlipping) {
228
+ const s = Math.sin(-flipPhase), c = Math.cos(-flipPhase);
229
+ const nx = bdx * c - bdy * s, ny = bdx * s + bdy * c;
230
+ bdx = nx; bdy = ny;
231
+ }
232
+ // Blush
233
+ const blx1 = bdx + 0.16, bly1 = bdy - 0.04;
234
+ const blx2 = bdx - 0.16, bly2 = bdy - 0.04;
235
+ const blush = Math.exp(-(blx1 * blx1 + bly1 * bly1) * 80) + Math.exp(-(blx2 * blx2 + bly2 * bly2) * 80);
236
+ if (!isSleeping) {
237
+ r = r * (1 - blush) + 255 * blush; g = g * (1 - blush) + 80 * blush; b = b * (1 - blush) + 100 * blush;
238
+ }
239
+ // Eyes
240
+ const isTired = (energy < 20 || hunger < 30) && !isSleeping;
241
+ const eyeOpen = isSleeping ? 0.05 : (isTired ? 0.4 : 1.0) - blinkFade;
242
+ const ex1 = bdx - lookX * 0.08 + 0.1, ey1 = bdy - lookY * 0.05 + 0.02;
243
+ const ex2 = bdx - lookX * 0.08 - 0.1, ey2 = bdy - lookY * 0.05 + 0.02;
244
+
245
+ if (isSleeping || currentState === "singing") {
246
+ if (isSleeping) {
247
+ if ((Math.abs(ey1) < 0.01 && Math.abs(ex1) < 0.05) || (Math.abs(ey2) < 0.01 && Math.abs(ex2) < 0.05)) { r = 30; g = 20; b = 30; }
248
+ } else {
249
+ if ((Math.abs(ey1 + ex1 * ex1 * 15) < 0.015 && Math.abs(ex1) < 0.06 && ey1 > -ex1 * ex1 * 15) ||
250
+ (Math.abs(ey2 + ex2 * ex2 * 15) < 0.015 && Math.abs(ex2) < 0.06 && ey2 > -ex2 * ex2 * 15)) { r = 30; g = 20; b = 30; }
251
+ }
252
+ } else {
253
+ const eDist1 = ex1 * ex1 + (ey1 * ey1) / (eyeOpen * eyeOpen + 0.001);
254
+ const eDist2 = ex2 * ex2 + (ey2 * ey2) / (eyeOpen * eyeOpen + 0.001);
255
+ if (eDist1 < 0.004 || eDist2 < 0.004) {
256
+ r = 15; g = 10; b = 20;
257
+ if (ey1 > 0 || ey2 > 0) { r = 50; g = 180; b = 100; }
258
+ if ((ex1 + 0.012) ** 2 + (ey1 + 0.012) ** 2 < 0.0003 || (ex2 + 0.012) ** 2 + (ey2 + 0.012) ** 2 < 0.0003) {
259
+ if (!isTired) { r = 255; g = 255; b = 255; }
260
+ } else if ((ex1 - 0.015) ** 2 + (ey1 - 0.015) ** 2 < 0.0001 || (ex2 - 0.015) ** 2 + (ey2 - 0.015) ** 2 < 0.0001) {
261
+ if (!isTired) { r = 255; g = 255; b = 255; }
262
+ }
263
+ }
264
+ }
265
+ // Nose
266
+ const nnx = bdx - lookX * 0.06, nny = bdy - lookY * 0.05 - 0.02;
267
+ if (nnx * nnx * 1.5 + nny * nny < 0.0006 && !isSleeping) { r = 30; g = 20; b = 30; }
268
+ // Mouth
269
+ if (!isSleeping && !hasBall) {
270
+ const mx = bdx - lookX * 0.06, my = bdy - lookY * 0.05 - 0.06;
271
+ if ((Math.abs(my - (mx - 0.025) ** 2 * 20 + 0.01) < 0.01 && mx > 0 && mx < 0.05) ||
272
+ (Math.abs(my - (mx + 0.025) ** 2 * 20 + 0.01) < 0.01 && mx < 0 && mx > -0.05)) {
273
+ r = 50; g = 30; b = 40;
274
+ }
275
+ if (currentState === "excited" || currentState === "singing" || speechTimer > 0 || isTalking) {
276
+ const mouthOpen = (speechTimer > 0 || currentState === "singing" || isTalking)
277
+ ? (isTalking ? talkAudioLevel * 0.04 + 0.005 : Math.abs(Math.sin(time * 12)) * 0.025)
278
+ : 0.015;
279
+ if (mx * mx + (my + 0.01) ** 2 < mouthOpen && my < -0.01) {
280
+ r = 240; g = 80; b = 100;
281
+ if (my < -0.025) { r = 255; g = 120; b = 140; }
282
+ }
283
+ }
284
+ }
285
+ } else {
286
+ if (hitObj.id === "earL" || hitObj.id === "earR") {
287
+ if (hitU > -0.3 && hitU < 0.3 && hitV > -0.5 && hitV < 0.5) { r = 255; g = 130; b = 160; }
288
+ }
289
+ }
290
+ if (hitNz < 0.25) {
291
+ r *= 0.6; g *= 0.6; b *= 0.6;
292
+ }
293
+ } else if (hitObj.mat === 2) {
294
+ r = Math.max(0, th.r - 20); g = Math.max(0, th.g - 15); b = Math.max(0, th.b - 10);
295
+ if (hitNy > 0.5) { r = 255; g = 180; b = 190; }
296
+ } else if (hitObj.mat === 3) {
297
+ r = 255; g = 230; b = 90; gloss = 128;
298
+ } else if (hitObj.mat === 5) {
299
+ return [100, 255, 200];
300
+ } else if (hitObj.mat === 6) {
301
+ r = 240; g = 220; b = 180; gloss = 16;
302
+ } else if (hitObj.mat === 7) {
303
+ r = 120; g = 130; b = 140;
304
+ } else if (hitObj.mat === 8) {
305
+ const pulse = Math.sin(time * 6) * 0.5 + 0.5;
306
+ return [255, Math.floor(100 + pulse * 150), Math.floor(150 + pulse * 105)];
307
+ } else if (hitObj.mat === 9) {
308
+ r = 255; g = 60; b = 80;
309
+ const curve = Math.abs(hitNx * 0.7 - hitNy * 0.7);
310
+ if (curve > 0.4 && curve < 0.55) { r = 255; g = 200; b = 200; }
311
+ gloss = 128;
312
+ } else if (hitObj.mat === 10) {
313
+ r = 230; g = 210; b = 220;
314
+ const check = Math.sin(hitU * 20) * Math.sin(hitV * 20);
315
+ if (check > 0) { r = 200; g = 180; b = 200; }
316
+ }
317
+
318
+ // Lighting
319
+ let lx = 0.6, ly = -0.7, lz = 0.8;
320
+ const ll = Math.sqrt(lx * lx + ly * ly + lz * lz);
321
+ lx /= ll; ly /= ll; lz /= ll;
322
+
323
+ const diff = Math.max(0, hitNx * lx + hitNy * ly + hitNz * lz);
324
+ const wrap = Math.max(0, hitNx * lx + hitNy * ly + hitNz * lz + 0.5) / 1.5;
325
+ const amb = 0.45;
326
+
327
+ let ao = 1.0;
328
+ if (hitObj.id === "earL" || hitObj.id === "earR") ao = 0.8;
329
+ if (hitObj.id === "pawL" || hitObj.id === "pawR") ao = 0.7;
330
+ if (hitObj.id === "body" && hitNy > 0.5) ao = 0.6;
331
+ if (hitObj.id === "pillow" && isSleeping) {
332
+ const bodyDist = Math.sqrt((px - posX) ** 2 + (py - posY) ** 2);
333
+ if (bodyDist < 0.4) ao = 0.5 + (bodyDist / 0.4) * 0.5;
334
+ }
335
+
336
+ // Firefly light
337
+ const fdx = ffX - px, fdy = ffY - py, fdz = ffZ - (hitObj.z || 0);
338
+ const fDistSq = fdx * fdx + fdy * fdy + fdz * fdz;
339
+ const fll = Math.max(0.001, Math.sqrt(fDistSq));
340
+ const fnx = fdx / fll, fny = fdy / fll, fnz = fdz / fll;
341
+ const fdiff = Math.max(0, hitNx * fnx + hitNy * fny + hitNz * fnz);
342
+ const fatten = 1.0 / (1.0 + fDistSq * 20.0);
343
+
344
+ let lightR = (diff * 0.5 + wrap * 0.3 + amb) * ao + fdiff * fatten * 2.0;
345
+ let lightG = (diff * 0.5 + wrap * 0.3 + amb) * ao + fdiff * fatten * 3.0;
346
+ let lightB = (diff * 0.5 + wrap * 0.3 + amb) * ao + fdiff * fatten * 2.5;
347
+
348
+ // Antenna glow
349
+ if (hitObj.id === "body") {
350
+ const antObj = objects.find(o => o.id === "antenna_bulb");
351
+ if (antObj) {
352
+ const antDx = px - antObj.x, antDy = py - antObj.y;
353
+ const antDist = Math.sqrt(antDx * antDx + antDy * antDy);
354
+ const antAtten = 1.0 / (1.0 + antDist * antDist * 40.0);
355
+ lightR += antAtten * 1.5; lightG += antAtten * 0.5; lightB += antAtten * 0.8;
356
+ }
357
+ }
358
+
359
+ r = Math.min(255, Math.floor(r * lightR));
360
+ g = Math.min(255, Math.floor(g * lightG));
361
+ b = Math.min(255, Math.floor(b * lightB));
362
+
363
+ if (gloss > 0 && diff > 0) {
364
+ const spec = Math.pow(Math.max(0, hitNx * lx + hitNy * ly + hitNz * lz), gloss);
365
+ r = Math.min(255, Math.floor(r + spec * 255));
366
+ g = Math.min(255, Math.floor(g + spec * 255));
367
+ b = Math.min(255, Math.floor(b + spec * 255));
368
+ }
369
+
370
+ return [r, g, b];
371
+ }
372
+
373
+ function getPixel(px: number, py: number, objects: RenderObj[], skyColors: ReturnType<typeof getWeatherAndTime>): [number, number, number] {
374
+ if (py > 0.6) {
375
+ let shadowDist = Math.sqrt((px - posX) ** 2 + ((py - 0.6) * 2.5) ** 2);
376
+ let shadow = Math.max(0.2, Math.min(1.0, shadowDist / 0.7));
377
+ if (isSleeping) {
378
+ const pillowDist = Math.sqrt(px ** 2 + ((py - 0.6) * 2.5) ** 2);
379
+ shadow = Math.min(shadow, Math.max(0.3, Math.min(1.0, pillowDist / 1.5)));
380
+ }
381
+ if (ballY > 0.4 && ballX !== -10 && !hasBall) {
382
+ const bShadowDist = Math.sqrt((px - ballX) ** 2 + ((py - 0.6) * 2.5) ** 2);
383
+ shadow = Math.min(shadow, Math.max(0.4, Math.min(1.0, bShadowDist / 0.2)));
384
+ }
385
+ const isWood = (Math.sin(px * 10) + Math.sin(py * 40)) > 0;
386
+ const wr = isWood ? 55 : 45, wg = isWood ? 35 : 30, wb = isWood ? 25 : 20;
387
+ const grad = (py - 0.6) / 0.4;
388
+ let fr = Math.floor((wr - grad * 10) * shadow);
389
+ let fg = Math.floor((wg - grad * 10) * shadow);
390
+ let fb = Math.floor((wb - grad * 10) * shadow);
391
+ // Floor reflection
392
+ const refPy = 1.2 - py;
393
+ const refHit = getObjHit(px, refPy, objects);
394
+ if (refHit.hitObj) {
395
+ const refC = shadeObject(refHit, px, refPy, objects);
396
+ fr = Math.floor(fr * 0.7 + refC[0] * 0.3);
397
+ fg = Math.floor(fg * 0.7 + refC[1] * 0.3);
398
+ fb = Math.floor(fb * 0.7 + refC[2] * 0.3);
399
+ }
400
+ return [fr, fg, fb];
401
+ }
402
+
403
+ const directHit = getObjHit(px, py, objects);
404
+ if (directHit.hitObj) return shadeObject(directHit, px, py, objects);
405
+
406
+ const grad = Math.max(0, (1.0 + py) / 2.0);
407
+ let bgR = Math.floor(skyColors.rTop * (1 - grad) + skyColors.rBot * grad);
408
+ let bgG = Math.floor(skyColors.gTop * (1 - grad) + skyColors.gBot * grad);
409
+ let bgB = Math.floor(skyColors.bTop * (1 - grad) + skyColors.bBot * grad);
410
+ if (skyColors.isNight) {
411
+ const star = Math.sin(px * 80) * Math.cos(py * 80);
412
+ if (star > 0.99) { bgR = 255; bgG = 255; bgB = 255; }
413
+ else if (star > 0.97) { bgR += 50; bgG += 50; bgB += 50; }
414
+ }
415
+ return [bgR, bgG, bgB];
416
+ }
417
+
418
+ function buildObjects(): RenderObj[] {
419
+ breathe = Math.sin(time * (isSleeping ? 1.5 : 3)) * 0.015;
420
+ let earWave = Math.sin(time * 4) * 0.08;
421
+ if (currentState === "excited" || currentState === "fetching" || currentState === "singing") earWave = Math.sin(time * 15) * 0.2;
422
+ if (isWalking) earWave += Math.sin(time * 10) * 0.1;
423
+ const pawSwing = (isWalking || currentState === "chasing" || currentState === "fetching" || currentState === "peek") ? Math.sin(time * 12) * 0.08 : 0;
424
+ const antRot = Math.sin(time * 2.5) * 0.15 + (isWalking || currentState === "fetching" ? Math.sin(time * 12) * 0.3 : 0);
425
+
426
+ const objects: RenderObj[] = [];
427
+ if (isSleeping) objects.push({ id: "pillow", mat: 10, x: 0, y: 0.65, rx: 0.6, ry: 0.15, z: posZ - 0.1 });
428
+
429
+ objects.push(
430
+ { id: "antenna_stalk", mat: 7, x: posX + Math.sin(antRot) * 0.08, y: posY + bounceY + breathe - 0.35, rx: 0.012, ry: 0.08, rot: antRot, z: 0.05 },
431
+ { id: "antenna_bulb", mat: 8, x: posX + Math.sin(antRot) * 0.16, y: posY + bounceY + breathe - 0.42, r: 0.035, z: 0.08 },
432
+ { id: "body", mat: 1, x: posX, y: posY + bounceY + breathe, r: 0.32, z: 0 },
433
+ { id: "earL", mat: 1, x: posX - 0.28, y: posY + bounceY + breathe - 0.05, rx: 0.08, ry: 0.22, rot: 0.5 + earWave, z: 0.1 },
434
+ { id: "earR", mat: 1, x: posX + 0.28, y: posY + bounceY + breathe - 0.05, rx: 0.08, ry: 0.22, rot: -0.5 - earWave, z: 0.1 },
435
+ { id: "pawL", mat: 2, x: posX - 0.14, y: posY + bounceY + breathe + 0.22, r: 0.05, z: 0.2 + pawSwing },
436
+ { id: "pawR", mat: 2, x: posX + 0.14, y: posY + bounceY + breathe + 0.22, r: 0.05, z: 0.2 - pawSwing },
437
+ { id: "tail", mat: 3, x: posX + Math.cos(time * 2) * 0.35, y: posY + bounceY + breathe - 0.05, r: 0.06, z: Math.sin(time * 2) * 0.4 },
438
+ { id: "firefly", mat: 5, x: ffX, y: ffY, r: 0.015, z: ffZ },
439
+ );
440
+
441
+ if (isSleeping) {
442
+ const eL = objects.find(o => o.id === "earL")!;
443
+ const eR = objects.find(o => o.id === "earR")!;
444
+ eL.rot = 1.3; eL.y += 0.08; eL.x -= 0.08;
445
+ eR.rot = -1.3; eR.y += 0.08; eR.x += 0.08;
446
+ const pL = objects.find(o => o.id === "pawL")!;
447
+ const pR = objects.find(o => o.id === "pawR")!;
448
+ pL.y += 0.05; pL.x -= 0.1; pR.y += 0.05; pR.x += 0.1;
449
+ }
450
+
451
+ if (ballY !== -10) {
452
+ if (hasBall) objects.push({ id: "ball", mat: 9, x: posX + lookX * 0.05, y: posY + bounceY + 0.05, r: 0.035, z: posZ + 0.15 });
453
+ else objects.push({ id: "ball", mat: 9, x: ballX, y: ballY, r: 0.035, z: 0.15 });
454
+ }
455
+
456
+ for (const f of foods) objects.push({ id: "food", mat: 6, x: f.x, y: f.y, r: 0.03, z: 0.1 });
457
+
458
+ objects.sort((a, b) => a.z - b.z);
459
+ for (const obj of objects) {
460
+ if (obj.rot !== undefined) { obj.s = Math.sin(obj.rot); obj.c = Math.cos(obj.rot); }
461
+ }
462
+ return objects;
463
+ }
464
+
465
+ function getScreenEdgeX(): number {
466
+ const effectDim = Math.max(40, Math.min(W, H * 2.8));
467
+ const scale = 2.0 / effectDim;
468
+ return (W / 2.0) * scale;
469
+ }
470
+
471
+ function updatePhysics(dt: number) {
472
+ if (actionTimer > 0) actionTimer -= dt;
473
+ if (speechTimer > 0) speechTimer -= dt;
474
+
475
+ // Needs decay
476
+ const now = Date.now();
477
+ if (now - lastNeedsTick > 1000) {
478
+ lastNeedsTick = now;
479
+ if (!isSleeping) { energy = Math.max(0, energy - 0.5); hunger = Math.max(0, hunger - 0.8); }
480
+ else { energy = Math.min(100, energy + 5.0); hunger = Math.max(0, hunger - 0.2); }
481
+ }
482
+
483
+ // Firefly
484
+ ffX = posX + Math.sin(time * 1.2) * 0.7;
485
+ ffY = Math.sin(time * 2.0) * 0.3 + 0.1;
486
+ ffZ = posZ + Math.sin(time * 0.9) * 0.4;
487
+
488
+ // Weather particles
489
+ const isRaining = new Date().getMinutes() % 10 < 3 && !getWeatherAndTime().isNight;
490
+ if (isRaining && Math.random() < 0.3) {
491
+ const effectDim = Math.max(40, Math.min(W, H * 2.8));
492
+ const scale = 2.0 / effectDim;
493
+ particles.push({
494
+ x: (Math.random() - 0.5) * W * scale, y: -H * scale,
495
+ vx: 0.1, vy: 2.0 + Math.random(), char: "|", r: 150, g: 200, b: 255, life: 1.0, type: "rain",
496
+ });
497
+ }
498
+
499
+ // Ball physics
500
+ if (ballY !== -10 && !hasBall) {
501
+ ballVy += dt * 5.0;
502
+ ballX += ballVx * dt; ballY += ballVy * dt;
503
+ if (ballY > 0.55) { ballY = 0.55; ballVy *= -0.7; ballVx *= 0.8; if (Math.abs(ballVy) < 0.2) ballVy = 0; if (Math.abs(ballVx) < 0.1) ballVx = 0; }
504
+ if (ballX < -getScreenEdgeX() + 0.1) { ballX = -getScreenEdgeX() + 0.1; ballVx *= -0.8; }
505
+ if (ballX > getScreenEdgeX() - 0.1) { ballX = getScreenEdgeX() - 0.1; ballVx *= -0.8; }
506
+ }
507
+
508
+ // State machine
509
+ if (currentState === "idle") {
510
+ if (Math.random() < 0.01) blinkFade = 1.0;
511
+ else blinkFade = Math.max(0, blinkFade - dt * 6.0);
512
+ bounceY += (0 - bounceY) * dt * 5.0;
513
+ lookX += (0 - lookX) * dt * 3.0;
514
+ if (ballY !== -10 && !hasBall) { currentState = "fetching"; say("Ball!! 🎾", 2.0); }
515
+ else if (Math.random() < 0.005) {
516
+ if (Math.random() < 0.15) targetX = (Math.random() > 0.5 ? 1 : -1) * (getScreenEdgeX() + 1.5); // occasional offscreen walk
517
+ else targetX = (Math.random() - 0.5) * (getScreenEdgeX() * 0.6);
518
+ currentState = "walk"; isWalking = true;
519
+ }
520
+ else if (Math.random() < 0.003) { currentState = "flip"; isFlipping = true; flipPhase = 0; say("Wheee! 💫"); }
521
+ else if (Math.random() < 0.002) { currentState = "chasing"; actionTimer = 3.0; }
522
+ else if (Math.random() < 0.001 && speechTimer <= 0) {
523
+ say(idleSpeech[Math.floor(Math.random() * idleSpeech.length)], 3.0);
524
+ }
525
+ }
526
+ if (currentState === "walk") {
527
+ const dir = Math.sign(targetX - posX);
528
+ posX += dir * dt * 0.6;
529
+ bounceY = -Math.abs(Math.sin(time * 10)) * 0.08;
530
+ lookX = dir * 0.5;
531
+ if (Math.abs(posX - targetX) < 0.05) {
532
+ isWalking = false; posX = targetX; bounceY = 0; lookX = 0;
533
+ if (Math.abs(posX) >= getScreenEdgeX() + 0.5) { currentState = "offscreen"; actionTimer = 2.0 + Math.random() * 3.0; }
534
+ else currentState = "idle";
535
+ }
536
+ }
537
+ if (currentState === "offscreen") {
538
+ if (actionTimer <= 0) { currentState = "peek"; actionTimer = 4.0; targetX = Math.sign(posX) * (getScreenEdgeX() + 0.35); isWalking = true; }
539
+ }
540
+ if (currentState === "peek") {
541
+ const dir = Math.sign(targetX - posX);
542
+ if (Math.abs(posX - targetX) > 0.05) {
543
+ posX += dir * dt * 0.4; bounceY = -Math.abs(Math.sin(time * 6)) * 0.05;
544
+ lookX = -Math.sign(posX) * 0.8;
545
+ } else {
546
+ isWalking = false; posX = targetX; bounceY = 0;
547
+ lookX = -Math.sign(posX) * 0.6 + Math.sin(time * 2) * 0.2;
548
+ if (actionTimer < 3.0 && speechTimer <= 0 && Math.random() < 0.05) say("Peekaboo! 👀", 2.0);
549
+ if (actionTimer <= 0) { currentState = "walk"; targetX = 0; isWalking = true; }
550
+ }
551
+ }
552
+ if (currentState === "chasing") {
553
+ const dir = Math.sign(ffX - posX);
554
+ posX += dir * dt * 0.8;
555
+ bounceY = -Math.abs(Math.sin(time * 12)) * 0.1;
556
+ lookX = dir * 0.6; isWalking = true;
557
+ if (actionTimer <= 0) { currentState = "idle"; isWalking = false; bounceY = 0; }
558
+ }
559
+ if (currentState === "flip") {
560
+ flipPhase += dt * Math.PI * 2.0;
561
+ bounceY = -Math.sin(flipPhase) * 0.6;
562
+ if (flipPhase >= Math.PI * 2) { isFlipping = false; bounceY = 0; currentState = "idle"; }
563
+ }
564
+ if (currentState === "sleep") {
565
+ blinkFade = 1.0;
566
+ const dir = Math.sign(0 - posX);
567
+ if (Math.abs(posX) > 0.05) { posX += dir * dt * 1.5; bounceY = -Math.abs(Math.sin(time * 12)) * 0.1; }
568
+ else { posX = 0; bounceY += (0.4 - bounceY) * dt * 5.0; }
569
+ if (Math.random() < 0.02) {
570
+ particles.push({ x: posX + 0.2, y: posY + bounceY, vx: 0.15, vy: -0.2, char: "z", r: 150, g: 200, b: 255, life: 1.2, type: "z" });
571
+ }
572
+ if (actionTimer <= 0) { currentState = "idle"; isSleeping = false; say("What a nice nap! ✨"); }
573
+ }
574
+ if (currentState === "excited") {
575
+ blinkFade = 1.0;
576
+ bounceY = -Math.abs(Math.sin(time * 12) * 0.15);
577
+ if (Math.random() < 0.15) {
578
+ particles.push({ x: posX + (Math.random() - 0.5) * 0.6, y: posY + bounceY + (Math.random() - 0.5) * 0.4, vx: (Math.random() - 0.5) * 0.4, vy: -0.4 - Math.random() * 0.4, char: "*", r: 255, g: 255, b: 150, life: 1.0, type: "sparkle" });
579
+ }
580
+ if (actionTimer <= 0) currentState = "idle";
581
+ }
582
+ if (currentState === "singing") {
583
+ blinkFade = 1.0;
584
+ bounceY = -Math.abs(Math.sin(time * 8) * 0.1);
585
+ lookX = Math.sin(time * 4) * 0.3;
586
+ if (Math.random() < 0.08) {
587
+ particles.push({ x: posX + (Math.random() - 0.5) * 0.6, y: posY + bounceY - 0.4, vx: (Math.random() - 0.5) * 0.4, vy: -0.6 - Math.random() * 0.4, char: "~", r: 255, g: 150, b: 200, life: 1.5, type: "note" });
588
+ }
589
+ if (actionTimer <= 0) currentState = "idle";
590
+ }
591
+ if (currentState === "dance") {
592
+ bounceY = -Math.abs(Math.sin(time * 16)) * 0.12;
593
+ lookX = Math.sin(time * 6) * 0.4;
594
+ posX += Math.sin(time * 8) * dt * 0.3;
595
+ if (Math.random() < 0.12) {
596
+ particles.push({ x: posX + (Math.random() - 0.5) * 0.5, y: posY + bounceY - 0.3, vx: (Math.random() - 0.5) * 0.6, vy: -0.5 - Math.random() * 0.3, char: "*", r: 255, g: 200, b: 100, life: 1.2, type: "sparkle" });
597
+ }
598
+ if (actionTimer <= 0) currentState = "idle";
599
+ }
600
+ if (currentState === "fetching") {
601
+ if (!hasBall) {
602
+ const dir = Math.sign(ballX - posX);
603
+ posX += dir * dt * 1.5;
604
+ bounceY = -Math.abs(Math.sin(time * 18)) * 0.15;
605
+ lookX = dir * 0.5;
606
+ if (Math.abs(posX - ballX) < 0.15 && Math.abs(posY + bounceY - ballY) < 0.3) { hasBall = true; say("Got it! 🎾"); }
607
+ } else {
608
+ const dir = Math.sign(0 - posX);
609
+ posX += dir * dt * 0.8;
610
+ bounceY = -Math.abs(Math.sin(time * 15)) * 0.1;
611
+ lookX = dir * 0.5;
612
+ if (Math.abs(posX) < 0.08) {
613
+ hasBall = false; ballX = posX + 0.15; ballY = 0.5; ballVx = 0.8; ballVy = -1.5;
614
+ currentState = "excited"; actionTimer = 2.0; say("Here you go! ✨");
615
+ }
616
+ }
617
+ }
618
+
619
+ // Food physics & eating
620
+ for (let i = foods.length - 1; i >= 0; i--) {
621
+ const f = foods[i];
622
+ f.vy += dt * 2.0; f.y += f.vy * dt;
623
+ if (f.y >= 0.5) { f.y = 0.5; f.vy = 0; }
624
+ if (Math.sqrt((f.x - posX) ** 2 + (f.y - (posY + bounceY)) ** 2) < 0.25 && !isSleeping) {
625
+ currentState = "excited"; actionTimer = 2.0;
626
+ for (let k = 0; k < 5; k++) {
627
+ particles.push({ x: f.x, y: f.y, vx: (Math.random() - 0.5) * 0.4, vy: -0.2 - Math.random() * 0.3, char: "*", r: 255, g: 255, b: 200, life: 1.0, type: "crumb" });
628
+ }
629
+ hunger = Math.min(100, hunger + 20);
630
+ say("Yum! 🍪", 2.0);
631
+ foods.splice(i, 1);
632
+ }
633
+ }
634
+
635
+ // Particles
636
+ for (let i = particles.length - 1; i >= 0; i--) {
637
+ const p = particles[i];
638
+ p.x += p.vx * dt; p.y += p.vy * dt;
639
+ if (p.type === "z") p.x += Math.sin(p.y * 4.0) * 0.005;
640
+ if (p.type === "note") p.x += Math.sin(p.y * 6.0) * 0.01;
641
+ if (p.type === "rain" && p.y > 0.6) { p.type = "splash"; p.char = "."; p.vy = -0.5; p.vx = (Math.random() - 0.5) * 0.5; p.life = 0.2; }
642
+ p.life -= dt * 0.8;
643
+ if (p.life <= 0) particles.splice(i, 1);
644
+ }
645
+ }
646
+
647
+ function renderToBuffers() {
648
+ const effectDim = Math.max(40, Math.min(W, H * 2.8));
649
+ const scale = 2.0 / effectDim;
650
+ const objects = buildObjects();
651
+ const skyColors = getWeatherAndTime();
652
+
653
+ // 4×4 supersampling for maximum anti-aliasing on half-block fallback
654
+ const step = scale * 0.25;
655
+ const offsets = [-0.375, -0.125, 0.125, 0.375]; // 4 sub-pixel positions
656
+
657
+ for (let cy = 0; cy < H; cy++) {
658
+ for (let cx = 0; cx < W; cx++) {
659
+ const px = (cx - W / 2.0) * scale;
660
+ const py1 = (cy * 2.0 - H) * scale + VIEW_OFFSET_Y;
661
+ const py2 = (cy * 2.0 + 1.0 - H) * scale + VIEW_OFFSET_Y;
662
+
663
+ // Top half: 4×4 supersample (16 samples)
664
+ let tr = 0, tg = 0, tb = 0;
665
+ for (const oy of offsets) for (const ox of offsets) {
666
+ const c = getPixel(px + ox * step, py1 + oy * step, objects, skyColors);
667
+ tr += c[0]; tg += c[1]; tb += c[2];
668
+ }
669
+
670
+ // Bottom half: 4×4 supersample (16 samples)
671
+ let br = 0, bg = 0, bb = 0;
672
+ for (const oy of offsets) for (const ox of offsets) {
673
+ const c = getPixel(px + ox * step, py2 + oy * step, objects, skyColors);
674
+ br += c[0]; bg += c[1]; bb += c[2];
675
+ }
676
+
677
+ screenChars[cy][cx] = "▀";
678
+ screenColors[cy][cx] = `\x1b[38;2;${tr >> 4};${tg >> 4};${tb >> 4}m\x1b[48;2;${br >> 4};${bg >> 4};${bb >> 4}m`;
679
+ }
680
+ }
681
+
682
+ // Overlay particles
683
+ for (const p of particles) {
684
+ const [scX, scY] = project2D(p.x, p.y);
685
+ if (scX >= 0 && scX < W && scY >= 0 && scY < H * 2) {
686
+ const realCy = Math.floor(scY / 2);
687
+ if (realCy >= 0 && realCy < H) {
688
+ screenChars[realCy][scX] = p.char;
689
+ const bgMatch = screenColors[realCy][scX].match(/\x1b\[48;2;\d+;\d+;\d+m/);
690
+ const bg = bgMatch ? bgMatch[0] : "\x1b[49m";
691
+ screenColors[realCy][scX] = `\x1b[38;2;${p.r};${p.g};${p.b}m${bg}`;
692
+ }
693
+ }
694
+ }
695
+
696
+ // Speech bubble
697
+ if (speechTimer > 0 && speechText !== "") {
698
+ const [scX, scY] = project2D(posX, posY + bounceY - 0.6);
699
+ drawSpeechBubble(speechText, scX, Math.floor(scY / 2));
700
+ }
701
+ }
702
+
703
+ // ─── Public API ──────────────────────────────────────────────────────────────
704
+
705
+ /**
706
+ * Render the Pompom companion to an array of ANSI-colored string lines.
707
+ *
708
+ * @param width - Available widget width in characters
709
+ * @param audioLevel - 0.0 to 1.0, drives mouth animation during recording
710
+ * @param dt - Time delta in seconds since last frame
711
+ * @returns string[] of H lines, each with ANSI color codes
712
+ */
713
+ export function renderPompom(width: number, audioLevel: number, dt: number): string[] {
714
+ // Adapt dimensions — balanced: compact but sharp
715
+ if (width !== W && width > 10) {
716
+ W = width;
717
+ H = Math.max(14, Math.min(18, Math.floor(W * 0.24)));
718
+ allocBuffers();
719
+ }
720
+
721
+ // Update talking state from audio
722
+ talkAudioLevel = audioLevel;
723
+
724
+ // Sub-step physics for stability
725
+ time += dt;
726
+ let remaining = dt;
727
+ while (remaining > 0) {
728
+ const step = Math.min(remaining, PHYSICS_DT);
729
+ remaining -= step;
730
+ updatePhysics(step);
731
+ }
732
+
733
+ renderToBuffers();
734
+
735
+ const lines: string[] = [];
736
+ for (let cy = 0; cy < H; cy++) {
737
+ let line = "";
738
+ let lastColor = "";
739
+ for (let cx = 0; cx < W; cx++) {
740
+ if (screenColors[cy][cx] !== lastColor) {
741
+ line += screenColors[cy][cx];
742
+ lastColor = screenColors[cy][cx];
743
+ }
744
+ line += screenChars[cy][cx];
745
+ }
746
+ line += "\x1b[0m";
747
+ lines.push(line);
748
+ }
749
+
750
+ // ── Compact single-line status ──
751
+ const dim = "\x1b[38;5;239m";
752
+ const keyC = "\x1b[38;5;252m";
753
+ const lblC = "\x1b[38;5;244m";
754
+ const accC = "\x1b[38;5;153m";
755
+ const mod = process.platform === "darwin" ? "⌥" : "Alt+";
756
+
757
+ // State message — descriptive so users know what's happening & what to do
758
+ let stateMsg = "";
759
+ if (hunger < 30) stateMsg = `Pompom is starving! Drop a treat with ${mod}f`;
760
+ else if (energy < 20 && !isSleeping) stateMsg = `Pompom looks exhausted... put them to sleep with ${mod}s`;
761
+ else if (currentState === "excited") stateMsg = "Pompom is bouncing with joy!";
762
+ else if (isSleeping) stateMsg = "Shhh... Pompom is napping on a cozy pillow";
763
+ else if (currentState === "walk") stateMsg = "Pompom is out for a little stroll";
764
+ else if (currentState === "chasing") stateMsg = "Pompom spotted a firefly and is chasing it!";
765
+ else if (currentState === "fetching") stateMsg = hasBall ? "Pompom grabbed the ball! Bringing it back" : "Pompom dashes after the ball!";
766
+ else if (currentState === "singing") stateMsg = "Pompom is humming a little melody";
767
+ else if (currentState === "dance") stateMsg = "Pompom is busting out some moves!";
768
+ else if (currentState === "peek") stateMsg = "Pompom is peeking back in... hi!";
769
+ else if (currentState === "offscreen") stateMsg = "Pompom wandered off... they'll be back";
770
+ else if (isTalking) stateMsg = "Pompom is listening to you speak";
771
+ else stateMsg = "Pompom is vibing. Pet, feed, or play!";
772
+
773
+ // Build status: "─ ⌥ w·Wake p·Pet ... │ State ───" exactly W visible chars
774
+ const shortcuts: [string, string][] = [
775
+ ["w","Wake"],["p","Pet"],["f","Feed"],["b","Ball"],
776
+ ["m","Music"],["c","Color"],["s","Sleep"],["d","Flip"],
777
+ ];
778
+
779
+ // Build plain text to measure, styled text to render
780
+ let plainHints = "";
781
+ let styledHints = "";
782
+ const stateW = getStringWidth(stateMsg);
783
+ // Fixed parts: "─ " + mod + " " + hints + "│ " + state + " " + pad
784
+ const fixedW = 2 + getStringWidth(mod) + 1 + 2 + stateW + 1; // "─ ⌥ " ... "│ state ─"
785
+ for (const [k, l] of shortcuts) {
786
+ const part = `${k}·${l} `;
787
+ if (getStringWidth(plainHints + part) + fixedW + 1 > W) break;
788
+ plainHints += part;
789
+ styledHints += `${keyC}${k}${lblC}·${l} `;
790
+ }
791
+
792
+ const usedW = 2 + getStringWidth(mod) + 1 + getStringWidth(plainHints) + 2 + stateW + 1;
793
+ const padR = Math.max(0, W - usedW);
794
+ lines.push(`${dim}─ ${lblC}${mod} ${styledHints}${dim}│ ${accC}${stateMsg} ${dim}${"─".repeat(padR)}\x1b[0m`);
795
+
796
+ return lines;
797
+ }
798
+
799
+ /** Set talking state (driven by voice recording) */
800
+ export function pompomSetTalking(active: boolean) {
801
+ isTalking = active;
802
+ if (active && currentState !== "excited" && currentState !== "singing") {
803
+ speechTimer = 0; // Clear auto-speech when user is talking
804
+ }
805
+ }
806
+
807
+ /** Handle a user keypress command */
808
+ export function pompomKeypress(key: string) {
809
+ if (key === "p") { currentState = "excited"; actionTimer = 2.5; isSleeping = false; say("Purrrrr... ♥"); }
810
+ else if (key === "w") { currentState = "idle"; isSleeping = false; blinkFade = 0; say("I'm awake! 👀"); }
811
+ else if (key === "s") { currentState = "sleep"; isSleeping = true; actionTimer = 10; say("Time for a nap... zZz"); }
812
+ else if (key === "f") {
813
+ isSleeping = false; currentState = "idle";
814
+ foods.push({ x: posX + (Math.random() - 0.5) * 0.4, y: -0.8, vy: 0 });
815
+ }
816
+ else if (key === "b") {
817
+ isSleeping = false;
818
+ if (ballY === 0.55 && !hasBall && Math.abs(posX - ballX) < 0.4) { ballVy = -1.8; ballVx = (Math.random() - 0.5) * 2.5; say("Boing! 🎾", 2.0); }
819
+ else { ballX = posX + (Math.random() > 0.5 ? 0.8 : -0.8); ballY = -0.4; ballVx = (Math.random() - 0.5) * 1.5; ballVy = -1.2; hasBall = false; }
820
+ }
821
+ else if (key === "m") { isSleeping = false; currentState = "singing"; actionTimer = 5.0; say("La la la~ 🎵"); }
822
+ else if (key === "c") { activeTheme = (activeTheme + 1) % themes.length; }
823
+ else if (key === "d") { currentState = "flip"; isFlipping = true; flipPhase = 0; isSleeping = false; }
824
+ else if (key === "o") { isSleeping = false; currentState = "walk"; targetX = (Math.random() > 0.5 ? 1 : -1) * (getScreenEdgeX() + 1.5); isWalking = true; }
825
+ else if (key === "x") { isSleeping = false; currentState = "dance"; actionTimer = 4.0; say("Let's dance! 💃"); }
826
+ else if (key === "t") {
827
+ isSleeping = false; currentState = "excited"; actionTimer = 2.5;
828
+ foods.push({ x: posX + (Math.random() - 0.5) * 0.3, y: -0.8, vy: 0 });
829
+ hunger = Math.min(100, hunger + 30);
830
+ say("A special treat! 🍰", 2.0);
831
+ }
832
+ else if (key === "h") { isSleeping = false; currentState = "excited"; actionTimer = 3.0; energy = Math.min(100, energy + 10); say("Aww, hugs! 💕"); }
833
+ }
834
+
835
+ /** Reset companion state */
836
+ export function resetPompom() {
837
+ time = 0; currentState = "idle"; blinkFade = 0; actionTimer = 0;
838
+ speechTimer = 0; speechText = "";
839
+ posX = 0; posY = 0.15; posZ = 0; bounceY = 0; lookX = 0; lookY = 0;
840
+ isWalking = false; isFlipping = false; isSleeping = false; isTalking = false;
841
+ talkAudioLevel = 0; flipPhase = 0;
842
+ hunger = 100; energy = 100; lastNeedsTick = 0;
843
+ activeTheme = 0;
844
+ ballX = -10; ballY = -10; ballVx = 0; ballVy = 0; ballVz = 0; hasBall = false;
845
+ ffX = 0; ffY = 0; ffZ = 0;
846
+ targetX = 0;
847
+ foods.length = 0; particles.length = 0;
848
+ }
849
+
850
+ /** Get current companion stats */
851
+ export function pompomStatus(): { hunger: number; energy: number; mood: string; theme: string } {
852
+ let mood = "content";
853
+ if (currentState === "excited" || currentState === "dance") mood = "happy";
854
+ else if (isSleeping) mood = "sleeping";
855
+ else if (hunger < 30) mood = "hungry";
856
+ else if (energy < 20) mood = "tired";
857
+ else if (currentState === "singing") mood = "musical";
858
+ else if (currentState === "chasing") mood = "playful";
859
+ else if (currentState === "fetching") mood = "playful";
860
+ return { hunger: Math.round(hunger), energy: Math.round(energy), mood, theme: themes[activeTheme].name };
861
+ }
862
+
863
+ /** Current widget height in character rows (scene + 1 status line).
864
+ * Returns a live value since H can change when renderPompom resizes. */
865
+ export function pompomHeight(): number { return H + 1; }
866
+ /** @deprecated Use pompomHeight() — this constant is stale after resize. */
867
+ export const POMPOM_HEIGHT = H + 1;
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@codexstar/pi-pompom",
3
+ "version": "1.0.0",
4
+ "description": "Pompom — a 3D raymarched virtual pet companion for Pi CLI",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-extension",
10
+ "pompom",
11
+ "companion",
12
+ "virtual-pet",
13
+ "terminal-pet"
14
+ ],
15
+ "license": "MIT",
16
+ "scripts": {
17
+ "typecheck": "bunx tsc -p tsconfig.json",
18
+ "check": "bun run typecheck"
19
+ },
20
+ "peerDependencies": {
21
+ "@mariozechner/pi-coding-agent": "*",
22
+ "@mariozechner/pi-tui": "*"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "pi": {
28
+ "extensions": [
29
+ "./extensions/pompom-extension.ts"
30
+ ]
31
+ },
32
+ "files": [
33
+ "extensions",
34
+ "README.md",
35
+ "package.json"
36
+ ]
37
+ }