dsh-whale-break 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 夏悸
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,43 @@
1
- # Temporary Holding Version
1
+ # dsh-whale-break
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DeepSeek Harness 的"等待小鲸鱼":对话执行时在页面顶部居中弹出一个 DeepSeek 小鲸鱼跑酷游戏,打发等待时间;回合结束或需要你处理时自动暂停隐藏,下一轮开始再出现。
4
+
5
+ ![等待小鲸鱼](assets/screenshot.png)
6
+
7
+ ## 功能
8
+
9
+ - **跟随回合状态**:`running` 时出现;回合结束、审批/提问等等待你处理时隐藏并暂停,不占用注意力。
10
+ - **DeepSeek 小鲸鱼**:彩色海底场景、渐变鲸身、摆动尾鳍、珊瑚障碍、小鱼与气泡视差。
11
+ - **操作**:单击画布或按空格起跳;输入法组字不拦截空格,`Shift+Space` 仍可正常输入空格。
12
+ - **位置**:默认顶部居中;拖动底部提示条可移动,双击提示条回到居中。
13
+ - **成绩**:右上角 `HI` 最高分,本地 `localStorage` 保存。
14
+ - **总开关**:输入框左侧工具行的鲸鱼按钮,一键全局开关。
15
+
16
+ ## 安装
17
+
18
+ 插件市场(推荐):设置 → 插件 → 发现 → 「等待小鲸鱼」→ 安装。
19
+
20
+ 命令行:
21
+
22
+ ```bash
23
+ dsh plugin --profile web add dsh-whale-break
24
+ ```
25
+
26
+ 然后在 设置 → 插件 中启用本插件。
27
+
28
+ ## 开发
29
+
30
+ ```bash
31
+ npm test # 纯逻辑自检:物理、碰撞、绘制调用
32
+ npm pack --dry-run
33
+ ```
34
+
35
+ 客户端代码在 `client.js`(无构建步骤,`window.__ModuleLoader__` 直接加载),宿主半侧 `index.js` 为空。
36
+
37
+ ## 仓库
38
+
39
+ https://github.com/btboys/dsh-rex-game
40
+
41
+ ## License
42
+
43
+ MIT
Binary file
package/client.js ADDED
@@ -0,0 +1,681 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-whale-break',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const ReactDOM = require('react-dom')
6
+ const h = React.createElement
7
+
8
+ const NS = 'rex-game'
9
+ const zh = {
10
+ hint: '拖动移动 · 空格 / 点击 起跳',
11
+ enable: '开启等待小鲸鱼',
12
+ disable: '关闭等待小鲸鱼',
13
+ reset: '双击回到顶部居中',
14
+ }
15
+ const en = {
16
+ hint: 'Drag to move · Space / click to jump',
17
+ enable: 'Turn the whale game on',
18
+ disable: 'Turn the whale game off',
19
+ reset: 'Double-click to reset position',
20
+ }
21
+
22
+ // ---------------------------------------------------------------- game core
23
+ const W = 240
24
+ const H = 120
25
+ const GROUND = 98
26
+ const WHALE = { x: 22, w: 36, h: 22 }
27
+ const FLOOR_Y = GROUND - WHALE.h
28
+ const GRAVITY = 2600
29
+ const JUMP_VELOCITY = -540
30
+ const SPEED_START = 230
31
+ const SPEED_MAX = 430
32
+ const SPEED_ACCEL = 7
33
+ const PANEL_WIDTH = 640
34
+
35
+ function readBest() {
36
+ try {
37
+ const value = Number(localStorage.getItem('dsh-rex-best'))
38
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
39
+ } catch (_error) {
40
+ return 0
41
+ }
42
+ }
43
+
44
+ function saveBest(value) {
45
+ try {
46
+ localStorage.setItem('dsh-rex-best', String(Math.floor(value)))
47
+ } catch (_error) {}
48
+ }
49
+
50
+ const ENABLED_KEY = 'dsh-rex-enabled'
51
+ const enabledStore = {
52
+ value: undefined,
53
+ listeners: new Set(),
54
+ get() {
55
+ if (enabledStore.value === undefined) {
56
+ try {
57
+ enabledStore.value = localStorage.getItem(ENABLED_KEY) !== '0'
58
+ } catch (_error) {
59
+ enabledStore.value = true
60
+ }
61
+ }
62
+ return enabledStore.value
63
+ },
64
+ subscribe(listener) {
65
+ enabledStore.listeners.add(listener)
66
+ return () => enabledStore.listeners.delete(listener)
67
+ },
68
+ set(next) {
69
+ enabledStore.value = next
70
+ try {
71
+ localStorage.setItem(ENABLED_KEY, next ? '1' : '0')
72
+ } catch (_error) {}
73
+ for (const listener of enabledStore.listeners) listener()
74
+ },
75
+ }
76
+
77
+ function createWorld() {
78
+ const bubbles = []
79
+ for (let index = 0; index < 9; index += 1) {
80
+ bubbles.push({
81
+ x: Math.random() * W,
82
+ y: Math.random() * H,
83
+ r: 0.8 + Math.random() * 2.2,
84
+ vy: -10 - Math.random() * 16,
85
+ trail: false,
86
+ })
87
+ }
88
+ const fish = []
89
+ for (let index = 0; index < 4; index += 1) {
90
+ fish.push({ x: Math.random() * W, y: 16 + Math.random() * 52, v: 6 + Math.random() * 12 })
91
+ }
92
+ return {
93
+ phase: 'ready',
94
+ y: FLOOR_Y,
95
+ vy: 0,
96
+ speed: SPEED_START,
97
+ score: 0,
98
+ best: readBest(),
99
+ ground: 0,
100
+ leg: 0,
101
+ spawnIn: 260 + Math.random() * 120,
102
+ corals: [],
103
+ fish,
104
+ bubbles,
105
+ bubbleIn: 0.2,
106
+ }
107
+ }
108
+
109
+ function resetWorld(world) {
110
+ const fresh = createWorld()
111
+ world.phase = fresh.phase
112
+ world.y = fresh.y
113
+ world.vy = fresh.vy
114
+ world.speed = fresh.speed
115
+ world.score = 0
116
+ world.best = fresh.best
117
+ world.ground = 0
118
+ world.leg = 0
119
+ world.spawnIn = fresh.spawnIn
120
+ world.corals = []
121
+ world.fish = fresh.fish
122
+ world.bubbles = fresh.bubbles
123
+ world.bubbleIn = fresh.bubbleIn
124
+ }
125
+
126
+ function jump(world) {
127
+ if (world.phase === 'over') resetWorld(world)
128
+ if (world.phase === 'ready') world.phase = 'run'
129
+ if (Math.abs(world.y - FLOOR_Y) < 0.5) world.vy = JUMP_VELOCITY
130
+ }
131
+
132
+ function whaleBox(world) {
133
+ return { x: WHALE.x + 5, y: world.y + 5, w: WHALE.w - 10, h: WHALE.h - 8 }
134
+ }
135
+
136
+ function coralBox(coral) {
137
+ return { x: coral.x - 1, y: GROUND - coral.h + 3, w: 10, h: coral.h - 3 }
138
+ }
139
+
140
+ function hit(a, b) {
141
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
142
+ }
143
+
144
+ function stepWorld(world, dt) {
145
+ for (const bubble of world.bubbles) {
146
+ bubble.y += bubble.vy * dt
147
+ bubble.x -= world.speed * 0.4 * dt + Math.sin(bubble.y * 0.04) * 4 * dt
148
+ if (bubble.y < -6) {
149
+ bubble.y = H + 4 + Math.random() * 18
150
+ bubble.x = Math.random() * W
151
+ bubble.trail = false
152
+ }
153
+ if (bubble.x < -8) bubble.x = W + 8
154
+ }
155
+ for (const fish of world.fish) {
156
+ fish.x -= (world.speed * 0.12 + fish.v) * dt
157
+ if (fish.x < -12) {
158
+ fish.x = W + 10 + Math.random() * 20
159
+ fish.y = 16 + Math.random() * 52
160
+ }
161
+ }
162
+ if (world.phase !== 'run') return
163
+
164
+ world.speed = Math.min(SPEED_MAX, world.speed + SPEED_ACCEL * dt)
165
+ world.score += world.speed * dt * 0.06
166
+ world.ground += world.speed * dt
167
+ world.leg += (world.speed * dt) / 24
168
+
169
+ world.vy += GRAVITY * dt
170
+ world.y += world.vy * dt
171
+ if (world.y > FLOOR_Y) {
172
+ world.y = FLOOR_Y
173
+ world.vy = 0
174
+ }
175
+
176
+ world.bubbleIn -= dt
177
+ if (world.bubbleIn <= 0) {
178
+ world.bubbles.push({
179
+ x: WHALE.x + 3,
180
+ y: world.y + 9 + Math.random() * 8,
181
+ r: 0.9 + Math.random() * 1.6,
182
+ vy: -16 - Math.random() * 14,
183
+ trail: true,
184
+ })
185
+ if (world.bubbles.length > 26) world.bubbles.shift()
186
+ world.bubbleIn = 0.16 + Math.random() * 0.12
187
+ }
188
+
189
+ world.spawnIn -= world.speed * dt
190
+ if (world.spawnIn <= 0) {
191
+ world.corals.push({ x: W + 14, h: 26 + Math.random() * 16, tint: Math.floor(Math.random() * 4) })
192
+ world.spawnIn = 250 + Math.random() * 200
193
+ }
194
+ for (const coral of world.corals) coral.x -= world.speed * dt
195
+ world.corals = world.corals.filter((coral) => coral.x > -24)
196
+
197
+ const box = whaleBox(world)
198
+ for (const coral of world.corals) {
199
+ if (!hit(box, coralBox(coral))) continue
200
+ world.phase = 'over'
201
+ world.best = Math.max(world.best, Math.floor(world.score))
202
+ saveBest(world.best)
203
+ break
204
+ }
205
+ }
206
+
207
+ // ---------------------------------------------------------------- painting
208
+ const ART = {
209
+ coral: ['#ff7a9c', '#ffb15e', '#9b7bff', '#4fd6b8'],
210
+ fish: 'rgba(170, 220, 255, 0.22)',
211
+ bubble: 'rgba(220, 240, 255, 0.55)',
212
+ trailBubble: 'rgba(140, 180, 255, 0.5)',
213
+ }
214
+
215
+ function drawCoral(ctx2d, coral) {
216
+ const x = coral.x
217
+ const height = coral.h
218
+ const base = GROUND - 1
219
+ const color = ART.coral[(coral.tint ?? 0) % ART.coral.length]
220
+ ctx2d.save()
221
+ ctx2d.strokeStyle = color
222
+ ctx2d.globalAlpha = 0.95
223
+ ctx2d.lineWidth = 2.6
224
+ ctx2d.lineCap = 'round'
225
+ ctx2d.beginPath()
226
+ ctx2d.moveTo(x, base)
227
+ ctx2d.quadraticCurveTo(x + 3, base - height * 0.5, x + 1, base - height)
228
+ ctx2d.moveTo(x + 1, base - height * 0.42)
229
+ ctx2d.quadraticCurveTo(x - 3, base - height * 0.55, x - 5, base - height * 0.78)
230
+ ctx2d.moveTo(x + 2, base - height * 0.58)
231
+ ctx2d.quadraticCurveTo(x + 6, base - height * 0.64, x + 7, base - height * 0.86)
232
+ ctx2d.stroke()
233
+
234
+ ctx2d.fillStyle = color
235
+ ctx2d.globalAlpha = 0.55
236
+ for (const [dx, dy] of [
237
+ [1, -height],
238
+ [-5, -height * 0.78],
239
+ [7, -height * 0.86],
240
+ ]) {
241
+ ctx2d.beginPath()
242
+ ctx2d.arc(x + dx, base + dy, 1.7, 0, Math.PI * 2)
243
+ ctx2d.fill()
244
+ }
245
+ ctx2d.restore()
246
+ }
247
+
248
+ function drawWhale(ctx2d, world) {
249
+ const x = WHALE.x
250
+ const airborne = world.y < FLOOR_Y - 0.5
251
+ const bob = airborne ? 0 : Math.sin(world.leg * 1.7) * 1.1
252
+ const y = world.y + bob
253
+ const wag = Math.sin(world.leg * 1.9) * (airborne ? 0.38 : 0.22)
254
+
255
+ ctx2d.save()
256
+ ctx2d.translate(x + 5, y + 11)
257
+ ctx2d.rotate(wag)
258
+ ctx2d.fillStyle = '#4d6bfe'
259
+ ctx2d.beginPath()
260
+ ctx2d.moveTo(1, 0)
261
+ ctx2d.quadraticCurveTo(-6, -8, -10, -8)
262
+ ctx2d.quadraticCurveTo(-7, -2, -7, 2)
263
+ ctx2d.quadraticCurveTo(-10, 8, -9, 9)
264
+ ctx2d.quadraticCurveTo(-4, 5, 1, 3)
265
+ ctx2d.closePath()
266
+ ctx2d.fill()
267
+ ctx2d.restore()
268
+
269
+ if (airborne) {
270
+ ctx2d.fillStyle = 'rgba(225, 240, 255, 0.85)'
271
+ for (const [dx, dy, radius] of [
272
+ [22, -2, 1.8],
273
+ [26, -6, 1.4],
274
+ [19, -9, 1.1],
275
+ ]) {
276
+ ctx2d.beginPath()
277
+ ctx2d.arc(x + dx, y + dy, radius, 0, Math.PI * 2)
278
+ ctx2d.fill()
279
+ }
280
+ }
281
+
282
+ const body = ctx2d.createLinearGradient(0, y, 0, y + 22)
283
+ body.addColorStop(0, '#8b9dff')
284
+ body.addColorStop(0.5, '#5b77ff')
285
+ body.addColorStop(1, '#3550cc')
286
+ ctx2d.fillStyle = body
287
+ ctx2d.beginPath()
288
+ ctx2d.ellipse(x + 18, y + 11, 16, 10.5, 0, 0, Math.PI * 2)
289
+ ctx2d.fill()
290
+
291
+ ctx2d.fillStyle = '#e6eeff'
292
+ ctx2d.globalAlpha = 0.92
293
+ ctx2d.beginPath()
294
+ ctx2d.ellipse(x + 19, y + 15.5, 10.5, 4.8, 0, 0, Math.PI * 2)
295
+ ctx2d.fill()
296
+ ctx2d.globalAlpha = 1
297
+
298
+ ctx2d.fillStyle = '#5b77ff'
299
+ ctx2d.beginPath()
300
+ ctx2d.ellipse(x + 13, y + 16, 5, 2.6, -0.5, 0, Math.PI * 2)
301
+ ctx2d.fill()
302
+
303
+ ctx2d.fillStyle = '#ffffff'
304
+ ctx2d.beginPath()
305
+ ctx2d.arc(x + 28, y + 8, 2.7, 0, Math.PI * 2)
306
+ ctx2d.fill()
307
+ ctx2d.fillStyle = '#0d1830'
308
+ ctx2d.beginPath()
309
+ ctx2d.arc(x + 28.9, y + 8.2, 1.35, 0, Math.PI * 2)
310
+ ctx2d.fill()
311
+ ctx2d.fillStyle = 'rgba(255, 255, 255, 0.9)'
312
+ ctx2d.beginPath()
313
+ ctx2d.arc(x + 28.4, y + 7.6, 0.6, 0, Math.PI * 2)
314
+ ctx2d.fill()
315
+
316
+ ctx2d.strokeStyle = 'rgba(255, 255, 255, 0.8)'
317
+ ctx2d.lineWidth = 1.1
318
+ ctx2d.lineCap = 'round'
319
+ ctx2d.beginPath()
320
+ ctx2d.arc(x + 29, y + 11.5, 3.4, 0.15 * Math.PI, 0.55 * Math.PI)
321
+ ctx2d.stroke()
322
+ }
323
+
324
+ function pad(value) {
325
+ return String(Math.floor(value)).padStart(5, '0')
326
+ }
327
+
328
+ function paintWorld(ctx2d, world, scale) {
329
+ ctx2d.setTransform(scale, 0, 0, scale, 0, 0)
330
+ ctx2d.clearRect(0, 0, W, H)
331
+
332
+ const water = ctx2d.createLinearGradient(0, 0, 0, GROUND)
333
+ water.addColorStop(0, '#0a1a45')
334
+ water.addColorStop(0.55, '#0e4f83')
335
+ water.addColorStop(1, '#0f7196')
336
+ ctx2d.fillStyle = water
337
+ ctx2d.fillRect(0, 0, W, GROUND)
338
+
339
+ ctx2d.fillStyle = 'rgba(255, 255, 255, 0.05)'
340
+ for (const shaft of [34, 118, 194]) {
341
+ ctx2d.beginPath()
342
+ ctx2d.moveTo(shaft, 0)
343
+ ctx2d.lineTo(shaft + 26, 0)
344
+ ctx2d.lineTo(shaft + 74, GROUND)
345
+ ctx2d.lineTo(shaft + 50, GROUND)
346
+ ctx2d.closePath()
347
+ ctx2d.fill()
348
+ }
349
+
350
+ for (const fish of world.fish) {
351
+ ctx2d.fillStyle = ART.fish
352
+ ctx2d.beginPath()
353
+ ctx2d.ellipse(fish.x, fish.y, 4.2, 2.2, 0, 0, Math.PI * 2)
354
+ ctx2d.fill()
355
+ ctx2d.beginPath()
356
+ ctx2d.moveTo(fish.x - 3.4, fish.y)
357
+ ctx2d.lineTo(fish.x - 7.6, fish.y - 2.4)
358
+ ctx2d.lineTo(fish.x - 7.6, fish.y + 2.4)
359
+ ctx2d.closePath()
360
+ ctx2d.fill()
361
+ }
362
+
363
+ const sand = ctx2d.createLinearGradient(0, GROUND, 0, H)
364
+ sand.addColorStop(0, '#e8b877')
365
+ sand.addColorStop(1, '#a4683a')
366
+ ctx2d.fillStyle = sand
367
+ ctx2d.fillRect(0, GROUND, W, H - GROUND)
368
+ ctx2d.fillStyle = 'rgba(255, 255, 255, 0.35)'
369
+ ctx2d.fillRect(0, GROUND, W, 1)
370
+ ctx2d.fillStyle = 'rgba(120, 70, 30, 0.35)'
371
+ for (let x = -((world.ground * 0.6) % 40); x < W; x += 40) {
372
+ ctx2d.fillRect(x + 6, GROUND + 7, 2, 2)
373
+ ctx2d.fillRect(x + 22, GROUND + 13, 2, 2)
374
+ ctx2d.fillRect(x + 33, GROUND + 5, 2, 2)
375
+ }
376
+
377
+ for (const coral of world.corals) drawCoral(ctx2d, coral)
378
+ drawWhale(ctx2d, world)
379
+
380
+ for (const bubble of world.bubbles) {
381
+ ctx2d.fillStyle = bubble.trail ? ART.trailBubble : ART.bubble
382
+ ctx2d.beginPath()
383
+ ctx2d.arc(bubble.x, bubble.y, bubble.r, 0, Math.PI * 2)
384
+ ctx2d.fill()
385
+ if (!bubble.trail) {
386
+ ctx2d.fillStyle = 'rgba(255, 255, 255, 0.8)'
387
+ ctx2d.beginPath()
388
+ ctx2d.arc(bubble.x - bubble.r * 0.3, bubble.y - bubble.r * 0.3, bubble.r * 0.3, 0, Math.PI * 2)
389
+ ctx2d.fill()
390
+ }
391
+ }
392
+
393
+ ctx2d.save()
394
+ ctx2d.shadowColor = 'rgba(3, 10, 28, 0.6)'
395
+ ctx2d.shadowBlur = 4
396
+ ctx2d.fillStyle = '#ffffff'
397
+ ctx2d.font = '600 11px ui-monospace, SFMono-Regular, Menlo, monospace'
398
+ ctx2d.textAlign = 'right'
399
+ ctx2d.fillText('HI ' + pad(world.best) + ' ' + pad(world.score), W - 10, 17)
400
+ ctx2d.restore()
401
+
402
+ if (world.phase === 'over') {
403
+ ctx2d.fillStyle = 'rgba(5, 13, 34, 0.58)'
404
+ ctx2d.fillRect(0, 0, W, H)
405
+ ctx2d.save()
406
+ ctx2d.shadowColor = 'rgba(0, 0, 0, 0.5)'
407
+ ctx2d.shadowBlur = 6
408
+ ctx2d.fillStyle = '#ffffff'
409
+ ctx2d.font = '700 13px ui-monospace, SFMono-Regular, Menlo, monospace'
410
+ ctx2d.textAlign = 'center'
411
+ ctx2d.fillText('GAME OVER', W / 2, 58)
412
+ ctx2d.restore()
413
+ }
414
+ ctx2d.textAlign = 'left'
415
+ }
416
+
417
+ // ---------------------------------------------------------------- react
418
+ function useTranslate(locale) {
419
+ React.useSyncExternalStore(
420
+ (listener) => locale.subscribe(listener),
421
+ () => locale.getSnapshot().revision,
422
+ )
423
+ return React.useMemo(() => locale.bind(NS), [locale])
424
+ }
425
+
426
+ function useEnabled() {
427
+ return React.useSyncExternalStore(enabledStore.subscribe, enabledStore.get)
428
+ }
429
+
430
+ function RexDockEntry(props) {
431
+ const status = props.useSessionStatus((all) => all.get(props.sessionId))
432
+ const enabled = useEnabled()
433
+ const waiting = enabled && status !== undefined && status.running === true && status.pendingInteraction === undefined
434
+ const t = useTranslate(props.locale)
435
+
436
+ const canvasRef = React.useRef(null)
437
+ const loopRef = React.useRef(null)
438
+ const [pos, setPos] = React.useState(undefined)
439
+ const dragRef = React.useRef(null)
440
+
441
+ const finishDrag = () => {
442
+ const drag = dragRef.current
443
+ if (!drag) return
444
+ dragRef.current = null
445
+ window.removeEventListener('pointermove', drag.move)
446
+ window.removeEventListener('pointerup', drag.end)
447
+ window.removeEventListener('pointercancel', drag.end)
448
+ }
449
+
450
+ const resetPos = () => setPos(undefined)
451
+
452
+ const startDrag = (event) => {
453
+ if (event.button !== 0) return
454
+ const rect = event.currentTarget.parentElement.getBoundingClientRect()
455
+ const drag = { dx: event.clientX - rect.left, dy: event.clientY - rect.top, w: rect.width, h: rect.height }
456
+ drag.move = (moveEvent) => {
457
+ const next = {
458
+ x: Math.max(0, Math.min(moveEvent.clientX - drag.dx, window.innerWidth - drag.w)),
459
+ y: Math.max(0, Math.min(moveEvent.clientY - drag.dy, window.innerHeight - drag.h)),
460
+ }
461
+ setPos(next)
462
+ }
463
+ drag.end = () => finishDrag()
464
+ dragRef.current = drag
465
+ event.preventDefault()
466
+ window.addEventListener('pointermove', drag.move)
467
+ window.addEventListener('pointerup', drag.end)
468
+ window.addEventListener('pointercancel', drag.end)
469
+ }
470
+
471
+ React.useEffect(() => {
472
+ const canvas = canvasRef.current
473
+ if (!canvas || typeof canvas.getContext !== 'function') return undefined
474
+ const ctx2d = canvas.getContext('2d')
475
+ const world = createWorld()
476
+ let raf = 0
477
+ let last = 0
478
+ let active = false
479
+ let scale = 1
480
+
481
+ const resize = () => {
482
+ const width = canvas.getBoundingClientRect().width || 480
483
+ const ratio = Math.min(2, window.devicePixelRatio || 1)
484
+ canvas.width = Math.round(width * ratio)
485
+ canvas.height = Math.round((width / 2) * ratio)
486
+ scale = canvas.width / W
487
+ }
488
+ const paint = () => paintWorld(ctx2d, world, scale)
489
+ const frame = (now) => {
490
+ if (!active) return
491
+ const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))
492
+ last = now
493
+ stepWorld(world, dt)
494
+ paint()
495
+ raf = requestAnimationFrame(frame)
496
+ }
497
+ const stop = () => {
498
+ active = false
499
+ cancelAnimationFrame(raf)
500
+ }
501
+ const onResize = () => {
502
+ if (!active) return
503
+ resize()
504
+ paint()
505
+ }
506
+ window.addEventListener('resize', onResize)
507
+
508
+ loopRef.current = {
509
+ show() {
510
+ if (world.phase === 'ready' || world.phase === 'over') {
511
+ resetWorld(world)
512
+ world.phase = 'run'
513
+ }
514
+ resize()
515
+ paint()
516
+ if (active) return
517
+ active = true
518
+ last = performance.now()
519
+ raf = requestAnimationFrame(frame)
520
+ },
521
+ hide: stop,
522
+ tap() {
523
+ jump(world)
524
+ if (!active) paint()
525
+ },
526
+ dispose: stop,
527
+ }
528
+ return () => {
529
+ window.removeEventListener('resize', onResize)
530
+ stop()
531
+ loopRef.current = null
532
+ }
533
+ }, [])
534
+
535
+ React.useEffect(() => () => finishDrag(), [])
536
+
537
+ React.useEffect(() => {
538
+ const loop = loopRef.current
539
+ if (!loop) return
540
+ if (waiting) {
541
+ setPos(undefined)
542
+ loop.show()
543
+ } else {
544
+ loop.hide()
545
+ }
546
+ }, [waiting])
547
+
548
+ React.useEffect(() => {
549
+ if (!waiting) return undefined
550
+ const onKey = (event) => {
551
+ if (event.isComposing || event.keyCode === 229) return
552
+ if (event.code !== 'Space' && event.key !== 'ArrowUp' && event.key !== 'w' && event.key !== 'W') return
553
+ // Shift+Space stays a literal space so a message can still be typed mid-turn.
554
+ if (event.shiftKey) return
555
+ event.preventDefault()
556
+ event.stopPropagation()
557
+ loopRef.current?.tap()
558
+ }
559
+ window.addEventListener('keydown', onKey, true)
560
+ return () => window.removeEventListener('keydown', onKey, true)
561
+ }, [waiting])
562
+
563
+ const panelStyle = { display: waiting ? 'block' : 'none' }
564
+ if (pos) {
565
+ panelStyle.left = pos.x + 'px'
566
+ panelStyle.top = pos.y + 'px'
567
+ panelStyle.transform = 'none'
568
+ }
569
+
570
+ return ReactDOM.createPortal(
571
+ h(
572
+ 'div',
573
+ { className: 'dre-wrap', style: panelStyle, 'aria-hidden': waiting ? undefined : 'true' },
574
+ h('canvas', {
575
+ ref: canvasRef,
576
+ className: 'dre-canvas',
577
+ role: 'img',
578
+ 'aria-label': t('hint'),
579
+ onPointerDown: (event) => {
580
+ event.preventDefault()
581
+ loopRef.current?.tap()
582
+ },
583
+ }),
584
+ h(
585
+ 'p',
586
+ {
587
+ className: 'dre-hint',
588
+ title: t('reset'),
589
+ onPointerDown: startDrag,
590
+ onDoubleClick: resetPos,
591
+ },
592
+ t('hint'),
593
+ ),
594
+ ),
595
+ document.body,
596
+ )
597
+ }
598
+
599
+ function RexToggle(props) {
600
+ const enabled = useEnabled()
601
+ const t = useTranslate(props.locale)
602
+ const label = enabled ? t('disable') : t('enable')
603
+ return h(
604
+ 'button',
605
+ {
606
+ type: 'button',
607
+ className: enabled ? 'dre-toggle dre-toggle-on' : 'dre-toggle',
608
+ title: label,
609
+ 'aria-label': label,
610
+ 'aria-pressed': enabled,
611
+ onClick: () => enabledStore.set(!enabled),
612
+ },
613
+ h(
614
+ 'svg',
615
+ { viewBox: '0 0 24 24', width: 17, height: 17, 'aria-hidden': true, fill: 'currentColor' },
616
+ h('ellipse', { cx: 11, cy: 14, rx: 7, ry: 4.6 }),
617
+ h('path', { d: 'M4.4 12.6 1.6 9.8c1.9-.3 3.7.4 4.9 1.9z' }),
618
+ h('path', { d: 'M10.4 9.6c.7-1.5 1.8-2.4 3.3-2.6-.5 1.1-1.2 1.9-2.3 2.5z' }),
619
+ h('circle', { cx: 16.4, cy: 6.4, r: 1.5 }),
620
+ h('circle', { cx: 19.2, cy: 4.2, r: 1 }),
621
+ ),
622
+ )
623
+ }
624
+
625
+ const CSS = [
626
+ '.dre-wrap{position:fixed;top:88px;left:50%;transform:translateX(-50%);z-index:100;width:min(' + PANEL_WIDTH + 'px,calc(100vw - 40px));border:1px solid rgba(120,150,220,.28);border-radius:18px;background:#071127;box-shadow:var(--dsw-shadow-lv3,0 12px 32px rgba(0,0,0,.24));overflow:hidden;user-select:none;-webkit-user-select:none;animation:dre-fade .18s ease-out}',
627
+ '.dre-wrap::before{content:"";position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,#ff7a9c,#4d6bfe 45%,#4fd6b8);opacity:.9}',
628
+ '.dre-canvas{display:block;width:100%;height:auto;aspect-ratio:2/1;cursor:pointer;touch-action:manipulation}',
629
+ '.dre-hint{display:flex;align-items:center;justify-content:center;gap:8px;margin:0;padding:8px 12px 9px;font-size:12px;letter-spacing:.02em;line-height:1.3;color:#a9c2ee;background:linear-gradient(180deg,#0b1c3d,#071127);border-top:1px solid rgba(120,150,220,.16);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none}',
630
+ '.dre-hint:active{cursor:grabbing}',
631
+ '.dre-hint::before{content:"";width:16px;height:10px;flex:none;background:radial-gradient(circle,currentColor 1px,transparent 1.4px) 0 0/5px 5px repeat;opacity:.5}',
632
+ '.dre-toggle{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}',
633
+ '.dre-toggle:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14));color:var(--dsw-alias-label-primary)}',
634
+ '.dre-toggle-on{color:var(--dsw-alias-brand-primary)}',
635
+ '.dre-toggle-on:hover{color:var(--dsw-alias-brand-primary)}',
636
+ '.dre-toggle svg{display:block}',
637
+ '@keyframes dre-fade{from{opacity:0}to{opacity:1}}',
638
+ '@media (prefers-reduced-motion:reduce){.dre-wrap{animation:none}}',
639
+ ].join('')
640
+
641
+ if (typeof document !== 'undefined') {
642
+ const style = document.createElement('style')
643
+ style.textContent = CSS
644
+ document.head.append(style)
645
+ }
646
+
647
+ // Run the pure game core in Node (see test/selfcheck.mjs); absent in the browser.
648
+ if (typeof globalThis.__REX_SELFTEST__ === 'function') {
649
+ globalThis.__REX_SELFTEST__({ W, H, GROUND, FLOOR_Y, createWorld, resetWorld, jump, stepWorld, whaleBox, coralBox, hit, paintWorld })
650
+ }
651
+
652
+ return {
653
+ inject: ['slots', 'locale'],
654
+ apply(ctx) {
655
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'rex-game: locale dictionary')
656
+ ctx.slots.inject('conversation.input.left', () =>
657
+ ctx.slots.register(
658
+ {
659
+ name: 'conversation.input.left',
660
+ id: 'whale-toggle',
661
+ order: 30,
662
+ inject: () => ({ locale: ctx.locale }),
663
+ },
664
+ RexToggle,
665
+ ),
666
+ )
667
+ ctx.slots.inject('conversation.composer.dock', () =>
668
+ ctx.slots.register(
669
+ {
670
+ name: 'conversation.composer.dock',
671
+ id: 'whale-break',
672
+ order: 40,
673
+ inject: () => ({ locale: ctx.locale }),
674
+ },
675
+ RexDockEntry,
676
+ ),
677
+ )
678
+ },
679
+ }
680
+ },
681
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: rex-game
3
+ name: 'dsh-whale-break'
package/icon.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <rect width="64" height="64" rx="14" fill="#4d6bfe"/>
3
+ <g fill="#ffffff">
4
+ <path d="M16 41c0-6 5-11 12-11h15c5 0 8 2 9 6 1 3 3 5 5 5-2 4-6 6-11 6H25c-5 0-9-2-9-6z"/>
5
+ <path d="M17 38c-4-1-7-4-8-8 4 0 8 2 10 5l-2 3z"/>
6
+ <circle cx="30" cy="21" r="2.6"/>
7
+ <circle cx="35" cy="15" r="2"/>
8
+ <circle cx="27" cy="14" r="1.6"/>
9
+ </g>
10
+ <circle cx="44" cy="35" r="2.4" fill="#10203a"/>
11
+ </svg>
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Host half of the dsh-whale-break bundle; the Client module owns the game. */
2
+ export function apply() {}
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Whale break",
4
+ "description": "Pops a DeepSeek whale mini-game while a turn is running, and hides it when the turn finishes or waits for you."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "等待小鲸鱼",
4
+ "description": "对话执行时弹出 DeepSeek 小鲸鱼游戏打发等待时间;回合结束或需要你处理时自动隐藏暂停,下次开始再显示。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "dsh-whale-break",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "A DeepSeek whale mini-game for DeepSeek Harness: it pops up while a turn is running and hides when the turn finishes or waits for you.",
6
+ "author": "夏悸",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/btboys/dsh-rex-game#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/btboys/dsh-rex-game.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/btboys/dsh-rex-game/issues"
15
+ },
16
+ "exports": {
17
+ ".": "./index.js",
18
+ "./client": "./client.js",
19
+ "./locale/*.json": "./locale/*.json",
20
+ "./icon": "./icon.svg",
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "index.js",
25
+ "client.js",
26
+ "cordis.patch.yml",
27
+ "locale/*.json",
28
+ "icon.svg",
29
+ "assets/*.png"
30
+ ],
31
+ "engines": {
32
+ "dsh": ">=0.2.2-rc.1",
33
+ "node": ">=20"
34
+ },
35
+ "keywords": [
36
+ "dsh",
37
+ "dsh-plugin",
38
+ "deepseek-harness",
39
+ "cordis",
40
+ "game",
41
+ "mini-game",
42
+ "whale",
43
+ "dino"
44
+ ],
45
+ "scripts": {
46
+ "test": "node test/selfcheck.mjs",
47
+ "pack:check": "npm pack --dry-run"
48
+ },
49
+ "dsh": {
50
+ "bundle": {
51
+ "patch": "./cordis.patch.yml"
52
+ },
53
+ "client": {
54
+ "platform": "web",
55
+ "immediately": true,
56
+ "inject": [
57
+ "@deepseek-ai/dsh-client-ui-conversation"
58
+ ]
59
+ }
60
+ }
61
+ }