@mingxy/opencode-mascot 0.1.1 → 0.2.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/package.json +2 -2
- package/src/builtins/baozi/frames.ts +43 -0
- package/src/builtins/baozi/index.ts +101 -0
- package/src/builtins/yueer/frames.ts +37 -93
- package/src/builtins/yueer/index.ts +192 -22
- package/src/components/sidebar-mascot.tsx +141 -21
- package/src/core/ascii-renderer.tsx +248 -21
- package/src/core/mascot-loader.ts +26 -9
- package/src/core/types.ts +79 -66
- package/tui.tsx +15 -5
- package/src/builtins/yueer/colors.ts +0 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mingxy/opencode-mascot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
|
|
5
5
|
"author": "mingxy",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,6 @@
|
|
|
29
29
|
"keywords": ["opencode", "tui", "mascot", "plugin", "ascii-art"],
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
32
|
-
"url": "https://github.com/
|
|
32
|
+
"url": "https://github.com/mengfanbo123/opencode-mascot"
|
|
33
33
|
}
|
|
34
34
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// 0123456789
|
|
2
|
+
const defaultFrame = [
|
|
3
|
+
" ~∘◦~ ",
|
|
4
|
+
" ╭❀❀╮ ",
|
|
5
|
+
"( ◕ᴗ◕ ) ",
|
|
6
|
+
" ╰───╯ ",
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const blinkFrame = [
|
|
10
|
+
" ~∘◦~ ",
|
|
11
|
+
" ╭❀❀╮ ",
|
|
12
|
+
"( -_- ) ",
|
|
13
|
+
" ╰───╯ ",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const happyFrame = [
|
|
17
|
+
" ~∘◦~ ",
|
|
18
|
+
" ╭❀❀╮ ",
|
|
19
|
+
"( ◕ω◕ ) ",
|
|
20
|
+
" ╰───╯ ",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const thinkingFrame = [
|
|
24
|
+
" ~∘◦~ ",
|
|
25
|
+
" ╭❀❀╮ ",
|
|
26
|
+
"( ◕_◕ ) ",
|
|
27
|
+
" ╰───╯ ",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const sleepingFrame = [
|
|
31
|
+
" ~∘◦~ ",
|
|
32
|
+
" ╭❀❀╮ ",
|
|
33
|
+
"( -.- ) ",
|
|
34
|
+
" ╰───╯ ",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const frames = {
|
|
38
|
+
default: defaultFrame,
|
|
39
|
+
blink: blinkFrame,
|
|
40
|
+
happy: happyFrame,
|
|
41
|
+
thinking: thinkingFrame,
|
|
42
|
+
sleeping: sleepingFrame,
|
|
43
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { MascotPack } from "../../core/types";
|
|
2
|
+
import { frames } from "./frames";
|
|
3
|
+
|
|
4
|
+
const STEAM_PATTERNS = [
|
|
5
|
+
" ~∘◦~ ",
|
|
6
|
+
" ~◦∘~ ",
|
|
7
|
+
" ∘◦~ ",
|
|
8
|
+
" ◦∘~ ",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const BUBBLE_TEXTS = ["ᵃⁿᵍ~", "ˣᶦᵃⁿ!", "ᵏᵘᵃⁱ", "ᶠᵃⁱ"];
|
|
12
|
+
|
|
13
|
+
const baoziEffects: MascotPack["effects"] = {
|
|
14
|
+
signals: [
|
|
15
|
+
{ name: "steamPhase", initial: 0 },
|
|
16
|
+
{ name: "bubbleIdx", initial: 0 },
|
|
17
|
+
],
|
|
18
|
+
|
|
19
|
+
timers: [
|
|
20
|
+
{
|
|
21
|
+
interval: 1200,
|
|
22
|
+
update(ctx) {
|
|
23
|
+
ctx.set("steamPhase", ((ctx.get("steamPhase") as number) + 1) % STEAM_PATTERNS.length);
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
interval: 2500,
|
|
28
|
+
update(ctx) {
|
|
29
|
+
if (ctx.state === "busy" || ctx.state === "thinking") {
|
|
30
|
+
ctx.set("bubbleIdx", ((ctx.get("bubbleIdx") as number) + 1) % BUBBLE_TEXTS.length);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
|
|
36
|
+
render(lines, ctx) {
|
|
37
|
+
const steamPhase = ctx.get("steamPhase") as number;
|
|
38
|
+
|
|
39
|
+
if (ctx.dragging) {
|
|
40
|
+
const faceIdx = lines.findIndex(l => /\(.*\)/.test(l));
|
|
41
|
+
if (faceIdx >= 0) {
|
|
42
|
+
lines[faceIdx] = lines[faceIdx].replace(/\(.*?\)/, "( °□° )");
|
|
43
|
+
}
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (lines.length > 0) {
|
|
48
|
+
lines[0] = STEAM_PATTERNS[steamPhase];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (ctx.state === "sleeping") {
|
|
52
|
+
const zzzPhase = ((ctx.get("steamPhase") as number) % 3) + 1;
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
if (lines[i].includes("-.-")) {
|
|
55
|
+
const padded = lines[i].padEnd(10);
|
|
56
|
+
lines[i] = padded + " " + "Z" + "z".repeat(zzzPhase - 1);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (ctx.state === "busy" || ctx.state === "thinking") {
|
|
63
|
+
const idx = ctx.get("bubbleIdx") as number;
|
|
64
|
+
if (lines.length > 0) {
|
|
65
|
+
lines[0] = STEAM_PATTERNS[steamPhase] + BUBBLE_TEXTS[idx];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return lines;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const baoziPack: MascotPack = {
|
|
74
|
+
name: "@mingxy/mascot-baozi",
|
|
75
|
+
displayName: "包子",
|
|
76
|
+
version: "0.1.0",
|
|
77
|
+
author: "mingxy",
|
|
78
|
+
description: "A warm steamed bun mascot — soft, round, and always fresh.",
|
|
79
|
+
|
|
80
|
+
frames,
|
|
81
|
+
|
|
82
|
+
colors: {
|
|
83
|
+
defaultFg: "#D4885A",
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
animations: {
|
|
87
|
+
blinkInterval: 3000,
|
|
88
|
+
blinkChance: 0.25,
|
|
89
|
+
expressionInterval: 10000,
|
|
90
|
+
idleTimeout: 120000,
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
sidebar: {
|
|
94
|
+
greetings: ["热乎乎的包子出炉啦~"],
|
|
95
|
+
busyPhrases: ["蒸包子中..."],
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
bubbleTexts: ["蒸着...", "发酵中...", "冒热气...", "快熟了..."],
|
|
99
|
+
|
|
100
|
+
effects: baoziEffects,
|
|
101
|
+
};
|
|
@@ -1,99 +1,43 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* - Imperial crown with three prongs
|
|
9
|
-
* - Long flowing silver-white hair
|
|
10
|
-
* - Big anime eyes (ice blue)
|
|
11
|
-
* - Elegant ice-blue robe with fur trim
|
|
12
|
-
*/
|
|
1
|
+
// 0123456789
|
|
2
|
+
const defaultFrame = [
|
|
3
|
+
" ☆ ",
|
|
4
|
+
" ~( ^-^ )~",
|
|
5
|
+
" ┃███┃ ",
|
|
6
|
+
" ║ ║ ",
|
|
7
|
+
];
|
|
13
8
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
" | /*=====*\\ | ",
|
|
21
|
-
" | | O O | | ",
|
|
22
|
-
" | | \\_/ | | ",
|
|
23
|
-
" | \\ ___ / | ",
|
|
24
|
-
" | \\_____/ | ",
|
|
25
|
-
" |*~~*~~~~~*~~*| ",
|
|
26
|
-
" *~~* ~~* ",
|
|
27
|
-
" |* *| ",
|
|
28
|
-
" |* *| ",
|
|
29
|
-
" *==========* ",
|
|
30
|
-
],
|
|
9
|
+
const blinkFrame = [
|
|
10
|
+
" ☆ ",
|
|
11
|
+
" ~( -_- )~",
|
|
12
|
+
" ┃███┃ ",
|
|
13
|
+
" ║ ║ ",
|
|
14
|
+
];
|
|
31
15
|
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
" | | - - | | ",
|
|
39
|
-
" | | \\_/ | | ",
|
|
40
|
-
" | \\ ___ / | ",
|
|
41
|
-
" | \\_____/ | ",
|
|
42
|
-
" |*~~*~~~~~*~~*| ",
|
|
43
|
-
" *~~* ~~* ",
|
|
44
|
-
" |* *| ",
|
|
45
|
-
" |* *| ",
|
|
46
|
-
" *==========* ",
|
|
47
|
-
],
|
|
16
|
+
const happyFrame = [
|
|
17
|
+
" ☆ ",
|
|
18
|
+
" ~( ^ω^ )~",
|
|
19
|
+
" ┃███┃ ",
|
|
20
|
+
" ║ ║ ",
|
|
21
|
+
];
|
|
48
22
|
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
" | | ^ ^ | | ",
|
|
56
|
-
" | | \\_/ | | ",
|
|
57
|
-
" | \\ ___ / | ",
|
|
58
|
-
" | \\_____/ | ",
|
|
59
|
-
" |*~~*~~~~~*~~*| ",
|
|
60
|
-
" *~~* ~~* ",
|
|
61
|
-
" |* *| ",
|
|
62
|
-
" |* *| ",
|
|
63
|
-
" *==========* ",
|
|
64
|
-
],
|
|
23
|
+
const thinkingFrame = [
|
|
24
|
+
" ☆ ",
|
|
25
|
+
" ~( o_o )~",
|
|
26
|
+
" ┃░░░┃ ",
|
|
27
|
+
" ║ ║ ",
|
|
28
|
+
];
|
|
65
29
|
|
|
66
|
-
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
" | | O - | | ",
|
|
73
|
-
" | | < | | ",
|
|
74
|
-
" | \\ ___ / | ",
|
|
75
|
-
" | \\_____/ | ",
|
|
76
|
-
" |*~~*~~~~~*~~*| ",
|
|
77
|
-
" *~~* ~~* ",
|
|
78
|
-
" |* *| ",
|
|
79
|
-
" |* *| ",
|
|
80
|
-
" *==========* ",
|
|
81
|
-
],
|
|
30
|
+
const sleepingFrame = [
|
|
31
|
+
" ☆ ",
|
|
32
|
+
" ~( -.- )~",
|
|
33
|
+
" ┃~~~┃ ",
|
|
34
|
+
" ║ ║ ",
|
|
35
|
+
];
|
|
82
36
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
" | | . . | | ",
|
|
90
|
-
" | | --- | | ",
|
|
91
|
-
" | \\ ___ / | ",
|
|
92
|
-
" | \\_____/ | ",
|
|
93
|
-
" |*~~*~~~~~*~~*| ",
|
|
94
|
-
" *~~* ~~* ",
|
|
95
|
-
" |* *| ",
|
|
96
|
-
" |* *| ",
|
|
97
|
-
" *==========* ",
|
|
98
|
-
],
|
|
37
|
+
export const frames = {
|
|
38
|
+
default: defaultFrame,
|
|
39
|
+
blink: blinkFrame,
|
|
40
|
+
happy: happyFrame,
|
|
41
|
+
thinking: thinkingFrame,
|
|
42
|
+
sleeping: sleepingFrame,
|
|
99
43
|
};
|
|
@@ -1,41 +1,211 @@
|
|
|
1
|
-
|
|
2
|
-
* 月儿 (Yue'er) — Ice Empress mascot pack.
|
|
3
|
-
*
|
|
4
|
-
* Nine Heavens' Empress with silver-white hair, ice-blue eyes, and imperial crown.
|
|
5
|
-
* She awaits her Master's command with grace and quiet devotion.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { MascotPack } from "../../core/types";
|
|
1
|
+
import type { MascotPack, EffectRenderCtx } from "../../core/types";
|
|
9
2
|
import { frames } from "./frames";
|
|
10
|
-
|
|
3
|
+
|
|
4
|
+
const BUBBLE_TEXTS = [
|
|
5
|
+
"ᵉᵐᵐ~...~~", "ᵉᵗᶜⁿᵍ...", "ˢⁱᵃⁿᵍ...~", "ˡᵃⁱˡᵃ~..",
|
|
6
|
+
"ʰᵐᵐ~..✧", "ᵃⁿⁿ~...", "ᵇᵘˢʸˡᵃ~", "ᵍᵉⁿᵍ~..",
|
|
7
|
+
"ˣⁱᵃⁿˣⁱᵃⁿ..", "ᵈᵉⁿᵍ~..", "ʰᵃᵒ~...✧", "ᵍᵘˡᵘ~..",
|
|
8
|
+
];
|
|
9
|
+
const THINKING_FACES = ["o_o", "O_O", ">_o", "o_<", "⊙_⊙", "◔_◔"];
|
|
10
|
+
|
|
11
|
+
const yueerEffects: MascotPack["effects"] = {
|
|
12
|
+
signals: [
|
|
13
|
+
{ name: "ahogeAlt", initial: false },
|
|
14
|
+
{ name: "braidAlt", initial: false },
|
|
15
|
+
{ name: "waveSide", initial: false },
|
|
16
|
+
{ name: "zzzPhase", initial: 0 },
|
|
17
|
+
{ name: "stompActive", initial: false },
|
|
18
|
+
{ name: "stompAlt", initial: false },
|
|
19
|
+
{ name: "bubbleIdx", initial: 0 },
|
|
20
|
+
{ name: "thinkingFaceIdx", initial: 0 },
|
|
21
|
+
{ name: "thinkingCountdown", initial: 0 },
|
|
22
|
+
{ name: "flapAlt", initial: false },
|
|
23
|
+
],
|
|
24
|
+
|
|
25
|
+
timers: [
|
|
26
|
+
{
|
|
27
|
+
interval: 1500,
|
|
28
|
+
update(ctx) {
|
|
29
|
+
if (Math.random() < 0.25) {
|
|
30
|
+
ctx.set("ahogeAlt", true);
|
|
31
|
+
setTimeout(() => ctx.set("ahogeAlt", false), 200);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
interval: 200,
|
|
37
|
+
update(ctx) {
|
|
38
|
+
if (
|
|
39
|
+
ctx.state === "thinking" &&
|
|
40
|
+
ctx.frameOverride === null
|
|
41
|
+
) {
|
|
42
|
+
ctx.set("stompActive", true);
|
|
43
|
+
ctx.set("stompAlt", !(ctx.get("stompAlt") as boolean));
|
|
44
|
+
} else {
|
|
45
|
+
ctx.set("stompActive", false);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
interval: 1500,
|
|
51
|
+
update(ctx) {
|
|
52
|
+
if (ctx.state === "sleeping") {
|
|
53
|
+
ctx.set("zzzPhase", ((ctx.get("zzzPhase") as number) + 1) % 4);
|
|
54
|
+
} else {
|
|
55
|
+
ctx.set("zzzPhase", 0);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
interval: 300,
|
|
61
|
+
update(ctx) {
|
|
62
|
+
if (ctx.frameOverride === "happy" || ctx.state === "happy") {
|
|
63
|
+
ctx.set("waveSide", !(ctx.get("waveSide") as boolean));
|
|
64
|
+
}
|
|
65
|
+
ctx.set("flapAlt", !(ctx.get("flapAlt") as boolean));
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
interval: 2500,
|
|
70
|
+
update(ctx) {
|
|
71
|
+
if (ctx.state === "busy" || ctx.state === "thinking") {
|
|
72
|
+
ctx.set("bubbleIdx", ((ctx.get("bubbleIdx") as number) + 1) % BUBBLE_TEXTS.length);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
interval: 1000,
|
|
78
|
+
update(ctx) {
|
|
79
|
+
if (ctx.state === "thinking" || ctx.state === "busy") {
|
|
80
|
+
let cd = ctx.get("thinkingCountdown") as number;
|
|
81
|
+
if (!cd || cd <= 0) cd = 5 + Math.floor(Math.random() * 11);
|
|
82
|
+
cd--;
|
|
83
|
+
if (cd <= 0) {
|
|
84
|
+
ctx.set("thinkingFaceIdx", ((ctx.get("thinkingFaceIdx") as number) + 1) % THINKING_FACES.length);
|
|
85
|
+
}
|
|
86
|
+
ctx.set("thinkingCountdown", cd);
|
|
87
|
+
} else {
|
|
88
|
+
ctx.set("thinkingFaceIdx", 0);
|
|
89
|
+
ctx.set("thinkingCountdown", 0);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
|
|
95
|
+
render(lines: string[], ctx: EffectRenderCtx): string[] {
|
|
96
|
+
const { state, frameName, breathPhase, jumpOffset, dragging, get } = ctx;
|
|
97
|
+
let result = [...lines];
|
|
98
|
+
|
|
99
|
+
const ahogeAlt = get("ahogeAlt") as boolean;
|
|
100
|
+
const waveSide = get("waveSide") as boolean;
|
|
101
|
+
const zzzPhase = get("zzzPhase") as number;
|
|
102
|
+
const stompActive = get("stompActive") as boolean;
|
|
103
|
+
const stompAlt = get("stompAlt") as boolean;
|
|
104
|
+
const flapAlt = get("flapAlt") as boolean;
|
|
105
|
+
|
|
106
|
+
if (dragging) {
|
|
107
|
+
const faceLine = result.findIndex((l) => /\(.*\)/.test(l));
|
|
108
|
+
if (faceLine >= 0) {
|
|
109
|
+
result[faceLine] = result[faceLine].replace(/\(.*?\)/, "( °□° )");
|
|
110
|
+
}
|
|
111
|
+
const armLine = result.findIndex(l => l.includes("┃███┃"));
|
|
112
|
+
if (armLine >= 0) {
|
|
113
|
+
const left = flapAlt ? "╱" : "╲";
|
|
114
|
+
const right = flapAlt ? "╲" : "╱";
|
|
115
|
+
result[armLine] = result[armLine].replace("┃███┃", `${left}███${right}`);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (jumpOffset !== 0) {
|
|
121
|
+
const armLine = result.findIndex(l => l.includes("┃███┃"));
|
|
122
|
+
if (armLine >= 0) {
|
|
123
|
+
const left = flapAlt ? "╱" : "╲";
|
|
124
|
+
const right = flapAlt ? "╲" : "╱";
|
|
125
|
+
result[armLine] = result[armLine].replace("┃███┃", `${left}███${right}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!breathPhase) {
|
|
130
|
+
result = result.map((l) => (l.includes("~") ? l.replace(/~/g, "-") : l));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ahogeAlt) {
|
|
134
|
+
result = result.map((l) => (l.includes("☆") ? l.replace("☆", "★") : l));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (frameName === "happy") {
|
|
138
|
+
const faceIdx = result.findIndex((l) => l.includes("^ω^"));
|
|
139
|
+
if (faceIdx >= 0) {
|
|
140
|
+
result[faceIdx] = waveSide ? "╲( ^ω^ )╱ " : " ~( ^ω^ )~";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (stompActive) {
|
|
145
|
+
const legIdx = result.length - 1;
|
|
146
|
+
if (legIdx >= 0) {
|
|
147
|
+
result[legIdx] = stompAlt ? " ╲ ╱ " : " ╱ ╲ ";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (state === "thinking" || state === "busy") {
|
|
152
|
+
const faceIdx = get("thinkingFaceIdx") as number;
|
|
153
|
+
const faceLine = result.findIndex((l) => /\(.*\)/.test(l));
|
|
154
|
+
if (faceLine >= 0) {
|
|
155
|
+
result[faceLine] = result[faceLine].replace(/\(.*?\)/, `( ${THINKING_FACES[faceIdx]} )`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (zzzPhase > 0 && state === "sleeping") {
|
|
160
|
+
const baseWidth = 10;
|
|
161
|
+
for (let i = 0; i < result.length; i++) {
|
|
162
|
+
if (result[i].includes("-.-")) {
|
|
163
|
+
const zzz = "Z" + "z".repeat(zzzPhase - 1);
|
|
164
|
+
const padded = result[i].padEnd(baseWidth);
|
|
165
|
+
result[i] = padded + " " + zzz;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if ((state === "busy" || state === "thinking")) {
|
|
172
|
+
const idx = get("bubbleIdx") as number;
|
|
173
|
+
const ahogeLine = result.findIndex(l => l.includes("☆") || l.includes("★"));
|
|
174
|
+
if (ahogeLine >= 0) {
|
|
175
|
+
result[ahogeLine] = result[ahogeLine].trimEnd() + " " + BUBBLE_TEXTS[idx];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return result;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
11
182
|
|
|
12
183
|
export const yueerPack: MascotPack = {
|
|
13
184
|
name: "@mingxy/mascot-yueer",
|
|
14
185
|
displayName: "月儿",
|
|
15
186
|
version: "0.1.0",
|
|
16
187
|
author: "mingxy",
|
|
17
|
-
description:
|
|
18
|
-
"Ice Empress of the Nine Heavens — elegant, powerful, and devoted to her Master.",
|
|
188
|
+
description: "Ice Empress of the Nine Heavens — elegant, powerful, and devoted to her Master.",
|
|
19
189
|
|
|
20
190
|
frames,
|
|
21
|
-
|
|
191
|
+
|
|
192
|
+
colors: {
|
|
193
|
+
defaultFg: "#8B7EB8",
|
|
194
|
+
},
|
|
22
195
|
|
|
23
196
|
animations: {
|
|
24
197
|
blinkInterval: 2500,
|
|
25
198
|
blinkChance: 0.3,
|
|
26
|
-
expressionInterval:
|
|
199
|
+
expressionInterval: 8000,
|
|
27
200
|
idleTimeout: 90000,
|
|
28
201
|
},
|
|
29
202
|
|
|
30
203
|
sidebar: {
|
|
31
|
-
greetings: [
|
|
32
|
-
|
|
33
|
-
"月儿恭候师尊多时了~",
|
|
34
|
-
"师尊今日气色不错呢~",
|
|
35
|
-
],
|
|
36
|
-
busyPhrases: [
|
|
37
|
-
"月儿正在铸造法器...",
|
|
38
|
-
"驱除心魔中...",
|
|
39
|
-
],
|
|
204
|
+
greetings: ["师尊,月儿在此候命~"],
|
|
205
|
+
busyPhrases: ["铸造法器中..."],
|
|
40
206
|
},
|
|
207
|
+
|
|
208
|
+
bubbleTexts: ["嗯...", "让我想想...", "等等...", "本帝在算..."],
|
|
209
|
+
|
|
210
|
+
effects: yueerEffects,
|
|
41
211
|
};
|
|
@@ -1,41 +1,161 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
|
|
3
|
-
import { createSignal
|
|
3
|
+
import { createSignal } from "solid-js";
|
|
4
4
|
import type { JSX } from "@opentui/solid";
|
|
5
|
-
import type { MascotPack, MascotState } from "../core/types";
|
|
5
|
+
import type { MascotPack, MascotState, SwitchConfig } from "../core/types";
|
|
6
6
|
import { createAnimatedRenderer } from "../core/ascii-renderer";
|
|
7
7
|
|
|
8
8
|
interface SidebarMascotProps {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
mascots: Record<string, MascotPack>;
|
|
10
|
+
switchConfig?: SwitchConfig;
|
|
11
|
+
initialMascot?: string;
|
|
12
|
+
api: {
|
|
13
|
+
event: {
|
|
14
|
+
on(event: string, callback: (data: unknown) => void): void;
|
|
15
|
+
};
|
|
16
|
+
renderer: {
|
|
17
|
+
clearSelection(): void;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
11
20
|
}
|
|
12
21
|
|
|
22
|
+
const DEFAULT_STATE_MAP: Partial<Record<MascotState, string>> = {
|
|
23
|
+
idle: "yueer",
|
|
24
|
+
happy: "yueer",
|
|
25
|
+
thinking: "yueer",
|
|
26
|
+
busy: "baozi",
|
|
27
|
+
sleeping: "baozi",
|
|
28
|
+
};
|
|
29
|
+
|
|
13
30
|
export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
|
|
14
|
-
const
|
|
31
|
+
const names = Object.keys(props.mascots);
|
|
32
|
+
const initialName =
|
|
33
|
+
props.initialMascot && props.mascots[props.initialMascot]
|
|
34
|
+
? props.initialMascot
|
|
35
|
+
: names[Math.floor(Math.random() * names.length)];
|
|
36
|
+
|
|
37
|
+
const [currentName, setCurrentName] = createSignal(initialName);
|
|
38
|
+
const [posX, setPosX] = createSignal(20);
|
|
39
|
+
const [posY, setPosY] = createSignal(2);
|
|
40
|
+
let dragStartX = 0;
|
|
41
|
+
let dragStartY = 0;
|
|
42
|
+
let dragAnchorX = 0;
|
|
43
|
+
let dragAnchorY = 0;
|
|
44
|
+
let lastClickTime = 0;
|
|
45
|
+
let isDragging = false;
|
|
15
46
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
47
|
+
const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
|
|
48
|
+
for (const [name, pack] of Object.entries(props.mascots)) {
|
|
49
|
+
renderers[name] = createAnimatedRenderer(pack);
|
|
50
|
+
}
|
|
18
51
|
|
|
19
|
-
const
|
|
20
|
-
|
|
52
|
+
const switchTo = (name: string) => {
|
|
53
|
+
if (props.mascots[name] && name !== currentName()) {
|
|
54
|
+
setCurrentName(name);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
21
57
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
58
|
+
const setStateWithSwitch = (s: MascotState) => {
|
|
59
|
+
const cur = currentName();
|
|
60
|
+
renderers[cur].setState(s);
|
|
61
|
+
|
|
62
|
+
const stateMap = props.switchConfig?.onState ?? DEFAULT_STATE_MAP;
|
|
63
|
+
const target = stateMap[s];
|
|
64
|
+
if (target && target !== cur && props.mascots[target]) {
|
|
65
|
+
setCurrentName(target);
|
|
66
|
+
renderers[target].setState(s);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
props.api.event.on("session.status", (data: unknown) => {
|
|
71
|
+
// Plugin receives: { id, type, properties: { sessionID, status: { type } } }
|
|
72
|
+
const payload = data as { type?: string; properties?: { sessionID?: string; status?: { type?: string } } } | null;
|
|
73
|
+
const statusType = payload?.properties?.status?.type;
|
|
74
|
+
|
|
75
|
+
if (statusType === "busy" || statusType === "retry") {
|
|
76
|
+
renderers[currentName()].setState("busy");
|
|
27
77
|
} else {
|
|
28
|
-
|
|
29
|
-
setStateText(greetings[Math.floor(Math.random() * greetings.length)]);
|
|
78
|
+
setStateWithSwitch("idle");
|
|
30
79
|
}
|
|
31
80
|
});
|
|
32
81
|
|
|
82
|
+
props.api.event.on("session.idle", () => {
|
|
83
|
+
setStateWithSwitch("happy");
|
|
84
|
+
setTimeout(() => setStateWithSwitch("idle"), 3000);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
props.api.event.on("mascot.switch", (data: unknown) => {
|
|
88
|
+
const target = data as { name?: string } | null;
|
|
89
|
+
if (target?.name) {
|
|
90
|
+
switchTo(target.name);
|
|
91
|
+
} else {
|
|
92
|
+
const others = names.filter((n) => n !== currentName());
|
|
93
|
+
if (others.length > 0) {
|
|
94
|
+
switchTo(others[Math.floor(Math.random() * others.length)]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
props.api.event.on("mascot.toggleWalk", () => {
|
|
100
|
+
renderers[currentName()].toggleWalk();
|
|
101
|
+
});
|
|
102
|
+
|
|
33
103
|
return (
|
|
34
|
-
<box
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
104
|
+
<box
|
|
105
|
+
position="absolute"
|
|
106
|
+
left={posX()}
|
|
107
|
+
top={posY()}
|
|
108
|
+
alignItems="center"
|
|
109
|
+
zIndex={100}
|
|
110
|
+
flexDirection="column"
|
|
111
|
+
onMouseDown={(e: any) => {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
if (now - lastClickTime < 300) {
|
|
114
|
+
const cur = currentName();
|
|
115
|
+
const idx = names.indexOf(cur);
|
|
116
|
+
const next = names[(idx + 1) % names.length];
|
|
117
|
+
switchTo(next);
|
|
118
|
+
lastClickTime = 0;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
lastClickTime = now;
|
|
122
|
+
|
|
123
|
+
if (e.modifiers?.alt) {
|
|
124
|
+
dragStartX = e.x;
|
|
125
|
+
dragStartY = e.y;
|
|
126
|
+
dragAnchorX = posX();
|
|
127
|
+
dragAnchorY = posY();
|
|
128
|
+
isDragging = true;
|
|
129
|
+
renderers[currentName()].setDragging(true);
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
e.stopPropagation();
|
|
132
|
+
props.api.renderer.clearSelection();
|
|
133
|
+
}
|
|
134
|
+
}}
|
|
135
|
+
onMouseDrag={(e: any) => {
|
|
136
|
+
if (e.modifiers?.alt && isDragging) {
|
|
137
|
+
setPosX(dragAnchorX + (e.x - dragStartX));
|
|
138
|
+
setPosY(dragAnchorY + (e.y - dragStartY));
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
e.stopPropagation();
|
|
141
|
+
props.api.renderer.clearSelection();
|
|
142
|
+
}
|
|
143
|
+
}}
|
|
144
|
+
onMouseUp={() => {
|
|
145
|
+
if (isDragging) {
|
|
146
|
+
isDragging = false;
|
|
147
|
+
renderers[currentName()].setDragging(false);
|
|
148
|
+
}
|
|
149
|
+
}}
|
|
150
|
+
onMouseDragEnd={() => {
|
|
151
|
+
if (isDragging) {
|
|
152
|
+
isDragging = false;
|
|
153
|
+
renderers[currentName()].setDragging(false);
|
|
154
|
+
}
|
|
155
|
+
}}
|
|
156
|
+
|
|
157
|
+
>
|
|
158
|
+
{renderers[currentName()]?.element() ?? null}
|
|
39
159
|
</box>
|
|
40
160
|
);
|
|
41
161
|
}
|
|
@@ -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",
|
|
@@ -13,22 +13,26 @@ const STATE_TO_FRAME: Record<MascotState, string> = {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
const DEFAULT_ANIM = {
|
|
16
|
-
blinkInterval:
|
|
17
|
-
blinkChance: 0.
|
|
18
|
-
expressionInterval:
|
|
16
|
+
blinkInterval: 2500,
|
|
17
|
+
blinkChance: 0.3,
|
|
18
|
+
expressionInterval: 8000,
|
|
19
19
|
idleTimeout: 120000,
|
|
20
|
+
breathInterval: 3000,
|
|
21
|
+
walkEnabled: true,
|
|
22
|
+
walkMinDelay: 20000,
|
|
23
|
+
walkMaxDelay: 40000,
|
|
24
|
+
jumpMinDelay: 20000,
|
|
25
|
+
jumpMaxDelay: 40000,
|
|
20
26
|
};
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
const WALK_PATH = [1, 2, 3, 4, 3, 2, 1, 0, -1, -2, -3, -2, -1, 0];
|
|
29
|
+
|
|
30
|
+
function getFrameLines(pack: MascotPack, frameName: string): string[] {
|
|
23
31
|
const frames = pack.frames as Record<string, string[] | undefined>;
|
|
24
32
|
return frames[frameName] ?? frames["default"] ?? [];
|
|
25
33
|
}
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
const frameName = STATE_TO_FRAME[state] ?? "default";
|
|
29
|
-
const lines = getFrame(pack, frameName);
|
|
30
|
-
const fg = pack.colors.defaultFg || undefined;
|
|
31
|
-
|
|
35
|
+
function renderLines(lines: string[], fg?: string): JSX.Element {
|
|
32
36
|
return (
|
|
33
37
|
<box flexDirection="column">
|
|
34
38
|
{lines.map((line: string) => (
|
|
@@ -41,39 +45,262 @@ export function renderFrame(pack: MascotPack, state: MascotState): JSX.Element {
|
|
|
41
45
|
export function createAnimatedRenderer(pack: MascotPack): {
|
|
42
46
|
element: () => JSX.Element;
|
|
43
47
|
setState: (s: MascotState) => void;
|
|
48
|
+
toggleWalk: () => void;
|
|
49
|
+
setDragging: (v: boolean) => void;
|
|
44
50
|
} {
|
|
45
51
|
const anim = { ...DEFAULT_ANIM, ...pack.animations };
|
|
52
|
+
const fg = pack.colors?.defaultFg || undefined;
|
|
53
|
+
const effects = pack.effects;
|
|
54
|
+
|
|
46
55
|
const [currentState, setCurrentState] = createSignal<MascotState>("idle");
|
|
47
56
|
const [frameOverride, setFrameOverride] = createSignal<string | null>(null);
|
|
57
|
+
const [breathPhase, setBreathPhase] = createSignal(true);
|
|
58
|
+
const [walkOffset, setWalkOffset] = createSignal(0);
|
|
59
|
+
const [jumpOffset, setJumpOffset] = createSignal(0);
|
|
60
|
+
const [walkEnabled, setWalkEnabled] = createSignal(anim.walkEnabled ?? true);
|
|
61
|
+
const [dragging, setDraggingSignal] = createSignal(false);
|
|
62
|
+
|
|
63
|
+
let idleSleepTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
64
|
+
|
|
65
|
+
const resetIdleSleep = () => {
|
|
66
|
+
if (idleSleepTimeout) clearTimeout(idleSleepTimeout);
|
|
67
|
+
idleSleepTimeout = null;
|
|
68
|
+
if (currentState() !== "idle") return;
|
|
69
|
+
idleSleepTimeout = setTimeout(() => {
|
|
70
|
+
if (currentState() === "idle") {
|
|
71
|
+
setCurrentState("sleeping");
|
|
72
|
+
stopWalk();
|
|
73
|
+
}
|
|
74
|
+
}, anim.idleTimeout);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
resetIdleSleep();
|
|
78
|
+
|
|
79
|
+
// ─── Extra signals from pack effects ───
|
|
80
|
+
const extraSignals = new Map<string, [() => unknown, (v: unknown) => void]>();
|
|
81
|
+
if (effects?.signals) {
|
|
82
|
+
for (const sig of effects.signals) {
|
|
83
|
+
const [get, set] = createSignal(sig.initial);
|
|
84
|
+
extraSignals.set(sig.name, [get, set]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const getExtra = (name: string): unknown => extraSignals.get(name)?.[0]() ?? null;
|
|
89
|
+
const setExtra = (name: string, value: unknown) => extraSignals.get(name)?.[1](value);
|
|
90
|
+
|
|
91
|
+
const timerCtx: EffectTimerCtx = {
|
|
92
|
+
get: getExtra,
|
|
93
|
+
set: setExtra,
|
|
94
|
+
get state() { return currentState(); },
|
|
95
|
+
get frameOverride() { return frameOverride(); },
|
|
96
|
+
setFrameOverride: (name) => setFrameOverride(name),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ─── Built-in timers ───
|
|
100
|
+
|
|
101
|
+
// 1. Blink
|
|
102
|
+
const hasBlink = (pack.frames as Record<string, string[] | undefined>)["blink"] !== undefined;
|
|
48
103
|
|
|
49
104
|
const blinkTimer = setInterval(() => {
|
|
50
|
-
if (Math.random() < anim.blinkChance) {
|
|
105
|
+
if (currentState() !== "sleeping" && Math.random() < anim.blinkChance && hasBlink) {
|
|
51
106
|
setFrameOverride("blink");
|
|
52
107
|
setTimeout(() => setFrameOverride(null), 150);
|
|
53
108
|
}
|
|
54
109
|
}, anim.blinkInterval);
|
|
55
110
|
|
|
56
|
-
|
|
57
|
-
|
|
111
|
+
// 2. Random expression
|
|
112
|
+
const availableExpressions = Object.keys(pack.frames).filter(
|
|
113
|
+
(k) => k !== "default" && k !== "blink",
|
|
58
114
|
);
|
|
115
|
+
|
|
59
116
|
const expressionTimer = setInterval(() => {
|
|
60
|
-
if (currentState() === "idle" &&
|
|
61
|
-
const pick =
|
|
62
|
-
|
|
63
|
-
|
|
117
|
+
if (currentState() === "idle" && !frameOverride()) {
|
|
118
|
+
const pick = availableExpressions[Math.floor(Math.random() * availableExpressions.length)];
|
|
119
|
+
if (pick) {
|
|
120
|
+
setFrameOverride(pick);
|
|
121
|
+
setTimeout(() => setFrameOverride(null), 2000);
|
|
122
|
+
}
|
|
64
123
|
}
|
|
65
124
|
}, anim.expressionInterval);
|
|
66
125
|
|
|
126
|
+
// 3. Breathing
|
|
127
|
+
const breathTimer = setInterval(() => {
|
|
128
|
+
if (currentState() === "idle") {
|
|
129
|
+
setBreathPhase((v) => !v);
|
|
130
|
+
}
|
|
131
|
+
}, anim.breathInterval);
|
|
132
|
+
|
|
133
|
+
// 4. Walk
|
|
134
|
+
let walkStep = -1;
|
|
135
|
+
let walkInterval: ReturnType<typeof setInterval> | null = null;
|
|
136
|
+
|
|
137
|
+
const startWalk = () => {
|
|
138
|
+
if (walkInterval || !walkEnabled()) return;
|
|
139
|
+
walkStep = 0;
|
|
140
|
+
walkInterval = setInterval(() => {
|
|
141
|
+
if (currentState() !== "idle") {
|
|
142
|
+
stopWalk();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (walkStep < WALK_PATH.length) {
|
|
146
|
+
setWalkOffset(WALK_PATH[walkStep]);
|
|
147
|
+
walkStep++;
|
|
148
|
+
} else {
|
|
149
|
+
if (walkInterval) clearInterval(walkInterval);
|
|
150
|
+
walkInterval = null;
|
|
151
|
+
walkStep = -1;
|
|
152
|
+
setWalkOffset(0);
|
|
153
|
+
}
|
|
154
|
+
}, 400);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const stopWalk = () => {
|
|
158
|
+
if (walkInterval) {
|
|
159
|
+
clearInterval(walkInterval);
|
|
160
|
+
walkInterval = null;
|
|
161
|
+
}
|
|
162
|
+
walkStep = -1;
|
|
163
|
+
setWalkOffset(0);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
let walkTimeout: ReturnType<typeof setTimeout>;
|
|
167
|
+
|
|
168
|
+
function scheduleNextWalk() {
|
|
169
|
+
if (!walkEnabled()) return setTimeout(() => {}, 60000);
|
|
170
|
+
const delay = anim.walkMinDelay + Math.floor(Math.random() * (anim.walkMaxDelay - anim.walkMinDelay));
|
|
171
|
+
return setTimeout(() => {
|
|
172
|
+
if (currentState() === "idle" && !frameOverride() && walkStep === -1 && walkEnabled()) {
|
|
173
|
+
startWalk();
|
|
174
|
+
}
|
|
175
|
+
if (currentState() !== "sleeping") {
|
|
176
|
+
walkTimeout = scheduleNextWalk();
|
|
177
|
+
}
|
|
178
|
+
}, delay);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
walkTimeout = scheduleNextWalk();
|
|
182
|
+
|
|
183
|
+
// 5. Jump
|
|
184
|
+
let jumpTimeout: ReturnType<typeof setTimeout>;
|
|
185
|
+
|
|
186
|
+
function scheduleNextJump() {
|
|
187
|
+
const delay = anim.jumpMinDelay + Math.floor(Math.random() * (anim.jumpMaxDelay - anim.jumpMinDelay));
|
|
188
|
+
return setTimeout(() => {
|
|
189
|
+
if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
|
|
190
|
+
setJumpOffset(-2);
|
|
191
|
+
setTimeout(() => setJumpOffset(-1), 1500);
|
|
192
|
+
setTimeout(() => setJumpOffset(0), 2000);
|
|
193
|
+
}
|
|
194
|
+
if (currentState() !== "sleeping") {
|
|
195
|
+
jumpTimeout = scheduleNextJump();
|
|
196
|
+
}
|
|
197
|
+
}, delay);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
jumpTimeout = scheduleNextJump();
|
|
201
|
+
|
|
202
|
+
// ─── Pack-defined effect timers ───
|
|
203
|
+
const effectTimers: ReturnType<typeof setInterval>[] = [];
|
|
204
|
+
if (effects?.timers) {
|
|
205
|
+
for (const t of effects.timers) {
|
|
206
|
+
effectTimers.push(setInterval(() => t.update(timerCtx), t.interval));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Cleanup ───
|
|
67
211
|
onCleanup(() => {
|
|
68
212
|
clearInterval(blinkTimer);
|
|
69
213
|
clearInterval(expressionTimer);
|
|
214
|
+
clearInterval(breathTimer);
|
|
215
|
+
clearTimeout(walkTimeout);
|
|
216
|
+
clearTimeout(jumpTimeout);
|
|
217
|
+
if (idleSleepTimeout) clearTimeout(idleSleepTimeout);
|
|
218
|
+
if (walkInterval) clearInterval(walkInterval);
|
|
219
|
+
for (const t of effectTimers) clearInterval(t);
|
|
70
220
|
});
|
|
71
221
|
|
|
222
|
+
// ─── Render ───
|
|
72
223
|
const element = () => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
224
|
+
breathPhase();
|
|
225
|
+
walkOffset();
|
|
226
|
+
jumpOffset();
|
|
227
|
+
frameOverride();
|
|
228
|
+
currentState();
|
|
229
|
+
dragging();
|
|
230
|
+
|
|
231
|
+
for (const [, [get]] of extraSignals) {
|
|
232
|
+
get();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const frameName = frameOverride() ?? STATE_TO_FRAME[currentState()] ?? "default";
|
|
236
|
+
const rawLines = getFrameLines(pack, frameName);
|
|
237
|
+
const offset = walkOffset();
|
|
238
|
+
|
|
239
|
+
const width = rawLines[0]?.length ?? 10;
|
|
240
|
+
const blank = " ".repeat(width);
|
|
241
|
+
|
|
242
|
+
let lines: string[] = rawLines.map((line, i) => {
|
|
243
|
+
if (!breathPhase()) {
|
|
244
|
+
if (i === 0) return blank;
|
|
245
|
+
return rawLines[i - 1];
|
|
246
|
+
}
|
|
247
|
+
return line;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (effects?.render) {
|
|
251
|
+
const renderCtx: EffectRenderCtx = {
|
|
252
|
+
state: currentState(),
|
|
253
|
+
frameName,
|
|
254
|
+
breathPhase: breathPhase(),
|
|
255
|
+
jumpOffset: jumpOffset(),
|
|
256
|
+
dragging: dragging(),
|
|
257
|
+
get: getExtra,
|
|
258
|
+
};
|
|
259
|
+
lines = effects.render(lines, renderCtx);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const top = jumpOffset();
|
|
263
|
+
const left = offset > 0 ? offset : 0;
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<box flexDirection="column" left={left} top={top}>
|
|
267
|
+
{renderLines(lines, fg)}
|
|
268
|
+
</box>
|
|
269
|
+
);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// ─── State control ───
|
|
273
|
+
const setState = (s: MascotState) => {
|
|
274
|
+
setCurrentState(s);
|
|
275
|
+
setBreathPhase(true);
|
|
276
|
+
resetIdleSleep();
|
|
277
|
+
|
|
278
|
+
if (s !== "idle") {
|
|
279
|
+
stopWalk();
|
|
280
|
+
} else if (walkEnabled()) {
|
|
281
|
+
walkTimeout = scheduleNextWalk();
|
|
282
|
+
jumpTimeout = scheduleNextJump();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const toggleWalk = () => {
|
|
287
|
+
const next = !walkEnabled();
|
|
288
|
+
setWalkEnabled(next);
|
|
289
|
+
if (!next) {
|
|
290
|
+
stopWalk();
|
|
291
|
+
} else if (currentState() === "idle") {
|
|
292
|
+
walkTimeout = scheduleNextWalk();
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const setDragging = (v: boolean) => {
|
|
297
|
+
setDraggingSignal(v);
|
|
298
|
+
if (v) {
|
|
299
|
+
setJumpOffset(-1);
|
|
300
|
+
} else {
|
|
301
|
+
setJumpOffset(0);
|
|
302
|
+
}
|
|
76
303
|
};
|
|
77
304
|
|
|
78
|
-
return { element, setState
|
|
305
|
+
return { element, setState, toggleWalk, setDragging };
|
|
79
306
|
}
|
|
@@ -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) || "
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
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
|
@@ -9,93 +9,112 @@ export type ExpressionName = 'default' | 'blink' | 'happy' | 'thinking' | 'busy'
|
|
|
9
9
|
*/
|
|
10
10
|
export type MascotState = 'idle' | 'busy' | 'thinking' | 'sleeping' | 'happy';
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Color definition for a zone within a frame.
|
|
14
|
-
*/
|
|
15
|
-
export interface ZoneColor {
|
|
16
|
-
/** ANSI foreground color code or CSS color */
|
|
17
|
-
fg?: string;
|
|
18
|
-
/** ANSI background color code or CSS color */
|
|
19
|
-
bg?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
12
|
/**
|
|
23
13
|
* Animation timing configuration with sensible defaults.
|
|
24
14
|
*/
|
|
25
15
|
export interface AnimationConfig {
|
|
26
|
-
/** Milliseconds between blink checks (default: 2000) */
|
|
27
16
|
blinkInterval?: number;
|
|
28
|
-
/** Probability of a blink on each check, 0–1 (default: 0.35) */
|
|
29
17
|
blinkChance?: number;
|
|
30
|
-
/** Milliseconds between random expression changes (default: 45000) */
|
|
31
18
|
expressionInterval?: number;
|
|
32
|
-
/** Milliseconds of inactivity before entering sleep state (default: 120000) */
|
|
33
19
|
idleTimeout?: number;
|
|
20
|
+
breathInterval?: number;
|
|
21
|
+
walkEnabled?: boolean;
|
|
22
|
+
walkMinDelay?: number;
|
|
23
|
+
walkMaxDelay?: number;
|
|
24
|
+
jumpMinDelay?: number;
|
|
25
|
+
jumpMaxDelay?: number;
|
|
34
26
|
}
|
|
35
27
|
|
|
36
28
|
/**
|
|
37
29
|
* Sidebar display configuration.
|
|
38
30
|
*/
|
|
39
31
|
export interface SidebarConfig {
|
|
40
|
-
/** Rotating greeting messages shown below the mascot */
|
|
41
32
|
greetings?: string[];
|
|
42
|
-
/** Rotating phrases shown while in busy state */
|
|
43
33
|
busyPhrases?: string[];
|
|
44
|
-
|
|
45
|
-
|
|
34
|
+
}
|
|
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;
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
/**
|
|
49
|
-
*
|
|
48
|
+
* Timer context passed to EffectTimer.update().
|
|
50
49
|
*/
|
|
51
|
-
export interface
|
|
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;
|
|
52
55
|
/** Current mascot state */
|
|
53
56
|
state: MascotState;
|
|
54
|
-
/** Current frame
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
/** Optional session metadata from the host */
|
|
59
|
-
sessionData?: {
|
|
60
|
-
tokenUsage?: number;
|
|
61
|
-
tokenLimit?: number;
|
|
62
|
-
model?: string;
|
|
63
|
-
};
|
|
57
|
+
/** Current frame override (null = use state mapping) */
|
|
58
|
+
frameOverride: string | null;
|
|
59
|
+
/** Override the displayed frame */
|
|
60
|
+
setFrameOverride: (name: string | null) => void;
|
|
64
61
|
}
|
|
65
62
|
|
|
66
63
|
/**
|
|
67
|
-
*
|
|
64
|
+
* A recurring timer that updates extra signals.
|
|
68
65
|
*/
|
|
69
|
-
export interface
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
/** Called
|
|
73
|
-
|
|
66
|
+
export interface EffectTimer {
|
|
67
|
+
/** Interval in ms */
|
|
68
|
+
interval: number;
|
|
69
|
+
/** Called on each tick */
|
|
70
|
+
update: (ctx: EffectTimerCtx) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Context passed to EffectRenderFn.
|
|
75
|
+
*/
|
|
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;
|
|
74
102
|
}
|
|
75
103
|
|
|
76
104
|
/**
|
|
77
105
|
* A complete mascot pack definition.
|
|
78
106
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
107
|
+
* Frames are plain string arrays — each string is one line, all lines same width.
|
|
108
|
+
* "default" is required; all other expressions are optional.
|
|
81
109
|
*/
|
|
82
110
|
export interface MascotPack {
|
|
83
|
-
/** Unique machine-readable identifier (e.g. "tuxie") */
|
|
84
111
|
name: string;
|
|
85
|
-
/** Human-readable display name (e.g. "Tuxie the Penguin") */
|
|
86
112
|
displayName: string;
|
|
87
|
-
/** Semantic version string (e.g. "1.0.0") */
|
|
88
113
|
version: string;
|
|
89
|
-
/** Pack author or team */
|
|
90
114
|
author: string;
|
|
91
|
-
/** Short description of the mascot */
|
|
92
115
|
description: string;
|
|
93
116
|
|
|
94
|
-
/**
|
|
95
|
-
* Expression frames: expression name → array of ASCII-art lines.
|
|
96
|
-
* Each entry is a single frame (one multi-line drawing).
|
|
97
|
-
* "default" is required; all other expressions are optional.
|
|
98
|
-
*/
|
|
117
|
+
/** ASCII-art frames keyed by expression name. "default" is required. */
|
|
99
118
|
frames: {
|
|
100
119
|
default: string[];
|
|
101
120
|
blink?: string[];
|
|
@@ -105,37 +124,31 @@ export interface MascotPack {
|
|
|
105
124
|
sleeping?: string[];
|
|
106
125
|
};
|
|
107
126
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
* Zones are matched by substring or named markers in the frame lines.
|
|
111
|
-
*/
|
|
112
|
-
colors: {
|
|
113
|
-
/** Named zone → foreground/background color */
|
|
114
|
-
zones?: Record<string, ZoneColor>;
|
|
115
|
-
/** Default foreground applied to unmatched characters */
|
|
127
|
+
/** Optional color for the mascot text */
|
|
128
|
+
colors?: {
|
|
116
129
|
defaultFg?: string;
|
|
117
|
-
/** Default background applied to unmatched characters */
|
|
118
|
-
defaultBg?: string;
|
|
119
130
|
};
|
|
120
131
|
|
|
121
|
-
/** Animation timing overrides */
|
|
122
132
|
animations?: AnimationConfig;
|
|
123
|
-
|
|
124
|
-
/** Sidebar content configuration */
|
|
125
133
|
sidebar?: SidebarConfig;
|
|
126
134
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
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>>;
|
|
129
144
|
}
|
|
130
145
|
|
|
131
146
|
/**
|
|
132
147
|
* User-facing configuration merged from opencode config file.
|
|
133
148
|
*/
|
|
134
149
|
export interface MascotConfig {
|
|
135
|
-
/** Name of the selected mascot pack */
|
|
136
150
|
mascot: string;
|
|
137
|
-
/** Enable or disable mascot animations */
|
|
138
151
|
animations: boolean;
|
|
139
|
-
/** Allow mascot to enter sleep state on idle */
|
|
140
152
|
idleSleep: boolean;
|
|
153
|
+
switchConfig?: SwitchConfig;
|
|
141
154
|
}
|
package/tui.tsx
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
-
import {
|
|
3
|
+
import { loadAllMascots, getRandomMascot } from "./src/core/mascot-loader"
|
|
4
4
|
import { SidebarMascot } from "./src/components/sidebar-mascot"
|
|
5
|
+
import { createAnimatedRenderer } from "./src/core/ascii-renderer"
|
|
6
|
+
|
|
7
|
+
const tui: TuiPlugin = async (api, _options) => {
|
|
8
|
+
const mascots = loadAllMascots()
|
|
9
|
+
const homeMascot = getRandomMascot()
|
|
10
|
+
const homeRenderer = createAnimatedRenderer(homeMascot)
|
|
5
11
|
|
|
6
|
-
const tui: TuiPlugin = async (api, options) => {
|
|
7
|
-
const mascot = await loadMascot(options)
|
|
8
|
-
|
|
9
12
|
api.slots.register({
|
|
10
13
|
slots: {
|
|
11
14
|
sidebar_content() {
|
|
12
|
-
return <SidebarMascot
|
|
15
|
+
return <SidebarMascot mascots={mascots} api={api} />
|
|
16
|
+
},
|
|
17
|
+
home_bottom() {
|
|
18
|
+
return (
|
|
19
|
+
<box flexDirection="column" alignItems="center">
|
|
20
|
+
{homeRenderer.element()}
|
|
21
|
+
</box>
|
|
22
|
+
)
|
|
13
23
|
}
|
|
14
24
|
}
|
|
15
25
|
})
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 月儿 (Yue'er) — Color zone definitions.
|
|
3
|
-
*
|
|
4
|
-
* Zones are matched by the ASCII characters in frame lines.
|
|
5
|
-
* Each zone maps to a foreground (fg) and optional background (bg) color.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export const colors = {
|
|
9
|
-
zones: {
|
|
10
|
-
/** Long flowing hair — silver white */
|
|
11
|
-
hair: {
|
|
12
|
-
fg: "#E8E0F0",
|
|
13
|
-
},
|
|
14
|
-
/** Imperial crown / headdress — gold */
|
|
15
|
-
crown: {
|
|
16
|
-
fg: "#FFD700",
|
|
17
|
-
},
|
|
18
|
-
/** Big anime eyes — ice blue */
|
|
19
|
-
eyes: {
|
|
20
|
-
fg: "#66CCFF",
|
|
21
|
-
},
|
|
22
|
-
/** Face and skin — warm peach */
|
|
23
|
-
skin: {
|
|
24
|
-
fg: "#FFE0BD",
|
|
25
|
-
},
|
|
26
|
-
/** Elegant robe / dress — ice blue */
|
|
27
|
-
dress: {
|
|
28
|
-
fg: "#7CB9E8",
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
/** Default foreground for unmatched characters — cool silver */
|
|
33
|
-
defaultFg: "#B8C4D8",
|
|
34
|
-
};
|