@mingxy/opencode-mascot 0.1.2 → 0.2.1

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { createSignal, onCleanup } from "solid-js";
4
4
  import type { JSX } from "@opentui/solid";
5
- import type { MascotPack, MascotState } from "./types";
5
+ import type { MascotPack, MascotState, EffectTimerCtx, EffectRenderCtx } from "./types";
6
6
 
7
7
  const STATE_TO_FRAME: Record<MascotState, string> = {
8
8
  idle: "default",
@@ -18,11 +18,11 @@ const DEFAULT_ANIM = {
18
18
  expressionInterval: 8000,
19
19
  idleTimeout: 120000,
20
20
  breathInterval: 3000,
21
- ahogeInterval: 1500,
22
- ahogeChance: 0.25,
21
+ walkEnabled: true,
22
+ walkMinDelay: 20000,
23
+ walkMaxDelay: 40000,
23
24
  jumpMinDelay: 20000,
24
25
  jumpMaxDelay: 40000,
25
- stompThreshold: 30000,
26
26
  };
27
27
 
28
28
  const WALK_PATH = [1, 2, 3, 4, 3, 2, 1, 0, -1, -2, -3, -2, -1, 0];
@@ -45,35 +45,72 @@ function renderLines(lines: string[], fg?: string): JSX.Element {
45
45
  export function createAnimatedRenderer(pack: MascotPack): {
46
46
  element: () => JSX.Element;
47
47
  setState: (s: MascotState) => void;
48
+ toggleWalk: () => void;
49
+ setDragging: (v: boolean) => void;
50
+ celebrateUpdate: (newVersion: string) => void;
48
51
  } {
49
52
  const anim = { ...DEFAULT_ANIM, ...pack.animations };
50
53
  const fg = pack.colors?.defaultFg || undefined;
54
+ const effects = pack.effects;
51
55
 
52
56
  const [currentState, setCurrentState] = createSignal<MascotState>("idle");
53
57
  const [frameOverride, setFrameOverride] = createSignal<string | null>(null);
54
- const [legsVisible, setLegsVisible] = createSignal(true);
58
+ const [breathPhase, setBreathPhase] = createSignal(true);
55
59
  const [walkOffset, setWalkOffset] = createSignal(0);
56
- const [ahogeAlt, setAhogeAlt] = createSignal(false);
57
- const [braidAlt, setBraidAlt] = createSignal(false);
58
- const [waveSide, setWaveSide] = createSignal(false);
59
- const [zzzPhase, setZzzPhase] = createSignal(0);
60
60
  const [jumpOffset, setJumpOffset] = createSignal(0);
61
- const [stompActive, setStompActive] = createSignal(false);
62
- const [stompAlt, setStompAlt] = createSignal(false);
61
+ const [walkEnabled, setWalkEnabled] = createSignal(anim.walkEnabled ?? true);
62
+ const [dragging, setDraggingSignal] = createSignal(false);
63
+ const [celebrate, setCelebrate] = createSignal<{ text: string; count: number } | null>(null);
64
+
65
+ let idleSleepTimeout: ReturnType<typeof setTimeout> | null = null;
66
+
67
+ const resetIdleSleep = () => {
68
+ if (idleSleepTimeout) clearTimeout(idleSleepTimeout);
69
+ idleSleepTimeout = null;
70
+ if (currentState() !== "idle") return;
71
+ idleSleepTimeout = setTimeout(() => {
72
+ if (currentState() === "idle") {
73
+ setCurrentState("sleeping");
74
+ stopWalk();
75
+ }
76
+ }, anim.idleTimeout);
77
+ };
63
78
 
64
- let thinkingStartTime = 0;
79
+ resetIdleSleep();
65
80
 
66
- // ============ 1. Blink ============
81
+ // ─── Extra signals from pack effects ───
82
+ const extraSignals = new Map<string, [() => unknown, (v: unknown) => void]>();
83
+ if (effects?.signals) {
84
+ for (const sig of effects.signals) {
85
+ const [get, set] = createSignal(sig.initial);
86
+ extraSignals.set(sig.name, [get, set]);
87
+ }
88
+ }
89
+
90
+ const getExtra = (name: string): unknown => extraSignals.get(name)?.[0]() ?? null;
91
+ const setExtra = (name: string, value: unknown) => extraSignals.get(name)?.[1](value);
92
+
93
+ const timerCtx: EffectTimerCtx = {
94
+ get: getExtra,
95
+ set: setExtra,
96
+ get state() { return currentState(); },
97
+ get frameOverride() { return frameOverride(); },
98
+ setFrameOverride: (name) => setFrameOverride(name),
99
+ };
100
+
101
+ // ─── Built-in timers ───
102
+
103
+ // 1. Blink
67
104
  const hasBlink = (pack.frames as Record<string, string[] | undefined>)["blink"] !== undefined;
68
105
 
69
106
  const blinkTimer = setInterval(() => {
70
- if (Math.random() < anim.blinkChance && hasBlink) {
107
+ if (currentState() !== "sleeping" && Math.random() < anim.blinkChance && hasBlink) {
71
108
  setFrameOverride("blink");
72
109
  setTimeout(() => setFrameOverride(null), 150);
73
110
  }
74
111
  }, anim.blinkInterval);
75
112
 
76
- // ============ 2. Random expression ============
113
+ // 2. Random expression
77
114
  const availableExpressions = Object.keys(pack.frames).filter(
78
115
  (k) => k !== "default" && k !== "blink",
79
116
  );
@@ -83,37 +120,30 @@ export function createAnimatedRenderer(pack: MascotPack): {
83
120
  const pick = availableExpressions[Math.floor(Math.random() * availableExpressions.length)];
84
121
  if (pick) {
85
122
  setFrameOverride(pick);
86
- if (pick === "happy") startWave();
87
- setTimeout(() => {
88
- setFrameOverride(null);
89
- stopWave();
90
- }, 2000);
123
+ setTimeout(() => setFrameOverride(null), 2000);
91
124
  }
92
125
  }
93
126
  }, anim.expressionInterval);
94
127
 
95
- // ============ 3. Breathing (legs tuck + braids flutter) ============
128
+ // 3. Breathing
96
129
  const breathTimer = setInterval(() => {
97
- setLegsVisible((v) => !v);
98
- setBraidAlt((v) => !v);
99
- }, anim.breathInterval);
100
-
101
- // ============ 4. Ahoge wobble (呆毛晃) ============
102
- const ahogeTimer = setInterval(() => {
103
- if (Math.random() < anim.ahogeChance) {
104
- setAhogeAlt(true);
105
- setTimeout(() => setAhogeAlt(false), 200);
130
+ if (currentState() === "idle") {
131
+ setBreathPhase((v) => !v);
106
132
  }
107
- }, anim.ahogeInterval);
133
+ }, anim.breathInterval);
108
134
 
109
- // ============ 5. Walk ============
135
+ // 4. Walk
110
136
  let walkStep = -1;
111
137
  let walkInterval: ReturnType<typeof setInterval> | null = null;
112
138
 
113
139
  const startWalk = () => {
114
- if (walkInterval) return;
140
+ if (walkInterval || !walkEnabled()) return;
115
141
  walkStep = 0;
116
142
  walkInterval = setInterval(() => {
143
+ if (currentState() !== "idle") {
144
+ stopWalk();
145
+ return;
146
+ }
117
147
  if (walkStep < WALK_PATH.length) {
118
148
  setWalkOffset(WALK_PATH[walkStep]);
119
149
  walkStep++;
@@ -138,18 +168,21 @@ export function createAnimatedRenderer(pack: MascotPack): {
138
168
  let walkTimeout: ReturnType<typeof setTimeout>;
139
169
 
140
170
  function scheduleNextWalk() {
141
- const delay = 5000 + Math.floor(Math.random() * 5000);
171
+ if (!walkEnabled()) return setTimeout(() => {}, 60000);
172
+ const delay = anim.walkMinDelay + Math.floor(Math.random() * (anim.walkMaxDelay - anim.walkMinDelay));
142
173
  return setTimeout(() => {
143
- if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
174
+ if (currentState() === "idle" && !frameOverride() && walkStep === -1 && walkEnabled()) {
144
175
  startWalk();
145
176
  }
146
- walkTimeout = scheduleNextWalk();
177
+ if (currentState() !== "sleeping") {
178
+ walkTimeout = scheduleNextWalk();
179
+ }
147
180
  }, delay);
148
181
  }
149
182
 
150
183
  walkTimeout = scheduleNextWalk();
151
184
 
152
- // ============ 6. Jump ============
185
+ // 5. Jump
153
186
  let jumpTimeout: ReturnType<typeof setTimeout>;
154
187
 
155
188
  function scheduleNextJump() {
@@ -157,77 +190,50 @@ export function createAnimatedRenderer(pack: MascotPack): {
157
190
  return setTimeout(() => {
158
191
  if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
159
192
  setJumpOffset(-2);
160
- setTimeout(() => setJumpOffset(0), 500);
193
+ setTimeout(() => setJumpOffset(-1), 1500);
194
+ setTimeout(() => setJumpOffset(0), 2000);
195
+ }
196
+ if (currentState() !== "sleeping") {
197
+ jumpTimeout = scheduleNextJump();
161
198
  }
162
- jumpTimeout = scheduleNextJump();
163
199
  }, delay);
164
200
  }
165
201
 
166
202
  jumpTimeout = scheduleNextJump();
167
203
 
168
- // ============ 7. Wave (挥手 — happy state) ============
169
- let waveTimer: ReturnType<typeof setInterval> | null = null;
170
-
171
- const startWave = () => {
172
- if (waveTimer) return;
173
- waveTimer = setInterval(() => setWaveSide((v) => !v), 300);
174
- };
175
-
176
- const stopWave = () => {
177
- if (waveTimer) {
178
- clearInterval(waveTimer);
179
- waveTimer = null;
180
- }
181
- setWaveSide(false);
182
- };
183
-
184
- // ============ 8. Zzz bubbles (sleeping state) ============
185
- const zzzTimer = setInterval(() => {
186
- if (currentState() === "sleeping") {
187
- setZzzPhase((p) => (p + 1) % 4);
188
- } else {
189
- setZzzPhase(0);
204
+ // ─── Pack-defined effect timers ───
205
+ const effectTimers: ReturnType<typeof setInterval>[] = [];
206
+ if (effects?.timers) {
207
+ for (const t of effects.timers) {
208
+ effectTimers.push(setInterval(() => t.update(timerCtx), t.interval));
190
209
  }
191
- }, 1500);
192
-
193
- // ============ 9. Stomp (跺脚 — thinking > threshold) ============
194
- const stompTimer = setInterval(() => {
195
- if (
196
- currentState() === "thinking" &&
197
- thinkingStartTime > 0 &&
198
- Date.now() - thinkingStartTime > anim.stompThreshold
199
- ) {
200
- setStompActive(true);
201
- setStompAlt((a) => !a);
202
- } else {
203
- setStompActive(false);
204
- }
205
- }, 200);
210
+ }
206
211
 
207
- // ============ Cleanup ============
212
+ // ─── Cleanup ───
208
213
  onCleanup(() => {
209
214
  clearInterval(blinkTimer);
210
215
  clearInterval(expressionTimer);
211
216
  clearInterval(breathTimer);
212
- clearInterval(ahogeTimer);
213
- clearInterval(zzzTimer);
214
- clearInterval(stompTimer);
215
217
  clearTimeout(walkTimeout);
216
218
  clearTimeout(jumpTimeout);
219
+ if (idleSleepTimeout) clearTimeout(idleSleepTimeout);
217
220
  if (walkInterval) clearInterval(walkInterval);
218
- stopWave();
221
+ for (const t of effectTimers) clearInterval(t);
219
222
  });
220
223
 
221
- // ============ Render ============
224
+ // ─── Render ───
222
225
  const element = () => {
223
- legsVisible();
224
- ahogeAlt();
225
- braidAlt();
226
- waveSide();
227
- zzzPhase();
226
+ breathPhase();
227
+ walkOffset();
228
228
  jumpOffset();
229
- stompActive();
230
- stompAlt();
229
+ frameOverride();
230
+ currentState();
231
+ dragging();
232
+ celebrate();
233
+
234
+ for (const [, [get]] of extraSignals) {
235
+ get();
236
+ }
231
237
 
232
238
  const frameName = frameOverride() ?? STATE_TO_FRAME[currentState()] ?? "default";
233
239
  const rawLines = getFrameLines(pack, frameName);
@@ -236,85 +242,96 @@ export function createAnimatedRenderer(pack: MascotPack): {
236
242
  const width = rawLines[0]?.length ?? 10;
237
243
  const blank = " ".repeat(width);
238
244
 
239
- let lines = rawLines.map((line, i) => {
240
- if (!legsVisible()) {
245
+ let lines: string[] = rawLines.map((line, i) => {
246
+ if (!breathPhase()) {
241
247
  if (i === 0) return blank;
242
248
  return rawLines[i - 1];
243
249
  }
244
250
  return line;
245
251
  });
246
252
 
247
- // 2. Ahoge wobble: ☆ ↔ ★
248
- if (ahogeAlt()) {
249
- lines = lines.map((l) => (l.includes("☆") ? l.replace("☆", "★") : l));
250
- }
251
-
252
- // 3. Braid flutter: ~ ↔ -
253
- if (braidAlt()) {
254
- lines = lines.map((l) => (l.includes("~") ? l.replace(/~/g, "-") : l));
255
- }
256
-
257
- // 4. Wave: braids become hands ╲ / ╱ on face row
258
- if (frameName === "happy") {
259
- const faceIdx = lines.findIndex((l) => l.includes("^ω^"));
260
- if (faceIdx >= 0) {
261
- lines[faceIdx] = waveSide()
262
- ? "╲( ^ω^ )╱ "
263
- : " ~( ^ω^ )~";
264
- }
265
- }
266
-
267
- // 5. Stomp: legs alternate ╲ ╱ ↔ ╱ ╲
268
- if (stompActive()) {
269
- const legIdx = lines.length - 1;
270
- if (legIdx >= 0) {
271
- lines[legIdx] = stompAlt() ? " ╲ ╱ " : " ╱ ╲ ";
272
- }
273
- }
274
-
275
- // 6. Zzz: floating Z bubbles beside face
276
- const zzz = zzzPhase();
277
- if (zzz > 0 && currentState() === "sleeping") {
278
- for (let i = 0; i < lines.length; i++) {
279
- if (lines[i].includes("-.-")) {
280
- lines[i] = lines[i] + " " + "Z".repeat(zzz);
281
- break;
282
- }
283
- }
253
+ if (effects?.render) {
254
+ const renderCtx: EffectRenderCtx = {
255
+ state: currentState(),
256
+ frameName,
257
+ breathPhase: breathPhase(),
258
+ jumpOffset: jumpOffset(),
259
+ dragging: dragging(),
260
+ get: getExtra,
261
+ };
262
+ lines = effects.render(lines, renderCtx);
284
263
  }
285
264
 
286
265
  const top = jumpOffset();
287
266
  const left = offset > 0 ? offset : 0;
267
+ const cel = celebrate();
288
268
 
289
269
  return (
290
270
  <box flexDirection="column" left={left} top={top}>
291
271
  {renderLines(lines, fg)}
272
+ {cel ? <text fg={fg}>{cel.text}</text> : null}
292
273
  </box>
293
274
  );
294
275
  };
295
276
 
296
- // ============ State control ============
277
+ // ─── State control ───
297
278
  const setState = (s: MascotState) => {
298
- const prev = currentState();
299
279
  setCurrentState(s);
280
+ setBreathPhase(true);
281
+ resetIdleSleep();
300
282
 
301
283
  if (s !== "idle") {
302
284
  stopWalk();
285
+ } else if (walkEnabled()) {
286
+ walkTimeout = scheduleNextWalk();
287
+ jumpTimeout = scheduleNextJump();
303
288
  }
289
+ };
304
290
 
305
- if (s === "thinking" && prev !== "thinking") {
306
- thinkingStartTime = Date.now();
307
- } else if (s !== "thinking") {
308
- thinkingStartTime = 0;
309
- setStompActive(false);
291
+ const toggleWalk = () => {
292
+ const next = !walkEnabled();
293
+ setWalkEnabled(next);
294
+ if (!next) {
295
+ stopWalk();
296
+ } else if (currentState() === "idle") {
297
+ walkTimeout = scheduleNextWalk();
310
298
  }
299
+ };
311
300
 
312
- if (s === "happy") {
313
- startWave();
301
+ const setDragging = (v: boolean) => {
302
+ setDraggingSignal(v);
303
+ if (v) {
304
+ // 睡着时被拖拽 → 惊醒到 idle,切回 default 帧后手臂 ┃███┃ 才能被扇手渲染匹配
305
+ if (currentState() === "sleeping") {
306
+ setState("idle");
307
+ }
308
+ setJumpOffset(-1);
314
309
  } else {
315
- stopWave();
310
+ setJumpOffset(0);
316
311
  }
317
312
  };
318
313
 
319
- return { element, setState };
314
+ // 连续跳跃 + 吐火星文泡泡庆祝更新成功
315
+ const celebrateUpdate = (newVersion: string) => {
316
+ const bubbles = pack.bubbleTexts ?? ["ᵘᵖ~"];
317
+ if (currentState() === "sleeping") setState("idle");
318
+
319
+ let step = 0;
320
+ const JUMPS = 3;
321
+ const tick = () => {
322
+ if (step >= JUMPS) {
323
+ setJumpOffset(0);
324
+ setCelebrate(null);
325
+ return;
326
+ }
327
+ setJumpOffset(step % 2 === 0 ? -2 : 0);
328
+ const word = bubbles[Math.floor(Math.random() * bubbles.length)];
329
+ setCelebrate({ text: `${word} ᵘᵖ→ᵛ${newVersion}`, count: step });
330
+ step++;
331
+ setTimeout(tick, 600);
332
+ };
333
+ tick();
334
+ };
335
+
336
+ return { element, setState, toggleWalk, setDragging, celebrateUpdate };
320
337
  }
@@ -0,0 +1,16 @@
1
+ const bus = new EventTarget();
2
+
3
+ const CELEBRATE_EVENT = "mascot:celebrate";
4
+
5
+ export function emitCelebrate(newVersion: string): void {
6
+ bus.dispatchEvent(new CustomEvent(CELEBRATE_EVENT, { detail: { newVersion } }));
7
+ }
8
+
9
+ export function onCelebrate(handler: (newVersion: string) => void): () => void {
10
+ const listener = (e: Event) => {
11
+ const detail = (e as CustomEvent).detail as { newVersion: string };
12
+ handler(detail.newVersion);
13
+ };
14
+ bus.addEventListener(CELEBRATE_EVENT, listener);
15
+ return () => bus.removeEventListener(CELEBRATE_EVENT, listener);
16
+ }
@@ -1,18 +1,35 @@
1
1
  import type { MascotPack } from "./types"
2
2
  import { yueerPack } from "../builtins/yueer"
3
+ import { baoziPack } from "../builtins/baozi"
3
4
 
4
5
  const BUILTINS: Record<string, MascotPack> = {
5
6
  yueer: yueerPack,
7
+ baozi: baoziPack,
8
+ }
9
+
10
+ const ALL_NAMES = Object.keys(BUILTINS)
11
+
12
+ function randomPick<T>(arr: T[]): T {
13
+ return arr[Math.floor(Math.random() * arr.length)]
14
+ }
15
+
16
+ export function loadAllMascots(): Record<string, MascotPack> {
17
+ return { ...BUILTINS }
18
+ }
19
+
20
+ export function getMascotNames(): string[] {
21
+ return [...ALL_NAMES]
22
+ }
23
+
24
+ export function getRandomMascot(): MascotPack {
25
+ return randomPick(Object.values(BUILTINS))
6
26
  }
7
27
 
8
28
  export async function loadMascot(options?: Record<string, unknown>): Promise<MascotPack> {
9
- const mascotName = (options?.mascot as string) || "yueer"
10
-
11
- if (BUILTINS[mascotName]) {
12
- return BUILTINS[mascotName]
13
- }
14
-
15
- // Future: npm package loading, local file loading
16
- // Fallback to yueer
17
- return yueerPack
29
+ const mascotName = (options?.mascot as string) || "random"
30
+
31
+ if (mascotName === "random") return getRandomMascot()
32
+ if (BUILTINS[mascotName]) return BUILTINS[mascotName]
33
+
34
+ return getRandomMascot()
18
35
  }
package/src/core/types.ts CHANGED
@@ -17,18 +17,12 @@ export interface AnimationConfig {
17
17
  blinkChance?: number;
18
18
  expressionInterval?: number;
19
19
  idleTimeout?: number;
20
- /** Breathing cycle in ms (default: 3000) */
21
20
  breathInterval?: number;
22
- /** Ahoge wobble check interval in ms (default: 1500) */
23
- ahogeInterval?: number;
24
- /** Probability of ahoge wobble per check (default: 0.25) */
25
- ahogeChance?: number;
26
- /** Minimum delay between jumps in ms (default: 20000) */
21
+ walkEnabled?: boolean;
22
+ walkMinDelay?: number;
23
+ walkMaxDelay?: number;
27
24
  jumpMinDelay?: number;
28
- /** Maximum delay between jumps in ms (default: 40000) */
29
25
  jumpMaxDelay?: number;
30
- /** Stomp trigger: ms in thinking state before stomping (default: 30000) */
31
- stompThreshold?: number;
32
26
  }
33
27
 
34
28
  /**
@@ -39,26 +33,72 @@ export interface SidebarConfig {
39
33
  busyPhrases?: string[];
40
34
  }
41
35
 
36
+ // ─── Effect system types ───
37
+
38
+ /**
39
+ * A single extra signal managed by the effect system.
40
+ * The renderer creates a Solid signal for each and exposes get/set to timers and render.
41
+ */
42
+ export interface SignalDef {
43
+ name: string;
44
+ initial: unknown;
45
+ }
46
+
42
47
  /**
43
- * Context passed to render hooks.
48
+ * Timer context passed to EffectTimer.update().
44
49
  */
45
- export interface RenderContext {
50
+ export interface EffectTimerCtx {
51
+ /** Read an extra signal by name */
52
+ get: (name: string) => unknown;
53
+ /** Write an extra signal by name */
54
+ set: (name: string, value: unknown) => void;
55
+ /** Current mascot state */
46
56
  state: MascotState;
47
- frameIndex: number;
48
- timestamp: number;
49
- sessionData?: {
50
- tokenUsage?: number;
51
- tokenLimit?: number;
52
- model?: string;
53
- };
57
+ /** Current frame override (null = use state mapping) */
58
+ frameOverride: string | null;
59
+ /** Override the displayed frame */
60
+ setFrameOverride: (name: string | null) => void;
61
+ }
62
+
63
+ /**
64
+ * A recurring timer that updates extra signals.
65
+ */
66
+ export interface EffectTimer {
67
+ /** Interval in ms */
68
+ interval: number;
69
+ /** Called on each tick */
70
+ update: (ctx: EffectTimerCtx) => void;
54
71
  }
55
72
 
56
73
  /**
57
- * Lifecycle hooks for customizing mascot behavior.
74
+ * Context passed to EffectRenderFn.
58
75
  */
59
- export interface MascotHooks {
60
- beforeRender?: (context: RenderContext, frame: string[]) => string[];
61
- onStateChange?: (prevState: MascotState, newState: MascotState) => void;
76
+ export interface EffectRenderCtx {
77
+ state: MascotState;
78
+ frameName: string;
79
+ breathPhase: boolean;
80
+ jumpOffset: number;
81
+ dragging: boolean;
82
+ get: (name: string) => unknown;
83
+ }
84
+
85
+ /**
86
+ * Pack-defined render effect. Called after built-in rendering (breathing),
87
+ * before final output. Return modified lines.
88
+ */
89
+ export type EffectRenderFn = (lines: string[], ctx: EffectRenderCtx) => string[];
90
+
91
+ /**
92
+ * Effect bundle: extra signals, timers, and render function.
93
+ * Packs use this to define mascot-specific animations.
94
+ */
95
+ export interface MascotEffects {
96
+ /** Extra signals (name → initial value) */
97
+ signals?: SignalDef[];
98
+ /** Recurring timers that drive signal changes */
99
+ timers?: EffectTimer[];
100
+ /** Render hook — modify lines based on signals */
101
+ render: EffectRenderFn;
62
102
  }
63
103
 
64
104
  /**
@@ -91,7 +131,16 @@ export interface MascotPack {
91
131
 
92
132
  animations?: AnimationConfig;
93
133
  sidebar?: SidebarConfig;
94
- hooks?: MascotHooks;
134
+
135
+ /** Mascot-specific animation effects (timers + render) */
136
+ effects?: MascotEffects;
137
+
138
+ /** Thinking bubble phrases, shown when mascot is in busy/thinking state */
139
+ bubbleTexts?: string[];
140
+ }
141
+
142
+ export interface SwitchConfig {
143
+ onState?: Partial<Record<MascotState, string>>;
95
144
  }
96
145
 
97
146
  /**
@@ -101,4 +150,5 @@ export interface MascotConfig {
101
150
  mascot: string;
102
151
  animations: boolean;
103
152
  idleSleep: boolean;
153
+ switchConfig?: SwitchConfig;
104
154
  }