@mingxy/opencode-mascot 0.7.11 → 0.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/opencode-mascot",
3
- "version": "0.7.11",
3
+ "version": "0.8.0",
4
4
  "description": "OpenCode TUI mascot plugin framework - customizable ASCII mascots for your terminal",
5
5
  "author": "mingxy",
6
6
  "license": "MIT",
@@ -35,23 +35,21 @@ const frames: string[][] = [
35
35
  "╰──────────╯─┐",
36
36
  " ╲ ",
37
37
  ],
38
- // 帧3: 打开(露出月儿剪影)
39
38
  [
40
39
  " ╭~~~~~~╮ ",
41
- " ╱╲ ",
40
+ " ╱ ╲ ",
42
41
  " ╱~~────~~╲ ",
43
- "│░(^-^)░░░│ ",
44
- "│░┃█┃░░░░░│ ",
42
+ "│░^-^ ░│ ",
43
+ "│░ ┃█┃ ░░░│ ",
45
44
  "│░░░░░░░░░│ ",
46
45
  "╰──────────╯─┐",
47
46
  " ╲ ",
48
47
  ],
49
- // 帧4: 月儿冒头
50
48
  [
51
49
  " ╭~~~~~~╮ ",
52
- " ╱ ╲ ",
50
+ " ╱ ╲ ",
53
51
  " ╱(^-^)───╲ ",
54
- "│░┃█┃░░░░░│ ",
52
+ "│░ ┃█┃ ░░░│ ",
55
53
  "│░░░░░░░░░│ ",
56
54
  "│░░░░░░░░░│ ",
57
55
  "╰──────────╯─┐",
@@ -33,6 +33,18 @@ const states = [
33
33
  ">ᵇᵘᵍ!ᵇᵘᵍ!",
34
34
  ">ⁿᵖᵐⁱⁿˢᵗᵃˡˡ",
35
35
  ">ᵈᵒⁿᵉ✓",
36
+ ">ᶜᵒᵐᵖⁱˡⁱⁿᵍ",
37
+ ">ᵗᵉˢᵗⁱⁿᵍ...",
38
+ ">ʳᵉᶠᵃᶜᵗᵒʳ",
39
+ ">ᵈᵉᵖˡᵒʸⁱⁿᵍ",
40
+ ">ᵐᵉʳᵍᵉ...",
41
+ ">ˡⁱⁿᵗ...",
42
+ ">ᶠᵒʳᵐᵃᵗ...",
43
+ ">ʳᵉᵛⁱᵉʷ...",
44
+ ">ᵒᵖˢ...",
45
+ ">ʰᵐᵐ~...",
46
+ ">ʰᵉˡᵖ...",
47
+ ">⁻ᵒ⁻",
36
48
  ];
37
49
 
38
50
  const frames: string[][] = states.map((st) => [
@@ -49,5 +61,5 @@ export const laptopProp: PropPack = {
49
61
  frameInterval: 800,
50
62
  trigger: "busy",
51
63
  position: "side-right",
52
- weight: 0.7,
64
+ weight: 0.65,
53
65
  };
@@ -15,17 +15,17 @@ const games: string[][] = [
15
15
  [" ☆ ᵇᵉᵍⁱⁿ ", "(^-^) ◯→ ●", " ┃█┃ ˢ:0 ", " "],
16
16
  [" ☆ ", "(^-^) ◯◯→ ●", " ┃█┃ ˢ:1 ", " "],
17
17
  [" ☆ ", "(×_×) ◯◯💥 ", " ┃█┃ ᵍᵍ! ", " "],
18
- [" ☆ ", "(╥_╥) ᵒᵛᵉʳ ", " ┃█┃ ˢ:1 ", " "],
18
+ [" ☆ ", "(╥_╥) ", " ┃█┃ ˢ:1 ", " ᵍᵃᵐᵉᵒᵛᵉʳ "],
19
19
  [" ☆ ", "(¬_¬) ᵃᵍᵃⁱⁿ", " ┃█┃ ", " "],
20
20
  [" ☆ ᵍᵒ! ", "(^-^) ◯→ ●", " ┃█┃ ˢ:0 ", " "],
21
21
  [" ☆ ᵇᵉᵍⁱⁿ ", "(^-^) ▣ ", " ┃█┃ ", " "],
22
22
  [" ☆ ", "(^-^) ▣▣ ", " ┃█┃ ▣▣▣▣ ", " "],
23
23
  [" ☆ ", "(×_×) ", " ┃█┃ ▣▣▣▣▣▣", " ᶠᵘˡˡ! "],
24
- [" ☆ ", "(╥_╥) ᵒᵛᵉʳ ", " ┃█┃ ", " "],
24
+ [" ☆ ", "(╥_╥) ", " ┃█┃ ", " ᵍᵃᵐᵉᵒᵛᵉʳ "],
25
25
  [" ☆ ᵇᵉᵍⁱⁿ ", "(^-^) [ 2 ]", " ┃█┃ ", " "],
26
26
  [" ☆ ", "(^-^) [ 4 ]", " ┃█┃ ᵒᵒⁿ ", " "],
27
27
  [" ☆ ", "(⊙_⊙) [128]", " ┃█┃ ʷᵒʷ ", " "],
28
- [" ☆ ", "(╥_╥) ᵒᵛᵉʳ ", " ┃█┃ ", " "],
28
+ [" ☆ ", "(╥_╥) ", " ┃█┃ ", " ᵍᵃᵐᵉᵒᵛᵉʳ "],
29
29
  [" ☆ ", "(^-^) ⁿᵉˣᵗ!", " ┃█┃ ", " "],
30
30
  ];
31
31
 
@@ -41,8 +41,8 @@ const frames: string[][] = games.map((rows) => [
41
41
  export const padProp: PropPack = {
42
42
  name: "pad",
43
43
  frames,
44
- frameInterval: 700,
44
+ frameInterval: 1000,
45
45
  trigger: "busy",
46
46
  position: "front",
47
- weight: 0.3,
47
+ weight: 0.35,
48
48
  };
@@ -16,20 +16,26 @@ interface HomeMascotProps {
16
16
  };
17
17
  }
18
18
 
19
+ let homeSingletonRenderers: Record<string, ReturnType<typeof createAnimatedRenderer>> | null = null;
20
+ let homeStartupTriggered = false;
21
+ const [homeCurX, setHomeCurX] = createSignal(0);
22
+ const [homeCurY, setHomeCurY] = createSignal(7);
23
+
19
24
  export function HomeMascot(props: HomeMascotProps): JSX.Element {
20
25
  const names = Object.keys(props.mascots);
21
26
  const initialName = props.mascots["yueer"] ? "yueer" : names[0];
22
27
 
23
28
  const cw = (typeof process !== "undefined" && process.stdout?.columns) || 80;
24
29
 
25
- const initX = Math.floor((Math.random() - 0.5) * Math.max(0, cw - 20));
26
- const initY = 0;
27
-
28
30
  const [currentName, setCurrentName] = createSignal(initialName);
29
31
  const [zBoost, setZBoost] = createSignal(false);
30
- let boxRef: any = null;
31
- let curX = initX;
32
- let curY = initY;
32
+ const curX = homeCurX;
33
+ const setCurX = setHomeCurX;
34
+ const curY = homeCurY;
35
+ const setCurY = setHomeCurY;
36
+ if (!homeStartupTriggered) {
37
+ setCurX(Math.floor(cw / 2) - 5);
38
+ }
33
39
  let dragStartX = 0;
34
40
  let dragStartY = 0;
35
41
  let dragAnchorX = 0;
@@ -37,10 +43,13 @@ export function HomeMascot(props: HomeMascotProps): JSX.Element {
37
43
  let lastClickTime = 0;
38
44
  let isDragging = false;
39
45
 
40
- const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
41
- for (const [name, pack] of Object.entries(props.mascots)) {
42
- renderers[name] = createAnimatedRenderer(pack);
46
+ if (!homeSingletonRenderers) {
47
+ homeSingletonRenderers = {};
48
+ for (const [name, pack] of Object.entries(props.mascots)) {
49
+ homeSingletonRenderers[name] = createAnimatedRenderer(pack);
50
+ }
43
51
  }
52
+ const renderers = homeSingletonRenderers;
44
53
 
45
54
  const switchToNext = () => {
46
55
  const cur = currentName();
@@ -48,13 +57,6 @@ export function HomeMascot(props: HomeMascotProps): JSX.Element {
48
57
  setCurrentName(names[(idx + 1) % names.length]);
49
58
  };
50
59
 
51
- const applyPos = () => {
52
- if (boxRef) {
53
- boxRef.translateX = curX;
54
- boxRef.translateY = curY;
55
- }
56
- };
57
-
58
60
  onCelebrate((newVersion) => {
59
61
  setZBoost(true);
60
62
  renderers[currentName()].celebrateUpdate(newVersion);
@@ -70,106 +72,124 @@ export function HomeMascot(props: HomeMascotProps): JSX.Element {
70
72
  onScatter(() => {
71
73
  });
72
74
 
73
- renderers[currentName()].setCharacterHidden(true);
74
- renderers[currentName()].setProp(getProp("box") ?? null);
75
-
76
- const finalY = curY;
77
- const finalX = curX;
78
- const fallStartY = finalY - 15;
79
- const fallDuration = 500;
80
- const fallStartTime = Date.now();
81
- curY = fallStartY;
82
- applyPos();
83
-
84
- const fallInterval = setInterval(() => {
85
- const elapsed = Date.now() - fallStartTime;
86
- const t = Math.min(elapsed / fallDuration, 1);
87
- const eased = t * t;
88
- curY = Math.round(fallStartY + (finalY - fallStartY) * eased);
89
- applyPos();
90
- if (t >= 1) {
91
- clearInterval(fallInterval);
92
- curY = finalY;
93
- applyPos();
94
-
95
- setTimeout(() => {
96
- const shakeSeq = [1, -1, 1, -1, 0];
97
- let shakeIdx = 0;
98
- const shakeInterval = setInterval(() => {
99
- if (shakeIdx >= shakeSeq.length) {
100
- clearInterval(shakeInterval);
101
- curX = finalX;
102
- applyPos();
103
- return;
104
- }
105
- curX = finalX + shakeSeq[shakeIdx];
106
- applyPos();
107
- shakeIdx++;
108
- }, 60);
109
- }, 2000);
110
- }
111
- }, 16);
112
-
113
- setTimeout(() => {
114
- renderers[currentName()].setProp(null);
115
- renderers[currentName()].setCharacterHidden(false);
116
- }, 6000);
75
+ if (!homeStartupTriggered) {
76
+ homeStartupTriggered = true;
77
+ renderers[currentName()].setCharacterHidden(true);
78
+ renderers[currentName()].setProp(getProp("box") ?? null);
79
+
80
+ const finalY = curY();
81
+ const finalX = curX();
82
+ const fallStartY = finalY - 15;
83
+ const fallDuration = 500;
84
+ const fallStartTime = Date.now();
85
+ setCurY(fallStartY);
86
+
87
+ const fallInterval = setInterval(() => {
88
+ const elapsed = Date.now() - fallStartTime;
89
+ const t = Math.min(elapsed / fallDuration, 1);
90
+ const eased = t * t;
91
+ setCurY(Math.round(fallStartY + (finalY - fallStartY) * eased));
92
+ if (t >= 1) {
93
+ clearInterval(fallInterval);
94
+ setCurY(finalY);
95
+
96
+ setTimeout(() => {
97
+ const shakeSeq = [1, -1, 1, -1, 0];
98
+ let shakeIdx = 0;
99
+ const shakeInterval = setInterval(() => {
100
+ if (shakeIdx >= shakeSeq.length) {
101
+ clearInterval(shakeInterval);
102
+ setCurX(finalX);
103
+ return;
104
+ }
105
+ setCurX(finalX + shakeSeq[shakeIdx]);
106
+ shakeIdx++;
107
+ }, 60);
108
+ }, 2000);
109
+ }
110
+ }, 16);
111
+
112
+ setTimeout(() => {
113
+ renderers[currentName()].setProp(null);
114
+ renderers[currentName()].setCharacterHidden(false);
115
+ const dropStart = curY();
116
+ const dropEnd = 10;
117
+ const dropDuration = 300;
118
+ const dropStartTime = Date.now();
119
+ const dropInterval = setInterval(() => {
120
+ const elapsed = Date.now() - dropStartTime;
121
+ const t = Math.min(elapsed / dropDuration, 1);
122
+ const eased = t * t;
123
+ setCurY(Math.round(dropStart + (dropEnd - dropStart) * eased));
124
+ if (t >= 1) {
125
+ clearInterval(dropInterval);
126
+ setCurY(dropEnd);
127
+ }
128
+ }, 16);
129
+ }, 6000);
130
+ }
117
131
 
118
132
  const stopDrag = () => {
119
133
  isDragging = false;
120
134
  renderers[currentName()].setDragging(false);
121
135
  };
122
136
 
137
+ const handleMouseDown = (e: any) => {
138
+ const now = Date.now();
139
+ if (now - lastClickTime < 300) {
140
+ switchToNext();
141
+ lastClickTime = 0;
142
+ return;
143
+ }
144
+ lastClickTime = now;
145
+ if (e.modifiers?.alt) {
146
+ dragStartX = e.x;
147
+ dragStartY = e.y;
148
+ dragAnchorX = curX();
149
+ dragAnchorY = curY();
150
+ isDragging = true;
151
+ renderers[currentName()].setDragging(true);
152
+ props.api.renderer.clearSelection();
153
+ }
154
+ };
155
+ const handleMouseDrag = (e: any) => {
156
+ if (e.modifiers?.alt && isDragging) {
157
+ setCurX(dragAnchorX + (e.x - dragStartX));
158
+ setCurY(dragAnchorY + (e.y - dragStartY));
159
+ }
160
+ };
161
+ const handleMouseUp = () => { stopDrag(); };
162
+
123
163
  return (
124
- <box
125
- zIndex={zBoost() ? 9999 : 100}
126
- flexDirection="column"
127
- ref={(node: any) => {
128
- boxRef = node;
129
- applyPos();
130
- }}
131
- onMouseDown={(e: any) => {
132
- const now = Date.now();
133
- if (now - lastClickTime < 300) {
134
- switchToNext();
135
- lastClickTime = 0;
136
- return;
137
- }
138
- lastClickTime = now;
139
-
140
- if (e.modifiers?.alt) {
141
- dragStartX = e.x;
142
- dragStartY = e.y;
143
- dragAnchorX = curX;
144
- dragAnchorY = curY;
145
- isDragging = true;
146
- renderers[currentName()].setDragging(true);
147
- props.api.renderer.clearSelection();
148
- }
149
- }}
150
- onMouseDrag={(e: any) => {
151
- if (e.modifiers?.alt && isDragging) {
152
- curX = dragAnchorX + (e.x - dragStartX);
153
- curY = dragAnchorY + (e.y - dragStartY);
154
- applyPos();
155
- }
156
- }}
157
- onMouseUp={() => { stopDrag(); }}
158
- onMouseDragEnd={() => { stopDrag(); }}
159
- >
164
+ <>
160
165
  {renderers[currentName()]?.propElement() ? (
161
166
  <box
162
167
  position="absolute"
163
- zIndex={50}
164
- left={renderers[currentName()].getPropPosition() === "side-left" ? -16 : renderers[currentName()].getPropPosition() === "side-right" ? 12 : 0}
165
- top={0}
168
+ left={renderers[currentName()].getPropPosition() === "front" ? curX() - 4 : curX()}
169
+ top={renderers[currentName()].getPropPosition() === "front" ? curY() - 5 : curY()}
170
+ zIndex={zBoost() ? 9998 : 50}
171
+ onMouseDown={handleMouseDown}
172
+ onMouseDrag={handleMouseDrag}
173
+ onMouseUp={handleMouseUp}
174
+ onMouseDragEnd={handleMouseUp}
166
175
  >
167
176
  {renderers[currentName()].propElement()}
168
177
  </box>
169
178
  ) : null}
170
- <box zIndex={100}>
171
- {renderers[currentName()]?.element() ?? null}
172
- </box>
173
- </box>
179
+ {renderers[currentName()]?.element() ? (
180
+ <box
181
+ position="absolute"
182
+ left={curX()}
183
+ top={curY()}
184
+ zIndex={zBoost() ? 9999 : 100}
185
+ onMouseDown={handleMouseDown}
186
+ onMouseDrag={handleMouseDrag}
187
+ onMouseUp={handleMouseUp}
188
+ onMouseDragEnd={handleMouseUp}
189
+ >
190
+ {renderers[currentName()].element()}
191
+ </box>
192
+ ) : null}
193
+ </>
174
194
  );
175
195
  }
@@ -4,7 +4,7 @@ import { createSignal, onCleanup } from "solid-js";
4
4
  import type { JSX } from "@opentui/solid";
5
5
  import type { MascotPack, MascotState } from "../core/types";
6
6
  import { createAnimatedRenderer } from "../core/ascii-renderer";
7
- import { onCelebrate, onVersion, onScatter } from "../core/celebration-bus";
7
+ import { onCelebrate, onVersion, onScatter, onPropShow } from "../core/celebration-bus";
8
8
  import { pickPropByTrigger } from "../core/prop-loader";
9
9
  import { log } from "../core/logger";
10
10
 
@@ -34,7 +34,80 @@ const PEEK = 2;
34
34
  const PEEK_INTERVAL = 1200;
35
35
  const EDGE_THRESHOLD = 3;
36
36
 
37
+ let singletonRenderers: Record<string, ReturnType<typeof createAnimatedRenderer>> | null = null;
38
+ let singletonListener = false;
39
+ const [globalCurrentName, setGlobalCurrentName] = createSignal<string>("yueer");
40
+ const [globalUserOverride, setGlobalUserOverride] = createSignal(false);
41
+ const [globalPosX, setGlobalPosX] = createSignal(20);
42
+ const [globalPosY, setGlobalPosY] = createSignal(2);
43
+ const [globalPacingX, setGlobalPacingX] = createSignal(0);
44
+ const [globalZBoost, setGlobalZBoost] = createSignal(false);
45
+ let globalScattered = false;
46
+ let globalLastUserY: number | null = null;
47
+ let globalLastUserX: number | null = null;
48
+ let globalFallTimer: ReturnType<typeof setInterval> | null = null;
49
+
50
+ const fallToWorkY = () => {
51
+ const targetY = globalLastUserY ?? 25;
52
+ const targetX = globalLastUserX ?? 5;
53
+ const startY = globalPosY();
54
+ const startX = globalPosX();
55
+ const needMove = Math.abs(startY - targetY) >= 2 || Math.abs(startX - targetX) >= 2;
56
+ if (!needMove) return;
57
+ const startTime = Date.now();
58
+ const duration = 500;
59
+ if (globalFallTimer) clearInterval(globalFallTimer);
60
+ globalFallTimer = setInterval(() => {
61
+ const elapsed = Date.now() - startTime;
62
+ const t = Math.min(elapsed / duration, 1);
63
+ const eased = t * t;
64
+ setGlobalPosY(Math.round(startY + (targetY - startY) * eased));
65
+ setGlobalPosX(Math.round(startX + (targetX - startX) * eased));
66
+ if (t >= 1) {
67
+ if (globalFallTimer) { clearInterval(globalFallTimer); globalFallTimer = null; }
68
+ }
69
+ }, 16);
70
+ };
71
+
72
+ let busyPacingTimer: ReturnType<typeof setInterval> | null = null;
73
+
74
+ const startBusyPacing = () => {
75
+ if (busyPacingTimer) clearInterval(busyPacingTimer);
76
+ let step = 0;
77
+ let direction = 1;
78
+ let phaseTimer = 0;
79
+ let walking = true;
80
+ busyPacingTimer = setInterval(() => {
81
+ const prop = singletonRenderers?.[globalCurrentName()]?.getProp();
82
+ if (prop && prop.position === "front") return;
83
+ phaseTimer += 100;
84
+ if (walking) {
85
+ if (phaseTimer >= 3000) {
86
+ walking = false;
87
+ phaseTimer = 0;
88
+ setGlobalPacingX(0);
89
+ return;
90
+ }
91
+ step += direction;
92
+ if (Math.abs(step) >= 3) direction *= -1;
93
+ setGlobalPacingX(step);
94
+ } else {
95
+ if (phaseTimer >= 2000) {
96
+ walking = true;
97
+ phaseTimer = 0;
98
+ step = 0;
99
+ direction = Math.random() < 0.5 ? -1 : 1;
100
+ }
101
+ }
102
+ }, 100);
103
+ };
104
+
105
+ const stopBusyPacing = () => {
106
+ if (busyPacingTimer) { clearInterval(busyPacingTimer); busyPacingTimer = null; }
107
+ };
108
+
37
109
  export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
110
+ log("DEBUG", "SidebarMascot mount");
38
111
  const names = Object.keys(props.mascots);
39
112
  const initialName =
40
113
  props.initialMascot && props.mascots[props.initialMascot]
@@ -43,12 +116,24 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
43
116
  ? "yueer"
44
117
  : names[0];
45
118
 
46
- const [currentName, setCurrentName] = createSignal(initialName);
47
- const [userOverride, setUserOverride] = createSignal(false);
48
- const [posX, setPosX] = createSignal(20);
49
- const [posY, setPosY] = createSignal(2);
119
+ if (!singletonRenderers) {
120
+ singletonRenderers = {};
121
+ for (const [name, pack] of Object.entries(props.mascots)) {
122
+ singletonRenderers[name] = createAnimatedRenderer(pack);
123
+ }
124
+ setGlobalCurrentName(initialName);
125
+ }
126
+ const renderers = singletonRenderers;
127
+
128
+ const currentName = globalCurrentName;
129
+ const setCurrentName = setGlobalCurrentName;
130
+ const setUserOverride = setGlobalUserOverride;
131
+ const posX = globalPosX;
132
+ const setPosX = setGlobalPosX;
133
+ const posY = globalPosY;
134
+ const setPosY = setGlobalPosY;
135
+
50
136
  const [containerWidth, setContainerWidth] = createSignal(0);
51
- const [zBoost, setZBoost] = createSignal(false);
52
137
  let dragStartX = 0;
53
138
  let dragStartY = 0;
54
139
  let dragAnchorX = 0;
@@ -59,11 +144,6 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
59
144
  let peekTimer: ReturnType<typeof setInterval> | null = null;
60
145
  let returnTimer: ReturnType<typeof setInterval> | null = null;
61
146
 
62
- const renderers: Record<string, ReturnType<typeof createAnimatedRenderer>> = {};
63
- for (const [name, pack] of Object.entries(props.mascots)) {
64
- renderers[name] = createAnimatedRenderer(pack);
65
- }
66
-
67
147
  const stopPeek = () => {
68
148
  if (peekTimer) { clearInterval(peekTimer); peekTimer = null; }
69
149
  };
@@ -143,81 +223,90 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
143
223
  }, 16);
144
224
  };
145
225
 
146
- const setStateWithSwitch = (s: MascotState) => {
147
- const cur = currentName();
148
- renderers[cur].setState(s);
149
- if (userOverride()) return;
150
- const target = DEFAULT_STATE_MAP[s];
151
- if (target && target !== cur && props.mascots[target]) {
152
- setCurrentName(target);
153
- renderers[target].setState(s);
154
- }
155
- };
226
+ if (!singletonListener) {
227
+ singletonListener = true;
156
228
 
157
- props.api.event.on("session.status", (data: unknown) => {
158
- const payload = data as { type?: string; properties?: { sessionID?: string; status?: { type?: string } } } | null;
159
- const statusType = payload?.properties?.status?.type;
160
- log("DEBUG", `session.status: statusType=${statusType}, full=${JSON.stringify(payload)?.slice(0, 200)}`);
161
- if (statusType === "busy" || statusType === "retry") {
162
- if (hideSide) returnToView();
163
- renderers[currentName()].setState("busy");
164
- const busyProp = pickPropByTrigger("busy");
165
- renderers[currentName()].setProp(busyProp);
166
- renderers[currentName()].setCharacterHidden(busyProp?.position === "front");
167
- } else {
168
- setStateWithSwitch("idle");
169
- renderers[currentName()].setProp(null);
170
- renderers[currentName()].setCharacterHidden(false);
171
- }
172
- });
173
-
174
- props.api.event.on("session.idle", () => {
175
- setStateWithSwitch("happy");
176
- setTimeout(() => setStateWithSwitch("idle"), 3000);
177
- });
178
-
179
- props.api.event.on("mascot.switch", (data: unknown) => {
180
- const target = data as { name?: string } | null;
181
- if (target?.name) {
182
- const name = target.name;
183
- if (props.mascots[name] && name !== currentName()) {
184
- setCurrentName(name);
185
- setUserOverride(true);
229
+ props.api.event.on("session.status", (data: unknown) => {
230
+ const payload = data as { type?: string; properties?: { sessionID?: string; status?: { type?: string } } } | null;
231
+ const statusType = payload?.properties?.status?.type;
232
+ log("DEBUG", `session.status: statusType=${statusType}`);
233
+ if (statusType === "busy" || statusType === "retry") {
234
+ renderers[globalCurrentName()].setState("busy");
235
+ if (!renderers[globalCurrentName()].getProp()) {
236
+ const busyProp = pickPropByTrigger("busy");
237
+ renderers[globalCurrentName()].setProp(busyProp);
238
+ renderers[globalCurrentName()].setCharacterHidden(busyProp?.position === "front");
239
+ fallToWorkY();
240
+ }
241
+ startBusyPacing();
242
+ } else {
243
+ renderers[globalCurrentName()].setState("idle");
244
+ stopBusyPacing();
245
+ if (!globalUserOverride()) {
246
+ const target = DEFAULT_STATE_MAP["idle" as MascotState];
247
+ if (target && target !== globalCurrentName() && singletonRenderers && singletonRenderers[target]) {
248
+ setGlobalCurrentName(target);
249
+ singletonRenderers[target].setState("idle");
250
+ }
251
+ }
252
+ renderers[globalCurrentName()].setProp(null);
253
+ renderers[globalCurrentName()].setCharacterHidden(false);
186
254
  }
187
- } else {
188
- switchToNext();
189
- }
190
- });
191
-
192
- props.api.event.on("mascot.toggleWalk", () => {
193
- renderers[currentName()].toggleWalk();
194
- });
195
-
196
- onCelebrate((newVersion) => {
197
- setZBoost(true);
198
- renderers[currentName()].celebrateUpdate(newVersion);
199
- setTimeout(() => setZBoost(false), 2000);
200
- });
201
-
202
- onVersion((version) => {
203
- setZBoost(true);
204
- renderers[currentName()].showVersion(version);
205
- setTimeout(() => setZBoost(false), 3500);
206
- });
207
-
208
- let scattered = false;
209
-
210
- onScatter(() => {
211
- if (scattered) return;
212
- scattered = true;
213
- renderers[currentName()].scatterIn();
214
- });
215
-
216
- setTimeout(() => {
217
- if (scattered) return;
218
- scattered = true;
219
- renderers[currentName()].scatterIn();
220
- }, 2000);
255
+ });
256
+
257
+ props.api.event.on("session.idle", () => {
258
+ renderers[globalCurrentName()].setState("happy");
259
+ setTimeout(() => {
260
+ renderers[globalCurrentName()].setState("idle");
261
+ }, 3000);
262
+ });
263
+
264
+ props.api.event.on("mascot.switch", (data: unknown) => {
265
+ const target = data as { name?: string } | null;
266
+ if (target?.name) {
267
+ const name = target.name;
268
+ if (singletonRenderers && singletonRenderers[name] && name !== globalCurrentName()) {
269
+ setGlobalCurrentName(name);
270
+ setGlobalUserOverride(true);
271
+ }
272
+ } else {
273
+ const cur = globalCurrentName();
274
+ const allNames = Object.keys(singletonRenderers ?? {});
275
+ const idx = allNames.indexOf(cur);
276
+ setGlobalCurrentName(allNames[(idx + 1) % allNames.length]);
277
+ setGlobalUserOverride(true);
278
+ }
279
+ });
280
+
281
+ props.api.event.on("mascot.toggleWalk", () => {
282
+ renderers[globalCurrentName()].toggleWalk();
283
+ });
284
+
285
+ onCelebrate((newVersion) => {
286
+ setGlobalZBoost(true);
287
+ renderers[globalCurrentName()].celebrateUpdate(newVersion);
288
+ setTimeout(() => setGlobalZBoost(false), 2000);
289
+ });
290
+
291
+ onVersion((version) => {
292
+ setGlobalZBoost(true);
293
+ renderers[globalCurrentName()].showVersion(version);
294
+ setTimeout(() => setGlobalZBoost(false), 3500);
295
+ });
296
+
297
+ onScatter(() => {
298
+ if (globalScattered) return;
299
+ globalScattered = true;
300
+ renderers[globalCurrentName()].scatterIn();
301
+ });
302
+
303
+ onPropShow(() => {
304
+ fallToWorkY();
305
+ });
306
+
307
+ globalScattered = true;
308
+ renderers[globalCurrentName()].scatterIn();
309
+ }
221
310
 
222
311
  const propOffset = () => {
223
312
  const pos = renderers[currentName()]?.getPropPosition();
@@ -226,6 +315,39 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
226
315
  return 0;
227
316
  };
228
317
 
318
+ const handleMouseDown = (e: any) => {
319
+ if (hideSide) { returnToView(); return; }
320
+ const now = Date.now();
321
+ if (now - lastClickTime < 300) {
322
+ switchToNext();
323
+ lastClickTime = 0;
324
+ return;
325
+ }
326
+ lastClickTime = now;
327
+ if (e.modifiers?.alt) {
328
+ dragStartX = e.x;
329
+ dragStartY = e.y;
330
+ dragAnchorX = posX();
331
+ dragAnchorY = posY();
332
+ isDragging = true;
333
+ renderers[currentName()].setDragging(true);
334
+ props.api.renderer.clearSelection();
335
+ }
336
+ };
337
+ const handleMouseDrag = (e: any) => {
338
+ if (e.modifiers?.alt && isDragging) {
339
+ setPosX(clampX(dragAnchorX + (e.x - dragStartX)));
340
+ setPosY(clampY(dragAnchorY + (e.y - dragStartY)));
341
+ globalLastUserY = posY();
342
+ globalLastUserX = posX();
343
+ }
344
+ };
345
+ const handleMouseUp = () => {
346
+ isDragging = false;
347
+ renderers[currentName()].setDragging(false);
348
+ checkEdge();
349
+ };
350
+
229
351
  return (
230
352
  <>
231
353
  {renderers[currentName()]?.propElement() ? (
@@ -233,17 +355,22 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
233
355
  position="absolute"
234
356
  left={posX() + propOffset()}
235
357
  top={posY()}
236
- zIndex={zBoost() ? 9998 : 50}
358
+ zIndex={globalZBoost() ? 9998 : 50}
359
+ onMouseDown={handleMouseDown}
360
+ onMouseDrag={handleMouseDrag}
361
+ onMouseUp={handleMouseUp}
362
+ onMouseDragEnd={handleMouseUp}
237
363
  >
238
364
  {renderers[currentName()].propElement()}
239
365
  </box>
240
366
  ) : null}
241
- <box
242
- position="absolute"
243
- left={posX()}
244
- top={posY()}
245
- alignItems="center"
246
- zIndex={zBoost() ? 9999 : 100}
367
+ {renderers[currentName()].getPropPosition() !== "front" ? (
368
+ <box
369
+ position="absolute"
370
+ left={posX() + globalPacingX()}
371
+ top={posY()}
372
+ alignItems="center"
373
+ zIndex={globalZBoost() ? 9999 : 100}
247
374
  flexDirection="column"
248
375
  ref={(node: any) => {
249
376
  if (node) {
@@ -255,46 +382,14 @@ export function SidebarMascot(props: SidebarMascotProps): JSX.Element {
255
382
  }
256
383
  }
257
384
  }}
258
- onMouseDown={(e: any) => {
259
- if (hideSide) { returnToView(); return; }
260
-
261
- const now = Date.now();
262
- if (now - lastClickTime < 300) {
263
- switchToNext();
264
- lastClickTime = 0;
265
- return;
266
- }
267
- lastClickTime = now;
268
-
269
- if (e.modifiers?.alt) {
270
- dragStartX = e.x;
271
- dragStartY = e.y;
272
- dragAnchorX = posX();
273
- dragAnchorY = posY();
274
- isDragging = true;
275
- renderers[currentName()].setDragging(true);
276
- props.api.renderer.clearSelection();
277
- }
278
- }}
279
- onMouseDrag={(e: any) => {
280
- if (e.modifiers?.alt && isDragging) {
281
- setPosX(clampX(dragAnchorX + (e.x - dragStartX)));
282
- setPosY(clampY(dragAnchorY + (e.y - dragStartY)));
283
- }
284
- }}
285
- onMouseUp={() => {
286
- isDragging = false;
287
- renderers[currentName()].setDragging(false);
288
- checkEdge();
289
- }}
290
- onMouseDragEnd={() => {
291
- isDragging = false;
292
- renderers[currentName()].setDragging(false);
293
- checkEdge();
294
- }}
385
+ onMouseDown={handleMouseDown}
386
+ onMouseDrag={handleMouseDrag}
387
+ onMouseUp={handleMouseUp}
388
+ onMouseDragEnd={handleMouseUp}
295
389
  >
296
390
  {renderers[currentName()]?.element() ?? null}
297
391
  </box>
392
+ ) : null}
298
393
  </>
299
394
  );
300
395
  }
@@ -3,6 +3,8 @@
3
3
  import { createSignal, onCleanup } from "solid-js";
4
4
  import type { JSX } from "@opentui/solid";
5
5
  import type { MascotPack, MascotState, EffectTimerCtx, EffectRenderCtx, PropPack, PropPosition } from "./types";
6
+ import { emitPropShow } from "./celebration-bus";
7
+ import { log } from "./logger";
6
8
 
7
9
  const SUPERSCRIPT: Record<string, string> = {
8
10
  "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
@@ -141,10 +143,17 @@ export function createAnimatedRenderer(pack: MascotPack): {
141
143
  };
142
144
 
143
145
  let idleSleepTimeout: ReturnType<typeof setTimeout> | null = null;
146
+ let idlePadTimeout: ReturnType<typeof setTimeout> | null = null;
147
+ let idleBoxTimeout: ReturnType<typeof setTimeout> | null = null;
148
+ let lastBoxEvent = 0;
144
149
 
145
150
  const resetIdleSleep = () => {
146
151
  if (idleSleepTimeout) clearTimeout(idleSleepTimeout);
152
+ if (idlePadTimeout) clearTimeout(idlePadTimeout);
153
+ if (idleBoxTimeout) clearTimeout(idleBoxTimeout);
147
154
  idleSleepTimeout = null;
155
+ idlePadTimeout = null;
156
+ idleBoxTimeout = null;
148
157
  if (currentState() !== "idle") return;
149
158
  idleSleepTimeout = setTimeout(() => {
150
159
  if (currentState() === "idle") {
@@ -152,6 +161,49 @@ export function createAnimatedRenderer(pack: MascotPack): {
152
161
  stopWalk();
153
162
  }
154
163
  }, anim.idleTimeout);
164
+
165
+ idlePadTimeout = setTimeout(async () => {
166
+ const { log } = await import("./logger");
167
+ log("DEBUG", `idlePadTimeout fired, state=${currentState()}, prop=${activeProp()?.name ?? "null"}`);
168
+ if (currentState() !== "idle") return;
169
+ if (activeProp()) return;
170
+ if (Math.random() >= 0.4) {
171
+ log("DEBUG", "idle Pad skipped (70% miss)");
172
+ return;
173
+ }
174
+ const { getProp } = await import("./prop-loader");
175
+ const pad = getProp("pad");
176
+ if (!pad) return;
177
+ log("DEBUG", "idle Pad triggered!");
178
+ setProp(pad);
179
+ setCharacterHiddenSignal(true);
180
+ emitPropShow();
181
+ const duration = 10000 + Math.random() * 10000;
182
+ idlePadTimeout = setTimeout(() => {
183
+ setProp(null);
184
+ setCharacterHiddenSignal(false);
185
+ }, duration);
186
+ }, 30000);
187
+
188
+ idleBoxTimeout = setTimeout(async () => {
189
+ if (currentState() !== "idle") return;
190
+ if (activeProp()) return;
191
+ const now = Date.now();
192
+ if (now - lastBoxEvent < 60000) return;
193
+ if (Math.random() >= 0.05) return;
194
+ lastBoxEvent = now;
195
+ const { getProp } = await import("./prop-loader");
196
+ const box = getProp("box");
197
+ if (!box) return;
198
+ setProp(box);
199
+ setCharacterHiddenSignal(true);
200
+ emitPropShow();
201
+ idleBoxTimeout = setTimeout(() => {
202
+ setProp(null);
203
+ setCharacterHiddenSignal(false);
204
+ bounce();
205
+ }, 3000);
206
+ }, 60000);
155
207
  };
156
208
 
157
209
  resetIdleSleep();
@@ -181,7 +233,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
181
233
  // 1. Blink
182
234
  const hasBlink = (pack.frames as Record<string, string[] | undefined>)["blink"] !== undefined;
183
235
 
184
- const blinkTimer = setInterval(() => {
236
+ setInterval(() => {
185
237
  if (currentState() !== "sleeping" && Math.random() < anim.blinkChance && hasBlink) {
186
238
  setFrameOverride("blink");
187
239
  setTimeout(() => setFrameOverride(null), 150);
@@ -193,7 +245,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
193
245
  (k) => k !== "default" && k !== "blink",
194
246
  );
195
247
 
196
- const expressionTimer = setInterval(() => {
248
+ setInterval(() => {
197
249
  if (currentState() === "idle" && !frameOverride()) {
198
250
  const pick = availableExpressions[Math.floor(Math.random() * availableExpressions.length)];
199
251
  if (pick) {
@@ -204,7 +256,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
204
256
  }, anim.expressionInterval);
205
257
 
206
258
  // 3. Breathing
207
- const breathTimer = setInterval(() => {
259
+ setInterval(() => {
208
260
  if (currentState() === "idle") {
209
261
  setBreathPhase((v) => !v);
210
262
  }
@@ -243,30 +295,28 @@ export function createAnimatedRenderer(pack: MascotPack): {
243
295
  setWalkOffset(0);
244
296
  };
245
297
 
246
- let walkTimeout: ReturnType<typeof setTimeout>;
247
-
248
298
  function scheduleNextWalk() {
249
299
  if (!walkEnabled()) return setTimeout(() => {}, 60000);
250
300
  const delay = anim.walkMinDelay + Math.floor(Math.random() * (anim.walkMaxDelay - anim.walkMinDelay));
251
301
  return setTimeout(() => {
252
302
  if (currentState() === "idle" && !frameOverride() && walkStep === -1 && walkEnabled()) {
253
- if (Math.random() < 0.1) {
303
+ const boom = Math.random() < 0.1;
304
+ log("DEBUG", `walk callback: boom=${boom}`);
305
+ if (boom) {
254
306
  explode();
255
307
  } else {
256
308
  startWalk();
257
309
  }
258
310
  }
259
311
  if (currentState() !== "sleeping") {
260
- walkTimeout = scheduleNextWalk();
312
+ scheduleNextWalk();
261
313
  }
262
314
  }, delay);
263
315
  }
264
316
 
265
- walkTimeout = scheduleNextWalk();
317
+ scheduleNextWalk();
266
318
 
267
319
  // 5. Jump
268
- let jumpTimeout: ReturnType<typeof setTimeout>;
269
-
270
320
  function scheduleNextJump() {
271
321
  const delay = anim.jumpMinDelay + Math.floor(Math.random() * (anim.jumpMaxDelay - anim.jumpMinDelay));
272
322
  return setTimeout(() => {
@@ -281,12 +331,12 @@ export function createAnimatedRenderer(pack: MascotPack): {
281
331
  }, 2000);
282
332
  }
283
333
  if (currentState() !== "sleeping") {
284
- jumpTimeout = scheduleNextJump();
334
+ scheduleNextJump();
285
335
  }
286
336
  }, delay);
287
337
  }
288
338
 
289
- jumpTimeout = scheduleNextJump();
339
+ scheduleNextJump();
290
340
 
291
341
  // ─── Pack-defined effect timers ───
292
342
  const effectTimers: ReturnType<typeof setInterval>[] = [];
@@ -296,16 +346,10 @@ export function createAnimatedRenderer(pack: MascotPack): {
296
346
  }
297
347
  }
298
348
 
349
+ resetIdleSleep();
350
+
299
351
  // ─── Cleanup ───
300
352
  onCleanup(() => {
301
- clearInterval(blinkTimer);
302
- clearInterval(expressionTimer);
303
- clearInterval(breathTimer);
304
- clearTimeout(walkTimeout);
305
- clearTimeout(jumpTimeout);
306
- if (idleSleepTimeout) clearTimeout(idleSleepTimeout);
307
- if (walkInterval) clearInterval(walkInterval);
308
- for (const t of effectTimers) clearInterval(t);
309
353
  stopFlash();
310
354
  stopDragMsg();
311
355
  stopBounce();
@@ -315,7 +359,6 @@ export function createAnimatedRenderer(pack: MascotPack): {
315
359
  stopFall();
316
360
  stopBomb();
317
361
  if (zzzTimer) { clearInterval(zzzTimer); zzzTimer = null; }
318
- stopPropTimer();
319
362
  });
320
363
 
321
364
  // ─── Render ───
@@ -333,6 +376,9 @@ export function createAnimatedRenderer(pack: MascotPack): {
333
376
  scatter();
334
377
  bomb();
335
378
  versionMsg();
379
+ characterHidden();
380
+ activeProp();
381
+ propPosition();
336
382
 
337
383
  for (const [, [get]] of extraSignals) {
338
384
  get();
@@ -345,7 +391,8 @@ export function createAnimatedRenderer(pack: MascotPack): {
345
391
  const width = rawLines[0]?.length ?? 10;
346
392
  const blank = " ".repeat(width);
347
393
 
348
- let lines: string[] = characterHidden()
394
+ const hideForProp = propPosition() === "front";
395
+ let lines: string[] = (characterHidden() || hideForProp)
349
396
  ? rawLines.map(() => blank)
350
397
  : rawLines.map((line, i) => {
351
398
  if (!breathPhase()) {
@@ -422,8 +469,8 @@ export function createAnimatedRenderer(pack: MascotPack): {
422
469
  if (s !== "idle") {
423
470
  stopWalk();
424
471
  } else if (walkEnabled()) {
425
- walkTimeout = scheduleNextWalk();
426
- jumpTimeout = scheduleNextJump();
472
+ scheduleNextWalk();
473
+ scheduleNextJump();
427
474
  }
428
475
 
429
476
  if (s === "thinking" || s === "busy") {
@@ -455,7 +502,7 @@ export function createAnimatedRenderer(pack: MascotPack): {
455
502
  if (!next) {
456
503
  stopWalk();
457
504
  } else if (currentState() === "idle") {
458
- walkTimeout = scheduleNextWalk();
505
+ scheduleNextWalk();
459
506
  }
460
507
  };
461
508
 
@@ -596,15 +643,22 @@ export function createAnimatedRenderer(pack: MascotPack): {
596
643
  clearInterval(fuseTimer);
597
644
  setBomb(null);
598
645
  setFrameOverride(null);
599
- setFlashColor("#FFFFFF");
646
+ const explosionColors = ["#FF0000", "#FF6600", "#FFCC00"];
647
+ let expColorIdx = 0;
648
+ setFlashColor(explosionColors[0]);
649
+ const expColorTimer = setInterval(() => {
650
+ expColorIdx++;
651
+ setFlashColor(explosionColors[expColorIdx % explosionColors.length]);
652
+ }, 100);
600
653
  const lineCount = getFrameLines(pack, "default").length;
601
654
  const offsets = Array.from({ length: lineCount }, () => ({
602
655
  dx: Math.floor((Math.random() - 0.5) * 30),
603
656
  dy: Math.floor((Math.random() - 0.5) * 15),
604
657
  }));
605
658
  setScatter(offsets);
606
- setCelebrate({ text: "ᵇᵒᵒᵐ~", count: 0 });
659
+ setCelebrate({ text: "ᵇᵒᵒᵐ~💥", count: 0 });
607
660
  explodeTimer = setTimeout(() => {
661
+ clearInterval(expColorTimer);
608
662
  setFlashColor(null);
609
663
  setCelebrate(null);
610
664
  scatterIn();
@@ -624,8 +678,14 @@ export function createAnimatedRenderer(pack: MascotPack): {
624
678
  setPropPosition(pos);
625
679
  stopPropTimer();
626
680
  if (Array.isArray(prop.frames[0]) && prop.frameInterval) {
681
+ const totalFrames = (prop.frames as string[][]).length;
682
+ const randomize = prop.name === "laptop";
627
683
  propTimer = setInterval(() => {
628
- setPropFrameIdx((idx) => (idx + 1) % (prop.frames as string[][]).length);
684
+ if (randomize) {
685
+ setPropFrameIdx(Math.floor(Math.random() * totalFrames));
686
+ } else {
687
+ setPropFrameIdx((idx) => (idx + 1) % totalFrames);
688
+ }
629
689
  }, prop.frameInterval);
630
690
  }
631
691
  } else {
@@ -3,6 +3,7 @@ const bus = new EventTarget();
3
3
  const CELEBRATE_EVENT = "mascot:celebrate";
4
4
  const VERSION_EVENT = "mascot:version";
5
5
  const SCATTER_EVENT = "mascot:scatter";
6
+ const PROP_SHOW_EVENT = "mascot:propshow";
6
7
 
7
8
  export function emitCelebrate(newVersion: string): void {
8
9
  bus.dispatchEvent(new CustomEvent(CELEBRATE_EVENT, { detail: { newVersion } }));
@@ -39,3 +40,13 @@ export function onScatter(handler: () => void): () => void {
39
40
  bus.addEventListener(SCATTER_EVENT, listener);
40
41
  return () => bus.removeEventListener(SCATTER_EVENT, listener);
41
42
  }
43
+
44
+ export function emitPropShow(): void {
45
+ bus.dispatchEvent(new CustomEvent(PROP_SHOW_EVENT));
46
+ }
47
+
48
+ export function onPropShow(handler: () => void): () => void {
49
+ const listener = () => { handler(); };
50
+ bus.addEventListener(PROP_SHOW_EVENT, listener);
51
+ return () => bus.removeEventListener(PROP_SHOW_EVENT, listener);
52
+ }