@mingxy/opencode-mascot 0.6.0 → 0.7.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.
package/package.json CHANGED
@@ -1,43 +1,43 @@
1
- {
2
- "name": "@mingxy/opencode-mascot",
3
- "version": "0.6.0",
4
- "description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
5
- "author": "mingxy",
6
- "license": "MIT",
7
- "type": "module",
8
- "main": "./tui.tsx",
9
- "types": "./src/core/types.ts",
10
- "exports": {
11
- "./tui": "./tui.tsx",
12
- "./types": "./src/core/types.ts"
13
- },
14
- "files": [
15
- "tui.tsx",
16
- "src/"
17
- ],
18
- "oc-plugin": [
19
- "tui"
20
- ],
21
- "scripts": {
22
- "typecheck": "tsc --noEmit"
23
- },
24
- "peerDependencies": {
25
- "@opencode-ai/plugin": ">=0.1.0",
26
- "@opentui/solid": ">=0.0.1"
27
- },
28
- "devDependencies": {
29
- "@types/node": "^25.9.3",
30
- "typescript": "^5.7.0"
31
- },
32
- "keywords": [
33
- "opencode",
34
- "tui",
35
- "mascot",
36
- "plugin",
37
- "ascii-art"
38
- ],
39
- "repository": {
40
- "type": "git",
41
- "url": "https://github.com/mengfanbo123/opencode-mascot"
42
- }
43
- }
1
+ {
2
+ "name": "@mingxy/opencode-mascot",
3
+ "version": "0.7.1",
4
+ "description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
5
+ "author": "mingxy",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./tui.tsx",
9
+ "types": "./src/core/types.ts",
10
+ "exports": {
11
+ "./tui": "./tui.tsx",
12
+ "./types": "./src/core/types.ts"
13
+ },
14
+ "files": [
15
+ "tui.tsx",
16
+ "src/"
17
+ ],
18
+ "oc-plugin": [
19
+ "tui"
20
+ ],
21
+ "scripts": {
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "peerDependencies": {
25
+ "@opencode-ai/plugin": ">=0.1.0",
26
+ "@opentui/solid": ">=0.0.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.9.3",
30
+ "typescript": "^5.7.0"
31
+ },
32
+ "keywords": [
33
+ "opencode",
34
+ "tui",
35
+ "mascot",
36
+ "plugin",
37
+ "ascii-art"
38
+ ],
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/mengfanbo123/opencode-mascot"
42
+ }
43
+ }
@@ -0,0 +1,69 @@
1
+ import type { PropPack } from "../../core/types";
2
+
3
+ /**
4
+ * 立体箱子道具 — idle 常驻容器
5
+ *
6
+ * 尺寸: 14 宽 × 8 高(等轴测视角,正面+顶面+侧面深度)
7
+ * 动画: 4 帧循环
8
+ * 1: 关闭(盖子盖上,正面 ᵇᵒˣ 火星文)
9
+ * 2: 微动(盖子抖动 + 内部物品晃动)
10
+ * 3: 打开(盖子翻开,露出月儿剪影)
11
+ * 4: 月儿冒头(月儿从箱子里探出)
12
+ * 正面 ᵇᵒˣ 火星文标识,里面是月儿剪影(呆毛☆+圆脸+迷你身体)
13
+ */
14
+
15
+ const frames: string[][] = [
16
+ // 帧1: 关闭
17
+ [
18
+ " ╭──────╮ ",
19
+ " ╱░░░░░░░╲ ",
20
+ " ╱────────╲ ",
21
+ "│░ᵇᵒˣ░░░░░│ ",
22
+ "│░░░░░░░░░│ ",
23
+ "│░░░░░░░░░│ ",
24
+ "╰──────────╯─┐",
25
+ " ╲ ",
26
+ ],
27
+ // 帧2: 微动
28
+ [
29
+ " ╭~~~~~~╮ ",
30
+ " ╱▒░░░▒░░╲ ",
31
+ " ╱~~────~~╲ ",
32
+ "│░ᵇᵒˣ░▒░░░│ ",
33
+ "│░░▓▓░░░░░│ ",
34
+ "│░░░░░░░░░│ ",
35
+ "╰──────────╯─┐",
36
+ " ╲ ",
37
+ ],
38
+ // 帧3: 打开(露出月儿剪影)
39
+ [
40
+ " ╭~~~~~~╮ ",
41
+ " ╱ ☆ ╲ ",
42
+ " ╱~~────~~╲ ",
43
+ "│░(^-^)░░░│ ",
44
+ "│░┃█┃░░░░░│ ",
45
+ "│░░░░░░░░░│ ",
46
+ "╰──────────╯─┐",
47
+ " ╲ ",
48
+ ],
49
+ // 帧4: 月儿冒头
50
+ [
51
+ " ╭~~~~~~╮ ",
52
+ " ╱ ☆ ╲ ",
53
+ " ╱(^-^)───╲ ",
54
+ "│░┃█┃░░░░░│ ",
55
+ "│░░░░░░░░░│ ",
56
+ "│░░░░░░░░░│ ",
57
+ "╰──────────╯─┐",
58
+ " ╲ ",
59
+ ],
60
+ ];
61
+
62
+ export const boxProp: PropPack = {
63
+ name: "box",
64
+ frames,
65
+ frameInterval: 1500,
66
+ trigger: "idle",
67
+ position: "side-left",
68
+ weight: 1,
69
+ };
@@ -0,0 +1,53 @@
1
+ import type { PropPack } from "../../core/types";
2
+
3
+ /**
4
+ * 显示器道具 — busy 状态主力道具
5
+ *
6
+ * 尺寸: 16 宽 × 4 高
7
+ * 结构: 屏幕框(┌┐) + 2行内容(提示符+状态) + 底座连接(||) + 底座(▓)
8
+ * 动画: 8 帧屏幕状态轮播
9
+ * 1-2: 光标闪(>_ ↔ > )
10
+ * 3: thinking
11
+ * 4: writing
12
+ * 5: git push
13
+ * 6: bug!
14
+ * 7: npm install
15
+ * 8: done ✓
16
+ * $ 保留,opencode 火星文,无空格
17
+ */
18
+
19
+ const W = 14; // 屏幕内容区宽
20
+
21
+ const TOP = "┌" + "─".repeat(W) + "┐";
22
+ const BOT = "└" + "─".repeat(6) + "||" + "─".repeat(6) + "┘";
23
+ const BASE = "▓".repeat(16);
24
+
25
+ const ln = (s: string) => "│" + s.padEnd(W) + "│";
26
+
27
+ const states = [
28
+ ">_",
29
+ "> ",
30
+ ">ᵗʰⁿᵏⁱⁿᵍ...",
31
+ ">ʷʳⁱᵗⁱⁿᵍ...",
32
+ ">ᵍⁱᵗᵖᵘˢʰ...",
33
+ ">ᵇᵘᵍ!ᵇᵘᵍ!",
34
+ ">ⁿᵖᵐⁱⁿˢᵗᵃˡˡ",
35
+ ">ᵈᵒⁿᵉ✓",
36
+ ];
37
+
38
+ const frames: string[][] = states.map((st) => [
39
+ TOP,
40
+ ln("~$ᵒᵖᵉⁿᶜᵒᵈᵉ"),
41
+ ln(st),
42
+ BOT,
43
+ BASE,
44
+ ]);
45
+
46
+ export const laptopProp: PropPack = {
47
+ name: "laptop",
48
+ frames,
49
+ frameInterval: 800,
50
+ trigger: "busy",
51
+ position: "side-right",
52
+ weight: 0.7,
53
+ };
@@ -0,0 +1,65 @@
1
+ import type { PropPack } from "../../core/types";
2
+
3
+ /**
4
+ * Pad 道具 — busy 状态变体道具
5
+ *
6
+ * 尺寸: 18 宽 × 9 高(双层框+.•摄像头+底部◉Home键)
7
+ * 屏幕区: 12 宽 × 4 高
8
+ * 叙事: 月儿小人玩游戏,又菜又爱玩
9
+ * 贪吃蛇 6帧: 开局→走→撞墙→哭→不服→重开
10
+ * 俄罗斯方块 4帧: 开局→堆积→满→over
11
+ * 2048 4帧: 开局→合并→128→over
12
+ * 切换 1帧: next!
13
+ * 月儿小人: ☆ 呆毛 + (^-^) 圆脸 + ┃█┃ 身体(迷你版)
14
+ */
15
+
16
+ const W = 12; // 屏幕内容区宽
17
+
18
+ const OUTER_TOP = "╭.•" + "─".repeat(14) + "╮";
19
+ const INNER_TOP = "│ ┌" + "─".repeat(W) + "┐ │";
20
+ const INNER_BOT = "│ └" + "─".repeat(W) + "┘ │";
21
+ const DOT_ROW = "│ " + " ".repeat(6) + "◉" + " ".repeat(7) + " │";
22
+ const OUTER_BOT = "╰" + "─".repeat(16) + "╯";
23
+
24
+ const scr = (rows: string[]) =>
25
+ rows.map((r) => "│ │" + r.padEnd(W) + "│ │");
26
+
27
+ const games: string[][] = [
28
+ // 贪吃蛇 6帧
29
+ [" ☆ ᵇᵉᵍⁱⁿ ", "(^-^) ◯→ ●", " ┃█┃ ˢ:0 ", " "],
30
+ [" ☆ ", "(^-^) ◯◯→ ●", " ┃█┃ ˢ:1 ", " "],
31
+ [" ☆ ", "(×_×) ◯◯💥 ", " ┃█┃ ᵍᵍ! ", " "],
32
+ [" ☆ ", "(╥_╥) ᵒᵛᵉʳ ", " ┃█┃ ˢ:1 ", " "],
33
+ [" ☆ ", "(¬_¬) ᵃᵍᵃⁱⁿ", " ┃█┃ ", " "],
34
+ [" ☆ ᵍᵒ! ", "(^-^) ◯→ ●", " ┃█┃ ˢ:0 ", " "],
35
+ // 俄罗斯方块 4帧
36
+ [" ☆ ᵇᵉᵍⁱⁿ ", "(^-^) ▣ ", " ┃█┃ ", " "],
37
+ [" ☆ ", "(^-^) ▣▣ ", " ┃█┃ ▣▣▣▣ ", " "],
38
+ [" ☆ ", "(×_×) ", " ┃█┃ ▣▣▣▣▣▣", " ᶠᵘˡˡ! "],
39
+ [" ☆ ", "(╥_╥) ᵒᵛᵉʳ ", " ┃█┃ ", " "],
40
+ // 2048 4帧
41
+ [" ☆ ᵇᵉᵍⁱⁿ ", "(^-^) [ 2 ]", " ┃█┃ ", " "],
42
+ [" ☆ ", "(^-^) [ 4 ]", " ┃█┃ ᵒᵒⁿ ", " "],
43
+ [" ☆ ", "(⊙_⊙) [128]", " ┃█┃ ʷᵒʷ ", " "],
44
+ [" ☆ ", "(╥_╥) ᵒᵛᵉʳ ", " ┃█┃ ", " "],
45
+ // 切换
46
+ [" ☆ ", "(^-^) ⁿᵉˣᵗ!", " ┃█┃ ", " "],
47
+ ];
48
+
49
+ const frames: string[][] = games.map((rows) => [
50
+ OUTER_TOP,
51
+ INNER_TOP,
52
+ ...scr(rows),
53
+ INNER_BOT,
54
+ DOT_ROW,
55
+ OUTER_BOT,
56
+ ]);
57
+
58
+ export const padProp: PropPack = {
59
+ name: "pad",
60
+ frames,
61
+ frameInterval: 700,
62
+ trigger: "busy",
63
+ position: "front",
64
+ weight: 0.3,
65
+ };
@@ -5,6 +5,7 @@ import type { JSX } from "@opentui/solid";
5
5
  import type { MascotPack } from "../core/types";
