@mingxy/opencode-mascot 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/opencode-mascot",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
5
5
  "author": "mingxy",
6
6
  "license": "MIT",
@@ -15,20 +15,29 @@
15
15
  "tui.tsx",
16
16
  "src/"
17
17
  ],
18
- "oc-plugin": ["tui"],
18
+ "oc-plugin": [
19
+ "tui"
20
+ ],
19
21
  "scripts": {
20
22
  "typecheck": "tsc --noEmit"
21
23
  },
22
24
  "peerDependencies": {
23
- "@opentui/solid": ">=0.0.1",
24
- "@opencode-ai/plugin": ">=0.1.0"
25
+ "@opencode-ai/plugin": ">=0.1.0",
26
+ "@opentui/solid": ">=0.0.1"
25
27
  },
26
28
  "devDependencies": {
29
+ "@types/node": "^25.9.3",
27
30
  "typescript": "^5.7.0"
28
31
  },
29
- "keywords": ["opencode", "tui", "mascot", "plugin", "ascii-art"],
32
+ "keywords": [
33
+ "opencode",
34
+ "tui",
35
+ "mascot",
36
+ "plugin",
37
+ "ascii-art"
38
+ ],
30
39
  "repository": {
31
40
  "type": "git",
32
- "url": "https://github.com/mingxy/opencode-mascot"
41
+ "url": "https://github.com/mengfanbo123/opencode-mascot"
33
42
  }
34
43
  }
@@ -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,105 @@
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
+ "ʳᵉⁿ~..", "ᵖᵃⁿ~", "ᵗᵃⁿᵍ!", "ʸᵉ~..",
14
+ "ᵐⁱᵃⁿ~", "ᵍᵘᵒ!", "ˢʰᵘ~..", "ʰᵘᵒ~",
15
+ ];
16
+
17
+ const baoziEffects: MascotPack["effects"] = {
18
+ signals: [
19
+ { name: "steamPhase", initial: 0 },
20
+ { name: "bubbleIdx", initial: 0 },
21
+ ],
22
+
23
+ timers: [
24
+ {
25
+ interval: 1200,
26
+ update(ctx) {
27
+ ctx.set("steamPhase", ((ctx.get("steamPhase") as number) + 1) % STEAM_PATTERNS.length);
28
+ },
29
+ },
30
+ {
31
+ interval: 2500,
32
+ update(ctx) {
33
+ if (ctx.state === "busy" || ctx.state === "thinking") {
34
+ ctx.set("bubbleIdx", ((ctx.get("bubbleIdx") as number) + 1) % BUBBLE_TEXTS.length);
35
+ }
36
+ },
37
+ },
38
+ ],
39
+
40
+ render(lines, ctx) {
41
+ const steamPhase = ctx.get("steamPhase") as number;
42
+
43
+ if (ctx.dragging) {
44
+ const faceIdx = lines.findIndex(l => /\(.*\)/.test(l));
45
+ if (faceIdx >= 0) {
46
+ lines[faceIdx] = lines[faceIdx].replace(/\(.*?\)/, "( °□° )");
47
+ }
48
+ return lines;
49
+ }
50
+
51
+ if (lines.length > 0) {
52
+ lines[0] = STEAM_PATTERNS[steamPhase];
53
+ }
54
+
55
+ if (ctx.state === "sleeping") {
56
+ const zzzPhase = ((ctx.get("steamPhase") as number) % 3) + 1;
57
+ for (let i = 0; i < lines.length; i++) {
58
+ if (lines[i].includes("-.-")) {
59
+ const padded = lines[i].padEnd(10);
60
+ lines[i] = padded + " " + "Z" + "z".repeat(zzzPhase - 1);
61
+ break;
62
+ }
63
+ }
64
+ }
65
+
66
+ if (ctx.state === "busy" || ctx.state === "thinking") {
67
+ const idx = ctx.get("bubbleIdx") as number;
68
+ if (lines.length > 0) {
69
+ lines[0] = STEAM_PATTERNS[steamPhase] + BUBBLE_TEXTS[idx];
70
+ }
71
+ }
72
+
73
+ return lines;
74
+ },
75
+ };
76
+
77
+ export const baoziPack: MascotPack = {
78
+ name: "@mingxy/mascot-baozi",
79
+ displayName: "包子",
80
+ version: "0.1.0",
81
+ author: "mingxy",
82
+ description: "A warm steamed bun mascot — soft, round, and always fresh.",
83
+
84
+ frames,
85
+
86
+ colors: {
87
+ defaultFg: "#D4885A",
88
+ },
89
+
90
+ animations: {
91
+ blinkInterval: 3000,
92
+ blinkChance: 0.25,
93
+ expressionInterval: 10000,
94
+ idleTimeout: 120000,
95
+ },
96
+
97
+ sidebar: {
98
+ greetings: ["热乎乎的包子出炉啦~"],
99
+ busyPhrases: ["蒸包子中..."],
100
+ },
101
+
102
+ bubbleTexts: ["蒸着...", "发酵中...", "冒热气...", "快熟了..."],
103
+
104
+ effects: baoziEffects,
105
+ };
@@ -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: "#B8C4D8",
193
+ defaultFg: "#8B7EB8",
22
194
  },
