@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 +43 -43
- package/src/builtins/props/box.ts +69 -0
- package/src/builtins/props/laptop.ts +53 -0
- package/src/builtins/props/pad.ts +65 -0
- package/src/components/home-mascot.tsx +7 -0
- package/src/components/sidebar-mascot.tsx +27 -1
- package/src/core/ascii-renderer.tsx +79 -3
- package/src/core/prop-loader.ts +38 -0
- package/src/core/types.ts +23 -0
- package/tui.tsx +1 -0
package/package.json
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mingxy/opencode-mascot",
|
|
3
|
-
"version": "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())
|
|
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; },
|
|
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
|
-
|
|
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
|
+
}
|