6
6
  import { createAnimatedRenderer } from "../core/ascii-renderer";
7
7
  import { onCelebrate, onVersion, onScatter } from "../core/celebration-bus";
8
+ import { getProp } from "../core/prop-loader";
8
9
 
9
10
  interface HomeMascotProps {
10
11
  mascots: Record<string, MascotPack>;
@@ -70,6 +71,12 @@ export function HomeMascot(props: HomeMascotProps): JSX.Element {
70
71
  renderers[currentName()].scatterIn();
71
72
  });
72
73
 
74
+ setTimeout(() => {
75
+ if (!renderers[currentName()].getProp()) {
76
+ renderers[currentName()].setProp(getProp("box") ?? null);
77
+ }
78
+ }, 2500);
79
+
73
80
  const stopDrag = () => {
74
81
  isDragging = false;
75
82
  renderers[currentName()].setDragging(false);
@@ -5,6 +5,7 @@ import type { JSX } from "@opentui/solid";
5
5
  import type { MascotPack, MascotState } from "../core/types";
6
6
  import { createAnimatedRenderer } from "../core/ascii-renderer";
7
7
  import { onCelebrate, onVersion, onScatter } from "../core/celebration-bus";
8
+ import { pickPropByTrigger, getProp } from "../core/prop-loader";
8
9
 
9
10
  interface SidebarMascotProps {
10
11
  mascots: Record<string, MascotPack>;
@@ -42,6 +43,7 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
42
43
  : names[0];
43
44
 
44
45
  const [currentName, setCurrentName] = createSignal(initialName);
46
+ const [userOverride, setUserOverride] = createSignal(false);
45
47
  const [posX, setPosX] = createSignal(20);
46
48
  const [posY, setPosY] = createSignal(2);
47
49
  const [containerWidth, setContainerWidth] = createSignal(0);
@@ -74,6 +76,7 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
74
76
  const cur = currentName();
75
77
  const idx = names.indexOf(cur);
76
78
  setCurrentName(names[(idx + 1) % names.length]);
79
+ setUserOverride(true);
77
80
  };
78
81
 
79
82
  const getCw = () => containerWidth() || 30;
@@ -142,6 +145,7 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
142
145
  const setStateWithSwitch = (s: MascotState) => {
143
146
  const cur = currentName();
144
147
  renderers[cur].setState(s);
148
+ if (userOverride()) return;
145
149
  const target = DEFAULT_STATE_MAP[s];
146
150
  if (target && target !== cur && props.mascots[target]) {
147
151
  setCurrentName(target);
@@ -155,8 +159,20 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
155
159
  if (statusType === "busy" || statusType === "retry") {
156
160
  if (hideSide) returnToView();
157
161
  renderers[currentName()].setState("busy");
162
+ // 先显示箱子"打开"动画300ms,再切换到 busy 道具(道具从箱子里掉出来)
163
+ const busyProp = pickPropByTrigger("busy");
164
+ if (busyProp) {
165
+ renderers[currentName()].setProp(getProp("box") ?? null);
166
+ setTimeout(() => {
167
+ renderers[currentName()].setProp(busyProp);
168
+ }, 300);
169
+ } else {
170
+ renderers[currentName()].setProp(getProp("box") ?? null);
171
+ }
158
172
  } else {
159
173
  setStateWithSwitch("idle");
174
+ // idle 时显示箱子常驻
175
+ renderers[currentName()].setProp(getProp("box") ?? null);
160
176
  }
161
177
  });
162
178
 
@@ -169,7 +185,10 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
169
185
  const target = data as { name?: string } | null;
170
186
  if (target?.name) {
171
187
  const name = target.name;
172
- if (props.mascots[name] && name !== currentName()) setCurrentName(name);
188
+ if (props.mascots[name] && name !== currentName()) {
189
+ setCurrentName(name);
190
+ setUserOverride(true);
191
+ }
173
192
  } else {
174
193
  switchToNext();
175
194
  }
@@ -205,6 +224,13 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
205
224
  renderers[currentName()].scatterIn();
206
225
  }, 2000);