23
195
 
24
196
  animations: {
25
197
  blinkInterval: 2500,
26
198
  blinkChance: 0.3,
27
- expressionInterval: 50000,
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
  };
@@ -0,0 +1,103 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { createSignal } from "solid-js";
4
+ import type { JSX } from "@opentui/solid";
5
+ import type { MascotPack } from "../core/types";
6
+ import { createAnimatedRenderer } from "../core/ascii-renderer";
7
+ import { onCelebrate } from "../core/celebration-bus";
8
+
9
+ interface HomeMascotProps {
10
+ mascots: Record<string, MascotPack>;
11
+ api: {
12
+ renderer: {
13
+ clearSelection(): void;
14
+ };
15
+ };
16
+ }
17
+
18
+ export function HomeMascot(props: HomeMascotProps): JSX.Element {
19
+ const names = Object.keys(props.mascots);
20
+ const initialName = names[Math.floor(Math.random() * names.length)];
21
+
22
+ const [currentName, setCurrentName] = createSignal(initialName);
23
+ const [posX, setPosX] = createSignal(0);
24
+ const [posY, setPosY] = createSignal(0);
25
+ let dragStartX = 0;
26
+ let dragStartY = 0;
27
+ let dragAnchorX = 0;
28
+ let dragAnchorY = 0;
29
+ let lastClickTime = 0;
30
+ let isDragging = false;
31
+
32
+ const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
33
+ for (const [name, pack] of Object.entries(props.mascots)) {
34
+ renderers[name] = createAnimatedRenderer(pack);
35
+ }
36
+
37
+ const switchTo = (name: string) => {
38
+ if (props.mascots[name] && name !== currentName()) {
39
+ setCurrentName(name);
40
+ }
41
+ };
42
+
43
+ onCelebrate((newVersion) => {
44
+ renderers[currentName()].celebrateUpdate(newVersion);
45
+ });
46
+
47
+ return (
48
+ <box
49
+ left={posX()}
50
+ top={posY()}
51
+ alignItems="center"
52
+ zIndex={100}
53
+ flexDirection="column"
54
+ onMouseDown={(e: any) => {
55
+ const now = Date.now();
56
+ if (now - lastClickTime < 300) {
57
+ const cur = currentName();
58
+ const idx = names.indexOf(cur);
59
+ const next = names[(idx + 1) % names.length];
60
+ switchTo(next);
61
+ lastClickTime = 0;
62
+ return;
63
+ }
64
+ lastClickTime = now;
65
+
66
+ if (e.modifiers?.alt) {
67
+ dragStartX = e.x;
68
+ dragStartY = e.y;
69
+ dragAnchorX = posX();
70
+ dragAnchorY = posY();
71
+ isDragging = true;
72
+ renderers[currentName()].setDragging(true);
73
+ e.preventDefault();
74
+ e.stopPropagation();
75
+ props.api.renderer.clearSelection();
76
+ }
77
+ }}
78
+ onMouseDrag={(e: any) => {
79
+ if (e.modifiers?.alt && isDragging) {
80
+ setPosX(dragAnchorX + (e.x - dragStartX));
81
+ setPosY(dragAnchorY + (e.y - dragStartY));
82
+ e.preventDefault();
83
+ e.stopPropagation();
84
+ props.api.renderer.clearSelection();
85
+ }
86
+ }}
87
+ onMouseUp={() => {
88
+ if (isDragging) {
89
+ isDragging = false;
90
+ renderers[currentName()].setDragging(false);
91
+ }
92
+ }}
93
+ onMouseDragEnd={() => {
94
+ if (isDragging) {
95
+ isDragging = false;
96
+ renderers[currentName()].setDragging(false);
97
+ }
98
+ }}
99
+ >
100
+ {renderers[currentName()]?.element() ?? null}
101
+ </box>
102
+ );
103
+ }
@@ -1,51 +1,166 @@
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";
7
+ import { onCelebrate } from "../core/celebration-bus";
6
8
 
