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.
@@ -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 CONTENT = PET_PANEL_WIDTH - PANEL_BORDER_COLUMNS - PANEL_PADDING_COLUMNS;
18
- const SPRITE_W = 8;
19
- const DIVIDER_LINE = "─".repeat(CONTENT);
20
-
21
- const R_SKY = 0;
22
- const R_BGA = 1;
23
- const R_BGB = 2;
24
- const R_DECO = 3;
25
- const R_SP0 = 4; // sprite occupies rows 4–9
26
- const R_FLOOR = 10;
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
- return [
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
- // ─── environments (simplified) ───────────────────────────────────────────────
96
-
97
- function drawOffice(rows: string[], frame: number, stats: PetStats): void {
98
- const hour = new Date().getHours();
99
- const isDay = hour >= 6 && hour < 20;
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
- const dl = String(Math.round(stats.deals)).padStart(3);
111
- rows[R_BGA] = place(rows[R_BGA], "╔═DEALS════╗", 20);
112
- rows[R_BGB] = place(rows[R_BGB], `║ DL:${dl}% ║`, 20);
49
+ function overlayFitted(row: string, text: string, x: number, width: number): string {
50
+ return place(row, padDisplayText(text, width), x);
51
+ }
113
52
 
114
- // Desk (rows 7–9, right: 10 wide at x=22)
115
- rows[R_SP0 + 3] = place(rows[R_SP0 + 3], "╔════════╗", 22);
116
- rows[R_SP0 + 4] = place(rows[R_SP0 + 4], "║ ║", 22);
117
- rows[R_SP0 + 5] = place(rows[R_SP0 + 5], "╚════════╝", 22);
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
- // Floor with coffee
120
- rows[R_FLOOR] = "─".repeat(CONTENT);
121
- const cup = stats.energy > 60 ? "[c~]" : stats.energy > 30 ? "[c-]" : "[c_]";
122
- rows[R_FLOOR] = place(rows[R_FLOOR], cup, CONTENT - 5);
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 drawHome(rows: string[], frame: number, stats: PetStats): void {
126
- const hour = new Date().getHours();
127
- const isDay = hour >= 6 && hour < 20;
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
- rows[R_SKY] = "─".repeat(CONTENT);
71
+ function sceneBoxBottom(width: number): string {
72
+ const safeWidth = Math.max(4, width);
73
+ return `╰${"─".repeat(safeWidth - 2)}╯`;
74
+ }
130
75
 
131
- // Window left (8 wide)
132
- rows[R_BGA] = place(rows[R_BGA], "╭──────╮", 0);
133
- const yard = isDay
134
- ? (frame % 8 < 4 ? "│ ~~~~ │" : "│~~~~~ │")
135
- : (frame % 8 < 4 ? "│ ** │" : "│ * * │");
136
- rows[R_BGB] = place(rows[R_BGB], yard, 0);
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
- // TV right (10 wide at x=22)
139
- const tvContent = ["[~~~]", "[~~ ]", "[ ~~]", "[~~~]"][frame % 4] ?? "[~~~]";
140
- rows[R_BGA] = place(rows[R_BGA], "╔══TV════╗", 22);
141
- rows[R_BGB] = place(rows[R_BGB], `║${tvContent} ║`, 22);
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
- // Couch (rows 6–8, right: 10 wide at x=22)
144
- rows[R_SP0 + 2] = place(rows[R_SP0 + 2], "╭────────╮", 22);
145
- rows[R_SP0 + 3] = place(rows[R_SP0 + 3], "│ ≈≈≈≈≈≈ │", 22);
146
- rows[R_SP0 + 4] = place(rows[R_SP0 + 4], "╰════════╯", 22);
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
- // Carpet floor (precomputed; placement only varies with stats)
149
- rows[R_FLOOR] = HOME_CARPET;
150
- const cup = stats.energy > 60 ? "[c~]" : stats.energy > 30 ? "[c-]" : "[c_]";
151
- rows[R_FLOOR] = place(rows[R_FLOOR], cup, 9);
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 drawOutdoors(rows: string[], frame: number, _stats: PetStats): void {
155
- const hour = new Date().getHours();
156
- const isDay = hour >= 6 && hour < 20;
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
- // Sky: dot texture + sun/moon + drifting cloud
159
- rows[R_SKY] = ". . . . . . . . . . . . . . . .".slice(0, CONTENT);
160
- rows[R_SKY] = place(rows[R_SKY], isDay ? "[O]" : "[*]", 1);
161
- const cloudX = 10 + Math.floor((Math.sin(frame * 0.08) * 0.5 + 0.5) * 10);
162
- rows[R_SKY] = place(rows[R_SKY], frame % 8 < 4 ? "(~~)" : "( ~)", cloudX);
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
- // Single tree (left, rows 1–2)
165
- rows[R_BGA] = place(rows[R_BGA], " /|\\", 0);
166
- rows[R_BGB] = place(rows[R_BGB], " |||", 0);
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
- // Grass floor (subtle shimmer)
169
- rows[R_FLOOR] = Array.from({ length: CONTENT }, (_, i) =>
170
- (i + frame) % 9 === 0 ? "w" : "─"
171
- ).join("");
214
+ rows[R_ACTIVITY] = centerText(
215
+ "─".repeat(width),
216
+ buildActivityLine("idle", frame),
217
+ );
172
218
  }
173
219
 
174
- function drawBackground(rows: string[], env: Environment, frame: number, stats: PetStats): void {
175
- if (env === "office") drawOffice(rows, frame, stats);
176
- else if (env === "home") drawHome(rows, frame, stats);
177
- else drawOutdoors(rows, frame, stats);
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
- // ─── fluid activity particles ─────────────────────────────────────────────────
181
- function drawParticles(rows: string[], activity: PetActivity, frame: number): void {
182
- const sx = SPRITE_X[activity];
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 "sleeping": {
186
- // z chain rising above sprite — each z climbs one row per 2 frames
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
- case "playing": {
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
- case "working": {
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
- case "praised": {
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
- case "vibing": {
286
- // Concentric ~ rings expanding each frame, reset at 5
287
- const wave = frame % 5;
288
- if (wave >= 1) {
289
- if (sx - 1 >= 0) rows[R_SP0 + 2] = place(rows[R_SP0 + 2], "~", sx - 1);
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
- // ─── deco text above sprite ───────────────────────────────────────────────────
312
- function buildDeco(activity: PetActivity, frame: number): string {
313
- switch (activity) {
314
- case "sleeping": return ["z . .", ". z .", ". . z", "Z Z Z"][frame % 4] ?? "z";
315
- case "eating": return Math.floor(frame / 3) % 2 === 0 ? " nom nom nom " : " NOM NOM NOM ";
316
- case "playing": {
317
- const f = frame % 6;
318
- return f < 2 ? " G A I N S ! " : f < 4 ? " *FLEX MODE ON*" : " CORPORATE PUMP";
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
- env: Environment,
302
+ width: number,
340
303
  ): string[] {
341
- const rows: string[] = Array.from({ length: SCENE_ROWS }, () => " ".repeat(CONTENT));
342
-
343
- drawBackground(rows, env, frame, stats);
344
- drawParticles(rows, activity, frame);
345
-
346
- const deco = buildDeco(activity, frame);
347
- if (deco) {
348
- const sx = SPRITE_X[activity];
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 >= R_SP0 && i < R_SP0 + 6) {
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 === R_DECO) {
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 === R_FLOOR) return t.primaryDim;
381
- if (i === R_SKY) return t.dim;
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
- // ─── stat bar ─────────────────────────────────────────────────────────────────
434
- function StatBarInner({
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 PetPanelProps {
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 PetPanelProps {
399
+ interface CompactPetPanelProps extends PetSceneProps {
462
400
  width: number;
463
401
  }
464
402
 
465
- function PetPanelView({ stats, activity, env = "office", isPaused = false }: PetPanelProps) {
466
- const t = useTheme();
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, env]);
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 || stats.dead === true) return;
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
- }, [isPaused, stats.dead]);
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, env),
486
- [activity, frame, stats, env],
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
- flexDirection="column"
506
- width={PET_PANEL_WIDTH}
507
- flexShrink={0}
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
- export function pickWorstStat(stats: PetStats): WorstStat {
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" ? env : `${env} / ${activity}`;
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]}`,