drexler 0.2.14 → 0.2.15
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 +7 -1
- package/package.json +1 -1
- package/src/commands.ts +127 -20
- package/src/config.ts +141 -32
- package/src/conversation.ts +0 -4
- package/src/index.ts +68 -5
- package/src/pet/petState.ts +408 -0
- package/src/repl.ts +1 -1
- package/src/ui/App.tsx +543 -144
- package/src/ui/CommandPalette.tsx +2 -0
- package/src/ui/DealDeskHeader.tsx +0 -5
- package/src/ui/DeathScreen.tsx +110 -0
- package/src/ui/MarkdownBody.tsx +29 -5
- package/src/ui/MascotIntro.tsx +158 -57
- package/src/ui/Message.tsx +2 -105
- package/src/ui/PetPanel.tsx +537 -0
- package/src/ui/TranscriptViewport.tsx +206 -48
- package/src/ui/displayContent.ts +5 -2
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { memo, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import { getPetMood, type PetActivity, type PetStats } from "../pet/petState.ts";
|
|
4
|
+
import { displayWidth, fitDisplayText } from "./graphemes.ts";
|
|
5
|
+
import { useTheme } from "./ThemeContext.tsx";
|
|
6
|
+
import { type Theme } from "./themes.ts";
|
|
7
|
+
|
|
8
|
+
export const PET_PANEL_WIDTH = 36;
|
|
9
|
+
export const PET_PANEL_ROWS = 22;
|
|
10
|
+
export type Environment = "office" | "home" | "outdoors";
|
|
11
|
+
|
|
12
|
+
const PANEL_BORDER_COLUMNS = 2;
|
|
13
|
+
const PANEL_PADDING_COLUMNS = 2;
|
|
14
|
+
const CONTENT = PET_PANEL_WIDTH - PANEL_BORDER_COLUMNS - PANEL_PADDING_COLUMNS;
|
|
15
|
+
const SPRITE_W = 8;
|
|
16
|
+
|
|
17
|
+
const R_SKY = 0;
|
|
18
|
+
const R_BGA = 1;
|
|
19
|
+
const R_BGB = 2;
|
|
20
|
+
const R_DECO = 3;
|
|
21
|
+
const R_SP0 = 4; // sprite occupies rows 4–9
|
|
22
|
+
const R_FLOOR = 10;
|
|
23
|
+
const SCENE_ROWS = 11;
|
|
24
|
+
|
|
25
|
+
// Fixed sprite X — no left/right walking
|
|
26
|
+
const SPRITE_X: Record<PetActivity, number> = {
|
|
27
|
+
idle: 12, eating: 3, playing: 12, working: 18,
|
|
28
|
+
sleeping: 12, praised: 12, vibing: 12,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
32
|
+
// All scene glyphs (box-drawing + ASCII) are BMP single-units, so string
|
|
33
|
+
// splicing is safe and avoids the per-call char-array allocation `[...base]`
|
|
34
|
+
// would do on the hot frame loop.
|
|
35
|
+
function place(base: string, text: string, x: number): string {
|
|
36
|
+
if (x < 0 || x >= base.length) return base;
|
|
37
|
+
const end = Math.min(base.length, x + text.length);
|
|
38
|
+
const fit = text.slice(0, end - x);
|
|
39
|
+
return base.slice(0, x) + fit + base.slice(end);
|
|
40
|
+
}
|
|
41
|
+
function pad(s: string, n: number): string {
|
|
42
|
+
const fitted = fitDisplayText(s, n);
|
|
43
|
+
return fitted + " ".repeat(Math.max(0, n - displayWidth(fitted)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Hoisted constant: floor pattern for home doesn't depend on frame.
|
|
47
|
+
const HOME_CARPET = Array.from({ length: CONTENT }, (_, i) =>
|
|
48
|
+
i % 4 === 0 ? "░" : i % 4 === 2 ? "▒" : "─",
|
|
49
|
+
).join("");
|
|
50
|
+
|
|
51
|
+
// ─── sprite (8 wide × 6 tall) ────────────────────────────────────────────────
|
|
52
|
+
function buildSprite(activity: PetActivity, frame: number): string[] {
|
|
53
|
+
const smash = activity === "working" && frame % 32 >= 26;
|
|
54
|
+
|
|
55
|
+
let eyes: string;
|
|
56
|
+
if (smash) eyes = "║ X X ║";
|
|
57
|
+
else switch (activity) {
|
|
58
|
+
case "sleeping": eyes = "║ - - ║"; break;
|
|
59
|
+
case "working": eyes = "║ / \\ ║"; break;
|
|
60
|
+
case "playing": eyes = frame % 6 < 3 ? "║ * * ║" : "║ ◆ ◆ ║"; break;
|
|
61
|
+
case "vibing": eyes = frame % 4 < 2 ? "║ ~ ~ ║" : "║ ◆ ◆ ║"; break;
|
|
62
|
+
case "eating": eyes = frame % 4 < 2 ? "║ o o ║" : "║ ◆ ◆ ║"; break;
|
|
63
|
+
case "praised": eyes = "║ ◆ ◆ ║"; break;
|
|
64
|
+
default: eyes = frame % 22 === 0 ? "║ - - ║" : "║ ◆ ◆ ║"; break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Flex top during playing peak
|
|
68
|
+
const top = (activity === "playing" && frame % 6 >= 3) ? "╠═╩══╩═╣" : "╔═╩══╩═╗";
|
|
69
|
+
|
|
70
|
+
let lock: string;
|
|
71
|
+
if (smash) lock = "║ ║**║ ║";
|
|
72
|
+
else switch (activity) {
|
|
73
|
+
case "eating": lock = frame % 2 === 0 ? "║ ║>>║ ║" : "║ ║<<║ ║"; break;
|
|
74
|
+
case "sleeping": lock = "║ ║"; break;
|
|
75
|
+
case "praised": lock = frame % 3 === 0 ? "║ ║**║ ║" : "║ ║$$║ ║"; break;
|
|
76
|
+
case "vibing": lock = frame % 4 < 2 ? "║ ║~~║ ║" : "║ ║$$║ ║"; break;
|
|
77
|
+
case "playing": lock = frame % 4 < 2 ? "║ ║!!║ ║" : "║ ║$$║ ║"; break;
|
|
78
|
+
default: lock = "║ ║$$║ ║"; break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
" ╔══╗ ",
|
|
83
|
+
top,
|
|
84
|
+
eyes,
|
|
85
|
+
activity === "sleeping" ? "║ ║" : "║ ╔══╗ ║",
|
|
86
|
+
lock,
|
|
87
|
+
"╚══════╝",
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── environments (simplified) ───────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function drawOffice(rows: string[], frame: number, stats: PetStats): void {
|
|
94
|
+
const hour = new Date().getHours();
|
|
95
|
+
const isDay = hour >= 6 && hour < 20;
|
|
96
|
+
|
|
97
|
+
rows[R_SKY] = "─".repeat(CONTENT);
|
|
98
|
+
|
|
99
|
+
// Window left (6 wide) + deals board right (12 wide at x=20)
|
|
100
|
+
const sky = isDay
|
|
101
|
+
? (frame % 10 < 5 ? "─^──" : "──^─")
|
|
102
|
+
: (frame % 10 < 5 ? "─*──" : "──*─");
|
|
103
|
+
rows[R_BGA] = place(rows[R_BGA], `╭${sky}╮`, 0);
|
|
104
|
+
rows[R_BGB] = place(rows[R_BGB], "╰────╯", 0);
|
|
105
|
+
|
|
106
|
+
const dl = String(Math.round(stats.deals)).padStart(3);
|
|
107
|
+
rows[R_BGA] = place(rows[R_BGA], "╔═DEALS════╗", 20);
|
|
108
|
+
rows[R_BGB] = place(rows[R_BGB], `║ DL:${dl}% ║`, 20);
|
|
109
|
+
|
|
110
|
+
// Desk (rows 7–9, right: 10 wide at x=22)
|
|
111
|
+
rows[R_SP0 + 3] = place(rows[R_SP0 + 3], "╔════════╗", 22);
|
|
112
|
+
rows[R_SP0 + 4] = place(rows[R_SP0 + 4], "║ ║", 22);
|
|
113
|
+
rows[R_SP0 + 5] = place(rows[R_SP0 + 5], "╚════════╝", 22);
|
|
114
|
+
|
|
115
|
+
// Floor with coffee
|
|
116
|
+
rows[R_FLOOR] = "─".repeat(CONTENT);
|
|
117
|
+
const cup = stats.energy > 60 ? "[c~]" : stats.energy > 30 ? "[c-]" : "[c_]";
|
|
118
|
+
rows[R_FLOOR] = place(rows[R_FLOOR], cup, CONTENT - 5);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function drawHome(rows: string[], frame: number, stats: PetStats): void {
|
|
122
|
+
const hour = new Date().getHours();
|
|
123
|
+
const isDay = hour >= 6 && hour < 20;
|
|
124
|
+
|
|
125
|
+
rows[R_SKY] = "─".repeat(CONTENT);
|
|
126
|
+
|
|
127
|
+
// Window left (8 wide)
|
|
128
|
+
rows[R_BGA] = place(rows[R_BGA], "╭──────╮", 0);
|
|
129
|
+
const yard = isDay
|
|
130
|
+
? (frame % 8 < 4 ? "│ ~~~~ │" : "│~~~~~ │")
|
|
131
|
+
: (frame % 8 < 4 ? "│ ** │" : "│ * * │");
|
|
132
|
+
rows[R_BGB] = place(rows[R_BGB], yard, 0);
|
|
133
|
+
|
|
134
|
+
// TV right (10 wide at x=22)
|
|
135
|
+
const tvContent = ["[~~~]", "[~~ ]", "[ ~~]", "[~~~]"][frame % 4] ?? "[~~~]";
|
|
136
|
+
rows[R_BGA] = place(rows[R_BGA], "╔══TV════╗", 22);
|
|
137
|
+
rows[R_BGB] = place(rows[R_BGB], `║${tvContent} ║`, 22);
|
|
138
|
+
|
|
139
|
+
// Couch (rows 6–8, right: 10 wide at x=22)
|
|
140
|
+
rows[R_SP0 + 2] = place(rows[R_SP0 + 2], "╭────────╮", 22);
|
|
141
|
+
rows[R_SP0 + 3] = place(rows[R_SP0 + 3], "│ ≈≈≈≈≈≈ │", 22);
|
|
142
|
+
rows[R_SP0 + 4] = place(rows[R_SP0 + 4], "╰════════╯", 22);
|
|
143
|
+
|
|
144
|
+
// Carpet floor (precomputed; placement only varies with stats)
|
|
145
|
+
rows[R_FLOOR] = HOME_CARPET;
|
|
146
|
+
const cup = stats.energy > 60 ? "[c~]" : stats.energy > 30 ? "[c-]" : "[c_]";
|
|
147
|
+
rows[R_FLOOR] = place(rows[R_FLOOR], cup, 9);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function drawOutdoors(rows: string[], frame: number, _stats: PetStats): void {
|
|
151
|
+
const hour = new Date().getHours();
|
|
152
|
+
const isDay = hour >= 6 && hour < 20;
|
|
153
|
+
|
|
154
|
+
// Sky: dot texture + sun/moon + drifting cloud
|
|
155
|
+
rows[R_SKY] = ". . . . . . . . . . . . . . . .".slice(0, CONTENT);
|
|
156
|
+
rows[R_SKY] = place(rows[R_SKY], isDay ? "[O]" : "[*]", 1);
|
|
157
|
+
const cloudX = 10 + Math.floor((Math.sin(frame * 0.08) * 0.5 + 0.5) * 10);
|
|
158
|
+
rows[R_SKY] = place(rows[R_SKY], frame % 8 < 4 ? "(~~)" : "( ~)", cloudX);
|
|
159
|
+
|
|
160
|
+
// Single tree (left, rows 1–2)
|
|
161
|
+
rows[R_BGA] = place(rows[R_BGA], " /|\\", 0);
|
|
162
|
+
rows[R_BGB] = place(rows[R_BGB], " |||", 0);
|
|
163
|
+
|
|
164
|
+
// Grass floor (subtle shimmer)
|
|
165
|
+
rows[R_FLOOR] = Array.from({ length: CONTENT }, (_, i) =>
|
|
166
|
+
(i + frame) % 9 === 0 ? "w" : "─"
|
|
167
|
+
).join("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function drawBackground(rows: string[], env: Environment, frame: number, stats: PetStats): void {
|
|
171
|
+
if (env === "office") drawOffice(rows, frame, stats);
|
|
172
|
+
else if (env === "home") drawHome(rows, frame, stats);
|
|
173
|
+
else drawOutdoors(rows, frame, stats);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── fluid activity particles ─────────────────────────────────────────────────
|
|
177
|
+
function drawParticles(rows: string[], activity: PetActivity, frame: number): void {
|
|
178
|
+
const sx = SPRITE_X[activity];
|
|
179
|
+
|
|
180
|
+
switch (activity) {
|
|
181
|
+
case "sleeping": {
|
|
182
|
+
// z chain rising above sprite — each z climbs one row per 2 frames
|
|
183
|
+
const chars: ["z", "z", "Z"] = ["z", "z", "Z"];
|
|
184
|
+
for (let i = 0; i < 3; i++) {
|
|
185
|
+
const age = (frame + i * 4) % 14;
|
|
186
|
+
if (age < 10) {
|
|
187
|
+
const py = R_DECO - Math.floor(age / 3);
|
|
188
|
+
const px = sx + 3 + i;
|
|
189
|
+
if (py >= 0 && px < CONTENT) rows[py] = place(rows[py], chars[i] ?? "z", px);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case "playing": {
|
|
196
|
+
// Arms progressively extend each phase
|
|
197
|
+
const phase = frame % 6;
|
|
198
|
+
if (phase >= 1) {
|
|
199
|
+
rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "\\", Math.max(0, sx - 1));
|
|
200
|
+
rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "/", sx + SPRITE_W);
|
|
201
|
+
}
|
|
202
|
+
if (phase >= 2) {
|
|
203
|
+
rows[R_SP0] = place(rows[R_SP0], "\\", Math.max(0, sx - 2));
|
|
204
|
+
rows[R_SP0] = place(rows[R_SP0], "/", Math.min(CONTENT - 1, sx + SPRITE_W + 1));
|
|
205
|
+
}
|
|
206
|
+
if (phase >= 3) {
|
|
207
|
+
rows[R_SP0] = place(rows[R_SP0], "\\", Math.max(0, sx - 3));
|
|
208
|
+
rows[R_SP0] = place(rows[R_SP0], "/", Math.min(CONTENT - 1, sx + SPRITE_W + 2));
|
|
209
|
+
// Stars burst at peak flex
|
|
210
|
+
rows[R_BGB] = place(rows[R_BGB], "*", Math.max(0, sx - 4));
|
|
211
|
+
rows[R_BGB] = place(rows[R_BGB], "*", Math.min(CONTENT - 1, sx + SPRITE_W + 3));
|
|
212
|
+
rows[R_BGA] = place(rows[R_BGA], "*", Math.max(0, sx - 2));
|
|
213
|
+
rows[R_BGA] = place(rows[R_BGA], "*", Math.min(CONTENT - 1, sx + SPRITE_W + 1));
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case "working": {
|
|
219
|
+
const smash = frame % 32 >= 26;
|
|
220
|
+
if (smash) {
|
|
221
|
+
const sf = frame % 32 - 26;
|
|
222
|
+
if (sf < 2) rows[R_DECO] = place(rows[R_DECO], " !!! SMASH !!! ", 7);
|
|
223
|
+
else if (sf < 4) { rows[R_BGB] = place(rows[R_BGB], " * B O O M * ", 9); }
|
|
224
|
+
else rows[R_BGA] = place(rows[R_BGA], " . . . . ", 10);
|
|
225
|
+
} else {
|
|
226
|
+
// 3 staggered $ streams raining from sky through deco row
|
|
227
|
+
const cols = [7, 14, 25] as const;
|
|
228
|
+
for (let i = 0; i < 3; i++) {
|
|
229
|
+
const colX = cols[i] ?? 7;
|
|
230
|
+
for (const off of [0, 9] as const) {
|
|
231
|
+
const age = (frame + i * 6 + off) % 18;
|
|
232
|
+
const py = Math.floor(age / 2);
|
|
233
|
+
if (py <= R_DECO) rows[py] = place(rows[py], "$", colX);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
case "eating": {
|
|
241
|
+
// Deal memo arcs smoothly from right toward sprite
|
|
242
|
+
const memos: [string, number][] = [["$", 0], ["%", 4]];
|
|
243
|
+
for (const [char, off] of memos) {
|
|
244
|
+
const p = (frame + off) % 8;
|
|
245
|
+
if (p < 6) {
|
|
246
|
+
const endX = sx + SPRITE_W + 1;
|
|
247
|
+
const startX = CONTENT - 3;
|
|
248
|
+
const px = Math.round(startX - (p / 5) * (startX - endX));
|
|
249
|
+
const row = R_SP0 + 2 + (off > 0 ? 1 : 0);
|
|
250
|
+
if (px >= endX && row < R_FLOOR) rows[row] = place(rows[row], char, px);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case "praised": {
|
|
257
|
+
// Expanding star burst radiating outward over 5 frames
|
|
258
|
+
const cx = sx + Math.floor(SPRITE_W / 2);
|
|
259
|
+
const phase = frame % 5;
|
|
260
|
+
if (phase === 0) {
|
|
261
|
+
rows[R_DECO] = place(rows[R_DECO], "*", cx);
|
|
262
|
+
} else if (phase === 1) {
|
|
263
|
+
rows[R_DECO] = place(rows[R_DECO], "*", Math.max(0, cx - 2));
|
|
264
|
+
rows[R_DECO] = place(rows[R_DECO], "*", Math.min(CONTENT - 1, cx + 2));
|
|
265
|
+
rows[R_BGB] = place(rows[R_BGB], "*", cx);
|
|
266
|
+
} else if (phase === 2) {
|
|
267
|
+
rows[R_DECO] = place(rows[R_DECO], "*", Math.max(0, cx - 4));
|
|
268
|
+
rows[R_DECO] = place(rows[R_DECO], "*", Math.min(CONTENT - 1, cx + 4));
|
|
269
|
+
rows[R_BGB] = place(rows[R_BGB], "*", Math.max(0, cx - 2));
|
|
270
|
+
rows[R_BGB] = place(rows[R_BGB], "*", Math.min(CONTENT - 1, cx + 2));
|
|
271
|
+
rows[R_BGA] = place(rows[R_BGA], "*", cx);
|
|
272
|
+
} else if (phase === 3) {
|
|
273
|
+
rows[R_SKY] = place(rows[R_SKY], "*", Math.max(0, cx - 6));
|
|
274
|
+
rows[R_SKY] = place(rows[R_SKY], "*", cx);
|
|
275
|
+
rows[R_SKY] = place(rows[R_SKY], "*", Math.min(CONTENT - 1, cx + 6));
|
|
276
|
+
}
|
|
277
|
+
// phase 4 = clear
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case "vibing": {
|
|
282
|
+
// Concentric ~ rings expanding each frame, reset at 5
|
|
283
|
+
const wave = frame % 5;
|
|
284
|
+
if (wave >= 1) {
|
|
285
|
+
if (sx - 1 >= 0) rows[R_SP0 + 2] = place(rows[R_SP0 + 2], "~", sx - 1);
|
|
286
|
+
rows[R_SP0 + 2] = place(rows[R_SP0 + 2], "~", sx + SPRITE_W);
|
|
287
|
+
}
|
|
288
|
+
if (wave >= 2) {
|
|
289
|
+
if (sx - 2 >= 0) rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "~", sx - 2);
|
|
290
|
+
rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "~", Math.min(CONTENT - 1, sx + SPRITE_W + 1));
|
|
291
|
+
if (sx - 1 >= 0) rows[R_DECO] = place(rows[R_DECO], "~", sx - 1);
|
|
292
|
+
rows[R_DECO] = place(rows[R_DECO], "~", Math.min(CONTENT - 1, sx + SPRITE_W));
|
|
293
|
+
}
|
|
294
|
+
if (wave >= 3) {
|
|
295
|
+
if (sx - 3 >= 0) rows[R_BGB] = place(rows[R_BGB], "~", sx - 3);
|
|
296
|
+
rows[R_BGB] = place(rows[R_BGB], "~", Math.min(CONTENT - 1, sx + SPRITE_W + 2));
|
|
297
|
+
}
|
|
298
|
+
if (wave >= 4) {
|
|
299
|
+
if (sx - 4 >= 0) rows[R_BGA] = place(rows[R_BGA], "~", sx - 4);
|
|
300
|
+
rows[R_BGA] = place(rows[R_BGA], "~", Math.min(CONTENT - 1, sx + SPRITE_W + 3));
|
|
301
|
+
}
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── deco text above sprite ───────────────────────────────────────────────────
|
|
308
|
+
function buildDeco(activity: PetActivity, frame: number): string {
|
|
309
|
+
switch (activity) {
|
|
310
|
+
case "sleeping": return ["z . .", ". z .", ". . z", "Z Z Z"][frame % 4] ?? "z";
|
|
311
|
+
case "eating": return Math.floor(frame / 3) % 2 === 0 ? " nom nom nom " : " NOM NOM NOM ";
|
|
312
|
+
case "playing": {
|
|
313
|
+
const f = frame % 6;
|
|
314
|
+
return f < 2 ? " G A I N S ! " : f < 4 ? " *FLEX MODE ON*" : " CORPORATE PUMP";
|
|
315
|
+
}
|
|
316
|
+
case "praised": return frame % 2 === 0 ? " * * * * * * *" : " * * * * * * * ";
|
|
317
|
+
case "working": {
|
|
318
|
+
if (frame % 32 >= 26) return ""; // smash handled by particles
|
|
319
|
+
const d = ["[............]","[o...........]","[.o..........]","[..o.........]",
|
|
320
|
+
"[...o........]","[....o.......]","[.....o......]","[......o.....]",
|
|
321
|
+
"[.......o....]","[........o...]","[.........o..]","[..........o.]",
|
|
322
|
+
"[...........o]"];
|
|
323
|
+
return d[frame % d.length] ?? "[............]";
|
|
324
|
+
}
|
|
325
|
+
case "vibing": return ["~ ~ ~ ~ ~ ~ ~"," ~ ~ ~ ~ ~ ~ "," ~ ~ ~ ~ ~ "][frame % 3] ?? "~";
|
|
326
|
+
default: return "";
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ─── full scene builder ───────────────────────────────────────────────────────
|
|
331
|
+
function buildScene(
|
|
332
|
+
activity: PetActivity,
|
|
333
|
+
frame: number,
|
|
334
|
+
stats: PetStats,
|
|
335
|
+
env: Environment,
|
|
336
|
+
): string[] {
|
|
337
|
+
const rows: string[] = Array.from({ length: SCENE_ROWS }, () => " ".repeat(CONTENT));
|
|
338
|
+
|
|
339
|
+
drawBackground(rows, env, frame, stats);
|
|
340
|
+
drawParticles(rows, activity, frame);
|
|
341
|
+
|
|
342
|
+
const deco = buildDeco(activity, frame);
|
|
343
|
+
if (deco) {
|
|
344
|
+
const sx = SPRITE_X[activity];
|
|
345
|
+
const dx = Math.max(0, Math.min(CONTENT - deco.length, sx - Math.floor((deco.length - SPRITE_W) / 2)));
|
|
346
|
+
rows[R_DECO] = place(rows[R_DECO], deco, dx);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const sprite = buildSprite(activity, frame);
|
|
350
|
+
const sx = SPRITE_X[activity];
|
|
351
|
+
for (let i = 0; i < sprite.length; i++) {
|
|
352
|
+
rows[R_SP0 + i] = place(rows[R_SP0 + i], sprite[i] ?? "", sx);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return rows;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─── row colors ───────────────────────────────────────────────────────────────
|
|
359
|
+
function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): string {
|
|
360
|
+
if (i >= R_SP0 && i < R_SP0 + 6) {
|
|
361
|
+
if (activity === "sleeping") return t.dim;
|
|
362
|
+
if (activity === "praised") return t.primaryLight;
|
|
363
|
+
if (activity === "eating") return t.warning;
|
|
364
|
+
if (activity === "playing") return frame % 6 >= 3 ? t.primaryLight : t.primary;
|
|
365
|
+
if (activity === "working" && frame % 32 >= 26) return t.error;
|
|
366
|
+
return t.primary;
|
|
367
|
+
}
|
|
368
|
+
if (i === R_DECO) {
|
|
369
|
+
if (activity === "working" && frame % 32 >= 26) return t.error;
|
|
370
|
+
if (activity === "sleeping") return t.dim;
|
|
371
|
+
if (activity === "praised") return t.warning;
|
|
372
|
+
if (activity === "eating") return t.warning;
|
|
373
|
+
if (activity === "playing") return t.primaryLight;
|
|
374
|
+
return t.primaryLight;
|
|
375
|
+
}
|
|
376
|
+
if (i === R_FLOOR) return t.primaryDim;
|
|
377
|
+
if (i === R_SKY) return t.dim;
|
|
378
|
+
return t.primaryDim;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ─── status messages ──────────────────────────────────────────────────────────
|
|
382
|
+
type MsgLevel = "critical" | "low" | "ok" | "good" | "great";
|
|
383
|
+
function statLevel(v: number): MsgLevel {
|
|
384
|
+
if (v < 20) return "critical";
|
|
385
|
+
if (v < 40) return "low";
|
|
386
|
+
if (v < 65) return "ok";
|
|
387
|
+
if (v < 85) return "good";
|
|
388
|
+
return "great";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const MESSAGES: Record<string, readonly string[]> = {
|
|
392
|
+
"hunger.critical": ["Feed him. Now.","Pipeline empty. Stomach emptier.","Caloric intake: zero.","Deal intake required. Urgent.","No lunch. Board concerned."],
|
|
393
|
+
"hunger.low": ["Could use a deal snack.","Peckish. Dangerously so.","Running on fumes and spite.","Lunch was conceptual.","Hunger creeping. Bad sign."],
|
|
394
|
+
"hunger.ok": ["Fed. Functional.","Satiated. Marginally.","Nourishment confirmed.","Caloric metrics acceptable.","Pipeline: sufficient."],
|
|
395
|
+
"hunger.good": ["Well fed. Projecting strength.","Deal appetite satisfied.","Lunch closed. Board nods.","Caloric position: strong.","Nutritionally sound."],
|
|
396
|
+
"hunger.great": ["Fully loaded. Ready to close.","Peak caloric window open.","Briefcase well-stocked.","Drexler has eaten. Fear him.","Maximum deal absorption."],
|
|
397
|
+
|
|
398
|
+
"happiness.critical": ["Morale: sub-basement.","Joy metrics: catastrophic.","Drexler deeply dissatisfied.","Considering self-restructure.","Send help. Immediately."],
|
|
399
|
+
"happiness.low": ["Confidence is flagging.","Sentiment negative. Act now.","Drexler is not thriving.","Market ungrateful, apparently.","Spirits declining. Alarming."],
|
|
400
|
+
"happiness.ok": ["Maintaining composure.","Cautiously optimistic.","Neutral outlook. For now.","Equilibrium: tenuous.","Tolerable. Barely."],
|
|
401
|
+
"happiness.good": ["Pipeline robust. Spirits up.","Drexler is in the zone.","Good day in the deal room.","Shareholders pleased. Briefly.","Morale: acceptable."],
|
|
402
|
+
"happiness.great": ["Unstoppable. Frankly.","Peak performance window.","Manic energy. Deploy wisely.","Drexler ascendant. Watch out.","Maximum euphoria. Imminent."],
|
|
403
|
+
|
|
404
|
+
"energy.critical": ["Running on fumes. Critical.","System depleted. Recharge.","Drexler barely upright.","Energy: dangerous lows.","Rest now. Non-negotiable."],
|
|
405
|
+
"energy.low": ["Coffee required. Urgently.","Flagging slightly. Or a lot.","Energy deficit detected.","Strategic nap advised.","Reserves low. Efficiency shaky."],
|
|
406
|
+
"energy.ok": ["Operational. Barely.","Chugging along.","Energy acceptable. Recheck.","Functional. Not inspired.","Adequate. For now."],
|
|
407
|
+
"energy.good": ["Energized. Alert. Ready.","Ready to close deals.","Drexler is firing well.","Full capacity. Mostly.","Energy surplus confirmed."],
|
|
408
|
+
"energy.great": ["Fully charged. Dangerous.","Drexler is electrified.","Energy max. Scope unlimited.","Running at 110%. Somehow.","Kinetic. Caffeinated."],
|
|
409
|
+
|
|
410
|
+
"deals.critical": ["Pipeline: bone dry.","No deals. Board is watching.","Zero live mandates. Shameful.","Origination needed. Now.","Empty pipe. Reputation at risk."],
|
|
411
|
+
"deals.low": ["Pipeline thin. Worrying.","Deal flow: trickling.","Source aggressively.","Activity light. Drexler restless.","Book thin. Posture defensive."],
|
|
412
|
+
"deals.ok": ["Pipeline: moderate.","Deal flow steady. Could improve.","Working the book.","Several irons in the fire.","Deal cadence acceptable."],
|
|
413
|
+
"deals.good": ["Pipeline full. Drexler pleased.","Multiple term sheets live.","Deal machine: operational.","The book is healthy.","Deal flow strong. Board nods."],
|
|
414
|
+
"deals.great": ["Crushing it, frankly.","Overflowing pipeline. Good problem.","Drexler is the deal machine.","Maximum origination achieved.","Board in awe. Secretly."],
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
function getStatusMsg(stats: PetStats, frame: number): string {
|
|
418
|
+
const entries: [string, number][] = [
|
|
419
|
+
["hunger", stats.hunger], ["happiness", stats.happiness],
|
|
420
|
+
["energy", stats.energy], ["deals", stats.deals],
|
|
421
|
+
];
|
|
422
|
+
entries.sort((a, b) => a[1] - b[1]);
|
|
423
|
+
const [worstStat, worstVal] = entries[0] ?? ["happiness", 50];
|
|
424
|
+
const key = `${worstStat}.${statLevel(worstVal)}`;
|
|
425
|
+
const msgs = MESSAGES[key] ?? ["Operational."];
|
|
426
|
+
return msgs[Math.floor(frame / 10) % msgs.length] ?? "Operational.";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ─── stat bar ─────────────────────────────────────────────────────────────────
|
|
430
|
+
function StatBarInner({
|
|
431
|
+
label, value, barColor, labelColor, warnColor,
|
|
432
|
+
}: {
|
|
433
|
+
label: string; value: number; barColor: string; labelColor: string; warnColor: string;
|
|
434
|
+
}) {
|
|
435
|
+
const filled = Math.round((value / 100) * 14);
|
|
436
|
+
const bar = "█".repeat(filled) + "░".repeat(14 - filled);
|
|
437
|
+
const pct = String(Math.round(value)).padStart(3);
|
|
438
|
+
const isLow = value < 25;
|
|
439
|
+
return (
|
|
440
|
+
<Text>
|
|
441
|
+
<Text color={labelColor}>{pad(label, 6)}</Text>
|
|
442
|
+
<Text color={isLow ? warnColor : barColor}>{bar}</Text>
|
|
443
|
+
<Text color={isLow ? warnColor : labelColor}> {pct}%</Text>
|
|
444
|
+
</Text>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
const StatBar = memo(StatBarInner);
|
|
448
|
+
|
|
449
|
+
// ─── component ────────────────────────────────────────────────────────────────
|
|
450
|
+
interface PetPanelProps {
|
|
451
|
+
stats: PetStats;
|
|
452
|
+
activity: PetActivity;
|
|
453
|
+
env?: Environment;
|
|
454
|
+
isPaused?: boolean;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function PetPanelView({ stats, activity, env = "office", isPaused = false }: PetPanelProps) {
|
|
458
|
+
const t = useTheme();
|
|
459
|
+
const [frame, setFrame] = useState(0);
|
|
460
|
+
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
setFrame(0);
|
|
463
|
+
}, [activity, env]);
|
|
464
|
+
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
if (isPaused) return;
|
|
467
|
+
const id = setInterval(() => {
|
|
468
|
+
setFrame((f) => f + 1);
|
|
469
|
+
}, 800);
|
|
470
|
+
return () => clearInterval(id);
|
|
471
|
+
}, [isPaused]);
|
|
472
|
+
|
|
473
|
+
const scene = useMemo(
|
|
474
|
+
() => buildScene(activity, frame, stats, env),
|
|
475
|
+
[activity, frame, stats, env],
|
|
476
|
+
);
|
|
477
|
+
const mood = useMemo(() => getPetMood(stats), [stats]);
|
|
478
|
+
const status = useMemo(
|
|
479
|
+
() => pad(`memo ${getStatusMsg(stats, frame)}`, CONTENT),
|
|
480
|
+
[stats, frame],
|
|
481
|
+
);
|
|
482
|
+
const title = fitDisplayText(`DREXLER DEAL DESK [${env}]`, CONTENT);
|
|
483
|
+
const activityLabel = activity !== "idle" ? ` / ${activity}` : "";
|
|
484
|
+
const moodLabel = `mood ${mood}`;
|
|
485
|
+
const fittedMood = activityLabel
|
|
486
|
+
? fitDisplayText(moodLabel, Math.max(1, CONTENT - displayWidth(activityLabel)))
|
|
487
|
+
: fitDisplayText(moodLabel, CONTENT);
|
|
488
|
+
const fittedActivity = activityLabel && displayWidth(fittedMood) < CONTENT
|
|
489
|
+
? fitDisplayText(activityLabel, CONTENT - displayWidth(fittedMood))
|
|
490
|
+
: "";
|
|
491
|
+
|
|
492
|
+
return (
|
|
493
|
+
<Box
|
|
494
|
+
flexDirection="column"
|
|
495
|
+
width={PET_PANEL_WIDTH}
|
|
496
|
+
flexShrink={0}
|
|
497
|
+
borderStyle="round"
|
|
498
|
+
borderColor={t.primaryDim}
|
|
499
|
+
>
|
|
500
|
+
<Box paddingX={1} justifyContent="center">
|
|
501
|
+
<Text color={t.primary} bold>{title}</Text>
|
|
502
|
+
</Box>
|
|
503
|
+
|
|
504
|
+
<Box flexDirection="column" paddingX={1}>
|
|
505
|
+
{scene.map((row, i) => (
|
|
506
|
+
<Text key={i} color={rowColor(i, activity, frame, t)}>{row}</Text>
|
|
507
|
+
))}
|
|
508
|
+
</Box>
|
|
509
|
+
|
|
510
|
+
<Box paddingX={1}>
|
|
511
|
+
<Text color={t.primaryDim}>{"─".repeat(CONTENT)}</Text>
|
|
512
|
+
</Box>
|
|
513
|
+
|
|
514
|
+
<Box flexDirection="column" paddingX={1}>
|
|
515
|
+
<StatBar label="happy" value={stats.happiness} barColor={t.primary} labelColor={t.dim} warnColor={t.error} />
|
|
516
|
+
<StatBar label="hungr" value={stats.hunger} barColor={t.primaryLight} labelColor={t.dim} warnColor={t.warning} />
|
|
517
|
+
<StatBar label="enrgy" value={stats.energy} barColor={t.primaryLight} labelColor={t.dim} warnColor={t.warning} />
|
|
518
|
+
<StatBar label="deals" value={stats.deals} barColor={t.primaryDim} labelColor={t.dim} warnColor={t.warning} />
|
|
519
|
+
</Box>
|
|
520
|
+
|
|
521
|
+
<Box paddingX={1}>
|
|
522
|
+
<Text color={t.primaryDim}>{"─".repeat(CONTENT)}</Text>
|
|
523
|
+
</Box>
|
|
524
|
+
|
|
525
|
+
<Box paddingX={1}>
|
|
526
|
+
<Text color={t.dim}>{status}</Text>
|
|
527
|
+
</Box>
|
|
528
|
+
|
|
529
|
+
<Box paddingX={1}>
|
|
530
|
+
<Text color={t.dim}>{fittedMood}</Text>
|
|
531
|
+
{fittedActivity && <Text color={t.primaryDim}>{fittedActivity}</Text>}
|
|
532
|
+
</Box>
|
|
533
|
+
</Box>
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export const PetPanel = memo(PetPanelView);
|