opencube 0.1.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/README.md +106 -0
- package/assets/opencode-icon-3d-three-preview.html +168 -0
- package/assets/opencode-icon.png +0 -0
- package/assets/opencode-pet-3d-spin-concept.svg +99 -0
- package/assets/opencode-pet-3d-spin-preview.html +324 -0
- package/assets/pixel-opencode-pet.svg +74 -0
- package/package.json +30 -0
- package/src/main.js +1282 -0
- package/src/pixel-pet-reference.cjs +134 -0
- package/src/plugin-server.cjs +169 -0
- package/src/plugin-shared.cjs +230 -0
- package/src/plugin-tui.cjs +7 -0
package/src/main.js
ADDED
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
const { app, BrowserWindow, Menu, screen } = require("electron")
|
|
2
|
+
const fs = require("node:fs")
|
|
3
|
+
const http = require("node:http")
|
|
4
|
+
const os = require("node:os")
|
|
5
|
+
const path = require("node:path")
|
|
6
|
+
|
|
7
|
+
const { DEFAULT_SESSION_COLORS, pixelPetSvg } = require("./pixel-pet-reference.cjs")
|
|
8
|
+
|
|
9
|
+
const APP_NAME = "opencode pet"
|
|
10
|
+
const HOST = "127.0.0.1"
|
|
11
|
+
const PORT = Number(process.env.OPENCODE_PET_PORT || 47832)
|
|
12
|
+
const DATA_DIR = path.join(os.homedir(), ".local", "share", "opencode-pet")
|
|
13
|
+
const STATE_FILE = path.join(DATA_DIR, "state.json")
|
|
14
|
+
const PET_HTML_FILE = path.join(DATA_DIR, "pet.html")
|
|
15
|
+
const ICON_PATH = process.env.OPENCODE_PET_ICON || path.join(__dirname, "..", "assets", "opencode-icon.png")
|
|
16
|
+
const MAX_EVENTS = 100
|
|
17
|
+
const IDLE_TTL_MS = 5 * 60 * 1000
|
|
18
|
+
|
|
19
|
+
let petWindow = null
|
|
20
|
+
let panelWindow = null
|
|
21
|
+
let server = null
|
|
22
|
+
let events = []
|
|
23
|
+
let petSignals = []
|
|
24
|
+
let sessionMap = new Map()
|
|
25
|
+
let cleanupTimer = null
|
|
26
|
+
|
|
27
|
+
function ensureDataDir() {
|
|
28
|
+
fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeState(extra = {}) {
|
|
32
|
+
ensureDataDir()
|
|
33
|
+
fs.writeFileSync(
|
|
34
|
+
STATE_FILE,
|
|
35
|
+
JSON.stringify(
|
|
36
|
+
{
|
|
37
|
+
pid: process.pid,
|
|
38
|
+
startedAt: Date.now(),
|
|
39
|
+
iconPath: ICON_PATH,
|
|
40
|
+
...extra,
|
|
41
|
+
},
|
|
42
|
+
null,
|
|
43
|
+
2,
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function shouldQuit(argv = process.argv) {
|
|
49
|
+
return argv.includes("--quit") || argv.includes("--stop") || argv.includes("stop") || argv.includes("quit")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function randomBetween(min, max) {
|
|
53
|
+
return min + Math.random() * (max - min)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createBounceDynamics() {
|
|
57
|
+
const bounds = { left: 42, top: 44, right: 154, bottom: 156 }
|
|
58
|
+
const half = 8
|
|
59
|
+
const speed = randomBetween(74, 122)
|
|
60
|
+
const angle = randomBetween(-Math.PI, Math.PI)
|
|
61
|
+
return {
|
|
62
|
+
x: randomBetween(bounds.left + half, bounds.right - half),
|
|
63
|
+
y: randomBetween(bounds.top + half, bounds.bottom - half),
|
|
64
|
+
vx: Math.cos(angle) * speed,
|
|
65
|
+
vy: Math.sin(angle) * speed,
|
|
66
|
+
speed,
|
|
67
|
+
bounds,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function json(res, status, body) {
|
|
72
|
+
const text = JSON.stringify(body)
|
|
73
|
+
res.writeHead(status, {
|
|
74
|
+
"content-type": "application/json; charset=utf-8",
|
|
75
|
+
"access-control-allow-origin": "*",
|
|
76
|
+
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
77
|
+
"access-control-allow-headers": "content-type",
|
|
78
|
+
})
|
|
79
|
+
res.end(text)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readRequestJson(req) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
let raw = ""
|
|
85
|
+
req.on("data", (chunk) => {
|
|
86
|
+
raw += chunk
|
|
87
|
+
if (raw.length > 1024 * 1024) {
|
|
88
|
+
reject(new Error("request body too large"))
|
|
89
|
+
req.destroy()
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
req.on("end", () => {
|
|
93
|
+
if (!raw.trim()) return resolve({})
|
|
94
|
+
try {
|
|
95
|
+
resolve(JSON.parse(raw))
|
|
96
|
+
} catch (error) {
|
|
97
|
+
reject(error)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
req.on("error", reject)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function recordEvent(event) {
|
|
105
|
+
const item = {
|
|
106
|
+
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
107
|
+
receivedAt: Date.now(),
|
|
108
|
+
...event,
|
|
109
|
+
}
|
|
110
|
+
applySessionEvent(item)
|
|
111
|
+
if (item.type === "hello" || item.type === "fancy_hello") {
|
|
112
|
+
petSignals.push({
|
|
113
|
+
id: item.id,
|
|
114
|
+
type: "hello",
|
|
115
|
+
mode: item.type === "fancy_hello" ? "fancy" : "single",
|
|
116
|
+
receivedAt: item.receivedAt,
|
|
117
|
+
sessionID: item.sessionID,
|
|
118
|
+
})
|
|
119
|
+
petSignals = petSignals.slice(-20)
|
|
120
|
+
}
|
|
121
|
+
events.unshift(item)
|
|
122
|
+
events = events.slice(0, MAX_EVENTS)
|
|
123
|
+
updatePanel()
|
|
124
|
+
updatePet()
|
|
125
|
+
return item
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function applySessionEvent(event) {
|
|
129
|
+
if (!event || typeof event.sessionID !== "string") return
|
|
130
|
+
const now = event.receivedAt || Date.now()
|
|
131
|
+
const current = sessionMap.get(event.sessionID)
|
|
132
|
+
|
|
133
|
+
if (event.type === "session.busy") {
|
|
134
|
+
sessionMap.set(event.sessionID, {
|
|
135
|
+
sessionID: event.sessionID,
|
|
136
|
+
state: "busy",
|
|
137
|
+
busyAt: current?.state === "busy" ? current.busyAt : now,
|
|
138
|
+
idleAt: undefined,
|
|
139
|
+
lastAt: now,
|
|
140
|
+
orbitTouchedAt: now,
|
|
141
|
+
dynamics: current?.dynamics || createBounceDynamics(),
|
|
142
|
+
status: event.status || "busy",
|
|
143
|
+
})
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (event.type === "session.idle") {
|
|
148
|
+
sessionMap.set(event.sessionID, {
|
|
149
|
+
sessionID: event.sessionID,
|
|
150
|
+
state: "idle",
|
|
151
|
+
busyAt: current?.busyAt,
|
|
152
|
+
idleAt: now,
|
|
153
|
+
lastAt: now,
|
|
154
|
+
orbitTouchedAt: current?.orbitTouchedAt,
|
|
155
|
+
dynamics: current?.dynamics || createBounceDynamics(),
|
|
156
|
+
status: event.status || "idle",
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function pruneIdleSessions(refresh = true) {
|
|
162
|
+
const now = Date.now()
|
|
163
|
+
let changed = false
|
|
164
|
+
for (const [sessionID, session] of sessionMap) {
|
|
165
|
+
const expiresFrom = session.idleAt || session.lastAt
|
|
166
|
+
if (session.state === "idle" && expiresFrom && now - expiresFrom > IDLE_TTL_MS) {
|
|
167
|
+
sessionMap.delete(sessionID)
|
|
168
|
+
changed = true
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (changed && refresh) updatePet()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getPetState() {
|
|
175
|
+
pruneIdleSessions(false)
|
|
176
|
+
let busyIndex = 0
|
|
177
|
+
let idleIndex = 0
|
|
178
|
+
return {
|
|
179
|
+
now: Date.now(),
|
|
180
|
+
layout: {
|
|
181
|
+
width: 256,
|
|
182
|
+
height: 256,
|
|
183
|
+
bodyViewBox: { width: 256, height: 256 },
|
|
184
|
+
busyJuggle: { centerX: 108, baseY: 82, radiusX: 48, liftY: 58 },
|
|
185
|
+
idlePile: { x: 194, y: 186, stepX: 17, stepY: 15, columns: 2 },
|
|
186
|
+
ball: { size: 14 },
|
|
187
|
+
},
|
|
188
|
+
sessions: Array.from(sessionMap.values()).map((session, index) => ({
|
|
189
|
+
sessionID: session.sessionID,
|
|
190
|
+
state: session.state,
|
|
191
|
+
busyAt: session.busyAt,
|
|
192
|
+
idleAt: session.idleAt,
|
|
193
|
+
lastAt: session.lastAt,
|
|
194
|
+
orbitTouchedAt: session.orbitTouchedAt,
|
|
195
|
+
dynamics: session.dynamics,
|
|
196
|
+
index,
|
|
197
|
+
busyIndex: session.state === "busy" ? busyIndex++ : undefined,
|
|
198
|
+
idleIndex: session.state === "idle" ? idleIndex++ : undefined,
|
|
199
|
+
color: DEFAULT_SESSION_COLORS[index % DEFAULT_SESSION_COLORS.length],
|
|
200
|
+
})),
|
|
201
|
+
signals: petSignals,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function escapeHtml(value) {
|
|
206
|
+
return String(value)
|
|
207
|
+
.replaceAll("&", "&")
|
|
208
|
+
.replaceAll("<", "<")
|
|
209
|
+
.replaceAll(">", ">")
|
|
210
|
+
.replaceAll('"', """)
|
|
211
|
+
.replaceAll("'", "'")
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function createIconDataUrl() {
|
|
215
|
+
try {
|
|
216
|
+
const png = fs.readFileSync(ICON_PATH)
|
|
217
|
+
return `data:image/png;base64,${png.toString("base64")}`
|
|
218
|
+
} catch {
|
|
219
|
+
const fallback = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#111"/><rect x="30" y="18" width="36" height="60" fill="#f4f4ef"/><rect x="40" y="32" width="16" height="32" fill="#050505"/></svg>`
|
|
220
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(fallback)}`
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function panelHtml() {
|
|
225
|
+
const rows = events
|
|
226
|
+
.map((event) => {
|
|
227
|
+
const time = new Date(event.receivedAt).toLocaleTimeString()
|
|
228
|
+
const payload = JSON.stringify(event, null, 2)
|
|
229
|
+
return `<div class="event"><div class="meta">${escapeHtml(time)} · ${escapeHtml(event.type || "event")}</div><pre>${escapeHtml(payload)}</pre></div>`
|
|
230
|
+
})
|
|
231
|
+
.join("")
|
|
232
|
+
|
|
233
|
+
return `<!doctype html>
|
|
234
|
+
<html>
|
|
235
|
+
<head>
|
|
236
|
+
<meta charset="utf-8" />
|
|
237
|
+
<style>
|
|
238
|
+
html, body { margin: 0; width: 100%; height: 100%; background: transparent; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
239
|
+
.panel { box-sizing: border-box; width: 100%; height: 100%; padding: 12px; border-radius: 16px; background: rgba(24, 24, 27, 0.94); color: white; box-shadow: 0 14px 42px rgba(0,0,0,.28); overflow: hidden; }
|
|
240
|
+
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 13px; font-weight: 700; }
|
|
241
|
+
.hint { color: rgba(255,255,255,.6); font-size: 11px; font-weight: 500; }
|
|
242
|
+
.list { height: 292px; overflow: auto; padding-right: 4px; }
|
|
243
|
+
.empty { color: rgba(255,255,255,.6); font-size: 13px; padding: 18px 4px; }
|
|
244
|
+
.event { border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 8px; margin-bottom: 8px; background: rgba(255,255,255,.06); }
|
|
245
|
+
.meta { color: #a7f3d0; font-size: 11px; margin-bottom: 5px; }
|
|
246
|
+
pre { margin: 0; color: rgba(255,255,255,.88); white-space: pre-wrap; word-break: break-word; font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
247
|
+
</style>
|
|
248
|
+
</head>
|
|
249
|
+
<body>
|
|
250
|
+
<div class="panel">
|
|
251
|
+
<div class="header"><span>opencode pet inbox</span><span class="hint">${events.length}/${MAX_EVENTS}</span></div>
|
|
252
|
+
<div class="list">${rows || `<div class="empty">还没有收到事件。试试 /pet 或 /pet_stop。</div>`}</div>
|
|
253
|
+
</div>
|
|
254
|
+
</body>
|
|
255
|
+
</html>`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function updatePanel() {
|
|
259
|
+
if (!panelWindow || panelWindow.isDestroyed()) return
|
|
260
|
+
panelWindow.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(panelHtml()))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function petHtml() {
|
|
264
|
+
const petBodySvg = pixelPetSvg({ sessionCount: 0, showCaption: false })
|
|
265
|
+
const initialStateJson = JSON.stringify(getPetState()).replaceAll("<", "\\u003c")
|
|
266
|
+
const colorJson = JSON.stringify(DEFAULT_SESSION_COLORS)
|
|
267
|
+
return `<!doctype html>
|
|
268
|
+
<html>
|
|
269
|
+
<head>
|
|
270
|
+
<meta charset="utf-8" />
|
|
271
|
+
<style>
|
|
272
|
+
html, body {
|
|
273
|
+
margin: 0;
|
|
274
|
+
width: 100%;
|
|
275
|
+
height: 100%;
|
|
276
|
+
background: transparent;
|
|
277
|
+
overflow: hidden;
|
|
278
|
+
}
|
|
279
|
+
.stage {
|
|
280
|
+
box-sizing: border-box;
|
|
281
|
+
width: 100%;
|
|
282
|
+
height: 100%;
|
|
283
|
+
position: relative;
|
|
284
|
+
background: transparent;
|
|
285
|
+
-webkit-app-region: drag;
|
|
286
|
+
user-select: none;
|
|
287
|
+
image-rendering: pixelated;
|
|
288
|
+
}
|
|
289
|
+
.pet-art {
|
|
290
|
+
position: absolute;
|
|
291
|
+
inset: 0;
|
|
292
|
+
width: 256px;
|
|
293
|
+
height: 256px;
|
|
294
|
+
transform-origin: 106px 156px;
|
|
295
|
+
transition: transform 120ms steps(2, end), filter 120ms steps(2, end);
|
|
296
|
+
}
|
|
297
|
+
.pet-art svg {
|
|
298
|
+
display: block;
|
|
299
|
+
width: 256px;
|
|
300
|
+
height: 256px;
|
|
301
|
+
shape-rendering: crispEdges;
|
|
302
|
+
}
|
|
303
|
+
.stage.has-busy .pet-art {
|
|
304
|
+
animation: pet-work-bob 620ms steps(2, end) infinite;
|
|
305
|
+
filter: drop-shadow(0 4px 0 rgba(0,0,0,.14));
|
|
306
|
+
}
|
|
307
|
+
#ball-layer {
|
|
308
|
+
position: absolute;
|
|
309
|
+
inset: 0;
|
|
310
|
+
pointer-events: none;
|
|
311
|
+
}
|
|
312
|
+
.session-ball-runtime {
|
|
313
|
+
position: absolute;
|
|
314
|
+
left: 0;
|
|
315
|
+
top: 0;
|
|
316
|
+
width: 14px;
|
|
317
|
+
height: 14px;
|
|
318
|
+
margin-left: -7px;
|
|
319
|
+
margin-top: -7px;
|
|
320
|
+
box-sizing: border-box;
|
|
321
|
+
background: var(--ball-color, #ff5d73);
|
|
322
|
+
box-shadow: 0 3px 0 rgba(0,0,0,.32);
|
|
323
|
+
image-rendering: pixelated;
|
|
324
|
+
will-change: transform, opacity;
|
|
325
|
+
}
|
|
326
|
+
.session-ball-runtime::before {
|
|
327
|
+
content: "";
|
|
328
|
+
position: absolute;
|
|
329
|
+
left: 4px;
|
|
330
|
+
top: 2px;
|
|
331
|
+
width: 4px;
|
|
332
|
+
height: 4px;
|
|
333
|
+
background: rgba(255,255,255,.72);
|
|
334
|
+
}
|
|
335
|
+
.session-ball-runtime.busy {
|
|
336
|
+
z-index: 3;
|
|
337
|
+
}
|
|
338
|
+
.session-ball-runtime.idle {
|
|
339
|
+
z-index: 1;
|
|
340
|
+
box-shadow: 0 2px 0 rgba(0,0,0,.2);
|
|
341
|
+
}
|
|
342
|
+
@keyframes pet-work-bob {
|
|
343
|
+
0%, 100% { transform: translateY(0); }
|
|
344
|
+
50% { transform: translateY(-4px); }
|
|
345
|
+
}
|
|
346
|
+
</style>
|
|
347
|
+
</head>
|
|
348
|
+
<body>
|
|
349
|
+
<div class="stage" title="opencode pet:拖拽移动,右键打开菜单">
|
|
350
|
+
<div class="pet-art" aria-label="pixel opencode pet">${petBodySvg}</div>
|
|
351
|
+
<div id="ball-layer"></div>
|
|
352
|
+
</div>
|
|
353
|
+
<script>
|
|
354
|
+
window.__PET_STATE = ${initialStateJson}
|
|
355
|
+
const SESSION_COLORS = ${colorJson}
|
|
356
|
+
const stage = document.querySelector(".stage")
|
|
357
|
+
const layer = document.getElementById("ball-layer")
|
|
358
|
+
const physics = {
|
|
359
|
+
idleTtl: ${IDLE_TTL_MS},
|
|
360
|
+
busy: { leftHandX: 30, rightHandX: 184, handY: 112, peakY: 34, stepPx: 2, periodMs: 1180 },
|
|
361
|
+
idlePile: { x: 194, y: 188, stepX: 17, stepY: 15, columns: 2 },
|
|
362
|
+
}
|
|
363
|
+
const balls = new Map()
|
|
364
|
+
let lastFrame = performance.now()
|
|
365
|
+
let snapshot = window.__PET_STATE || { sessions: [] }
|
|
366
|
+
let latestDebug = { now: Date.now(), busy: 0, idle: 0, balls: [] }
|
|
367
|
+
|
|
368
|
+
function ease(current, target, factor) {
|
|
369
|
+
return current + (target - current) * factor
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function clamp(value, min, max) {
|
|
373
|
+
return Math.max(min, Math.min(max, value))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function snapPixel(value) {
|
|
377
|
+
return Math.round(value / physics.busy.stepPx) * physics.busy.stepPx
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function colorFor(session, index) {
|
|
381
|
+
return session.color || SESSION_COLORS[index % SESSION_COLORS.length]
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function ensureBall(session, index) {
|
|
385
|
+
let ball = balls.get(session.sessionID)
|
|
386
|
+
if (ball) {
|
|
387
|
+
ball.el.style.setProperty("--ball-color", colorFor(session, index))
|
|
388
|
+
return ball
|
|
389
|
+
}
|
|
390
|
+
const el = document.createElement("div")
|
|
391
|
+
el.className = "session-ball-runtime"
|
|
392
|
+
el.title = session.sessionID
|
|
393
|
+
el.style.setProperty("--ball-color", colorFor(session, index))
|
|
394
|
+
layer.appendChild(el)
|
|
395
|
+
ball = {
|
|
396
|
+
id: session.sessionID,
|
|
397
|
+
el,
|
|
398
|
+
x: 108,
|
|
399
|
+
y: 86,
|
|
400
|
+
alpha: 0,
|
|
401
|
+
scale: 0.86,
|
|
402
|
+
leaving: false,
|
|
403
|
+
state: session.state,
|
|
404
|
+
}
|
|
405
|
+
balls.set(session.sessionID, ball)
|
|
406
|
+
return ball
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function setSnapshot(next) {
|
|
410
|
+
snapshot = next || { sessions: [] }
|
|
411
|
+
const live = new Set(snapshot.sessions.map((session) => session.sessionID))
|
|
412
|
+
for (const ball of balls.values()) {
|
|
413
|
+
if (!live.has(ball.id)) ball.leaving = true
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
window.__setPetState = setSnapshot
|
|
418
|
+
window.__getPetDebug = () => latestDebug
|
|
419
|
+
|
|
420
|
+
function busyTarget(busyIndex, busyCount, now) {
|
|
421
|
+
const count = Math.max(1, busyCount)
|
|
422
|
+
const cycle = now / physics.busy.periodMs + busyIndex / count
|
|
423
|
+
const halfCycle = Math.floor(cycle)
|
|
424
|
+
const t = cycle - halfCycle
|
|
425
|
+
const leftToRight = halfCycle % 2 === 0
|
|
426
|
+
const fromX = leftToRight ? physics.busy.leftHandX : physics.busy.rightHandX
|
|
427
|
+
const toX = leftToRight ? physics.busy.rightHandX : physics.busy.leftHandX
|
|
428
|
+
const handY = physics.busy.handY
|
|
429
|
+
const peakLift = handY - physics.busy.peakY
|
|
430
|
+
const arc = 4 * t * (1 - t)
|
|
431
|
+
const laneOffset = (busyIndex - (count - 1) / 2) * Math.min(8, 2 + count * 2)
|
|
432
|
+
const catchDip = Math.abs(t - 0.5) > 0.43 ? 4 : 0
|
|
433
|
+
return {
|
|
434
|
+
x: snapPixel(fromX + (toX - fromX) * t + laneOffset),
|
|
435
|
+
y: snapPixel(handY - arc * peakLift + catchDip),
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function idleTarget(idleIndex) {
|
|
440
|
+
const col = idleIndex % physics.idlePile.columns
|
|
441
|
+
const row = Math.floor(idleIndex / physics.idlePile.columns)
|
|
442
|
+
return {
|
|
443
|
+
x: physics.idlePile.x + col * physics.idlePile.stepX,
|
|
444
|
+
y: physics.idlePile.y - row * physics.idlePile.stepY + (col ? 5 : 0),
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function tick(now) {
|
|
449
|
+
const dt = Math.min(48, now - lastFrame) / 1000
|
|
450
|
+
lastFrame = now
|
|
451
|
+
const sessions = snapshot.sessions || []
|
|
452
|
+
const busySessions = sessions.filter((session) => session.state === "busy")
|
|
453
|
+
const idleSessions = sessions.filter((session) => session.state === "idle")
|
|
454
|
+
const order = new Map()
|
|
455
|
+
busySessions.forEach((session, busyIndex) => order.set(session.sessionID, { kind: "busy", busyIndex, index: session.index ?? busyIndex, session }))
|
|
456
|
+
idleSessions.forEach((session, idleIndex) => order.set(session.sessionID, { kind: "idle", idleIndex, index: session.index ?? idleIndex, session }))
|
|
457
|
+
|
|
458
|
+
stage.classList.toggle("has-busy", busySessions.length > 0)
|
|
459
|
+
stage.classList.toggle("has-sessions", sessions.length > 0)
|
|
460
|
+
|
|
461
|
+
for (const session of sessions) ensureBall(session, session.index || 0)
|
|
462
|
+
const debugBalls = []
|
|
463
|
+
|
|
464
|
+
for (const [id, ball] of balls) {
|
|
465
|
+
const info = order.get(id)
|
|
466
|
+
let targetX = ball.x
|
|
467
|
+
let targetY = ball.y
|
|
468
|
+
let targetScale = 0.96
|
|
469
|
+
let targetAlpha = 0.9
|
|
470
|
+
let className = "session-ball-runtime"
|
|
471
|
+
|
|
472
|
+
if (ball.leaving || !info) {
|
|
473
|
+
targetScale = 0.2
|
|
474
|
+
targetAlpha = 0
|
|
475
|
+
} else if (info.kind === "busy") {
|
|
476
|
+
const target = busyTarget(info.busyIndex, busySessions.length, now)
|
|
477
|
+
targetX = target.x
|
|
478
|
+
targetY = target.y
|
|
479
|
+
targetScale = 1
|
|
480
|
+
targetAlpha = 1
|
|
481
|
+
className += " busy"
|
|
482
|
+
} else {
|
|
483
|
+
const target = idleTarget(info.idleIndex)
|
|
484
|
+
targetX = target.x
|
|
485
|
+
targetY = target.y
|
|
486
|
+
const expiresFrom = info.session?.idleAt || info.session?.lastAt || Date.now()
|
|
487
|
+
const remaining = expiresFrom + physics.idleTtl - Date.now()
|
|
488
|
+
const fade = clamp(remaining / physics.idleTtl, 0, 1)
|
|
489
|
+
targetAlpha = 0.88 * fade
|
|
490
|
+
targetScale = 0.92
|
|
491
|
+
className += " idle"
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const factor = 1 - Math.pow(0.06, dt)
|
|
495
|
+
ball.x = ease(ball.x, targetX, factor)
|
|
496
|
+
ball.y = ease(ball.y, targetY, factor)
|
|
497
|
+
ball.scale = ease(ball.scale, targetScale, factor)
|
|
498
|
+
ball.alpha = ease(ball.alpha, targetAlpha, factor)
|
|
499
|
+
ball.el.className = className
|
|
500
|
+
ball.el.style.transform = "translate3d(" + snapPixel(ball.x) + "px, " + snapPixel(ball.y) + "px, 0) scale(" + ball.scale + ")"
|
|
501
|
+
ball.el.style.opacity = String(ball.alpha)
|
|
502
|
+
debugBalls.push({
|
|
503
|
+
id,
|
|
504
|
+
state: info?.kind || "leaving",
|
|
505
|
+
x: snapPixel(ball.x),
|
|
506
|
+
y: snapPixel(ball.y),
|
|
507
|
+
alpha: Number(ball.alpha.toFixed(3)),
|
|
508
|
+
scale: Number(ball.scale.toFixed(3)),
|
|
509
|
+
className,
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
if (ball.leaving && ball.alpha < 0.03) {
|
|
513
|
+
ball.el.remove()
|
|
514
|
+
balls.delete(id)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
latestDebug = { now: Date.now(), busy: busySessions.length, idle: idleSessions.length, balls: debugBalls }
|
|
519
|
+
|
|
520
|
+
requestAnimationFrame(tick)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
requestAnimationFrame(tick)
|
|
524
|
+
</script>
|
|
525
|
+
</body>
|
|
526
|
+
</html>`
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function petHtml3D() {
|
|
530
|
+
const initialStateJson = JSON.stringify(getPetState()).replaceAll("<", "\\u003c")
|
|
531
|
+
const iconUrl = createIconDataUrl()
|
|
532
|
+
const threeCjsPath = JSON.stringify(path.join(path.dirname(require.resolve("three")), "three.cjs"))
|
|
533
|
+
return `<!doctype html>
|
|
534
|
+
<html>
|
|
535
|
+
<head>
|
|
536
|
+
<meta charset="utf-8" />
|
|
537
|
+
<style>
|
|
538
|
+
html, body {
|
|
539
|
+
margin: 0;
|
|
540
|
+
width: 100%;
|
|
541
|
+
height: 100%;
|
|
542
|
+
background: transparent;
|
|
543
|
+
overflow: hidden;
|
|
544
|
+
}
|
|
545
|
+
.stage {
|
|
546
|
+
box-sizing: border-box;
|
|
547
|
+
width: 100%;
|
|
548
|
+
height: 100%;
|
|
549
|
+
position: relative;
|
|
550
|
+
background: transparent;
|
|
551
|
+
-webkit-app-region: drag;
|
|
552
|
+
user-select: none;
|
|
553
|
+
}
|
|
554
|
+
#scene {
|
|
555
|
+
position: absolute;
|
|
556
|
+
inset: 0;
|
|
557
|
+
width: 100%;
|
|
558
|
+
height: 100%;
|
|
559
|
+
pointer-events: none;
|
|
560
|
+
}
|
|
561
|
+
</style>
|
|
562
|
+
</head>
|
|
563
|
+
<body>
|
|
564
|
+
<div class="stage" title="opencode pet:拖拽移动,右键打开菜单">
|
|
565
|
+
<canvas id="scene" aria-label="3D opencode pet"></canvas>
|
|
566
|
+
</div>
|
|
567
|
+
<script>
|
|
568
|
+
window.__PET_BOOT_ERROR = null
|
|
569
|
+
window.addEventListener("error", (event) => {
|
|
570
|
+
window.__PET_BOOT_ERROR = { ok: false, error: String(event.error?.stack || event.message || event), source: event.filename, line: event.lineno, column: event.colno }
|
|
571
|
+
})
|
|
572
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
573
|
+
window.__PET_BOOT_ERROR = { ok: false, error: String(event.reason?.stack || event.reason || event) }
|
|
574
|
+
})
|
|
575
|
+
</script>
|
|
576
|
+
<script>
|
|
577
|
+
const THREE = require(${threeCjsPath})
|
|
578
|
+
|
|
579
|
+
window.__PET_STATE = ${initialStateJson}
|
|
580
|
+
const stage = document.querySelector(".stage")
|
|
581
|
+
const faceOrder = ["front", "right", "top", "back", "left", "bottom"]
|
|
582
|
+
const sessionFaceMap = new Map()
|
|
583
|
+
const sessionColorMap = new Map()
|
|
584
|
+
const handledSignalIDs = new Set()
|
|
585
|
+
const faceFlashes = new Map()
|
|
586
|
+
const colorReleaseSpeed = 90
|
|
587
|
+
const faceMeshes = new Map()
|
|
588
|
+
const glowMeshes = new Map()
|
|
589
|
+
let snapshot = window.__PET_STATE || { sessions: [] }
|
|
590
|
+
let lastFrame = performance.now()
|
|
591
|
+
let rotation = { x: -14, y: -28, z: 0 }
|
|
592
|
+
let angularVelocity = { x: 0, y: 0, z: 0 }
|
|
593
|
+
let torque = { x: 0, y: 0, z: 0 }
|
|
594
|
+
let nextTorqueAt = 0
|
|
595
|
+
let wasBusy = false
|
|
596
|
+
let latestDebug = { now: Date.now(), busy: 0, rotation, angularVelocity, torque, speed: 0, faceRotations: {} }
|
|
597
|
+
|
|
598
|
+
const canvas = document.getElementById("scene")
|
|
599
|
+
const scene = new THREE.Scene()
|
|
600
|
+
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 20)
|
|
601
|
+
camera.position.set(0, 0.04, 3.25)
|
|
602
|
+
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true })
|
|
603
|
+
renderer.setClearColor(0x000000, 0)
|
|
604
|
+
renderer.setPixelRatio(Math.min((window.devicePixelRatio || 1) * 1.5, 3))
|
|
605
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace
|
|
606
|
+
|
|
607
|
+
const cubeGroup = new THREE.Group()
|
|
608
|
+
scene.add(cubeGroup)
|
|
609
|
+
const iconTexture = new THREE.TextureLoader().load("${iconUrl}")
|
|
610
|
+
iconTexture.colorSpace = THREE.SRGBColorSpace
|
|
611
|
+
iconTexture.generateMipmaps = false
|
|
612
|
+
iconTexture.minFilter = THREE.LinearFilter
|
|
613
|
+
iconTexture.magFilter = THREE.LinearFilter
|
|
614
|
+
iconTexture.anisotropy = 8
|
|
615
|
+
const iconMaterial = new THREE.MeshBasicMaterial({
|
|
616
|
+
map: iconTexture,
|
|
617
|
+
transparent: true,
|
|
618
|
+
alphaTest: 0.02,
|
|
619
|
+
side: THREE.DoubleSide,
|
|
620
|
+
})
|
|
621
|
+
const glowTexture = createGlowTexture()
|
|
622
|
+
const faceGeometry = new THREE.PlaneGeometry(0.60, 0.60)
|
|
623
|
+
const glowGeometry = new THREE.PlaneGeometry(1.18, 1.18)
|
|
624
|
+
const rad = THREE.MathUtils.degToRad
|
|
625
|
+
|
|
626
|
+
function createGlowTexture() {
|
|
627
|
+
const canvas = document.createElement("canvas")
|
|
628
|
+
canvas.width = 256
|
|
629
|
+
canvas.height = 256
|
|
630
|
+
const ctx = canvas.getContext("2d")
|
|
631
|
+
const gradient = ctx.createRadialGradient(128, 128, 4, 128, 128, 124)
|
|
632
|
+
gradient.addColorStop(0, "rgba(255,255,255,1)")
|
|
633
|
+
gradient.addColorStop(0.18, "rgba(255,255,255,1)")
|
|
634
|
+
gradient.addColorStop(0.46, "rgba(255,255,255,.62)")
|
|
635
|
+
gradient.addColorStop(0.80, "rgba(255,255,255,.22)")
|
|
636
|
+
gradient.addColorStop(1, "rgba(255,255,255,0)")
|
|
637
|
+
ctx.fillStyle = gradient
|
|
638
|
+
ctx.fillRect(0, 0, 256, 256)
|
|
639
|
+
const texture = new THREE.CanvasTexture(canvas)
|
|
640
|
+
texture.colorSpace = THREE.SRGBColorSpace
|
|
641
|
+
return texture
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function resize() {
|
|
645
|
+
const width = Math.max(1, window.innerWidth)
|
|
646
|
+
const height = Math.max(1, window.innerHeight)
|
|
647
|
+
renderer.setSize(width, height, false)
|
|
648
|
+
camera.aspect = width / height
|
|
649
|
+
camera.updateProjectionMatrix()
|
|
650
|
+
}
|
|
651
|
+
window.addEventListener("resize", resize)
|
|
652
|
+
resize()
|
|
653
|
+
|
|
654
|
+
function randomBetween(min, max) {
|
|
655
|
+
return min + Math.random() * (max - min)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function randomSign() {
|
|
659
|
+
return Math.random() > 0.5 ? 1 : -1
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function randomTorque() {
|
|
663
|
+
return {
|
|
664
|
+
x: randomBetween(45, 130) * randomSign(),
|
|
665
|
+
y: randomBetween(90, 260) * randomSign(),
|
|
666
|
+
z: randomBetween(18, 80) * randomSign(),
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function randomChoice(items) {
|
|
671
|
+
return items[Math.floor(Math.random() * items.length)]
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function hslToRgb(h, s, l) {
|
|
675
|
+
const c = (1 - Math.abs(2 * l - 1)) * s
|
|
676
|
+
const hp = h / 60
|
|
677
|
+
const x = c * (1 - Math.abs((hp % 2) - 1))
|
|
678
|
+
let r = 0
|
|
679
|
+
let g = 0
|
|
680
|
+
let b = 0
|
|
681
|
+
if (hp < 1) [r, g, b] = [c, x, 0]
|
|
682
|
+
else if (hp < 2) [r, g, b] = [x, c, 0]
|
|
683
|
+
else if (hp < 3) [r, g, b] = [0, c, x]
|
|
684
|
+
else if (hp < 4) [r, g, b] = [0, x, c]
|
|
685
|
+
else if (hp < 5) [r, g, b] = [x, 0, c]
|
|
686
|
+
else [r, g, b] = [c, 0, x]
|
|
687
|
+
const m = l - c / 2
|
|
688
|
+
return {
|
|
689
|
+
r: Math.round((r + m) * 255),
|
|
690
|
+
g: Math.round((g + m) * 255),
|
|
691
|
+
b: Math.round((b + m) * 255),
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function randomSessionGlowColor() {
|
|
696
|
+
const hue = randomBetween(0, 360)
|
|
697
|
+
const saturation = randomBetween(0.68, 0.94)
|
|
698
|
+
const lightness = randomBetween(0.50, 0.66)
|
|
699
|
+
const rgb = hslToRgb(hue, saturation, lightness)
|
|
700
|
+
return {
|
|
701
|
+
...rgb,
|
|
702
|
+
name: "random-" + Math.round(hue),
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function makeFaceRotations() {
|
|
707
|
+
const quarterTurns = [0, 90, 180, 270]
|
|
708
|
+
return {
|
|
709
|
+
front: Math.random() < 0.7 ? 0 : randomChoice(quarterTurns),
|
|
710
|
+
back: randomChoice(quarterTurns),
|
|
711
|
+
right: randomChoice(quarterTurns),
|
|
712
|
+
left: randomChoice(quarterTurns),
|
|
713
|
+
top: randomChoice(quarterTurns),
|
|
714
|
+
bottom: randomChoice(quarterTurns),
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const faceRotations = makeFaceRotations()
|
|
719
|
+
createFace("front", [0, 0, 0.30], [0, 0, rad(faceRotations.front || 0)], [0, 0, 0.314])
|
|
720
|
+
createFace("back", [0, 0, -0.30], [0, Math.PI, rad(faceRotations.back || 0)], [0, 0, -0.314])
|
|
721
|
+
createFace("right", [0.30, 0, 0], [0, Math.PI / 2, rad(faceRotations.right || 0)], [0.314, 0, 0])
|
|
722
|
+
createFace("left", [-0.30, 0, 0], [0, -Math.PI / 2, rad(faceRotations.left || 0)], [-0.314, 0, 0])
|
|
723
|
+
createFace("top", [0, 0.30, 0], [-Math.PI / 2, 0, rad(faceRotations.top || 0)], [0, 0.314, 0])
|
|
724
|
+
createFace("bottom", [0, -0.30, 0], [Math.PI / 2, 0, rad(faceRotations.bottom || 0)], [0, -0.314, 0])
|
|
725
|
+
|
|
726
|
+
function createFace(name, position, rotation, glowPosition) {
|
|
727
|
+
const face = new THREE.Mesh(faceGeometry, iconMaterial.clone())
|
|
728
|
+
face.position.set(...position)
|
|
729
|
+
face.rotation.set(...rotation)
|
|
730
|
+
cubeGroup.add(face)
|
|
731
|
+
faceMeshes.set(name, face)
|
|
732
|
+
|
|
733
|
+
const glow = new THREE.Mesh(
|
|
734
|
+
glowGeometry,
|
|
735
|
+
new THREE.MeshBasicMaterial({
|
|
736
|
+
map: glowTexture,
|
|
737
|
+
color: 0x1fdccd,
|
|
738
|
+
transparent: true,
|
|
739
|
+
opacity: 0,
|
|
740
|
+
blending: THREE.AdditiveBlending,
|
|
741
|
+
depthWrite: false,
|
|
742
|
+
side: THREE.DoubleSide,
|
|
743
|
+
}),
|
|
744
|
+
)
|
|
745
|
+
glow.position.set(...glowPosition)
|
|
746
|
+
glow.rotation.set(...rotation)
|
|
747
|
+
cubeGroup.add(glow)
|
|
748
|
+
glowMeshes.set(name, glow)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function magnitude(vector) {
|
|
752
|
+
return Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function clampMagnitude(vector, max) {
|
|
756
|
+
const length = magnitude(vector)
|
|
757
|
+
if (length <= max || length === 0) return vector
|
|
758
|
+
const scale = max / length
|
|
759
|
+
vector.x *= scale
|
|
760
|
+
vector.y *= scale
|
|
761
|
+
vector.z *= scale
|
|
762
|
+
return vector
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function setNextTorque(now) {
|
|
766
|
+
torque = randomTorque()
|
|
767
|
+
nextTorqueAt = now + randomBetween(4800, 5200)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function setSnapshot(next) {
|
|
771
|
+
snapshot = next || { sessions: [] }
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function syncBusyFaces(sessions, speed) {
|
|
775
|
+
const busySessions = sessions
|
|
776
|
+
.filter((session) => session.state === "busy" && typeof session.sessionID === "string")
|
|
777
|
+
.sort((a, b) => (b.busyAt || b.lastAt || 0) - (a.busyAt || a.lastAt || 0))
|
|
778
|
+
const busyIDs = busySessions.map((session) => session.sessionID)
|
|
779
|
+
const busySet = new Set(busyIDs)
|
|
780
|
+
|
|
781
|
+
if (busyIDs.length === 0) {
|
|
782
|
+
if (speed < colorReleaseSpeed) {
|
|
783
|
+
sessionFaceMap.clear()
|
|
784
|
+
sessionColorMap.clear()
|
|
785
|
+
}
|
|
786
|
+
} else {
|
|
787
|
+
for (const sessionID of Array.from(sessionColorMap.keys())) {
|
|
788
|
+
if (!busySet.has(sessionID)) sessionColorMap.delete(sessionID)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
for (const sessionID of busyIDs) {
|
|
793
|
+
if (!sessionColorMap.has(sessionID)) {
|
|
794
|
+
sessionColorMap.set(sessionID, randomSessionGlowColor())
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (busyIDs.length > 0) {
|
|
799
|
+
sessionFaceMap.clear()
|
|
800
|
+
for (const [index, session] of busySessions.slice(0, faceOrder.length).entries()) {
|
|
801
|
+
sessionFaceMap.set(session.sessionID, faceOrder[index])
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const faceColors = new Map()
|
|
806
|
+
for (const [sessionID, faceName] of sessionFaceMap) {
|
|
807
|
+
faceColors.set(faceName, sessionColorMap.get(sessionID) || randomSessionGlowColor())
|
|
808
|
+
}
|
|
809
|
+
for (const faceName of faceOrder) {
|
|
810
|
+
const color = faceColors.get(faceName)
|
|
811
|
+
const face = faceMeshes.get(faceName)
|
|
812
|
+
const glow = glowMeshes.get(faceName)
|
|
813
|
+
if (color) {
|
|
814
|
+
const threeColor = new THREE.Color(color.r / 255, color.g / 255, color.b / 255)
|
|
815
|
+
face.material.color.copy(threeColor).lerp(new THREE.Color(0xffffff), 0.58)
|
|
816
|
+
glow.material.color.setRGB(color.r / 255, color.g / 255, color.b / 255)
|
|
817
|
+
glow.material.opacity = 0.98
|
|
818
|
+
glow.scale.setScalar(1.24)
|
|
819
|
+
} else {
|
|
820
|
+
face.material.color.setRGB(1, 1, 1)
|
|
821
|
+
glow.material.opacity = 0
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return Object.fromEntries(Array.from(sessionFaceMap.entries()).map(([sessionID, faceName]) => {
|
|
825
|
+
const color = sessionColorMap.get(sessionID) || randomSessionGlowColor()
|
|
826
|
+
return [sessionID, { face: faceName, color: color.name, rgb: [color.r, color.g, color.b] }]
|
|
827
|
+
}))
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function chooseUnlitFace() {
|
|
831
|
+
const litFaces = new Set(sessionFaceMap.values())
|
|
832
|
+
const candidates = faceOrder.filter((faceName) => !litFaces.has(faceName) && !faceFlashes.has(faceName))
|
|
833
|
+
if (candidates.length === 0) return undefined
|
|
834
|
+
return randomChoice(candidates)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function processSignals(signals, now) {
|
|
838
|
+
for (const signal of signals || []) {
|
|
839
|
+
if (!signal?.id || handledSignalIDs.has(signal.id)) continue
|
|
840
|
+
handledSignalIDs.add(signal.id)
|
|
841
|
+
if (signal.type !== "hello") continue
|
|
842
|
+
|
|
843
|
+
if (signal.mode === "fancy") {
|
|
844
|
+
const litFaces = new Set(sessionFaceMap.values())
|
|
845
|
+
const candidates = faceOrder.filter((faceName) => !litFaces.has(faceName) && !faceFlashes.has(faceName))
|
|
846
|
+
for (const faceName of candidates) {
|
|
847
|
+
const burstCount = Math.floor(randomBetween(8, 15))
|
|
848
|
+
let cursor = randomBetween(0, 140)
|
|
849
|
+
const bursts = []
|
|
850
|
+
for (let index = 0; index < burstCount; index++) {
|
|
851
|
+
const duration = randomBetween(150, 300)
|
|
852
|
+
bursts.push({
|
|
853
|
+
at: cursor,
|
|
854
|
+
duration,
|
|
855
|
+
color: randomSessionGlowColor(),
|
|
856
|
+
peak: randomBetween(0.82, 1.24),
|
|
857
|
+
})
|
|
858
|
+
cursor += duration + randomBetween(35, 150)
|
|
859
|
+
}
|
|
860
|
+
faceFlashes.set(faceName, {
|
|
861
|
+
signalID: signal.id,
|
|
862
|
+
mode: "fancy",
|
|
863
|
+
startedAt: now,
|
|
864
|
+
duration: cursor + 260,
|
|
865
|
+
bursts,
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
continue
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const faceName = chooseUnlitFace()
|
|
872
|
+
if (!faceName) continue
|
|
873
|
+
faceFlashes.set(faceName, {
|
|
874
|
+
signalID: signal.id,
|
|
875
|
+
mode: "single",
|
|
876
|
+
startedAt: now,
|
|
877
|
+
duration: 1320,
|
|
878
|
+
bursts: [{ at: 0, duration: 1320, color: randomSessionGlowColor(), peak: 1 }],
|
|
879
|
+
})
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Keep the handled set bounded; only the latest signals are sent by the host.
|
|
883
|
+
if (handledSignalIDs.size > 80) {
|
|
884
|
+
const liveIDs = new Set((signals || []).map((signal) => signal?.id).filter(Boolean))
|
|
885
|
+
for (const id of Array.from(handledSignalIDs)) {
|
|
886
|
+
if (!liveIDs.has(id)) handledSignalIDs.delete(id)
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function applyFlashFaces(now) {
|
|
892
|
+
const litFaces = new Set(sessionFaceMap.values())
|
|
893
|
+
const active = {}
|
|
894
|
+
for (const [faceName, flash] of Array.from(faceFlashes.entries())) {
|
|
895
|
+
const elapsed = now - flash.startedAt
|
|
896
|
+
if (elapsed >= flash.duration || litFaces.has(faceName)) {
|
|
897
|
+
faceFlashes.delete(faceName)
|
|
898
|
+
continue
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const progress = elapsed / flash.duration
|
|
902
|
+
let strength = 0
|
|
903
|
+
let color = undefined
|
|
904
|
+
for (const burst of flash.bursts || []) {
|
|
905
|
+
const burstElapsed = elapsed - burst.at
|
|
906
|
+
if (burstElapsed < 0 || burstElapsed > burst.duration) continue
|
|
907
|
+
const burstProgress = burstElapsed / burst.duration
|
|
908
|
+
const pulses = flash.mode === "single"
|
|
909
|
+
? Math.sin(progress * Math.PI * 6)
|
|
910
|
+
: Math.sin(burstProgress * Math.PI)
|
|
911
|
+
const burstStrength = Math.max(0, pulses) * (burst.peak || 1) * (1 - progress * 0.08)
|
|
912
|
+
if (burstStrength > strength) {
|
|
913
|
+
strength = burstStrength
|
|
914
|
+
color = burst.color
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (strength <= 0.01) {
|
|
918
|
+
active[faceName] = { strength: 0, signalID: flash.signalID }
|
|
919
|
+
continue
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const face = faceMeshes.get(faceName)
|
|
923
|
+
const glow = glowMeshes.get(faceName)
|
|
924
|
+
color ??= randomSessionGlowColor()
|
|
925
|
+
const threeColor = new THREE.Color(color.r / 255, color.g / 255, color.b / 255)
|
|
926
|
+
face.material.color.copy(threeColor).lerp(new THREE.Color(0xffffff), 0.70)
|
|
927
|
+
glow.material.color.setRGB(color.r / 255, color.g / 255, color.b / 255)
|
|
928
|
+
glow.material.opacity = 0.18 + strength * 0.82
|
|
929
|
+
glow.scale.setScalar(1.00 + strength * 0.32)
|
|
930
|
+
active[faceName] = { strength, signalID: flash.signalID }
|
|
931
|
+
}
|
|
932
|
+
return active
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
window.__setPetState = setSnapshot
|
|
936
|
+
window.__getPetDebug = () => latestDebug
|
|
937
|
+
|
|
938
|
+
function renderCube() {
|
|
939
|
+
cubeGroup.rotation.x = rad(rotation.x)
|
|
940
|
+
cubeGroup.rotation.y = rad(rotation.y)
|
|
941
|
+
cubeGroup.rotation.z = rad(rotation.z)
|
|
942
|
+
renderer.render(scene, camera)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function tick(now) {
|
|
946
|
+
const dt = Math.min(64, now - lastFrame) / 1000
|
|
947
|
+
lastFrame = now
|
|
948
|
+
const sessions = snapshot.sessions || []
|
|
949
|
+
const busyCount = sessions.filter((session) => session.state === "busy").length
|
|
950
|
+
const isBusy = busyCount > 0
|
|
951
|
+
|
|
952
|
+
if (isBusy && !wasBusy) setNextTorque(now)
|
|
953
|
+
if (isBusy && now >= nextTorqueAt) setNextTorque(now)
|
|
954
|
+
if (!isBusy) torque = { x: 0, y: 0, z: 0 }
|
|
955
|
+
wasBusy = isBusy
|
|
956
|
+
stage.classList.toggle("has-busy", isBusy)
|
|
957
|
+
stage.classList.toggle("has-sessions", sessions.length > 0)
|
|
958
|
+
|
|
959
|
+
const inertia = 1.18
|
|
960
|
+
const friction = isBusy ? 0.58 : 2.85
|
|
961
|
+
angularVelocity.x += (torque.x / inertia) * dt
|
|
962
|
+
angularVelocity.y += (torque.y / inertia) * dt
|
|
963
|
+
angularVelocity.z += (torque.z / inertia) * dt
|
|
964
|
+
const damping = Math.exp(-friction * dt)
|
|
965
|
+
angularVelocity.x *= damping
|
|
966
|
+
angularVelocity.y *= damping
|
|
967
|
+
angularVelocity.z *= damping
|
|
968
|
+
clampMagnitude(angularVelocity, 1400)
|
|
969
|
+
|
|
970
|
+
rotation.x += angularVelocity.x * dt
|
|
971
|
+
rotation.y += angularVelocity.y * dt
|
|
972
|
+
rotation.z += angularVelocity.z * dt
|
|
973
|
+
|
|
974
|
+
const speed = magnitude(angularVelocity)
|
|
975
|
+
const speedRatio = Math.min(1, speed / 1400)
|
|
976
|
+
const glow = Math.pow(speedRatio, 2.3)
|
|
977
|
+
const glowR = Math.round(92 + (0 - 92) * glow)
|
|
978
|
+
const glowG = Math.round(255 + (190 - 255) * glow)
|
|
979
|
+
const glowB = Math.round(232 + (210 - 232) * glow)
|
|
980
|
+
const busyFaces = syncBusyFaces(sessions, speed)
|
|
981
|
+
processSignals(snapshot.signals || [], now)
|
|
982
|
+
const helloFlashes = applyFlashFaces(now)
|
|
983
|
+
renderCube()
|
|
984
|
+
latestDebug = {
|
|
985
|
+
now: Date.now(),
|
|
986
|
+
busy: busyCount,
|
|
987
|
+
rotation: { ...rotation },
|
|
988
|
+
angularVelocity: { ...angularVelocity },
|
|
989
|
+
torque: { ...torque },
|
|
990
|
+
speed,
|
|
991
|
+
speedRatio,
|
|
992
|
+
nextTorqueAt,
|
|
993
|
+
glow,
|
|
994
|
+
colorReleaseSpeed,
|
|
995
|
+
glowColor: { r: glowR, g: glowG, b: glowB },
|
|
996
|
+
faceRotations,
|
|
997
|
+
busyFaces,
|
|
998
|
+
helloFlashes,
|
|
999
|
+
}
|
|
1000
|
+
requestAnimationFrame(tick)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
renderCube()
|
|
1004
|
+
requestAnimationFrame(tick)
|
|
1005
|
+
</script>
|
|
1006
|
+
</body>
|
|
1007
|
+
</html>`
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function writePetHtmlFile() {
|
|
1011
|
+
ensureDataDir()
|
|
1012
|
+
fs.writeFileSync(PET_HTML_FILE, petHtml3D(), "utf8")
|
|
1013
|
+
return PET_HTML_FILE
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function updatePet() {
|
|
1017
|
+
if (!petWindow || petWindow.isDestroyed()) return
|
|
1018
|
+
const stateJson = JSON.stringify(getPetState()).replaceAll("<", "\\u003c")
|
|
1019
|
+
petWindow.webContents.executeJavaScript(`window.__setPetState?.(${stateJson})`).catch(() => {})
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function restorePosition(win) {
|
|
1023
|
+
try {
|
|
1024
|
+
const raw = fs.readFileSync(STATE_FILE, "utf8")
|
|
1025
|
+
const state = JSON.parse(raw)
|
|
1026
|
+
if (typeof state.x === "number" && typeof state.y === "number") {
|
|
1027
|
+
win.setPosition(state.x, state.y, false)
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
} catch {
|
|
1031
|
+
// no previous position
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const display = screen.getPrimaryDisplay().workArea
|
|
1035
|
+
const bounds = win.getBounds()
|
|
1036
|
+
const x = display.x + 24
|
|
1037
|
+
const y = display.y + Math.round(display.height * 0.62 - bounds.height / 2)
|
|
1038
|
+
win.setPosition(x, Math.max(display.y + 8, Math.min(y, display.y + display.height - bounds.height - 8)), false)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function createPetWindow() {
|
|
1042
|
+
if (petWindow && !petWindow.isDestroyed()) return petWindow
|
|
1043
|
+
petWindow = new BrowserWindow({
|
|
1044
|
+
width: 120,
|
|
1045
|
+
height: 120,
|
|
1046
|
+
show: false,
|
|
1047
|
+
frame: false,
|
|
1048
|
+
resizable: false,
|
|
1049
|
+
transparent: true,
|
|
1050
|
+
alwaysOnTop: true,
|
|
1051
|
+
skipTaskbar: true,
|
|
1052
|
+
movable: true,
|
|
1053
|
+
hasShadow: false,
|
|
1054
|
+
title: APP_NAME,
|
|
1055
|
+
webPreferences: {
|
|
1056
|
+
contextIsolation: false,
|
|
1057
|
+
nodeIntegration: true,
|
|
1058
|
+
},
|
|
1059
|
+
})
|
|
1060
|
+
petWindow.setAlwaysOnTop(true, "floating")
|
|
1061
|
+
petWindow.loadFile(writePetHtmlFile())
|
|
1062
|
+
petWindow.webContents.on("context-menu", () => buildMenu().popup({ window: petWindow }))
|
|
1063
|
+
petWindow.on("moved", () => {
|
|
1064
|
+
const [x, y] = petWindow.getPosition()
|
|
1065
|
+
writeState({ visible: true, x, y })
|
|
1066
|
+
positionPanel()
|
|
1067
|
+
})
|
|
1068
|
+
petWindow.on("closed", () => {
|
|
1069
|
+
petWindow = null
|
|
1070
|
+
})
|
|
1071
|
+
restorePosition(petWindow)
|
|
1072
|
+
return petWindow
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function createPanelWindow() {
|
|
1076
|
+
if (panelWindow && !panelWindow.isDestroyed()) return panelWindow
|
|
1077
|
+
|
|
1078
|
+
panelWindow = new BrowserWindow({
|
|
1079
|
+
width: 360,
|
|
1080
|
+
height: 360,
|
|
1081
|
+
show: false,
|
|
1082
|
+
frame: false,
|
|
1083
|
+
resizable: false,
|
|
1084
|
+
transparent: true,
|
|
1085
|
+
alwaysOnTop: true,
|
|
1086
|
+
skipTaskbar: true,
|
|
1087
|
+
hasShadow: false,
|
|
1088
|
+
title: `${APP_NAME} inbox`,
|
|
1089
|
+
webPreferences: {
|
|
1090
|
+
contextIsolation: true,
|
|
1091
|
+
nodeIntegration: false,
|
|
1092
|
+
},
|
|
1093
|
+
})
|
|
1094
|
+
panelWindow.setAlwaysOnTop(true, "floating")
|
|
1095
|
+
panelWindow.on("closed", () => {
|
|
1096
|
+
panelWindow = null
|
|
1097
|
+
})
|
|
1098
|
+
updatePanel()
|
|
1099
|
+
return panelWindow
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function positionPanel() {
|
|
1103
|
+
if (!petWindow || petWindow.isDestroyed() || !panelWindow || panelWindow.isDestroyed()) return
|
|
1104
|
+
const [x, y] = petWindow.getPosition()
|
|
1105
|
+
const petBounds = petWindow.getBounds()
|
|
1106
|
+
const panelBounds = panelWindow.getBounds()
|
|
1107
|
+
const display = screen.getDisplayNearestPoint({ x, y }).workArea
|
|
1108
|
+
let nextX = x - panelBounds.width - 10
|
|
1109
|
+
let nextY = y
|
|
1110
|
+
if (nextX < display.x) nextX = x + petBounds.width + 10
|
|
1111
|
+
if (nextX + panelBounds.width > display.x + display.width) nextX = display.x + display.width - panelBounds.width - 8
|
|
1112
|
+
if (nextY + panelBounds.height > display.y + display.height) nextY = display.y + display.height - panelBounds.height - 8
|
|
1113
|
+
if (nextY < display.y) nextY = display.y + 8
|
|
1114
|
+
panelWindow.setPosition(Math.round(nextX), Math.round(nextY), false)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function showPanel() {
|
|
1118
|
+
showPet()
|
|
1119
|
+
const win = createPanelWindow()
|
|
1120
|
+
updatePanel()
|
|
1121
|
+
positionPanel()
|
|
1122
|
+
win.show()
|
|
1123
|
+
win.moveTop()
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function hidePanel() {
|
|
1127
|
+
if (panelWindow && !panelWindow.isDestroyed()) panelWindow.hide()
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function togglePanel() {
|
|
1131
|
+
const win = createPanelWindow()
|
|
1132
|
+
if (win.isVisible()) hidePanel()
|
|
1133
|
+
else showPanel()
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function showPet() {
|
|
1137
|
+
const win = createPetWindow()
|
|
1138
|
+
win.show()
|
|
1139
|
+
win.moveTop()
|
|
1140
|
+
writeState({ visible: true })
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function hidePet() {
|
|
1144
|
+
if (petWindow && !petWindow.isDestroyed()) petWindow.hide()
|
|
1145
|
+
writeState({ visible: false })
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function buildMenu() {
|
|
1149
|
+
return Menu.buildFromTemplate([
|
|
1150
|
+
{ label: "Show Pet", click: showPet },
|
|
1151
|
+
{ label: "Show Inbox", click: showPanel },
|
|
1152
|
+
{ label: "Hide Pet", click: hidePet },
|
|
1153
|
+
{ label: "Hide Inbox", click: hidePanel },
|
|
1154
|
+
{ type: "separator" },
|
|
1155
|
+
{ label: "Quit Pet", click: () => app.quit() },
|
|
1156
|
+
])
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function startServer() {
|
|
1160
|
+
if (server) return
|
|
1161
|
+
server = http.createServer(async (req, res) => {
|
|
1162
|
+
try {
|
|
1163
|
+
if (req.method === "OPTIONS") return json(res, 200, { ok: true })
|
|
1164
|
+
const url = new URL(req.url || "/", `http://${HOST}:${PORT}`)
|
|
1165
|
+
|
|
1166
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
1167
|
+
return json(res, 200, {
|
|
1168
|
+
status: "good",
|
|
1169
|
+
pid: process.pid,
|
|
1170
|
+
port: PORT,
|
|
1171
|
+
events: events.length,
|
|
1172
|
+
pet: "pixel-opencode-pet",
|
|
1173
|
+
sessions: getPetState().sessions.map(({ sessionID, state, busyIndex, idleIndex, color }) => ({
|
|
1174
|
+
sessionID,
|
|
1175
|
+
state,
|
|
1176
|
+
busyIndex,
|
|
1177
|
+
idleIndex,
|
|
1178
|
+
color,
|
|
1179
|
+
})),
|
|
1180
|
+
})
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (req.method === "GET" && url.pathname === "/state") {
|
|
1184
|
+
return json(res, 200, getPetState())
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
if (req.method === "GET" && url.pathname === "/snapshot") {
|
|
1188
|
+
const win = createPetWindow()
|
|
1189
|
+
const image = await win.webContents.capturePage()
|
|
1190
|
+
const png = image.toPNG()
|
|
1191
|
+
res.writeHead(200, {
|
|
1192
|
+
"content-type": "image/png",
|
|
1193
|
+
"access-control-allow-origin": "*",
|
|
1194
|
+
})
|
|
1195
|
+
res.end(png)
|
|
1196
|
+
return
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
if (req.method === "GET" && url.pathname === "/debug-render") {
|
|
1200
|
+
const win = createPetWindow()
|
|
1201
|
+
const debug = await win.webContents.executeJavaScript("window.__getPetDebug?.() || window.__PET_BOOT_ERROR", true).catch(() => undefined)
|
|
1202
|
+
return json(res, 200, debug || { ok: false, error: "debug renderer not ready" })
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (req.method === "POST" && url.pathname === "/event") {
|
|
1206
|
+
const body = await readRequestJson(req)
|
|
1207
|
+
const item = recordEvent(body)
|
|
1208
|
+
return json(res, 200, { ok: true, event: item })
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (req.method === "POST" && url.pathname === "/show") {
|
|
1212
|
+
showPet()
|
|
1213
|
+
return json(res, 200, { ok: true })
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (req.method === "POST" && url.pathname === "/toggle-panel") {
|
|
1217
|
+
togglePanel()
|
|
1218
|
+
return json(res, 200, { ok: true, visible: panelWindow?.isVisible() === true })
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (req.method === "POST" && url.pathname === "/quit") {
|
|
1222
|
+
recordEvent({ type: "command.quit", source: "http" })
|
|
1223
|
+
json(res, 200, { ok: true, quitting: true })
|
|
1224
|
+
setTimeout(() => app.quit(), 50)
|
|
1225
|
+
return
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
return json(res, 404, { ok: false, error: "not found" })
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
return json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
1231
|
+
}
|
|
1232
|
+
})
|
|
1233
|
+
server.listen(PORT, HOST, () => {
|
|
1234
|
+
writeState({ visible: petWindow?.isVisible() === true, mode: "floating", port: PORT })
|
|
1235
|
+
})
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function start() {
|
|
1239
|
+
app.dock?.hide()
|
|
1240
|
+
writeState({ visible: false, mode: "floating" })
|
|
1241
|
+
startServer()
|
|
1242
|
+
cleanupTimer = setInterval(() => pruneIdleSessions(true), 30 * 1000)
|
|
1243
|
+
cleanupTimer.unref?.()
|
|
1244
|
+
showPet()
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const lock = app.requestSingleInstanceLock()
|
|
1248
|
+
|
|
1249
|
+
if (!lock) {
|
|
1250
|
+
app.quit()
|
|
1251
|
+
} else {
|
|
1252
|
+
app.on("second-instance", (_event, argv) => {
|
|
1253
|
+
if (shouldQuit(argv)) {
|
|
1254
|
+
app.quit()
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
showPet()
|
|
1258
|
+
recordEvent({ type: "app.second-instance", argv })
|
|
1259
|
+
})
|
|
1260
|
+
|
|
1261
|
+
app.whenReady().then(() => {
|
|
1262
|
+
if (shouldQuit()) {
|
|
1263
|
+
app.quit()
|
|
1264
|
+
return
|
|
1265
|
+
}
|
|
1266
|
+
start()
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
app.on("window-all-closed", (event) => {
|
|
1270
|
+
event.preventDefault()
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1273
|
+
app.on("before-quit", () => {
|
|
1274
|
+
try {
|
|
1275
|
+
server?.close()
|
|
1276
|
+
if (cleanupTimer) clearInterval(cleanupTimer)
|
|
1277
|
+
writeState({ stoppedAt: Date.now(), visible: false, mode: "floating" })
|
|
1278
|
+
} catch {
|
|
1279
|
+
// ignore shutdown errors
|
|
1280
|
+
}
|
|
1281
|
+
})
|
|
1282
|
+
}
|