@mingxy/opencode-mascot 0.1.2 → 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/index.ts +186 -10
- package/src/components/sidebar-mascot.tsx +125 -15
- package/src/core/ascii-renderer.tsx +125 -139
- package/src/core/mascot-loader.ts +26 -9
- package/src/core/types.ts +73 -23
- package/tui.tsx +6 -5
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,13 +1,185 @@
|
|
|
1
|
-
|
|
2
|
-
* 月儿 (Yue'er) — Ice Empress mascot pack.
|
|
3
|
-
*
|
|
4
|
-
* Nine Heavens' Empress with ice-blue eyes and silver-white hair.
|
|
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
|
+
};
|
|
182
|
+
|
|
11
183
|
export const yueerPack: MascotPack = {
|
|
12
184
|
name: "@mingxy/mascot-yueer",
|
|
13
185
|
displayName: "月儿",
|
|
@@ -18,13 +190,13 @@ export const yueerPack: MascotPack = {
|
|
|
18
190
|
frames,
|
|
19
191
|
|
|
20
192
|
colors: {
|
|
21
|
-
defaultFg: "#
|
|
193
|
+
defaultFg: "#8B7EB8",
|
|
22
194
|
},
|
|
23
195
|
|
|
24
196
|
animations: {
|
|
25
197
|
blinkInterval: 2500,
|
|
26
198
|
blinkChance: 0.3,
|
|
27
|
-
expressionInterval:
|
|
199
|
+
expressionInterval: 8000,
|
|
28
200
|
idleTimeout: 90000,
|
|
29
201
|
},
|
|
30
202
|
|
|
@@ -32,4 +204,8 @@ export const yueerPack: MascotPack = {
|
|
|
32
204
|
greetings: ["师尊,月儿在此候命~"],
|
|
33
205
|
busyPhrases: ["铸造法器中..."],
|
|
34
206
|
},
|
|
207
|
+
|
|
208
|
+
bubbleTexts: ["嗯...", "让我想想...", "等等...", "本帝在算..."],
|
|
209
|
+
|
|
210
|
+
effects: yueerEffects,
|
|
35
211
|
};
|
|
@@ -1,51 +1,161 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
|
|
3
|
+
import { createSignal } from "solid-js";
|
|
3
4
|
import type { JSX } from "@opentui/solid";
|
|
4
|
-
import type { MascotPack } from "../core/types";
|
|
5
|
+
import type { MascotPack, MascotState, SwitchConfig } from "../core/types";
|
|
5
6
|
import { createAnimatedRenderer } from "../core/ascii-renderer";
|
|
6
7
|
|
|
7
8
|
interface SidebarMascotProps {
|
|
8
|
-
|
|
9
|
+
mascots: Record<string, MascotPack>;
|
|
10
|
+
switchConfig?: SwitchConfig;
|
|
11
|
+
initialMascot?: string;
|
|
9
12
|
api: {
|
|
10
13
|
event: {
|
|
11
14
|
on(event: string, callback: (data: unknown) => void): void;
|
|
12
15
|
};
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
renderer: {
|
|
17
|
+
clearSelection(): void;
|
|
15
18
|
};
|
|
16
19
|
};
|
|
17
20
|
}
|
|
18
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
|
+
|
|
19
30
|
export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
|
|
20
|
-
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;
|
|
46
|
+
|
|
47
|
+
const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
|
|
48
|
+
for (const [name, pack] of Object.entries(props.mascots)) {
|
|
49
|
+
renderers[name] = createAnimatedRenderer(pack);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const switchTo = (name: string) => {
|
|
53
|
+
if (props.mascots[name] && name !== currentName()) {
|
|
54
|
+
setCurrentName(name);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
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
|
+
};
|
|
21
69
|
|
|
22
70
|
props.api.event.on("session.status", (data: unknown) => {
|
|
23
|
-
|
|
24
|
-
const
|
|
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;
|
|
25
74
|
|
|
26
75
|
if (statusType === "busy" || statusType === "retry") {
|
|
27
|
-
setState("busy");
|
|
76
|
+
renderers[currentName()].setState("busy");
|
|
28
77
|
} else {
|
|
29
|
-
|
|
78
|
+
setStateWithSwitch("idle");
|
|
30
79
|
}
|
|
31
80
|
});
|
|
32
81
|
|
|
33
82
|
props.api.event.on("session.idle", () => {
|
|
34
|
-
|
|
35
|
-
setTimeout(() =>
|
|
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();
|
|
36
101
|
});
|
|
37
102
|
|
|
38
103
|
return (
|
|
39
104
|
<box
|
|
40
105
|
position="absolute"
|
|
41
|
-
left={
|
|
42
|
-
|
|
43
|
-
top={2}
|
|
106
|
+
left={posX()}
|
|
107
|
+
top={posY()}
|
|
44
108
|
alignItems="center"
|
|
45
109
|
zIndex={100}
|
|
46
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
|
+
|
|
47
157
|
>
|
|
48
|
-
{element()}
|
|
158
|
+
{renderers[currentName()]?.element() ?? null}
|
|
49
159
|
</box>
|
|
50
160
|
);
|
|
51
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",
|
|
@@ -18,11 +18,11 @@ const DEFAULT_ANIM = {
|
|
|
18
18
|
expressionInterval: 8000,
|
|
19
19
|
idleTimeout: 120000,
|
|
20
20
|
breathInterval: 3000,
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
walkEnabled: true,
|
|
22
|
+
walkMinDelay: 20000,
|
|
23
|
+
walkMaxDelay: 40000,
|
|
23
24
|
jumpMinDelay: 20000,
|
|
24
25
|
jumpMaxDelay: 40000,
|
|
25
|
-
stompThreshold: 30000,
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
const WALK_PATH = [1, 2, 3, 4, 3, 2, 1, 0, -1, -2, -3, -2, -1, 0];
|
|
@@ -45,35 +45,70 @@ function renderLines(lines: string[], fg?: string): JSX.Element {
|
|
|
45
45
|
export function createAnimatedRenderer(pack: MascotPack): {
|
|
46
46
|
element: () => JSX.Element;
|
|
47
47
|
setState: (s: MascotState) => void;
|
|
48
|
+
toggleWalk: () => void;
|
|
49
|
+
setDragging: (v: boolean) => void;
|
|
48
50
|
} {
|
|
49
51
|
const anim = { ...DEFAULT_ANIM, ...pack.animations };
|
|
50
52
|
const fg = pack.colors?.defaultFg || undefined;
|
|
53
|
+
const effects = pack.effects;
|
|
51
54
|
|
|
52
55
|
const [currentState, setCurrentState] = createSignal<MascotState>("idle");
|
|
53
56
|
const [frameOverride, setFrameOverride] = createSignal<string | null>(null);
|
|
54
|
-
const [
|
|
57
|
+
const [breathPhase, setBreathPhase] = createSignal(true);
|
|
55
58
|
const [walkOffset, setWalkOffset] = createSignal(0);
|
|
56
|
-
const [ahogeAlt, setAhogeAlt] = createSignal(false);
|
|
57
|
-
const [braidAlt, setBraidAlt] = createSignal(false);
|
|
58
|
-
const [waveSide, setWaveSide] = createSignal(false);
|
|
59
|
-
const [zzzPhase, setZzzPhase] = createSignal(0);
|
|
60
59
|
const [jumpOffset, setJumpOffset] = createSignal(0);
|
|
61
|
-
const [
|
|
62
|
-
const [
|
|
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
|
+
}
|
|
63
87
|
|
|
64
|
-
|
|
88
|
+
const getExtra = (name: string): unknown => extraSignals.get(name)?.[0]() ?? null;
|
|
89
|
+
const setExtra = (name: string, value: unknown) => extraSignals.get(name)?.[1](value);
|
|
65
90
|
|
|
66
|
-
|
|
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
|
|
67
102
|
const hasBlink = (pack.frames as Record<string, string[] | undefined>)["blink"] !== undefined;
|
|
68
103
|
|
|
69
104
|
const blinkTimer = setInterval(() => {
|
|
70
|
-
if (Math.random() < anim.blinkChance && hasBlink) {
|
|
105
|
+
if (currentState() !== "sleeping" && Math.random() < anim.blinkChance && hasBlink) {
|
|
71
106
|
setFrameOverride("blink");
|
|
72
107
|
setTimeout(() => setFrameOverride(null), 150);
|
|
73
108
|
}
|
|
74
109
|
}, anim.blinkInterval);
|
|
75
110
|
|
|
76
|
-
//
|
|
111
|
+
// 2. Random expression
|
|
77
112
|
const availableExpressions = Object.keys(pack.frames).filter(
|
|
78
113
|
(k) => k !== "default" && k !== "blink",
|
|
79
114
|
);
|
|
@@ -83,37 +118,30 @@ export function createAnimatedRenderer(pack: MascotPack): {
|
|
|
83
118
|
const pick = availableExpressions[Math.floor(Math.random() * availableExpressions.length)];
|
|
84
119
|
if (pick) {
|
|
85
120
|
setFrameOverride(pick);
|
|
86
|
-
|
|
87
|
-
setTimeout(() => {
|
|
88
|
-
setFrameOverride(null);
|
|
89
|
-
stopWave();
|
|
90
|
-
}, 2000);
|
|
121
|
+
setTimeout(() => setFrameOverride(null), 2000);
|
|
91
122
|
}
|
|
92
123
|
}
|
|
93
124
|
}, anim.expressionInterval);
|
|
94
125
|
|
|
95
|
-
//
|
|
126
|
+
// 3. Breathing
|
|
96
127
|
const breathTimer = setInterval(() => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}, anim.breathInterval);
|
|
100
|
-
|
|
101
|
-
// ============ 4. Ahoge wobble (呆毛晃) ============
|
|
102
|
-
const ahogeTimer = setInterval(() => {
|
|
103
|
-
if (Math.random() < anim.ahogeChance) {
|
|
104
|
-
setAhogeAlt(true);
|
|
105
|
-
setTimeout(() => setAhogeAlt(false), 200);
|
|
128
|
+
if (currentState() === "idle") {
|
|
129
|
+
setBreathPhase((v) => !v);
|
|
106
130
|
}
|
|
107
|
-
}, anim.
|
|
131
|
+
}, anim.breathInterval);
|
|
108
132
|
|
|
109
|
-
//
|
|
133
|
+
// 4. Walk
|
|
110
134
|
let walkStep = -1;
|
|
111
135
|
let walkInterval: ReturnType<typeof setInterval> | null = null;
|
|
112
136
|
|
|
113
137
|
const startWalk = () => {
|
|
114
|
-
if (walkInterval) return;
|
|
138
|
+
if (walkInterval || !walkEnabled()) return;
|
|
115
139
|
walkStep = 0;
|
|
116
140
|
walkInterval = setInterval(() => {
|
|
141
|
+
if (currentState() !== "idle") {
|
|
142
|
+
stopWalk();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
117
145
|
if (walkStep < WALK_PATH.length) {
|
|
118
146
|
setWalkOffset(WALK_PATH[walkStep]);
|
|
119
147
|
walkStep++;
|
|
@@ -138,18 +166,21 @@ export function createAnimatedRenderer(pack: MascotPack): {
|
|
|
138
166
|
let walkTimeout: ReturnType<typeof setTimeout>;
|
|
139
167
|
|
|
140
168
|
function scheduleNextWalk() {
|
|
141
|
-
|
|
169
|
+
if (!walkEnabled()) return setTimeout(() => {}, 60000);
|
|
170
|
+
const delay = anim.walkMinDelay + Math.floor(Math.random() * (anim.walkMaxDelay - anim.walkMinDelay));
|
|
142
171
|
return setTimeout(() => {
|
|
143
|
-
if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
|
|
172
|
+
if (currentState() === "idle" && !frameOverride() && walkStep === -1 && walkEnabled()) {
|
|
144
173
|
startWalk();
|
|
145
174
|
}
|
|
146
|
-
|
|
175
|
+
if (currentState() !== "sleeping") {
|
|
176
|
+
walkTimeout = scheduleNextWalk();
|
|
177
|
+
}
|
|
147
178
|
}, delay);
|
|
148
179
|
}
|
|
149
180
|
|
|
150
181
|
walkTimeout = scheduleNextWalk();
|
|
151
182
|
|
|
152
|
-
//
|
|
183
|
+
// 5. Jump
|
|
153
184
|
let jumpTimeout: ReturnType<typeof setTimeout>;
|
|
154
185
|
|
|
155
186
|
function scheduleNextJump() {
|
|
@@ -157,77 +188,49 @@ export function createAnimatedRenderer(pack: MascotPack): {
|
|
|
157
188
|
return setTimeout(() => {
|
|
158
189
|
if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
|
|
159
190
|
setJumpOffset(-2);
|
|
160
|
-
setTimeout(() => setJumpOffset(
|
|
191
|
+
setTimeout(() => setJumpOffset(-1), 1500);
|
|
192
|
+
setTimeout(() => setJumpOffset(0), 2000);
|
|
193
|
+
}
|
|
194
|
+
if (currentState() !== "sleeping") {
|
|
195
|
+
jumpTimeout = scheduleNextJump();
|
|
161
196
|
}
|
|
162
|
-
jumpTimeout = scheduleNextJump();
|
|
163
197
|
}, delay);
|
|
164
198
|
}
|
|
165
199
|
|
|
166
200
|
jumpTimeout = scheduleNextJump();
|
|
167
201
|
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
waveTimer = setInterval(() => setWaveSide((v) => !v), 300);
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const stopWave = () => {
|
|
177
|
-
if (waveTimer) {
|
|
178
|
-
clearInterval(waveTimer);
|
|
179
|
-
waveTimer = null;
|
|
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));
|
|
180
207
|
}
|
|
181
|
-
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
// ============ 8. Zzz bubbles (sleeping state) ============
|
|
185
|
-
const zzzTimer = setInterval(() => {
|
|
186
|
-
if (currentState() === "sleeping") {
|
|
187
|
-
setZzzPhase((p) => (p + 1) % 4);
|
|
188
|
-
} else {
|
|
189
|
-
setZzzPhase(0);
|
|
190
|
-
}
|
|
191
|
-
}, 1500);
|
|
192
|
-
|
|
193
|
-
// ============ 9. Stomp (跺脚 — thinking > threshold) ============
|
|
194
|
-
const stompTimer = setInterval(() => {
|
|
195
|
-
if (
|
|
196
|
-
currentState() === "thinking" &&
|
|
197
|
-
thinkingStartTime > 0 &&
|
|
198
|
-
Date.now() - thinkingStartTime > anim.stompThreshold
|
|
199
|
-
) {
|
|
200
|
-
setStompActive(true);
|
|
201
|
-
setStompAlt((a) => !a);
|
|
202
|
-
} else {
|
|
203
|
-
setStompActive(false);
|
|
204
|
-
}
|
|
205
|
-
}, 200);
|
|
208
|
+
}
|
|
206
209
|
|
|
207
|
-
//
|
|
210
|
+
// ─── Cleanup ───
|
|
208
211
|
onCleanup(() => {
|
|
209
212
|
clearInterval(blinkTimer);
|
|
210
213
|
clearInterval(expressionTimer);
|
|
211
214
|
clearInterval(breathTimer);
|
|
212
|
-
clearInterval(ahogeTimer);
|
|
213
|
-
clearInterval(zzzTimer);
|
|
214
|
-
clearInterval(stompTimer);
|
|
215
215
|
clearTimeout(walkTimeout);
|
|
216
216
|
clearTimeout(jumpTimeout);
|
|
217
|
+
if (idleSleepTimeout) clearTimeout(idleSleepTimeout);
|
|
217
218
|
if (walkInterval) clearInterval(walkInterval);
|
|
218
|
-
|
|
219
|
+
for (const t of effectTimers) clearInterval(t);
|
|
219
220
|
});
|
|
220
221
|
|
|
221
|
-
//
|
|
222
|
+
// ─── Render ───
|
|
222
223
|
const element = () => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
braidAlt();
|
|
226
|
-
waveSide();
|
|
227
|
-
zzzPhase();
|
|
224
|
+
breathPhase();
|
|
225
|
+
walkOffset();
|
|
228
226
|
jumpOffset();
|
|
229
|
-
|
|
230
|
-
|
|
227
|
+
frameOverride();
|
|
228
|
+
currentState();
|
|
229
|
+
dragging();
|
|
230
|
+
|
|
231
|
+
for (const [, [get]] of extraSignals) {
|
|
232
|
+
get();
|
|
233
|
+
}
|
|
231
234
|
|
|
232
235
|
const frameName = frameOverride() ?? STATE_TO_FRAME[currentState()] ?? "default";
|
|
233
236
|
const rawLines = getFrameLines(pack, frameName);
|
|
@@ -236,51 +239,24 @@ export function createAnimatedRenderer(pack: MascotPack): {
|
|
|
236
239
|
const width = rawLines[0]?.length ?? 10;
|
|
237
240
|
const blank = " ".repeat(width);
|
|
238
241
|
|
|
239
|
-
let lines = rawLines.map((line, i) => {
|
|
240
|
-
if (!
|
|
242
|
+
let lines: string[] = rawLines.map((line, i) => {
|
|
243
|
+
if (!breathPhase()) {
|
|
241
244
|
if (i === 0) return blank;
|
|
242
245
|
return rawLines[i - 1];
|
|
243
246
|
}
|
|
244
247
|
return line;
|
|
245
248
|
});
|
|
246
249
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// 4. Wave: braids become hands ╲ / ╱ on face row
|
|
258
|
-
if (frameName === "happy") {
|
|
259
|
-
const faceIdx = lines.findIndex((l) => l.includes("^ω^"));
|
|
260
|
-
if (faceIdx >= 0) {
|
|
261
|
-
lines[faceIdx] = waveSide()
|
|
262
|
-
? "╲( ^ω^ )╱ "
|
|
263
|
-
: " ~( ^ω^ )~";
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// 5. Stomp: legs alternate ╲ ╱ ↔ ╱ ╲
|
|
268
|
-
if (stompActive()) {
|
|
269
|
-
const legIdx = lines.length - 1;
|
|
270
|
-
if (legIdx >= 0) {
|
|
271
|
-
lines[legIdx] = stompAlt() ? " ╲ ╱ " : " ╱ ╲ ";
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// 6. Zzz: floating Z bubbles beside face
|
|
276
|
-
const zzz = zzzPhase();
|
|
277
|
-
if (zzz > 0 && currentState() === "sleeping") {
|
|
278
|
-
for (let i = 0; i < lines.length; i++) {
|
|
279
|
-
if (lines[i].includes("-.-")) {
|
|
280
|
-
lines[i] = lines[i] + " " + "Z".repeat(zzz);
|
|
281
|
-
break;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
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);
|
|
284
260
|
}
|
|
285
261
|
|
|
286
262
|
const top = jumpOffset();
|
|
@@ -293,28 +269,38 @@ export function createAnimatedRenderer(pack: MascotPack): {
|
|
|
293
269
|
);
|
|
294
270
|
};
|
|
295
271
|
|
|
296
|
-
//
|
|
272
|
+
// ─── State control ───
|
|
297
273
|
const setState = (s: MascotState) => {
|
|
298
|
-
const prev = currentState();
|
|
299
274
|
setCurrentState(s);
|
|
275
|
+
setBreathPhase(true);
|
|
276
|
+
resetIdleSleep();
|
|
300
277
|
|
|
301
278
|
if (s !== "idle") {
|
|
302
279
|
stopWalk();
|
|
280
|
+
} else if (walkEnabled()) {
|
|
281
|
+
walkTimeout = scheduleNextWalk();
|
|
282
|
+
jumpTimeout = scheduleNextJump();
|
|
303
283
|
}
|
|
284
|
+
};
|
|
304
285
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
286
|
+
const toggleWalk = () => {
|
|
287
|
+
const next = !walkEnabled();
|
|
288
|
+
setWalkEnabled(next);
|
|
289
|
+
if (!next) {
|
|
290
|
+
stopWalk();
|
|
291
|
+
} else if (currentState() === "idle") {
|
|
292
|
+
walkTimeout = scheduleNextWalk();
|
|
310
293
|
}
|
|
294
|
+
};
|
|
311
295
|
|
|
312
|
-
|
|
313
|
-
|
|
296
|
+
const setDragging = (v: boolean) => {
|
|
297
|
+
setDraggingSignal(v);
|
|
298
|
+
if (v) {
|
|
299
|
+
setJumpOffset(-1);
|
|
314
300
|
} else {
|
|
315
|
-
|
|
301
|
+
setJumpOffset(0);
|
|
316
302
|
}
|
|
317
303
|
};
|
|
318
304
|
|
|
319
|
-
return { element, setState };
|
|
305
|
+
return { element, setState, toggleWalk, setDragging };
|
|
320
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
|
@@ -17,18 +17,12 @@ export interface AnimationConfig {
|
|
|
17
17
|
blinkChance?: number;
|
|
18
18
|
expressionInterval?: number;
|
|
19
19
|
idleTimeout?: number;
|
|
20
|
-
/** Breathing cycle in ms (default: 3000) */
|
|
21
20
|
breathInterval?: number;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
ahogeChance?: number;
|
|
26
|
-
/** Minimum delay between jumps in ms (default: 20000) */
|
|
21
|
+
walkEnabled?: boolean;
|
|
22
|
+
walkMinDelay?: number;
|
|
23
|
+
walkMaxDelay?: number;
|
|
27
24
|
jumpMinDelay?: number;
|
|
28
|
-
/** Maximum delay between jumps in ms (default: 40000) */
|
|
29
25
|
jumpMaxDelay?: number;
|
|
30
|
-
/** Stomp trigger: ms in thinking state before stomping (default: 30000) */
|
|
31
|
-
stompThreshold?: number;
|
|
32
26
|
}
|
|
33
27
|
|
|
34
28
|
/**
|
|
@@ -39,26 +33,72 @@ export interface SidebarConfig {
|
|
|
39
33
|
busyPhrases?: string[];
|
|
40
34
|
}
|
|
41
35
|
|
|
36
|
+
// ─── Effect system types ───
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A single extra signal managed by the effect system.
|
|
40
|
+
* The renderer creates a Solid signal for each and exposes get/set to timers and render.
|
|
41
|
+
*/
|
|
42
|
+
export interface SignalDef {
|
|
43
|
+
name: string;
|
|
44
|
+
initial: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
42
47
|
/**
|
|
43
|
-
*
|
|
48
|
+
* Timer context passed to EffectTimer.update().
|
|
44
49
|
*/
|
|
45
|
-
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;
|
|
55
|
+
/** Current mascot state */
|
|
46
56
|
state: MascotState;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
/** Current frame override (null = use state mapping) */
|
|
58
|
+
frameOverride: string | null;
|
|
59
|
+
/** Override the displayed frame */
|
|
60
|
+
setFrameOverride: (name: string | null) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A recurring timer that updates extra signals.
|
|
65
|
+
*/
|
|
66
|
+
export interface EffectTimer {
|
|
67
|
+
/** Interval in ms */
|
|
68
|
+
interval: number;
|
|
69
|
+
/** Called on each tick */
|
|
70
|
+
update: (ctx: EffectTimerCtx) => void;
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
/**
|
|
57
|
-
*
|
|
74
|
+
* Context passed to EffectRenderFn.
|
|
58
75
|
*/
|
|
59
|
-
export interface
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
export interface EffectRenderCtx {
|
|
77
|
+
state: MascotState;
|
|
78
|
+
frameName: string;
|
|
79
|
+
breathPhase: boolean;
|
|
80
|
+
jumpOffset: number;
|
|
81
|
+
dragging: boolean;
|
|
82
|
+
get: (name: string) => unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pack-defined render effect. Called after built-in rendering (breathing),
|
|
87
|
+
* before final output. Return modified lines.
|
|
88
|
+
*/
|
|
89
|
+
export type EffectRenderFn = (lines: string[], ctx: EffectRenderCtx) => string[];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Effect bundle: extra signals, timers, and render function.
|
|
93
|
+
* Packs use this to define mascot-specific animations.
|
|
94
|
+
*/
|
|
95
|
+
export interface MascotEffects {
|
|
96
|
+
/** Extra signals (name → initial value) */
|
|
97
|
+
signals?: SignalDef[];
|
|
98
|
+
/** Recurring timers that drive signal changes */
|
|
99
|
+
timers?: EffectTimer[];
|
|
100
|
+
/** Render hook — modify lines based on signals */
|
|
101
|
+
render: EffectRenderFn;
|
|
62
102
|
}
|
|
63
103
|
|
|
64
104
|
/**
|
|
@@ -91,7 +131,16 @@ export interface MascotPack {
|
|
|
91
131
|
|
|
92
132
|
animations?: AnimationConfig;
|
|
93
133
|
sidebar?: SidebarConfig;
|
|
94
|
-
|
|
134
|
+
|
|
135
|
+
/** Mascot-specific animation effects (timers + render) */
|
|
136
|
+
effects?: MascotEffects;
|
|
137
|
+
|
|
138
|
+
/** Thinking bubble phrases, shown when mascot is in busy/thinking state */
|
|
139
|
+
bubbleTexts?: string[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface SwitchConfig {
|
|
143
|
+
onState?: Partial<Record<MascotState, string>>;
|
|
95
144
|
}
|
|
96
145
|
|
|
97
146
|
/**
|
|
@@ -101,4 +150,5 @@ export interface MascotConfig {
|
|
|
101
150
|
mascot: string;
|
|
102
151
|
animations: boolean;
|
|
103
152
|
idleSleep: boolean;
|
|
153
|
+
switchConfig?: SwitchConfig;
|
|
104
154
|
}
|
package/tui.tsx
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
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
5
|
import { createAnimatedRenderer } from "./src/core/ascii-renderer"
|
|
6
6
|
|
|
7
|
-
const tui: TuiPlugin = async (api,
|
|
8
|
-
const
|
|
9
|
-
const
|
|
7
|
+
const tui: TuiPlugin = async (api, _options) => {
|
|
8
|
+
const mascots = loadAllMascots()
|
|
9
|
+
const homeMascot = getRandomMascot()
|
|
10
|
+
const homeRenderer = createAnimatedRenderer(homeMascot)
|
|
10
11
|
|
|
11
12
|
api.slots.register({
|
|
12
13
|
slots: {
|
|
13
14
|
sidebar_content() {
|
|
14
|
-
return <SidebarMascot
|
|
15
|
+
return <SidebarMascot mascots={mascots} api={api} />
|
|
15
16
|
},
|
|
16
17
|
home_bottom() {
|
|
17
18
|
return (
|