@mingxy/opencode-mascot 0.5.9 → 0.7.0

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/README.md CHANGED
@@ -6,16 +6,25 @@ Customizable ASCII mascots that breathe, walk, sleep, get launched across the sc
6
6
 
7
7
  [English](./README.md) | [简体中文](./README_zh-CN.md)
8
8
 
9
+ ## 🏠 Home Page
10
+
11
+ ![home demo](./assets/demo-home.gif)
12
+
13
+ ## 💼 Work Page
14
+
15
+ ![work demo](./assets/demo-work.gif)
16
+
9
17
  ---
10
18
 
11
19
  ## ✨ Features
12
20
 
13
- ### 🎭 Built-in Characters (2)
21
+ ### 🎭 Built-in Characters (3)
14
22
 
15
23
  | Character | Description | Color |
16
24
  |-----------|-------------|-------|
17
25
  | **yueer** | Purple-haired girl with an ahoge, tsundere style, default mascot | `#8B7EB8` lavender |
18
26
  | **baozi** | A steaming hot bun, warm and cozy | `#D4885A` warm orange |
27
+ | **cat** | Orange tabby cat — purring, kneading, tail-swishing | `#FFA500` orange |
19
28
 
20
29
  Each character includes **5 expression frames**: default / blink / happy / thinking / sleeping
21
30
 
@@ -54,6 +63,17 @@ Each character includes **5 expression frames**: default / blink / happy / think
54
63
  | 15 | Alien text bubble | busy/thinking | 12 alien-text phrases rotate |
55
64
  | 16 | Drag panic | While dragging | `( °□° )` |
56
65
 
66
+ **cat exclusive:**
67
+
68
+ | # | Animation | Trigger | Effect |
69
+ |---|-----------|---------|--------|
70
+ | 17 | Tail swish | Every 0.6s | Tail `|_)` ↔ `|~)` |
71
+ | 18 | Ear twitch | Random (20% / 2.5s) | Ear `/\` → `/╲` |
72
+ | 19 | Pupil dilate | Random (15% / 4s, idle) | Eyes `o.o` → `@.@` |
73
+ | 20 | Kneading | idle state | Paws alternate `/|| |\` ↔ `/| ||\` |
74
+ | 21 | Purr bubble | busy/thinking | 12 purr phrases rotate (purrr~/mrrrow~/nyaa~) |
75
+ | 22 | Drag bristle | While dragging | Ears bristle `/╲╱╲` + shocked face `>.<` |
76
+
57
77
  ---
58
78
 
59
79
  ### 🖱️ Interactions (5)
@@ -157,7 +177,8 @@ opencode-mascot/
157
177
  │ │ └── sidebar-mascot.tsx # Work page mascot (peek-a-boo)
158
178
  │ └── builtins/
159
179
  │ ├── yueer/ # yueer (frames + effects)
160
- └── baozi/ # baozi (frames + effects)
180
+ ├── baozi/ # baozi (frames + effects)
181
+ │ └── cat/ # cat (frames + effects)
161
182
  ```
162
183
 
163
184
  ## 🎨 Custom Characters
