drexler 0.2.16 → 0.2.18
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 +3 -2
- package/package.json +1 -1
- package/src/commands.ts +3 -0
- package/src/index.ts +1 -0
- package/src/ui/App.tsx +80 -57
- package/src/ui/MascotIntro.tsx +385 -0
- package/src/ui/PetPanel.tsx +296 -388
package/src/ui/PetPanel.tsx
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { getPetMood, type PetActivity, type PetStats } from "../pet/petState.ts";
|
|
4
|
+
import {
|
|
5
|
+
BRIEFCASE_FINAL,
|
|
6
|
+
MASCOT_WIDTH,
|
|
7
|
+
renderMascotLines,
|
|
8
|
+
type MascotState,
|
|
9
|
+
} from "./MascotFrame.tsx";
|
|
4
10
|
import { displayWidth, fitDisplayText } from "./graphemes.ts";
|
|
5
11
|
import { useTheme } from "./ThemeContext.tsx";
|
|
6
12
|
import { type Theme } from "./themes.ts";
|
|
7
13
|
|
|
8
|
-
export const PET_PANEL_WIDTH = 36;
|
|
9
|
-
export const PET_PANEL_ROWS = 22;
|
|
10
14
|
export const COMPACT_PET_PANEL_ROWS = 5;
|
|
11
15
|
export const TINY_PET_PANEL_ROWS = 1;
|
|
12
16
|
export const COMPACT_PET_PANEL_MIN_WIDTH = 48;
|
|
@@ -14,354 +18,302 @@ export type Environment = "office" | "home" | "outdoors";
|
|
|
14
18
|
|
|
15
19
|
const PANEL_BORDER_COLUMNS = 2;
|
|
16
20
|
const PANEL_PADDING_COLUMNS = 2;
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const SCENE_ROWS = 11;
|
|
28
|
-
|
|
29
|
-
// Fixed sprite X — no left/right walking
|
|
30
|
-
const SPRITE_X: Record<PetActivity, number> = {
|
|
31
|
-
idle: 12, eating: 3, playing: 12, working: 18,
|
|
32
|
-
sleeping: 12, praised: 12, vibing: 12,
|
|
33
|
-
};
|
|
21
|
+
const SCENE_ROWS = 14;
|
|
22
|
+
const R_WALL = 0;
|
|
23
|
+
const R_WINDOW_TOP = 1;
|
|
24
|
+
const R_WINDOW_BOTTOM = 3;
|
|
25
|
+
const R_ACTIVITY = 4;
|
|
26
|
+
const R_MASCOT_START = 5;
|
|
27
|
+
const R_DESK_SURFACE = R_MASCOT_START + BRIEFCASE_FINAL.length;
|
|
28
|
+
const R_DESK_FRONT = R_DESK_SURFACE + 1;
|
|
29
|
+
|
|
30
|
+
export const PET_SCENE_WIDTH = 52;
|
|
34
31
|
|
|
35
|
-
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
36
|
-
// All scene glyphs (box-drawing + ASCII) are BMP single-units, so string
|
|
37
|
-
// splicing is safe and avoids the per-call char-array allocation `[...base]`
|
|
38
|
-
// would do on the hot frame loop.
|
|
39
32
|
function place(base: string, text: string, x: number): string {
|
|
40
33
|
if (x < 0 || x >= base.length) return base;
|
|
41
34
|
const end = Math.min(base.length, x + text.length);
|
|
42
35
|
const fit = text.slice(0, end - x);
|
|
43
36
|
return base.slice(0, x) + fit + base.slice(end);
|
|
44
37
|
}
|
|
45
|
-
function pad(s: string, n: number): string {
|
|
46
|
-
const fitted = fitDisplayText(s, n);
|
|
47
|
-
return fitted + " ".repeat(Math.max(0, n - displayWidth(fitted)));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Hoisted constant: floor pattern for home doesn't depend on frame.
|
|
51
|
-
const HOME_CARPET = Array.from({ length: CONTENT }, (_, i) =>
|
|
52
|
-
i % 4 === 0 ? "░" : i % 4 === 2 ? "▒" : "─",
|
|
53
|
-
).join("");
|
|
54
|
-
|
|
55
|
-
// ─── sprite (8 wide × 6 tall) ────────────────────────────────────────────────
|
|
56
|
-
function buildSprite(activity: PetActivity, frame: number): string[] {
|
|
57
|
-
const smash = activity === "working" && frame % 32 >= 26;
|
|
58
|
-
|
|
59
|
-
let eyes: string;
|
|
60
|
-
if (smash) eyes = "║ X X ║";
|
|
61
|
-
else switch (activity) {
|
|
62
|
-
case "sleeping": eyes = "║ - - ║"; break;
|
|
63
|
-
case "working": eyes = "║ / \\ ║"; break;
|
|
64
|
-
case "playing": eyes = frame % 6 < 3 ? "║ * * ║" : "║ ◆ ◆ ║"; break;
|
|
65
|
-
case "vibing": eyes = frame % 4 < 2 ? "║ ~ ~ ║" : "║ ◆ ◆ ║"; break;
|
|
66
|
-
case "eating": eyes = frame % 4 < 2 ? "║ o o ║" : "║ ◆ ◆ ║"; break;
|
|
67
|
-
case "praised": eyes = "║ ◆ ◆ ║"; break;
|
|
68
|
-
default: eyes = frame % 22 === 0 ? "║ - - ║" : "║ ◆ ◆ ║"; break;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Flex top during playing peak
|
|
72
|
-
const top = (activity === "playing" && frame % 6 >= 3) ? "╠═╩══╩═╣" : "╔═╩══╩═╗";
|
|
73
|
-
|
|
74
|
-
let lock: string;
|
|
75
|
-
if (smash) lock = "║ ║**║ ║";
|
|
76
|
-
else switch (activity) {
|
|
77
|
-
case "eating": lock = frame % 2 === 0 ? "║ ║>>║ ║" : "║ ║<<║ ║"; break;
|
|
78
|
-
case "sleeping": lock = "║ ║"; break;
|
|
79
|
-
case "praised": lock = frame % 3 === 0 ? "║ ║**║ ║" : "║ ║$$║ ║"; break;
|
|
80
|
-
case "vibing": lock = frame % 4 < 2 ? "║ ║~~║ ║" : "║ ║$$║ ║"; break;
|
|
81
|
-
case "playing": lock = frame % 4 < 2 ? "║ ║!!║ ║" : "║ ║$$║ ║"; break;
|
|
82
|
-
default: lock = "║ ║$$║ ║"; break;
|
|
83
|
-
}
|
|
84
38
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
top,
|
|
88
|
-
eyes,
|
|
89
|
-
activity === "sleeping" ? "║ ║" : "║ ╔══╗ ║",
|
|
90
|
-
lock,
|
|
91
|
-
"╚══════╝",
|
|
92
|
-
];
|
|
39
|
+
function blankRow(width: number): string {
|
|
40
|
+
return " ".repeat(width);
|
|
93
41
|
}
|
|
94
42
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
rows[R_SKY] = "─".repeat(CONTENT);
|
|
102
|
-
|
|
103
|
-
// Window left (6 wide) + deals board right (12 wide at x=20)
|
|
104
|
-
const sky = isDay
|
|
105
|
-
? (frame % 10 < 5 ? "─^──" : "──^─")
|
|
106
|
-
: (frame % 10 < 5 ? "─*──" : "──*─");
|
|
107
|
-
rows[R_BGA] = place(rows[R_BGA], `╭${sky}╮`, 0);
|
|
108
|
-
rows[R_BGB] = place(rows[R_BGB], "╰────╯", 0);
|
|
43
|
+
function padDisplayText(input: string, width: number): string {
|
|
44
|
+
const safeWidth = Math.max(1, width);
|
|
45
|
+
const fitted = fitDisplayText(input, safeWidth);
|
|
46
|
+
return `${fitted}${" ".repeat(Math.max(0, safeWidth - displayWidth(fitted)))}`;
|
|
47
|
+
}
|
|
109
48
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
49
|
+
function overlayFitted(row: string, text: string, x: number, width: number): string {
|
|
50
|
+
return place(row, padDisplayText(text, width), x);
|
|
51
|
+
}
|
|
113
52
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
53
|
+
function centerText(row: string, text: string): string {
|
|
54
|
+
const safeText = fitDisplayText(text, row.length);
|
|
55
|
+
const x = Math.max(0, Math.floor((row.length - displayWidth(safeText)) / 2));
|
|
56
|
+
return place(row, safeText, x);
|
|
57
|
+
}
|
|
118
58
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
59
|
+
function sceneBoxTop(title: string, width: number): string {
|
|
60
|
+
const safeWidth = Math.max(4, width);
|
|
61
|
+
const label = ` ${fitDisplayText(title, Math.max(1, safeWidth - 4))} `;
|
|
62
|
+
const ruleWidth = Math.max(0, safeWidth - 2 - displayWidth(label));
|
|
63
|
+
return `╭${label}${"─".repeat(ruleWidth)}╮`;
|
|
123
64
|
}
|
|
124
65
|
|
|
125
|
-
function
|
|
126
|
-
const
|
|
127
|
-
|
|
66
|
+
function sceneBoxBody(text: string, width: number): string {
|
|
67
|
+
const safeWidth = Math.max(4, width);
|
|
68
|
+
return `│${padDisplayText(text, safeWidth - 2)}│`;
|
|
69
|
+
}
|
|
128
70
|
|
|
129
|
-
|
|
71
|
+
function sceneBoxBottom(width: number): string {
|
|
72
|
+
const safeWidth = Math.max(4, width);
|
|
73
|
+
return `╰${"─".repeat(safeWidth - 2)}╯`;
|
|
74
|
+
}
|
|
130
75
|
|
|
131
|
-
|
|
132
|
-
rows
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
76
|
+
function placeSceneBox(
|
|
77
|
+
rows: string[],
|
|
78
|
+
row: number,
|
|
79
|
+
x: number,
|
|
80
|
+
width: number,
|
|
81
|
+
title: string,
|
|
82
|
+
body: string,
|
|
83
|
+
): void {
|
|
84
|
+
rows[row] = place(rows[row] ?? "", sceneBoxTop(title, width), x);
|
|
85
|
+
rows[row + 1] = place(rows[row + 1] ?? "", sceneBoxBody(body, width), x);
|
|
86
|
+
rows[row + 2] = place(rows[row + 2] ?? "", sceneBoxBottom(width), x);
|
|
87
|
+
}
|
|
137
88
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
89
|
+
function cupForEnergy(energy: number): string {
|
|
90
|
+
if (energy > 60) return "coffee [c~]";
|
|
91
|
+
if (energy > 30) return "coffee [c-]";
|
|
92
|
+
return "coffee [c_]";
|
|
93
|
+
}
|
|
142
94
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
95
|
+
function progressTicker(frame: number): string {
|
|
96
|
+
const dots = ".............";
|
|
97
|
+
const idx = frame % dots.length;
|
|
98
|
+
return `[${dots.slice(0, idx)}o${dots.slice(idx + 1)}]`;
|
|
99
|
+
}
|
|
147
100
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
101
|
+
function buildActivityLine(activity: PetActivity, frame: number): string {
|
|
102
|
+
switch (activity) {
|
|
103
|
+
case "eating":
|
|
104
|
+
return frame % 4 < 2
|
|
105
|
+
? "deal snack memo routed"
|
|
106
|
+
: "lunch marked to market";
|
|
107
|
+
case "playing":
|
|
108
|
+
return frame % 6 < 3
|
|
109
|
+
? "boardroom putting drill"
|
|
110
|
+
: "team morale accretive";
|
|
111
|
+
case "working":
|
|
112
|
+
return `term sheet live ${progressTicker(frame)}`;
|
|
113
|
+
case "sleeping":
|
|
114
|
+
return frame % 4 < 2
|
|
115
|
+
? "lights dim · conference-line nap"
|
|
116
|
+
: "zzz · calendar defended";
|
|
117
|
+
case "praised":
|
|
118
|
+
return frame % 2 === 0
|
|
119
|
+
? "bonus memo approved * * *"
|
|
120
|
+
: "* * * board applause logged";
|
|
121
|
+
case "vibing":
|
|
122
|
+
return ["lo-fi deal room ~ ~ ~", "~ ~ valuation waves ~ ~"][frame % 2]!;
|
|
123
|
+
default:
|
|
124
|
+
return "calendar clear · office quiet";
|
|
125
|
+
}
|
|
152
126
|
}
|
|
153
127
|
|
|
154
|
-
function
|
|
155
|
-
|
|
156
|
-
|
|
128
|
+
function mascotStateForActivity(activity: PetActivity, frame: number): MascotState {
|
|
129
|
+
switch (activity) {
|
|
130
|
+
case "eating":
|
|
131
|
+
return {
|
|
132
|
+
walls: "on",
|
|
133
|
+
brows: "raised",
|
|
134
|
+
eyes: "open",
|
|
135
|
+
showLock: true,
|
|
136
|
+
dollars: frame % 4 < 2 ? "dim" : "on",
|
|
137
|
+
};
|
|
138
|
+
case "playing":
|
|
139
|
+
return {
|
|
140
|
+
walls: "on",
|
|
141
|
+
brows: frame % 6 < 3 ? "raised" : "normal",
|
|
142
|
+
eyes: "open",
|
|
143
|
+
showLock: true,
|
|
144
|
+
dollars: "on",
|
|
145
|
+
};
|
|
146
|
+
case "working":
|
|
147
|
+
return {
|
|
148
|
+
walls: "on",
|
|
149
|
+
brows: "focused",
|
|
150
|
+
eyes: "open",
|
|
151
|
+
showLock: true,
|
|
152
|
+
dollars: frame % 8 < 4 ? "on" : "dim",
|
|
153
|
+
};
|
|
154
|
+
case "sleeping":
|
|
155
|
+
return {
|
|
156
|
+
walls: "dim",
|
|
157
|
+
brows: "hidden",
|
|
158
|
+
eyes: "closed",
|
|
159
|
+
showLock: true,
|
|
160
|
+
dollars: "dim",
|
|
161
|
+
};
|
|
162
|
+
case "praised":
|
|
163
|
+
return {
|
|
164
|
+
walls: "on",
|
|
165
|
+
brows: "raised",
|
|
166
|
+
eyes: "open",
|
|
167
|
+
showLock: true,
|
|
168
|
+
dollars: "on",
|
|
169
|
+
};
|
|
170
|
+
case "vibing":
|
|
171
|
+
return {
|
|
172
|
+
walls: "on",
|
|
173
|
+
brows: "flat",
|
|
174
|
+
eyes: "open",
|
|
175
|
+
showLock: true,
|
|
176
|
+
dollars: frame % 4 < 2 ? "dim" : "on",
|
|
177
|
+
};
|
|
178
|
+
default:
|
|
179
|
+
return {
|
|
180
|
+
walls: "on",
|
|
181
|
+
brows: "normal",
|
|
182
|
+
eyes: frame > 0 && frame % 22 === 0 ? "closed" : "open",
|
|
183
|
+
showLock: true,
|
|
184
|
+
dollars: "on",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
157
188
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
rows[
|
|
189
|
+
function drawOfficeBackground(rows: string[], width: number, frame: number, stats: PetStats): void {
|
|
190
|
+
const wallLabel = "DREXLER OFFICE";
|
|
191
|
+
const dealPct = `${Math.round(stats.deals).toString().padStart(3)}%`;
|
|
192
|
+
rows[R_WALL] = centerText("─".repeat(width), wallLabel);
|
|
193
|
+
rows[R_WALL] = place(
|
|
194
|
+
rows[R_WALL],
|
|
195
|
+
fitDisplayText(`pipe ${dealPct}`, Math.max(1, width - 2)),
|
|
196
|
+
Math.max(0, width - displayWidth(`pipe ${dealPct}`) - 1),
|
|
197
|
+
);
|
|
163
198
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
199
|
+
const windowWidth = Math.min(22, Math.max(16, Math.floor(width * 0.36)));
|
|
200
|
+
const boardWidth = Math.min(28, Math.max(20, width - windowWidth - 6));
|
|
201
|
+
const boardX = Math.max(windowWidth + 4, width - boardWidth - 2);
|
|
202
|
+
const marketPulse = frame % 10 < 5 ? "market tape + + +" : "market tape + +";
|
|
203
|
+
|
|
204
|
+
placeSceneBox(rows, R_WINDOW_TOP, 1, windowWidth, "Market Window", marketPulse);
|
|
205
|
+
placeSceneBox(
|
|
206
|
+
rows,
|
|
207
|
+
R_WINDOW_TOP,
|
|
208
|
+
boardX,
|
|
209
|
+
Math.min(boardWidth, width - boardX),
|
|
210
|
+
"Deal Board",
|
|
211
|
+
`DL:${dealPct} fees:${Math.round(stats.happiness)}%`,
|
|
212
|
+
);
|
|
167
213
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
(
|
|
171
|
-
)
|
|
214
|
+
rows[R_ACTIVITY] = centerText(
|
|
215
|
+
"─".repeat(width),
|
|
216
|
+
buildActivityLine("idle", frame),
|
|
217
|
+
);
|
|
172
218
|
}
|
|
173
219
|
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
220
|
+
function drawActivityAccents(
|
|
221
|
+
rows: string[],
|
|
222
|
+
width: number,
|
|
223
|
+
activity: PetActivity,
|
|
224
|
+
frame: number,
|
|
225
|
+
mascotX: number,
|
|
226
|
+
): void {
|
|
227
|
+
rows[R_ACTIVITY] = centerText(
|
|
228
|
+
"─".repeat(width),
|
|
229
|
+
buildActivityLine(activity, frame),
|
|
230
|
+
);
|
|
179
231
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
232
|
+
const mascotRight = mascotX + MASCOT_WIDTH;
|
|
233
|
+
const leftAccentX = Math.max(1, mascotX - 10);
|
|
234
|
+
const rightAccentX = Math.min(width - 10, mascotRight + 2);
|
|
183
235
|
|
|
184
236
|
switch (activity) {
|
|
185
|
-
case "
|
|
186
|
-
|
|
187
|
-
const chars: ["z", "z", "Z"] = ["z", "z", "Z"];
|
|
188
|
-
for (let i = 0; i < 3; i++) {
|
|
189
|
-
const age = (frame + i * 4) % 14;
|
|
190
|
-
if (age < 10) {
|
|
191
|
-
const py = R_DECO - Math.floor(age / 3);
|
|
192
|
-
const px = sx + 3 + i;
|
|
193
|
-
if (py >= 0 && px < CONTENT) rows[py] = place(rows[py], chars[i] ?? "z", px);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
237
|
+
case "eating":
|
|
238
|
+
rows[R_DESK_SURFACE] = place(rows[R_DESK_SURFACE], "[$ memo]", rightAccentX);
|
|
196
239
|
break;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// Arms progressively extend each phase
|
|
201
|
-
const phase = frame % 6;
|
|
202
|
-
if (phase >= 1) {
|
|
203
|
-
rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "\\", Math.max(0, sx - 1));
|
|
204
|
-
rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "/", sx + SPRITE_W);
|
|
205
|
-
}
|
|
206
|
-
if (phase >= 2) {
|
|
207
|
-
rows[R_SP0] = place(rows[R_SP0], "\\", Math.max(0, sx - 2));
|
|
208
|
-
rows[R_SP0] = place(rows[R_SP0], "/", Math.min(CONTENT - 1, sx + SPRITE_W + 1));
|
|
209
|
-
}
|
|
210
|
-
if (phase >= 3) {
|
|
211
|
-
rows[R_SP0] = place(rows[R_SP0], "\\", Math.max(0, sx - 3));
|
|
212
|
-
rows[R_SP0] = place(rows[R_SP0], "/", Math.min(CONTENT - 1, sx + SPRITE_W + 2));
|
|
213
|
-
// Stars burst at peak flex
|
|
214
|
-
rows[R_BGB] = place(rows[R_BGB], "*", Math.max(0, sx - 4));
|
|
215
|
-
rows[R_BGB] = place(rows[R_BGB], "*", Math.min(CONTENT - 1, sx + SPRITE_W + 3));
|
|
216
|
-
rows[R_BGA] = place(rows[R_BGA], "*", Math.max(0, sx - 2));
|
|
217
|
-
rows[R_BGA] = place(rows[R_BGA], "*", Math.min(CONTENT - 1, sx + SPRITE_W + 1));
|
|
218
|
-
}
|
|
240
|
+
case "playing":
|
|
241
|
+
rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", leftAccentX);
|
|
242
|
+
rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", rightAccentX + 6);
|
|
219
243
|
break;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const smash = frame % 32 >= 26;
|
|
224
|
-
if (smash) {
|
|
225
|
-
const sf = frame % 32 - 26;
|
|
226
|
-
if (sf < 2) rows[R_DECO] = place(rows[R_DECO], " !!! SMASH !!! ", 7);
|
|
227
|
-
else if (sf < 4) { rows[R_BGB] = place(rows[R_BGB], " * B O O M * ", 9); }
|
|
228
|
-
else rows[R_BGA] = place(rows[R_BGA], " . . . . ", 10);
|
|
229
|
-
} else {
|
|
230
|
-
// 3 staggered $ streams raining from sky through deco row
|
|
231
|
-
const cols = [7, 14, 25] as const;
|
|
232
|
-
for (let i = 0; i < 3; i++) {
|
|
233
|
-
const colX = cols[i] ?? 7;
|
|
234
|
-
for (const off of [0, 9] as const) {
|
|
235
|
-
const age = (frame + i * 6 + off) % 18;
|
|
236
|
-
const py = Math.floor(age / 2);
|
|
237
|
-
if (py <= R_DECO) rows[py] = place(rows[py], "$", colX);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
244
|
+
case "working":
|
|
245
|
+
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "$", leftAccentX);
|
|
246
|
+
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "$", rightAccentX + 6);
|
|
241
247
|
break;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
case "eating": {
|
|
245
|
-
// Deal memo arcs smoothly from right toward sprite
|
|
246
|
-
const memos: [string, number][] = [["$", 0], ["%", 4]];
|
|
247
|
-
for (const [char, off] of memos) {
|
|
248
|
-
const p = (frame + off) % 8;
|
|
249
|
-
if (p < 6) {
|
|
250
|
-
const endX = sx + SPRITE_W + 1;
|
|
251
|
-
const startX = CONTENT - 3;
|
|
252
|
-
const px = Math.round(startX - (p / 5) * (startX - endX));
|
|
253
|
-
const row = R_SP0 + 2 + (off > 0 ? 1 : 0);
|
|
254
|
-
if (px >= endX && row < R_FLOOR) rows[row] = place(rows[row], char, px);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
248
|
+
case "sleeping":
|
|
249
|
+
rows[R_MASCOT_START] = place(rows[R_MASCOT_START], "z z Z", rightAccentX);
|
|
257
250
|
break;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
// Expanding star burst radiating outward over 5 frames
|
|
262
|
-
const cx = sx + Math.floor(SPRITE_W / 2);
|
|
263
|
-
const phase = frame % 5;
|
|
264
|
-
if (phase === 0) {
|
|
265
|
-
rows[R_DECO] = place(rows[R_DECO], "*", cx);
|
|
266
|
-
} else if (phase === 1) {
|
|
267
|
-
rows[R_DECO] = place(rows[R_DECO], "*", Math.max(0, cx - 2));
|
|
268
|
-
rows[R_DECO] = place(rows[R_DECO], "*", Math.min(CONTENT - 1, cx + 2));
|
|
269
|
-
rows[R_BGB] = place(rows[R_BGB], "*", cx);
|
|
270
|
-
} else if (phase === 2) {
|
|
271
|
-
rows[R_DECO] = place(rows[R_DECO], "*", Math.max(0, cx - 4));
|
|
272
|
-
rows[R_DECO] = place(rows[R_DECO], "*", Math.min(CONTENT - 1, cx + 4));
|
|
273
|
-
rows[R_BGB] = place(rows[R_BGB], "*", Math.max(0, cx - 2));
|
|
274
|
-
rows[R_BGB] = place(rows[R_BGB], "*", Math.min(CONTENT - 1, cx + 2));
|
|
275
|
-
rows[R_BGA] = place(rows[R_BGA], "*", cx);
|
|
276
|
-
} else if (phase === 3) {
|
|
277
|
-
rows[R_SKY] = place(rows[R_SKY], "*", Math.max(0, cx - 6));
|
|
278
|
-
rows[R_SKY] = place(rows[R_SKY], "*", cx);
|
|
279
|
-
rows[R_SKY] = place(rows[R_SKY], "*", Math.min(CONTENT - 1, cx + 6));
|
|
280
|
-
}
|
|
281
|
-
// phase 4 = clear
|
|
251
|
+
case "praised":
|
|
252
|
+
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", leftAccentX);
|
|
253
|
+
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", rightAccentX + 4);
|
|
282
254
|
break;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
rows[R_SP0 + 2] = place(rows[R_SP0 + 2], "~", sx + SPRITE_W);
|
|
291
|
-
}
|
|
292
|
-
if (wave >= 2) {
|
|
293
|
-
if (sx - 2 >= 0) rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "~", sx - 2);
|
|
294
|
-
rows[R_SP0 + 1] = place(rows[R_SP0 + 1], "~", Math.min(CONTENT - 1, sx + SPRITE_W + 1));
|
|
295
|
-
if (sx - 1 >= 0) rows[R_DECO] = place(rows[R_DECO], "~", sx - 1);
|
|
296
|
-
rows[R_DECO] = place(rows[R_DECO], "~", Math.min(CONTENT - 1, sx + SPRITE_W));
|
|
297
|
-
}
|
|
298
|
-
if (wave >= 3) {
|
|
299
|
-
if (sx - 3 >= 0) rows[R_BGB] = place(rows[R_BGB], "~", sx - 3);
|
|
300
|
-
rows[R_BGB] = place(rows[R_BGB], "~", Math.min(CONTENT - 1, sx + SPRITE_W + 2));
|
|
301
|
-
}
|
|
302
|
-
if (wave >= 4) {
|
|
303
|
-
if (sx - 4 >= 0) rows[R_BGA] = place(rows[R_BGA], "~", sx - 4);
|
|
304
|
-
rows[R_BGA] = place(rows[R_BGA], "~", Math.min(CONTENT - 1, sx + SPRITE_W + 3));
|
|
305
|
-
}
|
|
255
|
+
case "vibing":
|
|
256
|
+
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", leftAccentX);
|
|
257
|
+
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", rightAccentX + 4);
|
|
258
|
+
break;
|
|
259
|
+
default:
|
|
260
|
+
rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "[IN]", 2);
|
|
261
|
+
rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "[OUT]", Math.max(2, width - 8));
|
|
306
262
|
break;
|
|
307
|
-
}
|
|
308
263
|
}
|
|
309
264
|
}
|
|
310
265
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
case "praised": return frame % 2 === 0 ? " * * * * * * *" : " * * * * * * * ";
|
|
321
|
-
case "working": {
|
|
322
|
-
if (frame % 32 >= 26) return ""; // smash handled by particles
|
|
323
|
-
const d = ["[............]","[o...........]","[.o..........]","[..o.........]",
|
|
324
|
-
"[...o........]","[....o.......]","[.....o......]","[......o.....]",
|
|
325
|
-
"[.......o....]","[........o...]","[.........o..]","[..........o.]",
|
|
326
|
-
"[...........o]"];
|
|
327
|
-
return d[frame % d.length] ?? "[............]";
|
|
328
|
-
}
|
|
329
|
-
case "vibing": return ["~ ~ ~ ~ ~ ~ ~"," ~ ~ ~ ~ ~ ~ "," ~ ~ ~ ~ ~ "][frame % 3] ?? "~";
|
|
330
|
-
default: return "";
|
|
266
|
+
function drawMascot(rows: string[], width: number, activity: PetActivity, frame: number): number {
|
|
267
|
+
const mascot = renderMascotLines(mascotStateForActivity(activity, frame));
|
|
268
|
+
const mascotX = Math.max(0, Math.floor((width - MASCOT_WIDTH) / 2));
|
|
269
|
+
for (let i = 0; i < mascot.length; i++) {
|
|
270
|
+
rows[R_MASCOT_START + i] = place(
|
|
271
|
+
rows[R_MASCOT_START + i] ?? blankRow(width),
|
|
272
|
+
mascot[i] ?? "",
|
|
273
|
+
mascotX,
|
|
274
|
+
);
|
|
331
275
|
}
|
|
276
|
+
return mascotX;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function drawDesk(rows: string[], width: number, stats: PetStats): void {
|
|
280
|
+
const deskX = width > PET_SCENE_WIDTH ? 2 : 1;
|
|
281
|
+
const deskWidth = Math.max(4, width - deskX * 2);
|
|
282
|
+
const deskInner = Math.max(1, deskWidth - 2);
|
|
283
|
+
const surface = `laptop [▣] papers [///] ${cupForEnergy(stats.energy)}`;
|
|
284
|
+
const front = `DESK pipeline ${Math.round(stats.deals)}% covenants OK`;
|
|
285
|
+
|
|
286
|
+
rows[R_DESK_SURFACE] = place(
|
|
287
|
+
rows[R_DESK_SURFACE],
|
|
288
|
+
`╭${padDisplayText(surface, deskInner)}╮`,
|
|
289
|
+
deskX,
|
|
290
|
+
);
|
|
291
|
+
rows[R_DESK_FRONT] = place(
|
|
292
|
+
rows[R_DESK_FRONT],
|
|
293
|
+
`╰${padDisplayText(front, deskInner)}╯`,
|
|
294
|
+
deskX,
|
|
295
|
+
);
|
|
332
296
|
}
|
|
333
297
|
|
|
334
|
-
// ─── full scene builder ───────────────────────────────────────────────────────
|
|
335
298
|
function buildScene(
|
|
336
299
|
activity: PetActivity,
|
|
337
300
|
frame: number,
|
|
338
301
|
stats: PetStats,
|
|
339
|
-
|
|
302
|
+
width: number,
|
|
340
303
|
): string[] {
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const dx = Math.max(0, Math.min(CONTENT - deco.length, sx - Math.floor((deco.length - SPRITE_W) / 2)));
|
|
350
|
-
rows[R_DECO] = place(rows[R_DECO], deco, dx);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const sprite = buildSprite(activity, frame);
|
|
354
|
-
const sx = SPRITE_X[activity];
|
|
355
|
-
for (let i = 0; i < sprite.length; i++) {
|
|
356
|
-
rows[R_SP0 + i] = place(rows[R_SP0 + i], sprite[i] ?? "", sx);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return rows;
|
|
304
|
+
const sceneWidth = Math.max(PET_SCENE_WIDTH, Math.floor(width));
|
|
305
|
+
const rows: string[] = Array.from({ length: SCENE_ROWS }, () => blankRow(sceneWidth));
|
|
306
|
+
|
|
307
|
+
drawOfficeBackground(rows, sceneWidth, frame, stats);
|
|
308
|
+
drawDesk(rows, sceneWidth, stats);
|
|
309
|
+
const mascotX = drawMascot(rows, sceneWidth, activity, frame);
|
|
310
|
+
drawActivityAccents(rows, sceneWidth, activity, frame, mascotX);
|
|
311
|
+
return rows.map((row) => overlayFitted(blankRow(sceneWidth), row, 0, sceneWidth));
|
|
360
312
|
}
|
|
361
313
|
|
|
362
314
|
// ─── row colors ───────────────────────────────────────────────────────────────
|
|
363
315
|
function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): string {
|
|
364
|
-
if (i >=
|
|
316
|
+
if (i >= R_MASCOT_START && i < R_MASCOT_START + BRIEFCASE_FINAL.length) {
|
|
365
317
|
if (activity === "sleeping") return t.dim;
|
|
366
318
|
if (activity === "praised") return t.primaryLight;
|
|
367
319
|
if (activity === "eating") return t.warning;
|
|
@@ -369,7 +321,7 @@ function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): st
|
|
|
369
321
|
if (activity === "working" && frame % 32 >= 26) return t.error;
|
|
370
322
|
return t.primary;
|
|
371
323
|
}
|
|
372
|
-
if (i ===
|
|
324
|
+
if (i === R_ACTIVITY) {
|
|
373
325
|
if (activity === "working" && frame % 32 >= 26) return t.error;
|
|
374
326
|
if (activity === "sleeping") return t.dim;
|
|
375
327
|
if (activity === "praised") return t.warning;
|
|
@@ -377,8 +329,9 @@ function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): st
|
|
|
377
329
|
if (activity === "playing") return t.primaryLight;
|
|
378
330
|
return t.primaryLight;
|
|
379
331
|
}
|
|
380
|
-
if (i ===
|
|
381
|
-
if (i ===
|
|
332
|
+
if (i === R_DESK_SURFACE || i === R_DESK_FRONT) return t.primaryDim;
|
|
333
|
+
if (i === R_WALL) return t.dim;
|
|
334
|
+
if (i >= R_WINDOW_TOP && i <= R_WINDOW_BOTTOM) return t.primaryDim;
|
|
382
335
|
return t.primaryDim;
|
|
383
336
|
}
|
|
384
337
|
|
|
@@ -430,123 +383,79 @@ function getStatusMsg(stats: PetStats, frame: number): string {
|
|
|
430
383
|
return msgs[Math.floor(frame / 10) % msgs.length] ?? "Operational.";
|
|
431
384
|
}
|
|
432
385
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
label, value, barColor, labelColor, warnColor,
|
|
436
|
-
}: {
|
|
437
|
-
label: string; value: number; barColor: string; labelColor: string; warnColor: string;
|
|
438
|
-
}) {
|
|
439
|
-
const filled = Math.round((value / 100) * 14);
|
|
440
|
-
const bar = "█".repeat(filled) + "░".repeat(14 - filled);
|
|
441
|
-
const pct = String(Math.round(value)).padStart(3);
|
|
442
|
-
const isLow = value < 25;
|
|
443
|
-
return (
|
|
444
|
-
<Text>
|
|
445
|
-
<Text color={labelColor}>{pad(label, 6)}</Text>
|
|
446
|
-
<Text color={isLow ? warnColor : barColor}>{bar}</Text>
|
|
447
|
-
<Text color={isLow ? warnColor : labelColor}> {pct}%</Text>
|
|
448
|
-
</Text>
|
|
449
|
-
);
|
|
386
|
+
export function getPetStatusMessage(stats: PetStats, frame = 0): string {
|
|
387
|
+
return getStatusMsg(stats, frame);
|
|
450
388
|
}
|
|
451
|
-
const StatBar = memo(StatBarInner);
|
|
452
389
|
|
|
453
390
|
// ─── component ────────────────────────────────────────────────────────────────
|
|
454
|
-
interface
|
|
391
|
+
interface PetSceneProps {
|
|
455
392
|
stats: PetStats;
|
|
456
393
|
activity: PetActivity;
|
|
457
394
|
env?: Environment;
|
|
458
395
|
isPaused?: boolean;
|
|
396
|
+
width?: number;
|
|
459
397
|
}
|
|
460
398
|
|
|
461
|
-
interface CompactPetPanelProps extends
|
|
399
|
+
interface CompactPetPanelProps extends PetSceneProps {
|
|
462
400
|
width: number;
|
|
463
401
|
}
|
|
464
402
|
|
|
465
|
-
function
|
|
466
|
-
|
|
403
|
+
function usePetFrame({
|
|
404
|
+
activity,
|
|
405
|
+
isPaused,
|
|
406
|
+
dead,
|
|
407
|
+
}: {
|
|
408
|
+
activity: PetActivity;
|
|
409
|
+
isPaused: boolean;
|
|
410
|
+
dead: boolean;
|
|
411
|
+
}) {
|
|
467
412
|
const [frame, setFrame] = useState(0);
|
|
468
413
|
|
|
469
414
|
useEffect(() => {
|
|
470
415
|
setFrame(0);
|
|
471
|
-
}, [activity
|
|
416
|
+
}, [activity]);
|
|
472
417
|
|
|
473
418
|
useEffect(() => {
|
|
474
419
|
// Skip frame ticks when paused or when the pet has died — DeathScreen
|
|
475
420
|
// takes over the UI, no point burning a setInterval that mutates state
|
|
476
421
|
// nothing will read.
|
|
477
|
-
if (isPaused ||
|
|
422
|
+
if (isPaused || dead) return;
|
|
478
423
|
const id = setInterval(() => {
|
|
479
424
|
setFrame((f) => f + 1);
|
|
480
425
|
}, 800);
|
|
481
426
|
return () => clearInterval(id);
|
|
482
|
-
}, [
|
|
427
|
+
}, [dead, isPaused]);
|
|
428
|
+
|
|
429
|
+
return frame;
|
|
430
|
+
}
|
|
483
431
|
|
|
432
|
+
export function PetScene({
|
|
433
|
+
stats,
|
|
434
|
+
activity,
|
|
435
|
+
isPaused = false,
|
|
436
|
+
width = PET_SCENE_WIDTH,
|
|
437
|
+
}: PetSceneProps) {
|
|
438
|
+
const t = useTheme();
|
|
439
|
+
const sceneWidth = Math.max(PET_SCENE_WIDTH, Math.floor(width));
|
|
440
|
+
const frame = usePetFrame({
|
|
441
|
+
activity,
|
|
442
|
+
isPaused,
|
|
443
|
+
dead: stats.dead === true,
|
|
444
|
+
});
|
|
484
445
|
const scene = useMemo(
|
|
485
|
-
() => buildScene(activity, frame, stats,
|
|
486
|
-
[activity, frame,
|
|
487
|
-
);
|
|
488
|
-
const mood = useMemo(() => getPetMood(stats), [stats]);
|
|
489
|
-
const status = useMemo(
|
|
490
|
-
() => pad(`memo ${getStatusMsg(stats, frame)}`, CONTENT),
|
|
491
|
-
[stats, frame],
|
|
446
|
+
() => buildScene(activity, frame, stats, sceneWidth),
|
|
447
|
+
[activity, frame, sceneWidth, stats],
|
|
492
448
|
);
|
|
493
|
-
const title = fitDisplayText(`DREXLER PET DESK [${env}]`, CONTENT);
|
|
494
|
-
const activityLabel = activity !== "idle" ? ` / ${activity}` : "";
|
|
495
|
-
const moodLabel = `mood ${mood}`;
|
|
496
|
-
const fittedMood = activityLabel
|
|
497
|
-
? fitDisplayText(moodLabel, Math.max(1, CONTENT - displayWidth(activityLabel)))
|
|
498
|
-
: fitDisplayText(moodLabel, CONTENT);
|
|
499
|
-
const fittedActivity = activityLabel && displayWidth(fittedMood) < CONTENT
|
|
500
|
-
? fitDisplayText(activityLabel, CONTENT - displayWidth(fittedMood))
|
|
501
|
-
: "";
|
|
502
449
|
|
|
503
450
|
return (
|
|
504
|
-
<Box
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
borderStyle="round"
|
|
509
|
-
borderColor={t.primaryDim}
|
|
510
|
-
>
|
|
511
|
-
<Box paddingX={1} justifyContent="center">
|
|
512
|
-
<Text color={t.primary} bold>{title}</Text>
|
|
513
|
-
</Box>
|
|
514
|
-
|
|
515
|
-
<Box flexDirection="column" paddingX={1}>
|
|
516
|
-
{scene.map((row, i) => (
|
|
517
|
-
<Text key={i} color={rowColor(i, activity, frame, t)}>{row}</Text>
|
|
518
|
-
))}
|
|
519
|
-
</Box>
|
|
520
|
-
|
|
521
|
-
<Box paddingX={1}>
|
|
522
|
-
<Text color={t.primaryDim}>{DIVIDER_LINE}</Text>
|
|
523
|
-
</Box>
|
|
524
|
-
|
|
525
|
-
<Box flexDirection="column" paddingX={1}>
|
|
526
|
-
<StatBar label="happy" value={stats.happiness} barColor={t.primary} labelColor={t.dim} warnColor={t.error} />
|
|
527
|
-
<StatBar label="hungr" value={stats.hunger} barColor={t.primaryLight} labelColor={t.dim} warnColor={t.warning} />
|
|
528
|
-
<StatBar label="enrgy" value={stats.energy} barColor={t.primaryLight} labelColor={t.dim} warnColor={t.warning} />
|
|
529
|
-
<StatBar label="deals" value={stats.deals} barColor={t.primaryDim} labelColor={t.dim} warnColor={t.warning} />
|
|
530
|
-
</Box>
|
|
531
|
-
|
|
532
|
-
<Box paddingX={1}>
|
|
533
|
-
<Text color={t.primaryDim}>{DIVIDER_LINE}</Text>
|
|
534
|
-
</Box>
|
|
535
|
-
|
|
536
|
-
<Box paddingX={1}>
|
|
537
|
-
<Text color={t.dim}>{status}</Text>
|
|
538
|
-
</Box>
|
|
539
|
-
|
|
540
|
-
<Box paddingX={1}>
|
|
541
|
-
<Text color={t.dim}>{fittedMood}</Text>
|
|
542
|
-
{fittedActivity && <Text color={t.primaryDim}>{fittedActivity}</Text>}
|
|
543
|
-
</Box>
|
|
451
|
+
<Box flexDirection="column" width={sceneWidth} flexShrink={0}>
|
|
452
|
+
{scene.map((row, i) => (
|
|
453
|
+
<Text key={i} color={rowColor(i, activity, frame, t)}>{row}</Text>
|
|
454
|
+
))}
|
|
544
455
|
</Box>
|
|
545
456
|
);
|
|
546
457
|
}
|
|
547
458
|
|
|
548
|
-
export const PetPanel = memo(PetPanelView);
|
|
549
|
-
|
|
550
459
|
function pct(value: number): string {
|
|
551
460
|
return `${Math.round(value)}%`;
|
|
552
461
|
}
|
|
@@ -580,7 +489,7 @@ interface WorstStat {
|
|
|
580
489
|
value: number;
|
|
581
490
|
}
|
|
582
491
|
|
|
583
|
-
|
|
492
|
+
function pickWorstStat(stats: PetStats): WorstStat {
|
|
584
493
|
const entries: WorstStat[] = [
|
|
585
494
|
{ key: "hunger", value: stats.hunger },
|
|
586
495
|
{ key: "happiness", value: stats.happiness },
|
|
@@ -595,7 +504,6 @@ export function pickWorstStat(stats: PetStats): WorstStat {
|
|
|
595
504
|
function CompactPetPanelView({
|
|
596
505
|
stats,
|
|
597
506
|
activity,
|
|
598
|
-
env = "office",
|
|
599
507
|
isPaused = false,
|
|
600
508
|
width,
|
|
601
509
|
}: CompactPetPanelProps) {
|
|
@@ -615,7 +523,7 @@ function CompactPetPanelView({
|
|
|
615
523
|
|
|
616
524
|
const mood = getPetMood(stats);
|
|
617
525
|
const profile = compactStatProfile(stats);
|
|
618
|
-
const activityCopy = activity === "idle" ?
|
|
526
|
+
const activityCopy = activity === "idle" ? "office" : `office / ${activity}`;
|
|
619
527
|
const title = "Drexler Pet Desk";
|
|
620
528
|
const statLine = [
|
|
621
529
|
`happy ${STAT_LEVEL_LABEL[profile.happiness]}`,
|