drexler 0.2.21 → 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/commands.ts +18 -3
- package/src/config.ts +37 -16
- package/src/llm.ts +44 -3
- package/src/pet/petState.ts +10 -0
- package/src/repl.ts +8 -3
- package/src/ui/CommandPalette.tsx +10 -1
- package/src/ui/PetPanel.tsx +255 -237
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/commands.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { resolve as pathResolve } from "node:path";
|
|
3
|
+
import { isAbsolute, resolve as pathResolve, sep as pathSep } from "node:path";
|
|
4
4
|
import {
|
|
5
5
|
getConfigPath,
|
|
6
6
|
getDrexlerVersion,
|
|
@@ -499,9 +499,24 @@ function resolveWriteTarget(
|
|
|
499
499
|
ctx.print(error(`Invalid path: ${pathArg} (no '..' segments allowed).`));
|
|
500
500
|
return null;
|
|
501
501
|
}
|
|
502
|
+
const cwd = process.cwd();
|
|
502
503
|
const target = pathArg
|
|
503
|
-
? pathResolve(pathArg)
|
|
504
|
-
: pathResolve(`${defaultPrefix}-${Date.now()}${requiredExt}`);
|
|
504
|
+
? pathResolve(cwd, pathArg)
|
|
505
|
+
: pathResolve(cwd, `${defaultPrefix}-${Date.now()}${requiredExt}`);
|
|
506
|
+
// Sandbox relative paths to cwd. Absolute paths are user-explicit
|
|
507
|
+
// (e.g. `/save /tmp/notes.md`) and trusted — drexler runs with the
|
|
508
|
+
// user's own permissions, so they can write anywhere they could write
|
|
509
|
+
// from the shell. Defense in depth catches relative paths that, after
|
|
510
|
+
// resolution, would land outside cwd via a sneaky construction.
|
|
511
|
+
if (pathArg && !isAbsolute(pathArg)) {
|
|
512
|
+
const cwdPrefix = cwd.endsWith(pathSep) ? cwd : cwd + pathSep;
|
|
513
|
+
if (target !== cwd && !target.startsWith(cwdPrefix)) {
|
|
514
|
+
ctx.print(
|
|
515
|
+
error(`Invalid path: ${pathArg} (resolves outside current directory).`),
|
|
516
|
+
);
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
505
520
|
if (!target.toLowerCase().endsWith(requiredExt)) {
|
|
506
521
|
ctx.print(error(`Target must end in ${requiredExt}: ${target}`));
|
|
507
522
|
return null;
|
package/src/config.ts
CHANGED
|
@@ -151,28 +151,49 @@ async function validatePersonaFile(
|
|
|
151
151
|
return resolved;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const path = existsSync(cp) ? cp : existsSync(lp) ? lp : null;
|
|
158
|
-
if (!path) return {};
|
|
154
|
+
async function readConfigPath(
|
|
155
|
+
path: string,
|
|
156
|
+
): Promise<{ raw: string; missing: false } | { raw: null; missing: true } | { raw: null; missing: false; error: NodeJS.ErrnoException }> {
|
|
159
157
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
return { raw: await readFile(path, "utf-8"), missing: false };
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
161
|
+
if (code === "ENOENT") return { raw: null, missing: true };
|
|
162
|
+
return { raw: null, missing: false, error: err as NodeJS.ErrnoException };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function loadConfigFile(): Promise<Partial<Config>> {
|
|
167
|
+
// Try canonical XDG path first, then legacy ~/.drexlerrc. Reading
|
|
168
|
+
// unconditionally (instead of gating on existsSync) means EACCES
|
|
169
|
+
// surfaces a warning rather than silently masquerading as "no file".
|
|
170
|
+
for (const path of [configPath(), legacyConfigPath()]) {
|
|
171
|
+
const result = await readConfigPath(path);
|
|
172
|
+
if (result.missing) continue;
|
|
173
|
+
if (result.raw === null) {
|
|
163
174
|
console.warn(
|
|
164
|
-
`Drexler config at ${path}
|
|
175
|
+
`Drexler config at ${path} could not be read (${result.error.code ?? result.error.message}); ignoring (defaults applied).`,
|
|
176
|
+
);
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const parsed: unknown = JSON.parse(result.raw);
|
|
181
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
182
|
+
console.warn(
|
|
183
|
+
`Drexler config at ${path} is not a JSON object; ignoring (defaults applied).`,
|
|
184
|
+
);
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
return parsed as Partial<Config>;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
190
|
+
console.warn(
|
|
191
|
+
`Drexler config at ${path} could not be parsed (${msg}); ignoring (defaults applied).`,
|
|
165
192
|
);
|
|
166
193
|
return {};
|
|
167
194
|
}
|
|
168
|
-
return parsed as Partial<Config>;
|
|
169
|
-
} catch (err) {
|
|
170
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
171
|
-
console.warn(
|
|
172
|
-
`Drexler config at ${path} could not be read (${msg}); ignoring (defaults applied).`,
|
|
173
|
-
);
|
|
174
|
-
return {};
|
|
175
195
|
}
|
|
196
|
+
return {};
|
|
176
197
|
}
|
|
177
198
|
|
|
178
199
|
export async function saveConfig(partial: Partial<Config>): Promise<void> {
|
package/src/llm.ts
CHANGED
|
@@ -11,6 +11,32 @@ const STOP_SEQUENCES = [
|
|
|
11
11
|
];
|
|
12
12
|
const RETRY_DELAY_MS = 250;
|
|
13
13
|
|
|
14
|
+
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
15
|
+
return new Promise<void>((resolve, reject) => {
|
|
16
|
+
if (signal?.aborted) {
|
|
17
|
+
reject(new Error("aborted before retry"));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const timer = setTimeout(() => {
|
|
21
|
+
signal?.removeEventListener("abort", onAbort);
|
|
22
|
+
resolve();
|
|
23
|
+
}, ms);
|
|
24
|
+
const onAbort = () => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
reject(new Error("aborted before retry"));
|
|
27
|
+
};
|
|
28
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function debugWarn(label: string, detail: string): void {
|
|
33
|
+
if (process.env.DREXLER_DEBUG && process.env.DREXLER_DEBUG !== "0") {
|
|
34
|
+
try {
|
|
35
|
+
process.stderr.write(`[drexler ${label}] ${detail}\n`);
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
export type FetchFn = (
|
|
15
41
|
url: string | URL | Request,
|
|
16
42
|
init?: RequestInit,
|
|
@@ -112,7 +138,18 @@ async function attempt(
|
|
|
112
138
|
try {
|
|
113
139
|
await res.text();
|
|
114
140
|
} catch {}
|
|
115
|
-
|
|
141
|
+
// Retry delay must be abortable — if the user hits Esc during the
|
|
142
|
+
// wait, we should bail immediately instead of firing the second
|
|
143
|
+
// request only to cancel it once issued.
|
|
144
|
+
try {
|
|
145
|
+
await abortableDelay(RETRY_DELAY_MS, opts.signal);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return {
|
|
148
|
+
status: "http_error",
|
|
149
|
+
content: "",
|
|
150
|
+
error: err instanceof Error ? err.message : String(err),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
116
153
|
return attempt(model, opts, fetchFn, true);
|
|
117
154
|
}
|
|
118
155
|
|
|
@@ -189,8 +226,12 @@ export async function parseSSEStream(
|
|
|
189
226
|
acc += tok;
|
|
190
227
|
onToken(tok);
|
|
191
228
|
}
|
|
192
|
-
} catch {
|
|
193
|
-
//
|
|
229
|
+
} catch (err) {
|
|
230
|
+
// Tolerate malformed chunks — OpenRouter occasionally emits
|
|
231
|
+
// partial JSON during slow connections. Visible only when
|
|
232
|
+
// DREXLER_DEBUG is set so production stays quiet.
|
|
233
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
234
|
+
debugWarn("sse parse", `${msg}: ${data.slice(0, 80)}`);
|
|
194
235
|
}
|
|
195
236
|
};
|
|
196
237
|
|
package/src/pet/petState.ts
CHANGED
|
@@ -33,10 +33,16 @@ export interface PetStats {
|
|
|
33
33
|
|
|
34
34
|
const MAX_NAME_LEN = 16;
|
|
35
35
|
const NAME_SANITIZE_RE = /[^\p{L}\p{N} ._'-]/gu;
|
|
36
|
+
// Strip ALL Unicode format/control marks (bidi overrides, ZWJ/ZWNJ,
|
|
37
|
+
// BOM, word joiner, mathematical invisibles) before character-class
|
|
38
|
+
// filtering — otherwise a name like "Max" can render as "xaM" via an
|
|
39
|
+
// embedded U+202E RIGHT-TO-LEFT OVERRIDE that survives NFKC.
|
|
40
|
+
const NAME_BIDI_STRIP_RE = /\p{Cf}/gu;
|
|
36
41
|
|
|
37
42
|
export function sanitizePetName(input: string): string {
|
|
38
43
|
const cleaned = input
|
|
39
44
|
.normalize("NFKC")
|
|
45
|
+
.replace(NAME_BIDI_STRIP_RE, "")
|
|
40
46
|
.replace(NAME_SANITIZE_RE, "")
|
|
41
47
|
.replace(/\s+/g, " ")
|
|
42
48
|
.trim();
|
|
@@ -76,6 +82,10 @@ export function actionCooldown(
|
|
|
76
82
|
return { ok: true, remainingMs: 0 };
|
|
77
83
|
}
|
|
78
84
|
const elapsed = now - last;
|
|
85
|
+
// Clock skew backwards (timestamp set in the future) shouldn't lock
|
|
86
|
+
// the user out for the cooldown window — treat as no cooldown and
|
|
87
|
+
// let the next stampAction overwrite the stale future value.
|
|
88
|
+
if (elapsed < 0) return { ok: true, remainingMs: 0 };
|
|
79
89
|
if (elapsed >= PET_COOLDOWN_MS) return { ok: true, remainingMs: 0 };
|
|
80
90
|
return { ok: false, remainingMs: PET_COOLDOWN_MS - elapsed };
|
|
81
91
|
}
|
package/src/repl.ts
CHANGED
|
@@ -69,13 +69,18 @@ export function buildMessagesWithReminder(conv: Conversation): Message[] {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Confusable letters that look like Latin "I" — fold to ASCII before regex
|
|
72
|
-
// so detection isn't bypassed by Cyrillic І, Turkish İ, fullwidth I,
|
|
73
|
-
|
|
72
|
+
// so detection isn't bypassed by Cyrillic І, Turkish İ, fullwidth I,
|
|
73
|
+
// Greek Iota Ι/ι, script ℐ, Roman numeral Ⅰ.
|
|
74
|
+
const I_CONFUSABLES_RE = /[ІіİıIℐⅠΙι]/g;
|
|
74
75
|
|
|
75
76
|
export function detectPersonaDrift(content: string): boolean {
|
|
76
77
|
const noCode = content
|
|
77
78
|
.replace(/```[\s\S]*?```/g, "")
|
|
78
|
-
.replace(/`[^`]*`/g, "")
|
|
79
|
+
.replace(/`[^`]*`/g, "")
|
|
80
|
+
// Strip LaTeX-style inline math $...$ and display math $$...$$ so
|
|
81
|
+
// `$I = mc^2$` doesn't trip drift detection.
|
|
82
|
+
.replace(/\$\$[\s\S]*?\$\$/g, "")
|
|
83
|
+
.replace(/\$[^\$\n]*\$/g, "");
|
|
79
84
|
const folded = noCode.normalize("NFKC").replace(I_CONFUSABLES_RE, "I");
|
|
80
85
|
return /\bI\b|\bI'm\b|\bI'll\b|\bI've\b|\bI'd\b/.test(folded);
|
|
81
86
|
}
|
|
@@ -10,7 +10,7 @@ interface Props {
|
|
|
10
10
|
width?: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const COMMAND_HINTS: Record<string, string> = {
|
|
13
|
+
export const COMMAND_HINTS: Record<string, string> = {
|
|
14
14
|
"/help": "open directive list",
|
|
15
15
|
"/clear": "reset transcript",
|
|
16
16
|
"/exit": "close session",
|
|
@@ -31,6 +31,15 @@ const COMMAND_HINTS: Record<string, string> = {
|
|
|
31
31
|
"/copy-last": "copy latest response",
|
|
32
32
|
"/setup": "show config + key source",
|
|
33
33
|
"/update": "bun update -g drexler --latest",
|
|
34
|
+
"/pet": "open pet deal desk",
|
|
35
|
+
"/feed": "feed Drexler a deal memo",
|
|
36
|
+
"/play": "corporate synergy game",
|
|
37
|
+
"/work": "grind the deal pipeline",
|
|
38
|
+
"/praise": "affirm Drexler's contributions",
|
|
39
|
+
"/rest": "strategic nap",
|
|
40
|
+
"/vibe": "let Drexler pick the move",
|
|
41
|
+
"/name": "/name Bartholomew",
|
|
42
|
+
"/profile": "print personnel file",
|
|
34
43
|
};
|
|
35
44
|
|
|
36
45
|
const ARGUMENT_TITLES: Record<string, { title: string; hint: string }> = {
|
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,50 +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_";
|
|
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
|
+
};
|
|
121
173
|
}
|
|
122
174
|
|
|
123
175
|
function progressTicker(frame: number): string {
|
|
@@ -214,134 +266,124 @@ function mascotStateForActivity(activity: PetActivity, frame: number): MascotSta
|
|
|
214
266
|
}
|
|
215
267
|
}
|
|
216
268
|
|
|
217
|
-
function
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
? Math.min(26, Math.max(20, width - windowWidth - 5))
|
|
233
|
-
: Math.min(36, Math.max(26, Math.floor(width * 0.36)));
|
|
234
|
-
const boardX = Math.max(windowWidth + 3, width - boardWidth - 2);
|
|
235
|
-
const windowRight = 1 + windowWidth;
|
|
236
|
-
const gapWidth = boardX - windowRight;
|
|
237
|
-
const cloud = frame % 6 < 3 ? "(~~)" : " (~~)";
|
|
238
|
-
const sun = frame % 12 < 6 ? "\\o/" : "-o-";
|
|
239
|
-
const city = frame % 10 < 5 ? "▂▄▆ city" : "▃▅▇ city";
|
|
240
|
-
const tape = frame % 8 < 4 ? "▁▃▅▇" : "▂▄▆█";
|
|
241
|
-
const cursor = frame % 4 < 2 ? ">" : "*";
|
|
242
|
-
|
|
243
|
-
placeBoxLines(rows, R_WINDOW_TOP, 1, windowWidth, "Window", [
|
|
244
|
-
`╔╤╤╗ ${sun} ${cloud}`,
|
|
245
|
-
`║▥▥║ ${city}`,
|
|
246
|
-
]);
|
|
247
|
-
placeBoxLines(
|
|
248
|
-
rows,
|
|
249
|
-
R_WINDOW_TOP,
|
|
250
|
-
boardX,
|
|
251
|
-
Math.min(boardWidth, width - boardX),
|
|
252
|
-
"Deal Board",
|
|
253
|
-
[
|
|
254
|
-
`DL ${dealPct} FEE ${Math.round(stats.happiness).toString().padStart(3)}%`,
|
|
255
|
-
`PIPE ${tape} $ ${cursor}`,
|
|
256
|
-
],
|
|
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),
|
|
257
284
|
);
|
|
285
|
+
}
|
|
258
286
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
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
|
+
}
|
|
268
295
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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,
|
|
272
317
|
);
|
|
273
|
-
}
|
|
274
318
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
+
);
|
|
300
349
|
}
|
|
301
350
|
|
|
302
351
|
function drawActivityAccents(
|
|
303
352
|
rows: string[],
|
|
304
353
|
width: number,
|
|
305
354
|
activity: PetActivity,
|
|
306
|
-
|
|
355
|
+
_frame: number,
|
|
307
356
|
mascotX: number,
|
|
308
357
|
): void {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
);
|
|
313
|
-
|
|
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.
|
|
314
361
|
const mascotRight = mascotX + MASCOT_WIDTH;
|
|
315
|
-
const leftAccentX = Math.max(1, mascotX -
|
|
316
|
-
const
|
|
317
|
-
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);
|
|
318
364
|
|
|
319
365
|
switch (activity) {
|
|
320
366
|
case "eating":
|
|
321
|
-
rows[R_MASCOT_START +
|
|
322
|
-
rows[R_MASCOT_START + 5],
|
|
323
|
-
"╭$╮",
|
|
324
|
-
Math.max(1, Math.min(fileX - 5, rightAccentX + 1)),
|
|
325
|
-
);
|
|
367
|
+
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "[$]", rightAccentX);
|
|
326
368
|
break;
|
|
327
369
|
case "playing":
|
|
328
370
|
rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", leftAccentX);
|
|
329
|
-
rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*",
|
|
371
|
+
rows[R_MASCOT_START + 2] = place(rows[R_MASCOT_START + 2], "*", rightAccentX);
|
|
330
372
|
break;
|
|
331
373
|
case "working":
|
|
332
374
|
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "$", leftAccentX);
|
|
333
|
-
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "$",
|
|
375
|
+
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "$", rightAccentX);
|
|
334
376
|
break;
|
|
335
377
|
case "sleeping":
|
|
336
378
|
rows[R_MASCOT_START] = place(rows[R_MASCOT_START], "z z Z", rightAccentX);
|
|
337
379
|
break;
|
|
338
380
|
case "praised":
|
|
339
381
|
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", leftAccentX);
|
|
340
|
-
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *",
|
|
382
|
+
rows[R_MASCOT_START + 1] = place(rows[R_MASCOT_START + 1], "* *", rightAccentX);
|
|
341
383
|
break;
|
|
342
384
|
case "vibing":
|
|
343
385
|
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", leftAccentX);
|
|
344
|
-
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~",
|
|
386
|
+
rows[R_MASCOT_START + 3] = place(rows[R_MASCOT_START + 3], "~ ~", rightAccentX);
|
|
345
387
|
break;
|
|
346
388
|
default:
|
|
347
389
|
break;
|
|
@@ -361,82 +403,55 @@ function drawMascot(rows: string[], width: number, activity: PetActivity, frame:
|
|
|
361
403
|
return mascotX;
|
|
362
404
|
}
|
|
363
405
|
|
|
364
|
-
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(
|
|
365
419
|
rows: string[],
|
|
366
420
|
width: number,
|
|
367
421
|
activity: PetActivity,
|
|
368
422
|
frame: number,
|
|
369
423
|
stats: PetStats,
|
|
370
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.
|
|
371
428
|
const mascotX = Math.max(0, Math.floor((width - MASCOT_WIDTH) / 2));
|
|
372
429
|
const mascotRight = mascotX + MASCOT_WIDTH;
|
|
373
|
-
const
|
|
374
|
-
const
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
const screen =
|
|
379
|
-
activity === "working"
|
|
380
|
-
? `$>${cursor}DL`
|
|
381
|
-
: activity === "sleeping"
|
|
382
|
-
? "zzz..."
|
|
383
|
-
: "DREX";
|
|
384
|
-
const steam = stats.energy > 30
|
|
385
|
-
? frame % 4 < 2 ? " ((" : " ))"
|
|
386
|
-
: " ";
|
|
387
|
-
const paperFace = frame % 6 < 3 ? "▱▱▱" : "▰▱▱";
|
|
388
|
-
|
|
389
|
-
placeSprite(rows, R_MASCOT_START + 4, laptopX, [
|
|
390
|
-
"╭──────╮",
|
|
391
|
-
`│${padDisplayText(screen, 6)}│`,
|
|
392
|
-
"╰─┬──┬─╯",
|
|
393
|
-
]);
|
|
394
|
-
|
|
395
|
-
if (papersX > mascotRight) {
|
|
396
|
-
rows[R_MASCOT_START + 6] = place(rows[R_MASCOT_START + 6], paperFace, papersX);
|
|
397
|
-
}
|
|
398
|
-
|
|
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);
|
|
399
435
|
if (mugX > mascotRight + 1) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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);
|
|
403
446
|
}
|
|
404
447
|
}
|
|
405
448
|
|
|
406
|
-
function
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
const drawers = width < 68
|
|
413
|
-
? "╭────╮ ╭────╮ ╭────╮"
|
|
414
|
-
: "╭────╮ ╭────╮ ╭────╮ ╭────╮";
|
|
415
|
-
|
|
416
|
-
rows[R_DESK_SURFACE] = place(
|
|
417
|
-
rows[R_DESK_SURFACE],
|
|
418
|
-
`╭${padDisplayText(surface, deskInner)}╮`,
|
|
419
|
-
deskX,
|
|
420
|
-
);
|
|
421
|
-
rows[R_DESK_FRONT] = place(
|
|
422
|
-
rows[R_DESK_FRONT],
|
|
423
|
-
`│${padDisplayText(front, deskInner)}│`,
|
|
424
|
-
deskX,
|
|
425
|
-
);
|
|
426
|
-
rows[R_DESK_DRAWERS] = place(
|
|
427
|
-
rows[R_DESK_DRAWERS],
|
|
428
|
-
`│${centerPadDisplayText(drawers, deskInner)}│`,
|
|
429
|
-
deskX,
|
|
430
|
-
);
|
|
431
|
-
rows[R_DESK_BOTTOM] = place(
|
|
432
|
-
rows[R_DESK_BOTTOM],
|
|
433
|
-
`╰${"─".repeat(Math.max(0, deskInner))}╯`,
|
|
434
|
-
deskX,
|
|
435
|
-
);
|
|
436
|
-
rows[R_FLOOR] = centerText(
|
|
437
|
-
rows[R_FLOOR],
|
|
438
|
-
fitDisplayText("░░░░░░░ deal-room carpet shadow ░░░░░░░", width),
|
|
439
|
-
);
|
|
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));
|
|
440
455
|
}
|
|
441
456
|
|
|
442
457
|
function buildScene(
|
|
@@ -448,16 +463,22 @@ function buildScene(
|
|
|
448
463
|
const sceneWidth = Math.max(PET_SCENE_WIDTH, Math.floor(width));
|
|
449
464
|
const rows: string[] = Array.from({ length: SCENE_ROWS }, () => blankRow(sceneWidth));
|
|
450
465
|
|
|
451
|
-
|
|
452
|
-
|
|
466
|
+
drawTitleBar(rows, sceneWidth, stats);
|
|
467
|
+
drawBoardroomWindow(rows, sceneWidth, frame, stats, activity);
|
|
453
468
|
const mascotX = drawMascot(rows, sceneWidth, activity, frame);
|
|
454
|
-
|
|
455
|
-
|
|
469
|
+
drawDeskHorizon(rows, sceneWidth);
|
|
470
|
+
drawDeskProps(rows, sceneWidth, activity, frame, stats);
|
|
471
|
+
drawMemo(rows, sceneWidth, stats, frame);
|
|
456
472
|
drawActivityAccents(rows, sceneWidth, activity, frame, mascotX);
|
|
457
473
|
return rows.map((row) => overlayFitted(blankRow(sceneWidth), row, 0, sceneWidth));
|
|
458
474
|
}
|
|
459
475
|
|
|
460
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
|
|
461
482
|
function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): string {
|
|
462
483
|
if (i >= R_MASCOT_START && i < R_MASCOT_START + BRIEFCASE_FINAL.length) {
|
|
463
484
|
if (activity === "sleeping") return t.dim;
|
|
@@ -467,23 +488,20 @@ function rowColor(i: number, activity: PetActivity, frame: number, t: Theme): st
|
|
|
467
488
|
if (activity === "working" && frame % 32 >= 26) return t.error;
|
|
468
489
|
return t.primary;
|
|
469
490
|
}
|
|
470
|
-
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) {
|
|
471
498
|
if (activity === "working" && frame % 32 >= 26) return t.error;
|
|
472
499
|
if (activity === "sleeping") return t.dim;
|
|
473
500
|
if (activity === "praised") return t.warning;
|
|
474
501
|
if (activity === "eating") return t.warning;
|
|
475
502
|
if (activity === "playing") return t.primaryLight;
|
|
476
|
-
return t.
|
|
503
|
+
return t.dim;
|
|
477
504
|
}
|
|
478
|
-
if (
|
|
479
|
-
i === R_DESK_SURFACE ||
|
|
480
|
-
i === R_DESK_FRONT ||
|
|
481
|
-
i === R_DESK_DRAWERS ||
|
|
482
|
-
i === R_DESK_BOTTOM ||
|
|
483
|
-
i === R_FLOOR
|
|
484
|
-
) return t.primaryDim;
|
|
485
|
-
if (i === R_WALL) return t.dim;
|
|
486
|
-
if (i >= R_WINDOW_TOP && i <= R_WINDOW_BOTTOM) return t.primaryDim;
|
|
487
505
|
return t.primaryDim;
|
|
488
506
|
}
|
|
489
507
|
|