@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 +58 -0
- package/extensions/pompom-extension.ts +250 -0
- package/extensions/pompom.ts +867 -0
- package/package.json +37 -0
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
|
+
}
|