@mingxy/opencode-mascot 0.1.0 → 0.1.2
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 +1 -1
- package/src/builtins/yueer/frames.ts +37 -93
- package/src/builtins/yueer/index.ts +8 -14
- package/src/components/sidebar-mascot.tsx +28 -18
- package/src/core/ascii-renderer.tsx +261 -20
- package/src/core/types.ts +17 -54
- package/tui.tsx +11 -2
- package/src/builtins/yueer/colors.ts +0 -34
package/package.json
CHANGED
|
@@ -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,24 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 月儿 (Yue'er) — Ice Empress mascot pack.
|
|
3
3
|
*
|
|
4
|
-
* Nine Heavens' Empress with
|
|
4
|
+
* Nine Heavens' Empress with ice-blue eyes and silver-white hair.
|
|
5
5
|
* She awaits her Master's command with grace and quiet devotion.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { MascotPack } from "../../core/types";
|
|
9
9
|
import { frames } from "./frames";
|
|
10
|
-
import { colors } from "./colors";
|
|
11
10
|
|
|
12
11
|
export const yueerPack: MascotPack = {
|
|
13
12
|
name: "@mingxy/mascot-yueer",
|
|
14
13
|
displayName: "月儿",
|
|
15
14
|
version: "0.1.0",
|
|
16
15
|
author: "mingxy",
|
|
17
|
-
description:
|
|
18
|
-
"Ice Empress of the Nine Heavens — elegant, powerful, and devoted to her Master.",
|
|
16
|
+
description: "Ice Empress of the Nine Heavens — elegant, powerful, and devoted to her Master.",
|
|
19
17
|
|
|
20
18
|
frames,
|
|
21
|
-
|
|
19
|
+
|
|
20
|
+
colors: {
|
|
21
|
+
defaultFg: "#B8C4D8",
|
|
22
|
+
},
|
|
22
23
|
|
|
23
24
|
animations: {
|
|
24
25
|
blinkInterval: 2500,
|
|
@@ -28,14 +29,7 @@ export const yueerPack: MascotPack = {
|
|
|
28
29
|
},
|
|
29
30
|
|
|
30
31
|
sidebar: {
|
|
31
|
-
greetings: [
|
|
32
|
-
|
|
33
|
-
"月儿恭候师尊多时了~",
|
|
34
|
-
"师尊今日气色不错呢~",
|
|
35
|
-
],
|
|
36
|
-
busyPhrases: [
|
|
37
|
-
"月儿正在铸造法器...",
|
|
38
|
-
"驱除心魔中...",
|
|
39
|
-
],
|
|
32
|
+
greetings: ["师尊,月儿在此候命~"],
|
|
33
|
+
busyPhrases: ["铸造法器中..."],
|
|
40
34
|
},
|
|
41
35
|
};
|
|
@@ -1,41 +1,51 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
|
|
3
|
-
import { createSignal, createEffect } from "solid-js";
|
|
4
3
|
import type { JSX } from "@opentui/solid";
|
|
5
|
-
import type { MascotPack
|
|
4
|
+
import type { MascotPack } from "../core/types";
|
|
6
5
|
import { createAnimatedRenderer } from "../core/ascii-renderer";
|
|
7
6
|
|
|
8
7
|
interface SidebarMascotProps {
|
|
9
8
|
mascot: MascotPack;
|
|
10
|
-
|
|
9
|
+
api: {
|
|
10
|
+
event: {
|
|
11
|
+
on(event: string, callback: (data: unknown) => void): void;
|
|
12
|
+
};
|
|
13
|
+
slots: {
|
|
14
|
+
register(registration: { slots: Record<string, () => JSX.Element> }): void;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
|
|
14
20
|
const { element, setState } = createAnimatedRenderer(props.mascot);
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
props.api.event.on("session.status", (data: unknown) => {
|
|
23
|
+
const event = data as { status?: { type?: string } } | null;
|
|
24
|
+
const statusType = event?.status?.type;
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
createEffect(() => {
|
|
23
|
-
if (props.isRunning) {
|
|
24
|
-
setState("busy" as MascotState);
|
|
25
|
-
const phrase = busyPhrases[Math.floor(Math.random() * busyPhrases.length)];
|
|
26
|
-
setStateText(phrase);
|
|
26
|
+
if (statusType === "busy" || statusType === "retry") {
|
|
27
|
+
setState("busy");
|
|
27
28
|
} else {
|
|
28
29
|
setState("idle");
|
|
29
|
-
setStateText(greetings[Math.floor(Math.random() * greetings.length)]);
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
props.api.event.on("session.idle", () => {
|
|
34
|
+
setState("happy");
|
|
35
|
+
setTimeout(() => setState("idle"), 3000);
|
|
36
|
+
});
|
|
37
|
+
|
|
33
38
|
return (
|
|
34
|
-
<box
|
|
39
|
+
<box
|
|
40
|
+
position="absolute"
|
|
41
|
+
left={0}
|
|
42
|
+
right={0}
|
|
43
|
+
top={2}
|
|
44
|
+
alignItems="center"
|
|
45
|
+
zIndex={100}
|
|
46
|
+
flexDirection="column"
|
|
47
|
+
>
|
|
35
48
|
{element()}
|
|
36
|
-
<text fg="gray" wrapMode="word">
|
|
37
|
-
{stateText()}
|
|
38
|
-
</text>
|
|
39
49
|
</box>
|
|
40
50
|
);
|
|
41
51
|
}
|
|
@@ -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
|
+
ahogeInterval: 1500,
|
|
22
|
+
ahogeChance: 0.25,
|
|
23
|
+
jumpMinDelay: 20000,
|
|
24
|
+
jumpMaxDelay: 40000,
|
|
25
|
+
stompThreshold: 30000,
|
|
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) => (
|
|
@@ -43,37 +47,274 @@ export function createAnimatedRenderer(pack: MascotPack): {
|
|
|
43
47
|
setState: (s: MascotState) => void;
|
|
44
48
|
} {
|
|
45
49
|
const anim = { ...DEFAULT_ANIM, ...pack.animations };
|
|
50
|
+
const fg = pack.colors?.defaultFg || undefined;
|
|
51
|
+
|
|
46
52
|
const [currentState, setCurrentState] = createSignal<MascotState>("idle");
|
|
47
53
|
const [frameOverride, setFrameOverride] = createSignal<string | null>(null);
|
|
54
|
+
const [legsVisible, setLegsVisible] = createSignal(true);
|
|
55
|
+
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
|
+
const [jumpOffset, setJumpOffset] = createSignal(0);
|
|
61
|
+
const [stompActive, setStompActive] = createSignal(false);
|
|
62
|
+
const [stompAlt, setStompAlt] = createSignal(false);
|
|
63
|
+
|
|
64
|
+
let thinkingStartTime = 0;
|
|
65
|
+
|
|
66
|
+
// ============ 1. Blink ============
|
|
67
|
+
const hasBlink = (pack.frames as Record<string, string[] | undefined>)["blink"] !== undefined;
|
|
48
68
|
|
|
49
69
|
const blinkTimer = setInterval(() => {
|
|
50
|
-
if (Math.random() < anim.blinkChance) {
|
|
70
|
+
if (Math.random() < anim.blinkChance && hasBlink) {
|
|
51
71
|
setFrameOverride("blink");
|
|
52
72
|
setTimeout(() => setFrameOverride(null), 150);
|
|
53
73
|
}
|
|
54
74
|
}, anim.blinkInterval);
|
|
55
75
|
|
|
56
|
-
|
|
57
|
-
|
|
76
|
+
// ============ 2. Random expression ============
|
|
77
|
+
const availableExpressions = Object.keys(pack.frames).filter(
|
|
78
|
+
(k) => k !== "default" && k !== "blink",
|
|
58
79
|
);
|
|
80
|
+
|
|
59
81
|
const expressionTimer = setInterval(() => {
|
|
60
|
-
if (currentState() === "idle" &&
|
|
61
|
-
const pick =
|
|
62
|
-
|
|
63
|
-
|
|
82
|
+
if (currentState() === "idle" && !frameOverride()) {
|
|
83
|
+
const pick = availableExpressions[Math.floor(Math.random() * availableExpressions.length)];
|
|
84
|
+
if (pick) {
|
|
85
|
+
setFrameOverride(pick);
|
|
86
|
+
if (pick === "happy") startWave();
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
setFrameOverride(null);
|
|
89
|
+
stopWave();
|
|
90
|
+
}, 2000);
|
|
91
|
+
}
|
|
64
92
|
}
|
|
65
93
|
}, anim.expressionInterval);
|
|
66
94
|
|
|
95
|
+
// ============ 3. Breathing (legs tuck + braids flutter) ============
|
|
96
|
+
const breathTimer = setInterval(() => {
|
|
97
|
+
setLegsVisible((v) => !v);
|
|
98
|
+
setBraidAlt((v) => !v);
|
|
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);
|
|
106
|
+
}
|
|
107
|
+
}, anim.ahogeInterval);
|
|
108
|
+
|
|
109
|
+
// ============ 5. Walk ============
|
|
110
|
+
let walkStep = -1;
|
|
111
|
+
let walkInterval: ReturnType<typeof setInterval> | null = null;
|
|
112
|
+
|
|
113
|
+
const startWalk = () => {
|
|
114
|
+
if (walkInterval) return;
|
|
115
|
+
walkStep = 0;
|
|
116
|
+
walkInterval = setInterval(() => {
|
|
117
|
+
if (walkStep < WALK_PATH.length) {
|
|
118
|
+
setWalkOffset(WALK_PATH[walkStep]);
|
|
119
|
+
walkStep++;
|
|
120
|
+
} else {
|
|
121
|
+
if (walkInterval) clearInterval(walkInterval);
|
|
122
|
+
walkInterval = null;
|
|
123
|
+
walkStep = -1;
|
|
124
|
+
setWalkOffset(0);
|
|
125
|
+
}
|
|
126
|
+
}, 400);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const stopWalk = () => {
|
|
130
|
+
if (walkInterval) {
|
|
131
|
+
clearInterval(walkInterval);
|
|
132
|
+
walkInterval = null;
|
|
133
|
+
}
|
|
134
|
+
walkStep = -1;
|
|
135
|
+
setWalkOffset(0);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
let walkTimeout: ReturnType<typeof setTimeout>;
|
|
139
|
+
|
|
140
|
+
function scheduleNextWalk() {
|
|
141
|
+
const delay = 5000 + Math.floor(Math.random() * 5000);
|
|
142
|
+
return setTimeout(() => {
|
|
143
|
+
if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
|
|
144
|
+
startWalk();
|
|
145
|
+
}
|
|
146
|
+
walkTimeout = scheduleNextWalk();
|
|
147
|
+
}, delay);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
walkTimeout = scheduleNextWalk();
|
|
151
|
+
|
|
152
|
+
// ============ 6. Jump ============
|
|
153
|
+
let jumpTimeout: ReturnType<typeof setTimeout>;
|
|
154
|
+
|
|
155
|
+
function scheduleNextJump() {
|
|
156
|
+
const delay = anim.jumpMinDelay + Math.floor(Math.random() * (anim.jumpMaxDelay - anim.jumpMinDelay));
|
|
157
|
+
return setTimeout(() => {
|
|
158
|
+
if (currentState() === "idle" && !frameOverride() && walkStep === -1) {
|
|
159
|
+
setJumpOffset(-2);
|
|
160
|
+
setTimeout(() => setJumpOffset(0), 500);
|
|
161
|
+
}
|
|
162
|
+
jumpTimeout = scheduleNextJump();
|
|
163
|
+
}, delay);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
jumpTimeout = scheduleNextJump();
|
|
167
|
+
|
|
168
|
+
// ============ 7. Wave (挥手 — happy state) ============
|
|
169
|
+
let waveTimer: ReturnType<typeof setInterval> | null = null;
|
|
170
|
+
|
|
171
|
+
const startWave = () => {
|
|
172
|
+
if (waveTimer) return;
|
|
173
|
+
waveTimer = setInterval(() => setWaveSide((v) => !v), 300);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const stopWave = () => {
|
|
177
|
+
if (waveTimer) {
|
|
178
|
+
clearInterval(waveTimer);
|
|
179
|
+
waveTimer = null;
|
|
180
|
+
}
|
|
181
|
+
setWaveSide(false);
|
|
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);
|
|
206
|
+
|
|
207
|
+
// ============ Cleanup ============
|
|
67
208
|
onCleanup(() => {
|
|
68
209
|
clearInterval(blinkTimer);
|
|
69
210
|
clearInterval(expressionTimer);
|
|
211
|
+
clearInterval(breathTimer);
|
|
212
|
+
clearInterval(ahogeTimer);
|
|
213
|
+
clearInterval(zzzTimer);
|
|
214
|
+
clearInterval(stompTimer);
|
|
215
|
+
clearTimeout(walkTimeout);
|
|
216
|
+
clearTimeout(jumpTimeout);
|
|
217
|
+
if (walkInterval) clearInterval(walkInterval);
|
|
218
|
+
stopWave();
|
|
70
219
|
});
|
|
71
220
|
|
|
221
|
+
// ============ Render ============
|
|
72
222
|
const element = () => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
223
|
+
legsVisible();
|
|
224
|
+
ahogeAlt();
|
|
225
|
+
braidAlt();
|
|
226
|
+
waveSide();
|
|
227
|
+
zzzPhase();
|
|
228
|
+
jumpOffset();
|
|
229
|
+
stompActive();
|
|
230
|
+
stompAlt();
|
|
231
|
+
|
|
232
|
+
const frameName = frameOverride() ?? STATE_TO_FRAME[currentState()] ?? "default";
|
|
233
|
+
const rawLines = getFrameLines(pack, frameName);
|
|
234
|
+
const offset = walkOffset();
|
|
235
|
+
|
|
236
|
+
const width = rawLines[0]?.length ?? 10;
|
|
237
|
+
const blank = " ".repeat(width);
|
|
238
|
+
|
|
239
|
+
let lines = rawLines.map((line, i) => {
|
|
240
|
+
if (!legsVisible()) {
|
|
241
|
+
if (i === 0) return blank;
|
|
242
|
+
return rawLines[i - 1];
|
|
243
|
+
}
|
|
244
|
+
return line;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// 2. Ahoge wobble: ☆ ↔ ★
|
|
248
|
+
if (ahogeAlt()) {
|
|
249
|
+
lines = lines.map((l) => (l.includes("☆") ? l.replace("☆", "★") : l));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 3. Braid flutter: ~ ↔ -
|
|
253
|
+
if (braidAlt()) {
|
|
254
|
+
lines = lines.map((l) => (l.includes("~") ? l.replace(/~/g, "-") : l));
|
|
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
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const top = jumpOffset();
|
|
287
|
+
const left = offset > 0 ? offset : 0;
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<box flexDirection="column" left={left} top={top}>
|
|
291
|
+
{renderLines(lines, fg)}
|
|
292
|
+
</box>
|
|
293
|
+
);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// ============ State control ============
|
|
297
|
+
const setState = (s: MascotState) => {
|
|
298
|
+
const prev = currentState();
|
|
299
|
+
setCurrentState(s);
|
|
300
|
+
|
|
301
|
+
if (s !== "idle") {
|
|
302
|
+
stopWalk();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (s === "thinking" && prev !== "thinking") {
|
|
306
|
+
thinkingStartTime = Date.now();
|
|
307
|
+
} else if (s !== "thinking") {
|
|
308
|
+
thinkingStartTime = 0;
|
|
309
|
+
setStompActive(false);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (s === "happy") {
|
|
313
|
+
startWave();
|
|
314
|
+
} else {
|
|
315
|
+
stopWave();
|
|
316
|
+
}
|
|
76
317
|
};
|
|
77
318
|
|
|
78
|
-
return { element, setState
|
|
319
|
+
return { element, setState };
|
|
79
320
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -9,53 +9,43 @@ 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
|
+
/** Breathing cycle in ms (default: 3000) */
|
|
21
|
+
breathInterval?: number;
|
|
22
|
+
/** Ahoge wobble check interval in ms (default: 1500) */
|
|
23
|
+
ahogeInterval?: number;
|
|
24
|
+
/** Probability of ahoge wobble per check (default: 0.25) */
|
|
25
|
+
ahogeChance?: number;
|
|
26
|
+
/** Minimum delay between jumps in ms (default: 20000) */
|
|
27
|
+
jumpMinDelay?: number;
|
|
28
|
+
/** Maximum delay between jumps in ms (default: 40000) */
|
|
29
|
+
jumpMaxDelay?: number;
|
|
30
|
+
/** Stomp trigger: ms in thinking state before stomping (default: 30000) */
|
|
31
|
+
stompThreshold?: number;
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
/**
|
|
37
35
|
* Sidebar display configuration.
|
|
38
36
|
*/
|
|
39
37
|
export interface SidebarConfig {
|
|
40
|
-
/** Rotating greeting messages shown below the mascot */
|
|
41
38
|
greetings?: string[];
|
|
42
|
-
/** Rotating phrases shown while in busy state */
|
|
43
39
|
busyPhrases?: string[];
|
|
44
|
-
/** Sidebar width in characters (default: mascot frame width) */
|
|
45
|
-
width?: number;
|
|
46
40
|
}
|
|
47
41
|
|
|
48
42
|
/**
|
|
49
43
|
* Context passed to render hooks.
|
|
50
44
|
*/
|
|
51
45
|
export interface RenderContext {
|
|
52
|
-
/** Current mascot state */
|
|
53
46
|
state: MascotState;
|
|
54
|
-
/** Current frame index within the active expression */
|
|
55
47
|
frameIndex: number;
|
|
56
|
-
/** Unix timestamp (ms) of this render call */
|
|
57
48
|
timestamp: number;
|
|
58
|
-
/** Optional session metadata from the host */
|
|
59
49
|
sessionData?: {
|
|
60
50
|
tokenUsage?: number;
|
|
61
51
|
tokenLimit?: number;
|
|
@@ -67,35 +57,24 @@ export interface RenderContext {
|
|
|
67
57
|
* Lifecycle hooks for customizing mascot behavior.
|
|
68
58
|
*/
|
|
69
59
|
export interface MascotHooks {
|
|
70
|
-
/** Called before each frame render. Return modified frame lines or the original. */
|
|
71
60
|
beforeRender?: (context: RenderContext, frame: string[]) => string[];
|
|
72
|
-
/** Called when the mascot state transitions. */
|
|
73
61
|
onStateChange?: (prevState: MascotState, newState: MascotState) => void;
|
|
74
62
|
}
|
|
75
63
|
|
|
76
64
|
/**
|
|
77
65
|
* A complete mascot pack definition.
|
|
78
66
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
67
|
+
* Frames are plain string arrays — each string is one line, all lines same width.
|
|
68
|
+
* "default" is required; all other expressions are optional.
|
|
81
69
|
*/
|
|
82
70
|
export interface MascotPack {
|
|
83
|
-
/** Unique machine-readable identifier (e.g. "tuxie") */
|
|
84
71
|
name: string;
|
|
85
|
-
/** Human-readable display name (e.g. "Tuxie the Penguin") */
|
|
86
72
|
displayName: string;
|
|
87
|
-
/** Semantic version string (e.g. "1.0.0") */
|
|
88
73
|
version: string;
|
|
89
|
-
/** Pack author or team */
|
|
90
74
|
author: string;
|
|
91
|
-
/** Short description of the mascot */
|
|
92
75
|
description: string;
|
|
93
76
|
|
|
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
|
-
*/
|
|
77
|
+
/** ASCII-art frames keyed by expression name. "default" is required. */
|
|
99
78
|
frames: {
|
|
100
79
|
default: string[];
|
|
101
80
|
blink?: string[];
|
|
@@ -105,26 +84,13 @@ export interface MascotPack {
|
|
|
105
84
|
sleeping?: string[];
|
|
106
85
|
};
|
|
107
86
|
|
|
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 */
|
|
87
|
+
/** Optional color for the mascot text */
|
|
88
|
+
colors?: {
|
|
116
89
|
defaultFg?: string;
|
|
117
|
-
/** Default background applied to unmatched characters */
|
|
118
|
-
defaultBg?: string;
|
|
119
90
|
};
|
|
120
91
|
|
|
121
|
-
/** Animation timing overrides */
|
|
122
92
|
animations?: AnimationConfig;
|
|
123
|
-
|
|
124
|
-
/** Sidebar content configuration */
|
|
125
93
|
sidebar?: SidebarConfig;
|
|
126
|
-
|
|
127
|
-
/** Optional lifecycle hooks */
|
|
128
94
|
hooks?: MascotHooks;
|
|
129
95
|
}
|
|
130
96
|
|
|
@@ -132,10 +98,7 @@ export interface MascotPack {
|
|
|
132
98
|
* User-facing configuration merged from opencode config file.
|
|
133
99
|
*/
|
|
134
100
|
export interface MascotConfig {
|
|
135
|
-
/** Name of the selected mascot pack */
|
|
136
101
|
mascot: string;
|
|
137
|
-
/** Enable or disable mascot animations */
|
|
138
102
|
animations: boolean;
|
|
139
|
-
/** Allow mascot to enter sleep state on idle */
|
|
140
103
|
idleSleep: boolean;
|
|
141
104
|
}
|
package/tui.tsx
CHANGED
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
3
|
import { loadMascot } from "./src/core/mascot-loader"
|
|
4
4
|
import { SidebarMascot } from "./src/components/sidebar-mascot"
|
|
5
|
+
import { createAnimatedRenderer } from "./src/core/ascii-renderer"
|
|
5
6
|
|
|
6
7
|
const tui: TuiPlugin = async (api, options) => {
|
|
7
8
|
const mascot = await loadMascot(options)
|
|
8
|
-
|
|
9
|
+
const homeRenderer = createAnimatedRenderer(mascot)
|
|
10
|
+
|
|
9
11
|
api.slots.register({
|
|
10
12
|
slots: {
|
|
11
13
|
sidebar_content() {
|
|
12
|
-
return <SidebarMascot mascot={mascot} />
|
|
14
|
+
return <SidebarMascot mascot={mascot} api={api} />
|
|
15
|
+
},
|
|
16
|
+
home_bottom() {
|
|
17
|
+
return (
|
|
18
|
+
<box flexDirection="column" alignItems="center">
|
|
19
|
+
{homeRenderer.element()}
|
|
20
|
+
</box>
|
|
21
|
+
)
|
|
13
22
|
}
|
|
14
23
|
}
|
|
15
24
|
})
|
|
@@ -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
|
-
};
|