7
9
  interface SidebarMascotProps {
8
- mascot: MascotPack;
10
+ mascots: Record<string, MascotPack>;
11
+ switchConfig?: SwitchConfig;
12
+ initialMascot?: string;
9
13
  api: {
10
14
  event: {
11
15
  on(event: string, callback: (data: unknown) => void): void;
12
16
  };
13
- slots: {
14
- register(registration: { slots: Record<string, () => JSX.Element> }): void;
17
+ renderer: {
18
+ clearSelection(): void;
15
19
  };
16
20
  };
17
21
  }
18
22
 
23
+ const DEFAULT_STATE_MAP: Partial<Record<MascotState, string>> = {
24
+ idle: "yueer",
25
+ happy: "yueer",
26
+ thinking: "yueer",
27
+ busy: "baozi",
28
+ sleeping: "baozi",
29
+ };
30
+
19
31
  export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
20
- const { element, setState } = createAnimatedRenderer(props.mascot);
32
+ const names = Object.keys(props.mascots);
33
+ const initialName =
34
+ props.initialMascot && props.mascots[props.initialMascot]
35
+ ? props.initialMascot
36
+ : names[Math.floor(Math.random() * names.length)];
37
+
38
+ const [currentName, setCurrentName] = createSignal(initialName);
39
+ const [posX, setPosX] = createSignal(20);
40
+ const [posY, setPosY] = createSignal(2);
41
+ let dragStartX = 0;
42
+ let dragStartY = 0;
43
+ let dragAnchorX = 0;
44
+ let dragAnchorY = 0;
45
+ let lastClickTime = 0;
46
+ let isDragging = false;
47
+
48
+ const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
49
+ for (const [name, pack] of Object.entries(props.mascots)) {
50
+ renderers[name] = createAnimatedRenderer(pack);
51
+ }
52
+
53
+ const switchTo = (name: string) => {
54
+ if (props.mascots[name] && name !== currentName()) {
55
+ setCurrentName(name);
56
+ }
57
+ };
58
+
59
+ const setStateWithSwitch = (s: MascotState) => {
60
+ const cur = currentName();
61
+ renderers[cur].setState(s);
62
+
63
+ const stateMap = props.switchConfig?.onState ?? DEFAULT_STATE_MAP;
64
+ const target = stateMap[s];
65
+ if (target && target !== cur && props.mascots[target]) {
66
+ setCurrentName(target);
67
+ renderers[target].setState(s);
68
+ }
69
+ };
21
70
 
22
71
  props.api.event.on("session.status", (data: unknown) => {
23
- const event = data as { status?: { type?: string } } | null;
24
- const statusType = event?.status?.type;
72
+ // Plugin receives: { id, type, properties: { sessionID, status: { type } } }
73
+ const payload = data as { type?: string; properties?: { sessionID?: string; status?: { type?: string } } } | null;
74
+ const statusType = payload?.properties?.status?.type;
25
75
 
26
76
  if (statusType === "busy" || statusType === "retry") {
27
- setState("busy");
77
+ renderers[currentName()].setState("busy");
28
78
  } else {
29
- setState("idle");
79
+ setStateWithSwitch("idle");
30
80
  }
31
81
  });
32
82
 
33
83
  props.api.event.on("session.idle", () => {
34
- setState("happy");
35
- setTimeout(() => setState("idle"), 3000);
84
+ setStateWithSwitch("happy");
85
+ setTimeout(() => setStateWithSwitch("idle"), 3000);
86
+ });
87
+
88
+ props.api.event.on("mascot.switch", (data: unknown) => {
89
+ const target = data as { name?: string } | null;
90
+ if (target?.name) {
91
+ switchTo(target.name);
92
+ } else {
93
+ const others = names.filter((n) => n !== currentName());
94
+ if (others.length > 0) {
95
+ switchTo(others[Math.floor(Math.random() * others.length)]);
96
+ }
97
+ }
98
+ });
99
+
100
+ props.api.event.on("mascot.toggleWalk", () => {
101
+ renderers[currentName()].toggleWalk();
102
+ });
103
+
104
+ onCelebrate((newVersion) => {
105
+ renderers[currentName()].celebrateUpdate(newVersion);
36
106
  });
37
107
 
38
108
  return (
39
109
  <box
40
110
  position="absolute"
41
- left={0}
42
- right={0}
43
- top={2}
111
+ left={posX()}
112
+ top={posY()}
44
113
  alignItems="center"
45
114
  zIndex={100}
46
115
  flexDirection="column"
116
+ onMouseDown={(e: any) => {
117
+ const now = Date.now();
118
+ if (now - lastClickTime < 300) {
119
+ const cur = currentName();
120
+ const idx = names.indexOf(cur);
121
+ const next = names[(idx + 1) % names.length];
122
+ switchTo(next);
123
+ lastClickTime = 0;
124
+ return;
125
+ }
126
+ lastClickTime = now;
127
+
128
+ if (e.modifiers?.alt) {
129
+ dragStartX = e.x;
130
+ dragStartY = e.y;
131
+ dragAnchorX = posX();
132
+ dragAnchorY = posY();
133
+ isDragging = true;
134
+ renderers[currentName()].setDragging(true);
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ props.api.renderer.clearSelection();
138
+ }
139
+ }}
140
+ onMouseDrag={(e: any) => {
141
+ if (e.modifiers?.alt && isDragging) {
142
+ setPosX(dragAnchorX + (e.x - dragStartX));
143
+ setPosY(dragAnchorY + (e.y - dragStartY));
144
+ e.preventDefault();
145
+ e.stopPropagation();
146
+ props.api.renderer.clearSelection();
147
+ }
148
+ }}
149
+ onMouseUp={() => {
150
+ if (isDragging) {
151
+ isDragging = false;
152
+ renderers[currentName()].setDragging(false);
153
+ }
154
+ }}
155
+ onMouseDragEnd={() => {
156
+ if (isDragging) {
157
+ isDragging = false;
158
+ renderers[currentName()].setDragging(false);
159
+ }
160
+ }}
161
+
47
162
  >
48
- {element()}
163
+ {renderers[currentName()]?.element() ?? null}
49
164
  </box>
50
165
  );
51
166
  }