207
226
 
227
+ // 启动后显示箱子(在 scatter 之后)
228
+ setTimeout(() => {
229
+ if (!renderers[currentName()].getProp()) {
230
+ renderers[currentName()].setProp(getProp("box") ?? null);
231
+ }
232
+ }, 2500);
233
+
208
234
  return (
209
235
  <box
210
236
  position="absolute"
@@ -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, EffectTimerCtx, EffectRenderCtx } from "./types";
5
+ import type { MascotPack, MascotState, EffectTimerCtx, EffectRenderCtx, PropPack, PropPosition } from "./types";
6
6
 
7
7
  const SUPERSCRIPT: Record<string, string> = {
8
8
  "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
@@ -55,6 +55,8 @@ export function createAnimatedRenderer(pack: MascotPack): {
55
55
  showVersion: (version: string) => void;
56
56
  scatterIn: () => void;
57
57
  explode: () => void;
58
+ setProp: (prop: PropPack | null) => void;
59
+ getProp: () => PropPack | null;
58
60
  } {
59
61
  const anim = { ...DEFAULT_ANIM, ...pack.animations };
60
62
  const fg = pack.colors?.defaultFg || undefined;
@@ -74,7 +76,11 @@ export function createAnimatedRenderer(pack: MascotPack): {
74
76
  const [zzz, setZzz] = createSignal<string | null>(null);
75
77
  const [bomb, setBomb] = createSignal<{ fuse: string; count: string } | null>(null);
76
78
  const [scatter, setScatter] = createSignal<{ dx: number; dy: number }[] | null>(null);
79
+ const [activeProp, setActiveProp] = createSignal<PropPack | null>(null);
80
+ const [propFrameIdx, setPropFrameIdx] = createSignal(0);
81
+ const [propPosition, setPropPosition] = createSignal<PropPosition | null>(null);
77
82
 
83
+ let propTimer: ReturnType<typeof setInterval> | null = null;
78
84
  let flashTimer: ReturnType<typeof setInterval> | null = null;
79
85
  let dragMsgTimer: ReturnType<typeof setInterval> | null = null;
80
86
  let zzzTimer: ReturnType<typeof setInterval> | null = null;
@@ -118,6 +124,9 @@ export function createAnimatedRenderer(pack: MascotPack): {
118
124
  if (explodeTimer) { clearTimeout(explodeTimer); explodeTimer = null; }
119
125
  setBomb(null);
120
126
  };
127
+ const stopPropTimer = () => {
128
+ if (propTimer) { clearInterval(propTimer); propTimer = null; }
129
+ };
121
130
 
122
131
  const stopAllAnimations = () => {
123
132
  stopFlash();
@@ -302,6 +311,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
302
311
  stopFall();
303
312
  stopBomb();
304
313
  if (zzzTimer) { clearInterval(zzzTimer); zzzTimer = null; }
314
+ stopPropTimer();
305
315
  });
306
316
 
307
317
  // ─── Render ───
@@ -319,6 +329,9 @@ export function createAnimatedRenderer(pack: MascotPack): {
319
329
  scatter();
320
330
  bomb();
321
331
  versionMsg();
332
+ activeProp();
333
+ propFrameIdx();
334
+ propPosition();
322
335
 
323
336
  for (const [, [get]] of extraSignals) {
324
337
  get();
@@ -351,6 +364,47 @@ export function createAnimatedRenderer(pack: MascotPack): {
351
364
  lines = effects.render(lines, renderCtx);
352
365
  }
353
366
 
367
+ // ─── Prop overlay ───
368
+ const prop = activeProp();
369
+ if (prop) {
370
+ const propFramesRaw = Array.isArray(prop.frames[0])
371
+ ? (prop.frames as string[][])
372
+ : [prop.frames as string[]];
373
+ const propLines = propFramesRaw[propFrameIdx() % propFramesRaw.length] ?? propFramesRaw[0];
374
+
375
+ if (propLines.length > 0) {
376
+ const pos = propPosition();
377
+ if (pos === 'front') {
378
+ const overlayCount = Math.min(propLines.length, lines.length);
379
+ const startRow = Math.floor((lines.length - overlayCount) / 2);
380
+ for (let i = 0; i < overlayCount; i++) {
381
+ lines[startRow + i] = propLines[i];
382
+ }
383
+ } else {
384
+ const charWidth = lines[0]?.length ?? 0;
385
+ const propWidth = propLines[0]?.length ?? 0;
386
+ const charHeight = lines.length;
387
+ const propHeight = propLines.length;
388
+ const maxLines = Math.max(charHeight, propHeight);
389
+ const charPad = Math.floor((maxLines - charHeight) / 2);
390
+ const propPad = Math.floor((maxLines - propHeight) / 2);
391
+ const sep = " ";
392
+
393
+ const merged: string[] = [];
394
+ for (let i = 0; i < maxLines; i++) {
395
+ const cLine = (i >= charPad && i < charPad + charHeight)
396
+ ? lines[i - charPad]
397
+ : " ".repeat(charWidth);
398
+ const pLine = (i >= propPad && i < propPad + propHeight)
399
+ ? propLines[i - propPad]
400
+ : " ".repeat(propWidth);
401
+ merged.push(pos === 'side-right' ? cLine + sep + pLine : pLine + sep + cLine);
402
+ }
403
+ lines = merged;
404
+ }
405
+ }
406
+ }
407
+
354
408
  const top = jumpOffset();
355
409
  const left = offset > 0 ? offset : 0;
356
410
  const cel = celebrate();
@@ -505,7 +559,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
505
559
  const showVersion = (version: string) => {
506
560
  stopVersion();
507
561
  setVersionMsg(`ᵛ${toSuperscript(version)}`);
508
- versionTimer = setTimeout(() => { setVersionMsg(null); versionTimer = null; }, 3000);
562
+ versionTimer = setTimeout(() => { setVersionMsg(null); versionTimer = null; }, 5000);
509
563
  };
510
564
 
511
565
  const scatterIn = () => {
@@ -574,5 +628,27 @@ export function createAnimatedRenderer(pack: MascotPack): {
574
628
  }, 700);
575
629
  };
576
630
 
577
- return { element, getState: currentState, setState, toggleWalk, setDragging, celebrateUpdate, bounce, showVersion, scatterIn, explode };
631
+ const setProp = (prop: PropPack | null) => {
632
+ setActiveProp(prop);
633
+ setPropFrameIdx(0);
634
+ if (prop) {
635
+ const pos: PropPosition = prop.position === 'random'
636
+ ? (Math.random() < 0.5 ? 'side-left' : 'side-right')
637
+ : prop.position;
638
+ setPropPosition(pos);
639
+ stopPropTimer();
640
+ if (Array.isArray(prop.frames[0]) && prop.frameInterval) {
641
+ propTimer = setInterval(() => {
642
+ setPropFrameIdx((idx) => (idx + 1) % (prop.frames as string[][]).length);
643
+ }, prop.frameInterval);
644
+ }
645
+ } else {
646
+ setPropPosition(null);
647
+ stopPropTimer();
648
+ }
649
+ };
650
+
651
+ const getProp = () => activeProp();
652
+
653
+ return { element, getState: currentState, setState, toggleWalk, setDragging, celebrateUpdate, bounce, showVersion, scatterIn, explode, setProp, getProp };
578
654
  }
@@ -0,0 +1,38 @@
1
+ import type { PropPack, PropTrigger } from "./types";
2
+ import { laptopProp } from "../builtins/props/laptop";
3
+ import { padProp } from "../builtins/props/pad";
4
+ import { boxProp } from "../builtins/props/box";
5
+
6
+ // 内置道具注册表
7
+ const BUILTIN_PROPS: Record<string, PropPack> = {
8
+ laptop: laptopProp,
9
+ pad: padProp,
10
+ box: boxProp,
11
+ };
12
+
13
+ const loaded: Record<string, PropPack> = { ...BUILTIN_PROPS };
14
+
15
+ export function registerProp(prop: PropPack): void {
16
+ loaded[prop.name] = prop;
17
+ }
18
+
19
+ export function getProp(name: string): PropPack | undefined {
20
+ return loaded[name];
21
+ }
22
+
23
+ export function getAllProps(): Record<string, PropPack> {
24
+ return loaded;
25
+ }
26
+
27
+ /** 按 trigger 筛选道具,按 weight 加权随机选一个 */
28
+ export function pickPropByTrigger(trigger: PropTrigger): PropPack | null {
29
+ const candidates = Object.values(loaded).filter((p) => p.trigger === trigger);
30
+ if (candidates.length === 0) return null;
31
+ const totalWeight = candidates.reduce((sum, p) => sum + (p.weight ?? 1), 0);
32
+ let r = Math.random() * totalWeight;
33
+ for (const p of candidates) {
34
+ r -= p.weight ?? 1;
35
+ if (r <= 0) return p;
36
+ }
37
+ return candidates[candidates.length - 1];
38
+ }
package/src/core/types.ts CHANGED
@@ -126,3 +126,26 @@ export interface MascotPack {
126
126
  /** Mascot-specific animation effects (timers + render) */
127
127
  effects?: MascotEffects;
128
128
  }
129
+
130
+ // ─── Prop system types ───
131
+
132
+ /** 道具触发条件 */
133
+ export type PropTrigger = 'busy' | 'idle' | 'startup' | 'random';
134
+
135
+ /** 道具渲染位置 */
136
+ export type PropPosition = 'side-left' | 'side-right' | 'front' | 'random';
137
+
138
+ /** 道具包定义 */
139
+ export interface PropPack {
140
+ name: string;
141
+ /** 静态帧(string[])或多帧动画(string[][]),每行必须等宽 */
142
+ frames: string[] | string[][];
143
+ /** 多帧动画时的帧切换间隔ms,默认 800 */
144
+ frameInterval?: number;
145
+ /** 触发条件 */
146
+ trigger: PropTrigger;
147
+ /** 默认渲染位置 */
148
+ position: PropPosition;
149
+ /** 同trigger多个道具时按权重随机挑选,默认 1 */
150
+ weight?: number;
151
+ }
package/tui.tsx CHANGED
@@ -36,6 +36,7 @@ const tui: TuiPlugin = async (api, _options) => {
36
36
 
37
37
  checkAndUpdate(pluginVersion, (newVersion) => {
38
38
  emitCelebrate(newVersion);
39
+ emitVersion(newVersion);
39
40
  }).catch(() => {});
40
41
 
41
42
  setTimeout(() => emitScatter(), 100);