@@ -197,16 +218,16 @@ All built-in animations (blink/breath/walk/jump/sleep/drag/color-flash/bomb/fall
197
218
 
198
219
  | Category | Count |
199
220
  |----------|-------|
200
- | Built-in characters | 2 |
221
+ | Built-in characters | 3 |
201
222
  | Expression frames | 5 / character |
202
- | Auto animations | 16 |
223
+ | Auto animations | 22 |
203
224
  | Interactions | 5 |
204
225
  | Peek-a-boo behaviors | 3 |
205
226
  | Random events | 3 |
206
227
  | Alien text phrases | 24 (12/character) |
207
228
  | Flash colors | 8 |
208
229
  | Drag alien text | 6 |
209
- | **Total** | **33+** |
230
+ | **Total** | **39+** |
210
231
 
211
232
  ## 📄 License
212
233
 
package/package.json CHANGED
@@ -1,43 +1,43 @@
1
- {
2
- "name": "@mingxy/opencode-mascot",
3
- "version": "0.5.9",
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.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
+ }
@@ -0,0 +1,48 @@
1
+ // 0123456789
2
+ const defaultFrame = [
3
+ " /\\_/\\ ",
4
+ " ( o.o ) ",
5
+ " > ^ < ",
6
+ " /| |\\ ",
7
+ "(_| |_) ",
8
+ ];
9
+
10
+ const blinkFrame = [
11
+ " /\\_/\\ ",
12
+ " ( -.- ) ",
13
+ " > ^ < ",
14
+ " /| |\\ ",
15
+ "(_| |_) ",
16
+ ];
17
+
18
+ const happyFrame = [
19
+ " /\\_/\\ ",
20
+ " ( ^ω^ ) ",
21
+ " > ω < ",
22
+ " /| |\\ ",
23
+ "(_| |_) ",
24
+ ];
25
+
26
+ const thinkingFrame = [
27
+ " /\\_/\\ ",
28
+ " ( o_o ) ",
29
+ " > ? < ",
30
+ " /| |\\ ",
31
+ "(_| |_) ",
32
+ ];
33
+
34
+ const sleepingFrame = [
35
+ " /\\_/\\ ",
36
+ " ( -.- ) ",
37
+ " > z < ",
38
+ " /| |\\ ",
39
+ "(_| |_) ",
40
+ ];
41
+
42
+ export const frames = {
43
+ default: defaultFrame,
44
+ blink: blinkFrame,
45
+ happy: happyFrame,
46
+ thinking: thinkingFrame,
47
+ sleeping: sleepingFrame,
48
+ };
@@ -0,0 +1,167 @@
1
+ import type { MascotPack, EffectRenderCtx } from "../../core/types";
2
+ import { frames } from "./frames";
3
+
4
+ const PURR_TEXTS = [
5
+ "ᵖᵘʳʳʳ~", "ᵐʳʳᵒʷ~", "ᵐʷᵃᵃ~", "ⁿʸᵃᵃ~",
6
+ "ᵖᵘʳʳ..", "ᵐʸᵃ~..", "ᶠᵘʳʳ~", "ᵐⁱᵃᵒ~",
7
+ "ᶜʰᵘʳʳ..", "ᵉᵘⁿ~..", "ˢʰᵖᵘʳʳ~", "ⁿʸᵒ~",
8
+ ];
9
+ const THINKING_FACES = ["o_o", "O_O", ">_o", "o_<", "⊙_⊙", "◑_◑"];
10
+
11
+ const catEffects: MascotPack["effects"] = {
12
+ signals: [
13
+ { name: "tailAlt", initial: false },
14
+ { name: "earTwitch", initial: false },
15
+ { name: "pupilWide", initial: false },
16
+ { name: "kneadAlt", initial: false },
17
+ { name: "purrIdx", initial: 0 },
18
+ { name: "thinkingFaceIdx", initial: 0 },
19
+ { name: "thinkingCountdown", initial: 0 },
20
+ ],
21
+
22
+ timers: [
23
+ {
24
+ interval: 600,
25
+ update(ctx) {
26
+ ctx.set("tailAlt", !(ctx.get("tailAlt") as boolean));
27
+ },
28
+ },
29
+ {
30
+ interval: 2500,
31
+ update(ctx) {
32
+ if (Math.random() < 0.2) {
33
+ ctx.set("earTwitch", true);
34
+ setTimeout(() => ctx.set("earTwitch", false), 250);
35
+ }
36
+ },
37
+ },
38
+ {
39
+ interval: 4000,
40
+ update(ctx) {
41
+ if (ctx.state === "idle" && Math.random() < 0.15) {
42
+ ctx.set("pupilWide", true);
43
+ setTimeout(() => ctx.set("pupilWide", false), 1000);
44
+ }
45
+ },
46
+ },
47
+ {
48
+ interval: 400,
49
+ update(ctx) {
50
+ if (ctx.state === "idle") {
51
+ ctx.set("kneadAlt", !(ctx.get("kneadAlt") as boolean));
52
+ }
53
+ },
54
+ },
55
+ {
56
+ interval: 2000,
57
+ update(ctx) {
58
+ if (ctx.state === "busy" || ctx.state === "thinking") {
59
+ ctx.set("purrIdx", ((ctx.get("purrIdx") as number) + 1) % PURR_TEXTS.length);
60
+ }
61
+ },
62
+ },
63
+ {
64
+ interval: 1000,
65
+ update(ctx) {
66
+ if (ctx.state === "thinking" || ctx.state === "busy") {
67
+ let cd = ctx.get("thinkingCountdown") as number;
68
+ if (!cd || cd <= 0) cd = 5 + Math.floor(Math.random() * 11);
69
+ cd--;
70
+ if (cd <= 0) {
71
+ ctx.set("thinkingFaceIdx", ((ctx.get("thinkingFaceIdx") as number) + 1) % THINKING_FACES.length);
72
+ }
73
+ ctx.set("thinkingCountdown", cd);
74
+ } else {
75
+ ctx.set("thinkingFaceIdx", 0);
76
+ ctx.set("thinkingCountdown", 0);
77
+ }
78
+ },
79
+ },
80
+ ],
81
+
82
+ render(lines: string[], ctx: EffectRenderCtx): string[] {
83
+ const { state, breathPhase, dragging, get } = ctx;
84
+ let result = [...lines];
85
+
86
+ const tailAlt = get("tailAlt") as boolean;
87
+ const earTwitch = get("earTwitch") as boolean;
88
+ const pupilWide = get("pupilWide") as boolean;
89
+ const kneadAlt = get("kneadAlt") as boolean;
90
+
91
+ if (dragging) {
92
+ result[0] = " /╲╱╲\\ ";
93
+ result[1] = " ( >.< ) ";
94
+ result[2] = " > ! < ";
95
+ result[3] = " /| |\\ ";
96
+ result[4] = "(_| |_) ";
97
+ return result;
98
+ }
99
+
100
+ if (!breathPhase) {
101
+ result = result.map((l) => l.replace(/\^/g, "'"));
102
+ }
103
+
104
+ if (earTwitch) {
105
+ result[0] = result[0].replace("/\\_/\\", "/╲_/\\");
106
+ }
107
+
108
+ if (tailAlt) {
109
+ result[4] = "(_| |~) ";
110
+ } else {
111
+ result[4] = "(_| |_) ";
112
+ }
113
+
114
+ if (pupilWide) {
115
+ const faceLine = result.findIndex((l) => /\(.*\)/.test(l));
116
+ if (faceLine >= 0) {
117
+ result[faceLine] = result[faceLine].replace("o.o", "@.@");
118
+ }
119
+ }
120
+
121
+ if (state === "idle") {
122
+ const armLine = result.findIndex((l) => l.includes("/| |\\"));
123
+ if (armLine >= 0) {
124
+ result[armLine] = kneadAlt ? " /|| |\\ " : " /| ||\\ ";
125
+ }
126
+ }
127
+
128
+ if (state === "thinking" || state === "busy") {
129
+ const faceIdx = get("thinkingFaceIdx") as number;
130
+ const faceLine = result.findIndex((l) => /\(.*\)/.test(l));
131
+ if (faceLine >= 0) {
132
+ result[faceLine] = result[faceLine].replace(/\(.*?\)/, `( ${THINKING_FACES[faceIdx]} )`);
133
+ }
134
+ }
135
+
136
+ if (state === "busy" || state === "thinking") {
137
+ const idx = get("purrIdx") as number;
138
+ const earLine = 0;
139
+ result[earLine] = result[earLine].trimEnd() + " " + PURR_TEXTS[idx];
140
+ }
141
+
142
+ return result;
143
+ },
144
+ };
145
+
146
+ export const catPack: MascotPack = {
147
+ name: "@mingxy/mascot-cat",
148
+ displayName: "小猫",
149
+ version: "0.1.0",
150
+ author: "mingxy",
151
+ description: "Orange tabby cat — purring, kneading, and knocking things off your terminal.",
152
+
153
+ frames,
154
+
155
+ colors: {
156
+ defaultFg: "#FFA500",
157
+ },
158
+
159
+ animations: {
160
+ blinkInterval: 2500,
161
+ blinkChance: 0.3,
162
+ expressionInterval: 8000,
163
+ idleTimeout: 90000,
164
+ },
165
+
166
+ effects: catEffects,
167
+ };
@@ -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, 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
  }
@@ -1,10 +1,12 @@
1
1
  import type { MascotPack } from "./types"
2
2
  import { yueerPack } from "../builtins/yueer"
3
3
  import { baoziPack } from "../builtins/baozi"
4
+ import { catPack } from "../builtins/cat"
4
5
 
5
6
  const BUILTINS: Record<string, MascotPack> = {
6
7
  yueer: yueerPack,
7
8
  baozi: baoziPack,
9
+ cat: catPack,
8
10
  }
9
11
 
10
12
  const ALL_NAMES = Object.keys(BUILTINS)
@@ -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);