drexler 0.2.22 → 0.2.23
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/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/ui/PetPanel.tsx +255 -243
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.23
|
|
4
|
+
|
|
5
|
+
- Redesigned the office pet scene from the ground up against ANSI/TUI art best practices: focal hierarchy, rule-of-thirds composition, single border vocabulary, four-stop brightness ladder, density-gradient backgrounds.
|
|
6
|
+
- One dominant boardroom window now frames an animated city skyline made from half-block silhouettes (`▆▇█`) with lit-window flicker (`█▒` / `█░`) on a per-tower rotating phase.
|
|
7
|
+
- Sky band carries one sun/moon glyph and a slowly drifting cloud. Window top frame shows an in-fiction clock (advances one minute every 5 frames). Window bottom frame restates the activity line plus a single DL% readout — no chrome echo.
|
|
8
|
+
- Mascot is centered with only two desk props: `▭ DREX` nameplate (cursor blinks while working, switches to `▭ zzz` while sleeping) and the steaming `╭c~╮` mug.
|
|
9
|
+
- Single horizon rule replaces the bordered desk strip + floor dots. Steam wisp lives on the horizon row.
|
|
10
|
+
- Activity accents (`z z Z`, `* *`, `$ $`, `~ ~`, `[$]`) live in the empty cells flanking the mascot.
|
|
11
|
+
- Multiple subtle animation channels (skyline flicker, cloud drift, clock, brow/eye/lock, cursor blink, steam wisp, memo rotation) cap at ~3 fps so the scene reads alive without feeling jittery.
|
|
12
|
+
|
|
3
13
|
## 0.2.16
|
|
4
14
|
|
|
5
15
|
- Added an interactive pet system: feed, play, work, praise, rest, vibe, name, and profile commands; persistent stats with offline decay; intern→analyst→associate→VP→MD rank ladder driven by lifetime deal accumulation; 90-second cooldowns per action with in-character rejection copy.
|
package/package.json
CHANGED
package/src/ui/PetPanel.tsx
CHANGED
|
@@ -19,16 +19,30 @@ export type Environment = "office" | "home" | "outdoors";
|
|
|
19
19
|
const PANEL_BORDER_COLUMNS = 2;
|
|
20
20
|
const PANEL_PADDING_COLUMNS = 2;
|
|
21
21
|
const SCENE_ROWS = 18;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
// Row map for the redesigned office scene.
|
|
23
|
+
// 0 title bar (DREXLER OFFICE · stat readout)
|
|
24
|
+
// 1 window top border
|
|
25
|
+
// 2 sky band (sun/moon, drifting cloud)
|
|
26
|
+
// 3 skyscraper rooflines
|
|
27
|
+
// 4 skyscraper upper-window grid
|
|
28
|
+
// 5 skyscraper lower-window grid
|
|
29
|
+
// 6 window bottom border (date / time stamp)
|
|
30
|
+
// 7 breathing row (negative space anchor)
|
|
31
|
+
// 8-14 mascot (BRIEFCASE_FINAL is 7 rows)
|
|
32
|
+
// 15 desk horizon + " DESK " label
|
|
33
|
+
// 16 desktop props (nameplate, steaming mug)
|
|
34
|
+
// 17 memo / status line
|
|
35
|
+
const R_TITLE = 0;
|
|
36
|
+
const R_WIN_TOP = 1;
|
|
37
|
+
const R_WIN_SKY = 2;
|
|
38
|
+
const R_WIN_TOPS = 3;
|
|
39
|
+
const R_WIN_MID = 4;
|
|
40
|
+
const R_WIN_BASE = 5;
|
|
41
|
+
const R_WIN_BOTTOM = 6;
|
|
42
|
+
const R_MASCOT_START = 8;
|
|
43
|
+
const R_DESK_LINE = R_MASCOT_START + BRIEFCASE_FINAL.length;
|
|
44
|
+
const R_DESK_PROPS = R_DESK_LINE + 1;
|
|
45
|
+
const R_MEMO = R_DESK_PROPS + 1;
|
|
32
46
|
|
|
33
47
|
export const PET_SCENE_WIDTH = 52;
|
|
34
48
|
|
|
@@ -39,14 +53,6 @@ function place(base: string, text: string, x: number): string {
|
|
|
39
53
|
return base.slice(0, x) + fit + base.slice(end);
|
|
40
54
|
}
|
|
41
55
|
|
|
42
|
-
function placeSprite(rows: string[], row: number, x: number, sprite: readonly string[]): void {
|
|
43
|
-
for (let i = 0; i < sprite.length; i++) {
|
|
44
|
-
const targetRow = row + i;
|
|
45
|
-
if (targetRow < 0 || targetRow >= rows.length) continue;
|
|
46
|
-
rows[targetRow] = place(rows[targetRow] ?? "", sprite[i] ?? "", x);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
56
|
function blankRow(width: number): string {
|
|
51
57
|
return " ".repeat(width);
|
|
52
58
|
}
|
|
@@ -57,13 +63,6 @@ function padDisplayText(input: string, width: number): string {
|
|
|
57
63
|
return `${fitted}${" ".repeat(Math.max(0, safeWidth - displayWidth(fitted)))}`;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
function centerPadDisplayText(input: string, width: number): string {
|
|
61
|
-
const safeWidth = Math.max(1, width);
|
|
62
|
-
const fitted = fitDisplayText(input, safeWidth);
|
|
63
|
-
const left = Math.max(0, Math.floor((safeWidth - displayWidth(fitted)) / 2));
|
|
64
|
-
return `${" ".repeat(left)}${fitted}${" ".repeat(Math.max(0, safeWidth - left - displayWidth(fitted)))}`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
66
|
function overlayFitted(row: string, text: string, x: number, width: number): string {
|
|
68
67
|
return place(row, padDisplayText(text, width), x);
|
|
69
68
|
}
|
|
@@ -74,61 +73,103 @@ function centerText(row: string, text: string): string {
|
|
|
74
73
|
return place(row, safeText, x);
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return `╭${label}${"─".repeat(ruleWidth)}╮`;
|
|
76
|
+
function cupForEnergy(energy: number): string {
|
|
77
|
+
if (energy > 60) return "c~";
|
|
78
|
+
if (energy > 30) return "c-";
|
|
79
|
+
return "c_";
|
|
82
80
|
}
|
|
83
81
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
// Skyscraper recipe. Each entry is one tower placed left-to-right with
|
|
83
|
+
// roof / upper-windows / lower-windows rows of equal width. The window
|
|
84
|
+
// pattern repeats every `period` columns. `period` and `lit` step the
|
|
85
|
+
// flicker over time so the lit windows rotate without ever changing the
|
|
86
|
+
// tower silhouette.
|
|
87
|
+
interface SkyscraperRecipe {
|
|
88
|
+
width: number;
|
|
89
|
+
gap: number;
|
|
90
|
+
// Roofline glyphs. We pad with the building's edge fill below.
|
|
91
|
+
roof: (width: number) => string;
|
|
92
|
+
upper: (width: number, frame: number, period: number) => string;
|
|
93
|
+
lower: (width: number, frame: number, period: number) => string;
|
|
94
|
+
period: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function repeatPattern(width: number, period: number, picker: (i: number) => string): string {
|
|
98
|
+
let out = "";
|
|
99
|
+
for (let i = 0; i < width; i++) out += picker(i % period);
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function skyscraperTops(width: number): string {
|
|
104
|
+
// ▆▇ produces a fuller roof; flat parapets at the ends keep silhouettes square.
|
|
105
|
+
if (width <= 2) return "▇".repeat(width);
|
|
106
|
+
if (width <= 4) return "▆" + "▇".repeat(width - 2) + "▆";
|
|
107
|
+
return "▆▇" + "▇".repeat(width - 4) + "▇▆";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function skyscraperUpper(width: number, frame: number, period: number): string {
|
|
111
|
+
// Two-glyph alternation █▒ with a slow flicker swap that lights one
|
|
112
|
+
// window per tower every 4 frames. ░ reads as "lit" against ▒ "dim".
|
|
113
|
+
const lit = Math.floor(frame / 3) % period;
|
|
114
|
+
return repeatPattern(width, period, (col) => {
|
|
115
|
+
const base = col % 2 === 0 ? "█" : "▒";
|
|
116
|
+
if (col === lit) return col % 2 === 0 ? "█" : "░";
|
|
117
|
+
return base;
|
|
118
|
+
});
|
|
87
119
|
}
|
|
88
120
|
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
121
|
+
function skyscraperLower(width: number, frame: number, period: number): string {
|
|
122
|
+
// Offset flicker phase from the upper grid so the two rows feel
|
|
123
|
+
// independent without ever looking chaotic.
|
|
124
|
+
const lit = (Math.floor(frame / 3) + 1) % period;
|
|
125
|
+
return repeatPattern(width, period, (col) => {
|
|
126
|
+
const base = col % 2 === 0 ? "█" : "▒";
|
|
127
|
+
if (col === lit) return col % 2 === 0 ? "█" : "░";
|
|
128
|
+
return base;
|
|
129
|
+
});
|
|
92
130
|
}
|
|
93
131
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
width:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
132
|
+
const SKYLINE_RECIPE: ReadonlyArray<SkyscraperRecipe> = [
|
|
133
|
+
{ width: 4, gap: 2, period: 4, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
|
|
134
|
+
{ width: 6, gap: 2, period: 4, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
|
|
135
|
+
{ width: 3, gap: 2, period: 3, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
|
|
136
|
+
{ width: 7, gap: 2, period: 6, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
|
|
137
|
+
{ width: 4, gap: 2, period: 4, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
|
|
138
|
+
{ width: 5, gap: 3, period: 4, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
|
|
139
|
+
{ width: 3, gap: 2, period: 3, roof: skyscraperTops, upper: skyscraperUpper, lower: skyscraperLower },
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
function buildSkylineRows(width: number, frame: number): {
|
|
143
|
+
tops: string;
|
|
144
|
+
upper: string;
|
|
145
|
+
lower: string;
|
|
146
|
+
} {
|
|
147
|
+
// Compose left-to-right until we run out of room. Pad with spaces so
|
|
148
|
+
// the silhouette doesn't extend past the inner canvas width.
|
|
149
|
+
let tops = "";
|
|
150
|
+
let upper = "";
|
|
151
|
+
let lower = "";
|
|
152
|
+
let cursor = 0;
|
|
153
|
+
for (const tower of SKYLINE_RECIPE) {
|
|
154
|
+
if (cursor + tower.width > width) break;
|
|
155
|
+
tops += tower.roof(tower.width);
|
|
156
|
+
upper += tower.upper(tower.width, frame, tower.period);
|
|
157
|
+
lower += tower.lower(tower.width, frame, tower.period);
|
|
158
|
+
cursor += tower.width;
|
|
159
|
+
const gap = Math.min(tower.gap, Math.max(0, width - cursor));
|
|
160
|
+
if (gap > 0) {
|
|
161
|
+
tops += " ".repeat(gap);
|
|
162
|
+
upper += " ".repeat(gap);
|
|
163
|
+
lower += " ".repeat(gap);
|
|
164
|
+
cursor += gap;
|
|
165
|
+
}
|
|
109
166
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function cupForEnergy(energy: number): string {
|
|
118
|
-
if (energy > 60) return "c~";
|
|
119
|
-
if (energy > 30) return "c-";
|
|
120
|
-
return "c_";
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function analogClockSprite(frame: number): readonly string[] {
|
|
124
|
-
const hands = [
|
|
125
|
-
["│╲│ │", "│ └─│"],
|
|
126
|
-
["│ │╱│", "│─┘ │"],
|
|
127
|
-
["│ │ │", "│─┼─│"],
|
|
128
|
-
["│╲│ │", "│─┘ │"],
|
|
129
|
-
] as const;
|
|
130
|
-
const [upper, lower] = hands[Math.floor(frame / 2) % hands.length] ?? hands[0]!;
|
|
131
|
-
return ["╭───╮", upper, lower, "╰───╯"];
|
|
167
|
+
const remainder = Math.max(0, width - cursor);
|
|
168
|
+
return {
|
|
169
|
+
tops: tops + " ".repeat(remainder),
|
|
170
|
+
upper: upper + " ".repeat(remainder),
|
|
171
|
+
lower: lower + " ".repeat(remainder),
|
|
172
|
+
};
|
|
132
173
|
}
|
|
133
174
|
|
|
134
175
|
function progressTicker(frame: number): string {
|
|
@@ -225,129 +266,124 @@ function mascotStateForActivity(activity: PetActivity, frame: number): MascotSta
|
|
|
225
266
|
}
|
|
226
267
|
}
|
|
227
268
|
|
|
228
|
-
function
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
? Math.min(25, Math.max(20, width - windowWidth - 5))
|
|
244
|
-
: Math.min(36, Math.max(26, Math.floor(width * 0.36)));
|
|
245
|
-
const boardX = Math.max(windowWidth + 3, width - boardWidth - 2);
|
|
246
|
-
const windowRight = 1 + windowWidth;
|
|
247
|
-
const gapWidth = boardX - windowRight;
|
|
248
|
-
const cloud = frame % 6 < 3 ? "(~~)" : " (~~)";
|
|
249
|
-
const sun = frame % 12 < 6 ? "\\o/" : "-o-";
|
|
250
|
-
const city = frame % 10 < 5 ? "▂▄▆ city" : "▃▅▇ city";
|
|
251
|
-
const tape = frame % 8 < 4 ? "▁▃▅▇" : "▂▄▆█";
|
|
252
|
-
const cursor = frame % 4 < 2 ? ">" : "*";
|
|
253
|
-
|
|
254
|
-
placeBoxLines(rows, R_WINDOW_TOP, 1, windowWidth, "Window", [
|
|
255
|
-
`╔╤╤╗ ${sun} ${cloud}`,
|
|
256
|
-
`║▥▥║ ${city}`,
|
|
257
|
-
]);
|
|
258
|
-
placeBoxLines(
|
|
259
|
-
rows,
|
|
260
|
-
R_WINDOW_TOP,
|
|
261
|
-
boardX,
|
|
262
|
-
Math.min(boardWidth, width - boardX),
|
|
263
|
-
"Deal Board",
|
|
264
|
-
[
|
|
265
|
-
`DL ${dealPct} FEE ${Math.round(stats.happiness).toString().padStart(3)}%`,
|
|
266
|
-
`PIPE ${tape} $ ${cursor}`,
|
|
267
|
-
],
|
|
269
|
+
function drawTitleBar(rows: string[], width: number, stats: PetStats): void {
|
|
270
|
+
// Single-line title bar: "─ DREXLER OFFICE ─ … ─ deals 38% ─" with
|
|
271
|
+
// padded glyphs around each segment so the eye reads them as labels
|
|
272
|
+
// on a rule, not a rule running through text. The right-aligned
|
|
273
|
+
// readout names the most-pressing stat. Avoids the previous chrome
|
|
274
|
+
// echo where the same percentage appeared in both the title and the
|
|
275
|
+
// desk strip.
|
|
276
|
+
const label = " DREXLER OFFICE ";
|
|
277
|
+
const worst = pickWorstStat(stats);
|
|
278
|
+
const readout = ` ${worst.key} ${Math.round(worst.value)}% `;
|
|
279
|
+
rows[R_TITLE] = centerText("─".repeat(width), label);
|
|
280
|
+
rows[R_TITLE] = place(
|
|
281
|
+
rows[R_TITLE],
|
|
282
|
+
fitDisplayText(readout, Math.max(1, width - 2)),
|
|
283
|
+
Math.max(0, width - displayWidth(readout) - 1),
|
|
268
284
|
);
|
|
285
|
+
}
|
|
269
286
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
287
|
+
function clockFromFrame(frame: number): string {
|
|
288
|
+
// Slow ambient clock — advances roughly one minute every 5 frames.
|
|
289
|
+
const startHour = 9; // boardroom opens at 9 AM corporate time.
|
|
290
|
+
const totalMinutes = startHour * 60 + Math.floor(frame / 5);
|
|
291
|
+
const hour = Math.floor(totalMinutes / 60) % 24;
|
|
292
|
+
const minute = totalMinutes % 60;
|
|
293
|
+
return `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
|
294
|
+
}
|
|
274
295
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
296
|
+
function drawBoardroomWindow(
|
|
297
|
+
rows: string[],
|
|
298
|
+
width: number,
|
|
299
|
+
frame: number,
|
|
300
|
+
stats: PetStats,
|
|
301
|
+
activity: PetActivity,
|
|
302
|
+
): void {
|
|
303
|
+
// Outer rounded window frame spans nearly the full panel width. The
|
|
304
|
+
// city skyline lives entirely inside the frame; nothing else competes
|
|
305
|
+
// with it for upper-half attention.
|
|
306
|
+
const winX = 1;
|
|
307
|
+
const winWidth = Math.max(20, width - 2);
|
|
308
|
+
const innerWidth = Math.max(8, winWidth - 2);
|
|
309
|
+
|
|
310
|
+
// Top frame carries a quiet label so the eye knows what it's looking at.
|
|
311
|
+
const topLabel = ` Skyline · ${clockFromFrame(frame)} `;
|
|
312
|
+
const topRuleWidth = Math.max(0, innerWidth - displayWidth(topLabel));
|
|
313
|
+
rows[R_WIN_TOP] = place(
|
|
314
|
+
rows[R_WIN_TOP],
|
|
315
|
+
`╭${topLabel}${"─".repeat(topRuleWidth)}╮`,
|
|
316
|
+
winX,
|
|
278
317
|
);
|
|
279
|
-
}
|
|
280
318
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
319
|
+
// Sky row: sun/moon left, drifting cloud right, otherwise empty so
|
|
320
|
+
// the skyline has clean air to breathe.
|
|
321
|
+
const sunGlyph = frame % 24 < 12 ? "☼" : "☾";
|
|
322
|
+
const cloudOffset = (Math.floor(frame / 2) % (innerWidth - 6)) + 2;
|
|
323
|
+
let sky = " ".repeat(innerWidth);
|
|
324
|
+
sky = place(sky, sunGlyph, 1);
|
|
325
|
+
sky = place(sky, "(~~)", cloudOffset);
|
|
326
|
+
rows[R_WIN_SKY] = place(rows[R_WIN_SKY], `│${sky}│`, winX);
|
|
327
|
+
|
|
328
|
+
// Skyscrapers. Centered horizontally inside the inner canvas so the
|
|
329
|
+
// skyline reads as one continuous silhouette.
|
|
330
|
+
const skylineWidth = Math.max(8, innerWidth - 4);
|
|
331
|
+
const { tops, upper, lower } = buildSkylineRows(skylineWidth, frame);
|
|
332
|
+
const padLeft = Math.max(0, Math.floor((innerWidth - skylineWidth) / 2));
|
|
333
|
+
const padRight = Math.max(0, innerWidth - padLeft - skylineWidth);
|
|
334
|
+
const wrapSkylineRow = (row: string): string =>
|
|
335
|
+
`│${" ".repeat(padLeft)}${row}${" ".repeat(padRight)}│`;
|
|
336
|
+
rows[R_WIN_TOPS] = place(rows[R_WIN_TOPS], wrapSkylineRow(tops), winX);
|
|
337
|
+
rows[R_WIN_MID] = place(rows[R_WIN_MID], wrapSkylineRow(upper), winX);
|
|
338
|
+
rows[R_WIN_BASE] = place(rows[R_WIN_BASE], wrapSkylineRow(lower), winX);
|
|
339
|
+
|
|
340
|
+
// Bottom frame restates the current activity for at-a-glance status.
|
|
341
|
+
const bottomLabel = ` ${buildActivityLine(activity, frame)} · DL ${Math.round(stats.deals).toString().padStart(3)}% `;
|
|
342
|
+
const fittedBottom = fitDisplayText(bottomLabel, Math.max(1, innerWidth - 2));
|
|
343
|
+
const bottomRuleWidth = Math.max(0, innerWidth - displayWidth(fittedBottom));
|
|
344
|
+
rows[R_WIN_BOTTOM] = place(
|
|
345
|
+
rows[R_WIN_BOTTOM],
|
|
346
|
+
`╰${fittedBottom}${"─".repeat(bottomRuleWidth)}╯`,
|
|
347
|
+
winX,
|
|
348
|
+
);
|
|
306
349
|
}
|
|
307
350
|
|
|
308
351
|
function drawActivityAccents(
|
|
309
352
|
rows: string[],
|
|
310
353
|
width: number,
|
|
311
354
|
activity: PetActivity,
|
|
312
|
-
|
|
355
|
+
_frame: number,
|
|
313
356
|
mascotX: number,
|
|
314
357
|
): void {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
);
|
|
319
|
-
|
|
358
|
+
// Accents are now small, single-glyph flourishes positioned in the
|
|
359
|
+
// empty cells immediately flanking the mascot. No competing props,
|
|
360
|
+
// so accents always have clean space to land on.
|
|
320
361
|
const mascotRight = mascotX + MASCOT_WIDTH;
|
|
321
|
-
const leftAccentX = Math.max(1, mascotX -
|
|
322
|
-
const
|
|
323
|
-
const rightAccentX = Math.min(fileX - 6, mascotRight + 2);
|
|
362
|
+
const leftAccentX = Math.max(1, mascotX - 4);
|
|
363
|
+
const rightAccentX = Math.min(width - 2, mascotRight + 2);
|
|
324
364
|
|
|
325
365
|
switch (activity) {
|
|
326
366
|
case "eating":
|
|
327
|
-
rows[R_MASCOT_START + 3] = place(
|
|
328
|
-
rows[R_MASCOT_START + 3],
|
|
329
|
-
"[$]",
|
|
330
|
-
Math.max(1, Math.min(fileX - 5, rightAccentX + 1)),
|
|
331
|
-
);
|
|
367
|
+
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "[$]", rightAccentX);
|
|
332
368
|
break;
|
|
333
369
|
case "playing":
|
|
334
370
|
rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", leftAccentX);
|
|
335
|
-
rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*",
|
|
371
|
+
rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", rightAccentX);
|
|
336
372
|
break;
|
|
337
373
|
case "working":
|
|
338
374
|
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "$", leftAccentX);
|
|
339
|
-
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "$",
|
|
375
|
+
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "$", rightAccentX);
|
|
340
376
|
break;
|
|
341
377
|
case "sleeping":
|
|
342
378
|
rows[R_MASCOT_START] = place(rows[R_MASCOT_START], "z z Z", rightAccentX);
|
|
343
379
|
break;
|
|
344
380
|
case "praised":
|
|
345
381
|
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", leftAccentX);
|
|
346
|
-
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *",
|
|
382
|
+
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", rightAccentX);
|
|
347
383
|
break;
|
|
348
384
|
case "vibing":
|
|
349
385
|
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", leftAccentX);
|
|
350
|
-
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~",
|
|
386
|
+
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", rightAccentX);
|
|
351
387
|
break;
|
|
352
388
|
default:
|
|
353
389
|
break;
|
|
@@ -367,82 +403,55 @@ function drawMascot(rows: string[], width: number, activity: PetActivity, frame:
|
|
|
367
403
|
return mascotX;
|
|
368
404
|
}
|
|
369
405
|
|
|
370
|
-
function
|
|
406
|
+
function drawDeskHorizon(rows: string[], width: number): void {
|
|
407
|
+
// One quiet horizon line anchors the mascot in the room. The
|
|
408
|
+
// " DESK " label sits at the rule's center and identifies the
|
|
409
|
+
// working surface without needing a bordered strip below.
|
|
410
|
+
const label = " DREXLER DEAL DESK ";
|
|
411
|
+
const fitted = fitDisplayText(label, Math.max(1, width - 4));
|
|
412
|
+
const labelWidth = displayWidth(fitted);
|
|
413
|
+
const leftRule = Math.max(2, Math.floor((width - labelWidth) / 2));
|
|
414
|
+
const rightRule = Math.max(2, width - leftRule - labelWidth);
|
|
415
|
+
rows[R_DESK_LINE] = `${"─".repeat(leftRule)}${fitted}${"─".repeat(rightRule)}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function drawDeskProps(
|
|
371
419
|
rows: string[],
|
|
372
420
|
width: number,
|
|
373
421
|
activity: PetActivity,
|
|
374
422
|
frame: number,
|
|
375
423
|
stats: PetStats,
|
|
376
424
|
): void {
|
|
425
|
+
// Two props only: the mascot nameplate on the left and a coffee mug
|
|
426
|
+
// on the right. Both sit on the desk-props row so they share a
|
|
427
|
+
// baseline with the mascot above and never float.
|
|
377
428
|
const mascotX = Math.max(0, Math.floor((width - MASCOT_WIDTH) / 2));
|
|
378
429
|
const mascotRight = mascotX + MASCOT_WIDTH;
|
|
379
|
-
const
|
|
380
|
-
const
|
|
381
|
-
const
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
const screen =
|
|
385
|
-
activity === "working"
|
|
386
|
-
? `$>${cursor}DL`
|
|
387
|
-
: activity === "sleeping"
|
|
388
|
-
? "zzz..."
|
|
389
|
-
: "DREX";
|
|
390
|
-
const steam = stats.energy > 30
|
|
391
|
-
? frame % 4 < 2 ? " ((" : " ))"
|
|
392
|
-
: " ";
|
|
393
|
-
const paperFace = frame % 6 < 3 ? "▱▱▱" : "▰▱▱";
|
|
394
|
-
|
|
395
|
-
placeSprite(rows, R_MASCOT_START + 4, laptopX, [
|
|
396
|
-
"╭──────╮",
|
|
397
|
-
`│${padDisplayText(screen, 6)}│`,
|
|
398
|
-
"╰─┬──┬─╯",
|
|
399
|
-
]);
|
|
400
|
-
|
|
401
|
-
if (papersX > mascotRight) {
|
|
402
|
-
rows[R_MASCOT_START + 6] = place(rows[R_MASCOT_START + 6], paperFace, papersX);
|
|
403
|
-
}
|
|
404
|
-
|
|
430
|
+
const nameplateX = Math.max(2, mascotX - 12);
|
|
431
|
+
const mugX = Math.min(width - 6, mascotRight + 4);
|
|
432
|
+
const cursorAt = activity === "working" && frame % 2 === 0 ? "_" : " ";
|
|
433
|
+
const namePlate = activity === "sleeping" ? "▭ zzz " : `▭ DREX${cursorAt}`;
|
|
434
|
+
rows[R_DESK_PROPS] = place(rows[R_DESK_PROPS], namePlate, nameplateX);
|
|
405
435
|
if (mugX > mascotRight + 1) {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
436
|
+
// Steam wisp lives on the desk horizon row, just above the mug.
|
|
437
|
+
// Letting it tick frame-by-frame gives the room one quiet ambient
|
|
438
|
+
// beat without competing with the skyline flicker.
|
|
439
|
+
const steam = stats.energy > 30
|
|
440
|
+
? frame % 4 < 2 ? " ((" : " ))"
|
|
441
|
+
: "";
|
|
442
|
+
if (steam) {
|
|
443
|
+
rows[R_DESK_LINE] = place(rows[R_DESK_LINE], steam, mugX + 1);
|
|
444
|
+
}
|
|
445
|
+
rows[R_DESK_PROPS] = place(rows[R_DESK_PROPS], `╭${cupForEnergy(stats.energy)}╮`, mugX);
|
|
409
446
|
}
|
|
410
447
|
}
|
|
411
448
|
|
|
412
|
-
function
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
const drawers = width < 68
|
|
419
|
-
? "│▤▤│ │▤▤│ │▤▤│"
|
|
420
|
-
: "│▤▤│ │▤▤│ │▤▤│ │▤▤│";
|
|
421
|
-
|
|
422
|
-
rows[R_DESK_SURFACE] = place(
|
|
423
|
-
rows[R_DESK_SURFACE],
|
|
424
|
-
`╭${padDisplayText(surface, deskInner)}╮`,
|
|
425
|
-
deskX,
|
|
426
|
-
);
|
|
427
|
-
rows[R_DESK_FRONT] = place(
|
|
428
|
-
rows[R_DESK_FRONT],
|
|
429
|
-
`│${padDisplayText(front, deskInner)}│`,
|
|
430
|
-
deskX,
|
|
431
|
-
);
|
|
432
|
-
rows[R_DESK_DRAWERS] = place(
|
|
433
|
-
rows[R_DESK_DRAWERS],
|
|
434
|
-
`│${centerPadDisplayText(drawers, deskInner)}│`,
|
|
435
|
-
deskX,
|
|
436
|
-
);
|
|
437
|
-
rows[R_DESK_BOTTOM] = place(
|
|
438
|
-
rows[R_DESK_BOTTOM],
|
|
439
|
-
`╰${"─".repeat(Math.max(0, deskInner))}╯`,
|
|
440
|
-
deskX,
|
|
441
|
-
);
|
|
442
|
-
rows[R_FLOOR] = centerText(
|
|
443
|
-
rows[R_FLOOR],
|
|
444
|
-
fitDisplayText("· · · · · · · · · · · · · · · · ·", width),
|
|
445
|
-
);
|
|
449
|
+
function drawMemo(rows: string[], width: number, stats: PetStats, frame: number): void {
|
|
450
|
+
// Memo row carries the rotating status message. Centered, dim — the
|
|
451
|
+
// closing punctuation of the scene rather than a competing chrome
|
|
452
|
+
// strip with its own border.
|
|
453
|
+
const memo = `· ${getStatusMsg(stats, frame)} ·`;
|
|
454
|
+
rows[R_MEMO] = centerText(" ".repeat(width), fitDisplayText(memo, width));
|
|
446
455
|
}
|
|
447
456
|
|
|
448
457
|
function buildScene(
|
|
@@ -454,16 +463,22 @@ function buildScene(
|
|
|
454
463
|
const sceneWidth = Math.max(PET_SCENE_WIDTH, Math.floor(width));
|
|
455
464
|
const rows: string[] = Array.from({ length: SCENE_ROWS }, () => blankRow(sceneWidth));
|
|
456
465
|
|
|
457
|
-
|
|
458
|
-
|
|
466
|
+
drawTitleBar(rows, sceneWidth, stats);
|
|
467
|
+
drawBoardroomWindow(rows, sceneWidth, frame, stats, activity);
|
|
459
468
|
const mascotX = drawMascot(rows, sceneWidth, activity, frame);
|
|
460
|
-
|
|
461
|
-
|
|
469
|
+
drawDeskHorizon(rows, sceneWidth);
|
|
470
|
+
drawDeskProps(rows, sceneWidth, activity, frame, stats);
|
|
471
|
+
drawMemo(rows, sceneWidth, stats, frame);
|
|
462
472
|
drawActivityAccents(rows, sceneWidth, activity, frame, mascotX);
|
|
463
473
|
return rows.map((row) => overlayFitted(blankRow(sceneWidth), row, 0, sceneWidth));
|
|
464
474
|
}
|
|
465
475
|
|
|
466
476
|
// ─── row colors ───────────────────────────────────────────────────────────────
|
|
477
|
+
// Four-stop brightness ladder so the eye finds a hierarchy:
|
|
478
|
+
// dim → chrome (title rule, memo)
|
|
479
|
+
// primaryDim → window frame, desk horizon, skyline silhouettes
|
|
480
|
+
// primary → desk props, mascot body
|
|
481
|
+
// primaryLight → mascot eyes + activity accents
|
|
467
482
|
function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): string {
|
|
468
483
|
if (i >= R_MASCOT_START && i < R_MASCOT_START + BRIEFCASE_FINAL.length) {
|
|
469
484
|
if (activity === "sleeping") return t.dim;
|
|
@@ -473,23 +488,20 @@ function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): st
|
|
|
473
488
|
if (activity === "working" && frame % 32 >= 26) return t.error;
|
|
474
489
|
return t.primary;
|
|
475
490
|
}
|
|
476
|
-
if (i ===
|
|
491
|
+
if (i === R_TITLE) return t.dim;
|
|
492
|
+
if (i === R_WIN_SKY) return t.dim;
|
|
493
|
+
if (i === R_WIN_TOPS || i === R_WIN_MID || i === R_WIN_BASE) return t.primaryDim;
|
|
494
|
+
if (i === R_WIN_TOP || i === R_WIN_BOTTOM) return t.primaryDim;
|
|
495
|
+
if (i === R_DESK_LINE) return t.primaryDim;
|
|
496
|
+
if (i === R_DESK_PROPS) return t.primary;
|
|
497
|
+
if (i === R_MEMO) {
|
|
477
498
|
if (activity === "working" && frame % 32 >= 26) return t.error;
|
|
478
499
|
if (activity === "sleeping") return t.dim;
|
|
479
500
|
if (activity === "praised") return t.warning;
|
|
480
501
|
if (activity === "eating") return t.warning;
|
|
481
502
|
if (activity === "playing") return t.primaryLight;
|
|
482
|
-
return t.
|
|
503
|
+
return t.dim;
|
|
483
504
|
}
|
|
484
|
-
if (
|
|
485
|
-
i === R_DESK_SURFACE ||
|
|
486
|
-
i === R_DESK_FRONT ||
|
|
487
|
-
i === R_DESK_DRAWERS ||
|
|
488
|
-
i === R_DESK_BOTTOM ||
|
|
489
|
-
i === R_FLOOR
|
|
490
|
-
) return t.primaryDim;
|
|
491
|
-
if (i === R_WALL) return t.dim;
|
|
492
|
-
if (i >= R_WINDOW_TOP && i <= R_WINDOW_BOTTOM) return t.primaryDim;
|
|
493
505
|
return t.primaryDim;
|
|
494
506
|
}
|
|
495
507
|
|