dsh-plugin-vrmpet3d 0.1.2 → 0.1.3

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.
@@ -0,0 +1,615 @@
1
+ (function () {
2
+ 'use strict';
3
+ var api = globalThis.__VRMPET3D_MINI_GAME__;
4
+ try { api.setLevels(JSON.parse(document.getElementById('game-level-data').textContent)); } catch (_) {}
5
+ var game = new api.Game();
6
+ var $ = function (id) { return document.getElementById(id); };
7
+ var root = $('game'), canvas = $('arena'), ctx = canvas.getContext('2d'), pet = $('pet');
8
+ var scene = null, modelReady = false, stopped = false, frameId, pollTimer, statusRequest, lastTime = 0;
9
+ var view = { scale: 1, x: 0, y: 0 }, angle = 0, particles = [], targets = [], seen = new Set();
10
+ var noticeKey = '', clearTime = 0, bridge = globalThis.vrmpet3dDesktop;
11
+ var flightRig = null, heading = null, roll = null, up = null, direction = null;
12
+ var testMode = false;
13
+ var debugMode = false;
14
+ var saveKey = 'mini-game:v1:' + location.pathname + (testMode ? ':test' : debugMode ? ':debug' : ''), saveTime = 0;
15
+ var appearance = { size: 90, spin: 7 };
16
+ var lifePortrait = '', shownLives = -1;
17
+ var flightBall = null;
18
+ var resumeEnd = 0, resumeTimer = null;
19
+ function cancelResume() {
20
+ clearTimeout(resumeTimer); resumeTimer = null; resumeEnd = 0; $('resume-countdown').hidden = true;
21
+ }
22
+ function tickResume(now) {
23
+ if (!resumeEnd) return;
24
+ var remaining = Math.max(0, Math.ceil((resumeEnd - now) / 1000));
25
+ if (!remaining) {
26
+ if (document.pointerLockElement !== canvas || document.hidden || stopped) { pause(true); return; }
27
+ pause(false); root.classList.add('mouse-captured'); lastTime = 0; return;
28
+ }
29
+ $('countdown-number').textContent = remaining;
30
+ }
31
+ function beginResume() {
32
+ if (!game.paused || (game.phase === 'cleared' && game.level === api.levelCount()) || resumeEnd) return;
33
+ resumeEnd = performance.now() + 3000; $('overlay').hidden = true; $('resume-countdown').hidden = false;
34
+ $('countdown-number').textContent = '3'; captureMouse();
35
+ function tick() { tickResume(performance.now()); if (resumeEnd) resumeTimer = setTimeout(tick, 100); }
36
+ if (resumeEnd) resumeTimer = setTimeout(tick, 100);
37
+ }
38
+ var gameWindowFocused = document.hasFocus(), focusTimer = null;
39
+ var focusClient = globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2);
40
+ function reportGameFocus(active, keepalive) {
41
+ if (testMode || stopped && active) return;
42
+ return fetch('/vrmpet3d/desktop', { method: 'POST', credentials: 'omit', keepalive: keepalive === true,
43
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: 'game-focus', clientId: focusClient, active: active }) }).catch(function () {});
44
+ }
45
+ function fitLifeIcons() {
46
+ var count = Math.max(1, game.lives - 1), w = root.clientWidth, h = root.clientHeight;
47
+ var size = w >= 720 ? Math.min(100, Math.max(1, (h - 162 - (count - 1) * 3) / (count * 1.33)))
48
+ : Math.min(100, Math.max(1, (w - 32 - (count - 1) * 3) / count));
49
+ $('lives').style.setProperty('--life-width', size + 'px');
50
+ }
51
+ var hudGlyphs = {
52
+ L:['10000','10000','10000','10000','10000','10000','11111'], E:['11111','10000','10000','11110','10000','10000','11111'],
53
+ V:['10001','10001','10001','10001','10001','01010','00100'], R:['11110','10001','10001','11110','10100','10010','10001'],
54
+ I:['11111','00100','00100','00100','00100','00100','11111'], C:['01111','10000','10000','10000','10000','10000','01111'],
55
+ ':':['00000','00100','00100','00000','00100','00100','00000'], ' ':['00000','00000','00000','00000','00000','00000','00000'],
56
+ '0':['01110','10001','10011','10101','11001','10001','01110'], '1':['00100','01100','00100','00100','00100','00100','01110'],
57
+ '2':['01110','10001','00001','00010','00100','01000','11111'], '3':['11110','00001','00001','01110','00001','00001','11110'],
58
+ '4':['00010','00110','01010','10010','11111','00010','00010'], '5':['11111','10000','10000','11110','00001','00001','11110'],
59
+ '6':['01110','10000','10000','11110','10001','10001','01110'], '7':['11111','00001','00010','00100','01000','01000','01000'],
60
+ '8':['01110','10001','10001','01110','10001','10001','01110'], '9':['01110','10001','10001','01111','00001','00001','01110']
61
+ };
62
+ function drawHud(id, text) {
63
+ var element = $(id), dot = Math.min(3, Math.max(0.5, api.WIDTH * view.scale * 0.46 / (text.length * 6)));
64
+ var dpr = Math.min(devicePixelRatio || 1, 2), key = text + ':' + dot + ':' + dpr;
65
+ if (element.dataset.pixels === key) return;
66
+ element.dataset.pixels = key; element.textContent = text; element.setAttribute('aria-label', text);
67
+ var letters = document.createElement('canvas'); letters.setAttribute('aria-hidden', 'true');
68
+ var width = (text.length * 6 - 1) * dot, height = 7 * dot;
69
+ letters.width = Math.ceil(width * dpr); letters.height = Math.ceil(height * dpr);
70
+ letters.style.width = width + 'px'; letters.style.height = height + 'px';
71
+ var ink = letters.getContext('2d'); ink.scale(dpr, dpr); ink.fillStyle = '#e6cf9e';
72
+ text.split('').forEach(function (character, column) {
73
+ hudGlyphs[character].forEach(function (row, y) { row.split('').forEach(function (pixel, x) {
74
+ if (pixel === '1') { ink.beginPath(); ink.arc((column * 6 + x + 0.5) * dot, (y + 0.5) * dot, dot * 0.36, 0, Math.PI * 2); ink.fill(); }
75
+ }); });
76
+ });
77
+ element.appendChild(letters);
78
+ }
79
+ function renderLives() {
80
+ if (shownLives === game.lives) return;
81
+ shownLives = game.lives; $('lives').replaceChildren();
82
+ fitLifeIcons();
83
+ $('lives').setAttribute('aria-label', '备用 ' + Math.max(0, game.lives - 1) + ' 次机会');
84
+ for (var i = 0; i < game.lives - 1; i++) {
85
+ if (lifePortrait) { var img = document.createElement('img'); img.src = lifePortrait; img.alt = ''; $('lives').appendChild(img); }
86
+ else { var fallback = document.createElement('canvas'); fallback.width = 28; fallback.height = 40;
87
+ var c = fallback.getContext('2d'); c.fillStyle = '#efd3a0'; c.beginPath(); c.arc(14, 20, 10, 0, Math.PI * 2); c.fill(); $('lives').appendChild(fallback); }
88
+ }
89
+ }
90
+ function captureLifePortrait() {
91
+ // Capture once while upright, before flight orientation and spin start. Alpha stays transparent.
92
+ scene.resize(96, 128); scene.tick(0);
93
+ lifePortrait = pet.toDataURL('image/png'); scene.resize(128, 128);
94
+ shownLives = -1; renderLives();
95
+ }
96
+ // Keep the controls in the scrolling dialog panel, including on small displays.
97
+ $('overlay').querySelector('.panel').appendChild(document.querySelector('.model-controls'));
98
+ $('overlay').querySelector('.panel').appendChild($('debug-controls'));
99
+ $('overlay').querySelector('.panel').appendChild(document.querySelector('.model-controls:not(#debug-controls)'));
100
+ $('debug-level').replaceChildren();
101
+ api.LEVELS.forEach(function (level) {
102
+ var option = document.createElement('option'); option.value = level.id; option.textContent = '第' + level.id + '关 · ' + level.name;
103
+ $('debug-level').appendChild(option);
104
+ });
105
+ $('debug-controls').querySelector('legend').textContent = 'DEBUG · ' + api.levelCount() + ' 关';
106
+ $('debug-controls').hidden = !debugMode;
107
+ document.querySelector('.model-controls:not(#debug-controls)').hidden = !debugMode;
108
+ if (debugMode) document.querySelector('.brand small').textContent = 'DEBUG · ' + api.levelCount() + ' LEVELS';
109
+
110
+ $('debug-level').onchange = function () {
111
+ if (!debugMode || !game.selectLevel(Number(this.value), debugMode)) return;
112
+ particles = []; clearTime = 0; flightBall = null; syncAppearance(); draw(0); pause(true); save();
113
+ };
114
+ function syncAppearance() {
115
+ $('model-size').value = appearance.size; $('model-size-value').textContent = appearance.size;
116
+ $('spin-speed').value = appearance.spin; $('spin-speed-value').textContent = appearance.spin;
117
+ $('flight-speed').value = game.flightSpeed || 1; $('flight-speed-value').textContent = game.flightSpeed || 1;
118
+ $('paddle-boost').value = game.paddleBoost === undefined ? 1.5 : game.paddleBoost;
119
+ $('paddle-boost-value').textContent = $('paddle-boost').value;
120
+ }
121
+ function save() {
122
+ try { var snapshot = game.snapshot(); snapshot.appearance = appearance;
123
+ localStorage.setItem(saveKey, JSON.stringify(snapshot)); $('save-status').textContent = '进度已自动保存'; return true; }
124
+ catch (_) { $('save-status').textContent = '无法保存进度,请检查浏览器存储空间或隐私设置。'; return false; }
125
+ }
126
+ var restored = false;
127
+ try { var stored = JSON.parse(localStorage.getItem(saveKey)); restored = game.restore(stored);
128
+ var savedAppearance = stored && stored.appearance;
129
+ if (debugMode && savedAppearance && Number.isFinite(savedAppearance.size) && Number.isFinite(savedAppearance.spin)) {
130
+ appearance.size = Math.max(30, Math.min(150, Math.round(savedAppearance.size / 5) * 5));
131
+ appearance.spin = Math.max(0, Math.min(30, Math.round(savedAppearance.spin)));
132
+ }
133
+ }
134
+ catch (_) { $('save-status').textContent = '存档无法读取,已开始新游戏。'; }
135
+ if (!debugMode) { game.setFlightSpeed(1); game.setPaddleBoost(1.5); }
136
+ syncAppearance();
137
+ $('model-size').oninput = function () { if (!debugMode) return; appearance.size = Number(this.value); syncAppearance(); draw(0); save(); };
138
+ $('spin-speed').oninput = function () { if (!debugMode) return; appearance.spin = Number(this.value); syncAppearance(); save(); };
139
+ $('flight-speed').oninput = function () { if (!debugMode) return; game.setFlightSpeed(Number(this.value)); syncAppearance(); save(); };
140
+ $('paddle-boost').oninput = function () { if (!debugMode) return; game.setPaddleBoost(Number(this.value)); syncAppearance(); save(); };
141
+ function prepareFlightRig() {
142
+ var THREE = scene.THREE, middle = scene.modelHeight / 2;
143
+ flightRig = new THREE.Group(); flightRig.position.y = middle;
144
+ var centered = new THREE.Group(); centered.position.y = -middle;
145
+ scene.scene.add(flightRig); flightRig.add(centered); centered.add(scene.stage);
146
+ heading = new THREE.Quaternion(); roll = new THREE.Quaternion();
147
+ up = new THREE.Vector3(0, 1, 0); direction = new THREE.Vector3(0, 1, 0);
148
+ // No idle chest tilt or pointer gaze: gaze remains perpendicular to the long axis.
149
+ scene.motion.applyBreath = function () {};
150
+ scene.camera.position.set(0, middle, scene.cameraDistance);
151
+ scene.camera.lookAt(0, middle, 0);
152
+ // Model-authored centers can cancel all root movement. Game springs simulate in world space.
153
+ var manager = scene.avatar.vrm && scene.avatar.vrm.springBoneManager;
154
+ if (manager) { manager.joints.forEach(function (joint) { joint.center = null; }); }
155
+ }
156
+ function updateFlight(dt) {
157
+ if (game.paused) return;
158
+ orientFlight();
159
+ // CSS moves the canvas only. Move the actual rig too so the solver sees flight inertia;
160
+ // move the camera by the same amount to keep the avatar centered inside its canvas.
161
+ var unit = scene.modelHeight * 0.003;
162
+ var x = (game.ball.x - api.WIDTH / 2) * unit;
163
+ var y = (api.HEIGHT / 2 - game.ball.y) * unit;
164
+ flightRig.position.set(x, scene.modelHeight / 2 + y, 0);
165
+ scene.camera.position.set(x, scene.modelHeight / 2 + y, scene.cameraDistance);
166
+ scene.camera.lookAt(x, scene.modelHeight / 2 + y, 0);
167
+ scene.scene.updateMatrixWorld(true);
168
+ // A new ball is a respawn, not a high-speed teleport across the arena.
169
+ if (flightBall !== game.ball) {
170
+ var manager = scene.avatar.vrm && scene.avatar.vrm.springBoneManager;
171
+ if (manager) manager.reset();
172
+ scene.motion.physicsAccumulator = 0; flightBall = game.ball;
173
+ }
174
+ scene.tick(dt);
175
+ }
176
+ function orientFlight() {
177
+ if (!flightRig) return;
178
+ var ball = game.ball;
179
+ if (Math.hypot(ball.vx, ball.vy) > 0) direction.set(ball.vx, -ball.vy, 0).normalize();
180
+ else direction.set(0, 1, 0);
181
+ heading.setFromUnitVectors(up, direction);
182
+ roll.setFromAxisAngle(up, angle);
183
+ // Align model Y to flight, then spin around that axis, not around the screen normal.
184
+ flightRig.quaternion.copy(heading).multiply(roll);
185
+ }
186
+ function fit() {
187
+ var w = root.clientWidth, h = root.clientHeight;
188
+ var side = w >= 720, margin = 16, top = side ? 110 : 242, bottom = 52;
189
+ var availableW = Math.max(1, w - margin * 2 - (side ? 120 : 0));
190
+ var availableH = Math.max(1, h - top - bottom);
191
+ view.scale = Math.min(availableW / api.WIDTH, availableH / api.HEIGHT);
192
+ view.x = margin + (side ? 120 : 0) + (availableW - api.WIDTH * view.scale) / 2;
193
+ view.y = top + (availableH - api.HEIGHT * view.scale) / 2;
194
+ var field = $('playfield');
195
+ field.style.left = view.x + 'px'; field.style.top = view.y + 'px';
196
+ field.style.width = api.WIDTH * view.scale + 'px'; field.style.height = api.HEIGHT * view.scale + 'px';
197
+ $('game-hud').style.cssText = 'left:' + view.x + 'px;top:' + (view.y - 30) + 'px;width:' + api.WIDTH * view.scale + 'px';
198
+ $('notice').style.top = (view.y + 12) + 'px';
199
+ $('notice').style.right = (w - view.x - api.WIDTH * view.scale + 12) + 'px';
200
+ var iconWidth = side ? Math.min(100, availableH / 4) : Math.min(100, (w - 32) / 3.06);
201
+ $('lives').style.cssText = 'left:' + (side ? view.x - 120 : margin) + 'px;top:' + (side ? view.y : 72)
202
+ + 'px;flex-direction:' + (side ? 'column' : 'row') + ';--life-width:' + iconWidth + 'px';
203
+ fitLifeIcons();
204
+ var dpr = Math.min(devicePixelRatio || 1, 2);
205
+ canvas.width = Math.round(api.WIDTH * view.scale * dpr); canvas.height = Math.round(api.HEIGHT * view.scale * dpr);
206
+ canvas.style.width = api.WIDTH * view.scale + 'px'; canvas.style.height = api.HEIGHT * view.scale + 'px';
207
+ canvas.style.left = '0px'; canvas.style.top = '0px';
208
+ ctx.setTransform(canvas.width / api.WIDTH, 0, 0, canvas.height / api.HEIGHT, 0, 0);
209
+ drawHud('level', 'LEVEL:' + game.level); drawHud('score', 'RICE: ' + game.remainingRice());
210
+ }
211
+ function pause(value) {
212
+ cancelResume();
213
+ game.paused = value; $('overlay').hidden = !value;
214
+ if (value) root.classList.remove('mouse-captured');
215
+ if (value && document.pointerLockElement === canvas) document.exitPointerLock();
216
+ var complete = game.phase === 'cleared' && game.level === api.levelCount();
217
+ $('pause-title').textContent = complete ? (api.levelCount() === 2 ? '两关已全部通关' : '全部关卡已通关') : '休息一下';
218
+ $('resume').hidden = complete;
219
+ $('debug-level').value = game.level;
220
+ if (value) { save(); renderTargets();
221
+ if (complete) $('pause-copy').textContent = '目前的全部关卡已完成,可以选关重玩。';
222
+ (complete ? $('restart') : $('resume')).focus(); }
223
+ else $('pause').focus();
224
+ }
225
+ function renderTargets() {
226
+ var list = $('sessions'); list.replaceChildren();
227
+ targets.forEach(function (target) {
228
+ var button = document.createElement('button');
229
+ button.textContent = (target.kind === 'error' ? '✕ 任务失败' : target.kind === 'done' ? '✓ 任务完成' : '● 需要注意或授权') + ' · ' + target.id.slice(0, 12) + ' →';
230
+ button.onclick = function () { openTarget(target); }; list.appendChild(button);
231
+ });
232
+ $('pause-copy').textContent = targets.length ? 'DSH 有新消息。游戏已经暂停,点击会话即可前往。' : '饭还热着,随时回来继续。';
233
+ }
234
+ function openTarget(target) {
235
+ pause(true);
236
+ if (testMode) { $('connection').textContent = '测试会话跳转成功:' + target.id; return; }
237
+ if (!bridge) {
238
+ // Our own query contract, consumed by the DSH client bridge; no guessed host router paths.
239
+ var url = new URL('/', location.href); url.searchParams.set('vrmpet3d-session', target.id);
240
+ var opened = globalThis.open(url.href, '_blank', 'noopener');
241
+ // noopener may return null even on success, so do not interpret its return value as failure.
242
+ return opened;
243
+ }
244
+ fetch('/vrmpet3d/desktop', { method: 'POST', headers: { 'content-type': 'application/json' },
245
+ credentials: 'omit', body: JSON.stringify({ action: 'game-session', sessionId: target.id }) })
246
+ .then(function (r) { if (!r.ok) throw new Error('会话切换失败'); return r.json(); })
247
+ .then(function (body) { if (!body.ok) throw new Error('该会话已不再等待处理'); bridge.focusHost(); })
248
+ .catch(function (error) { $('connection').textContent = error.message + ',请重试。'; });
249
+ }
250
+ function poll() {
251
+ statusRequest = new AbortController();
252
+ var timeout = setTimeout(function () { statusRequest.abort(); }, 5000);
253
+ fetch('/vrmpet3d/state', { credentials: 'omit', cache: 'no-store', signal: statusRequest.signal }).then(function (r) {
254
+ if (!r.ok) throw new Error('status unavailable'); return r.json();
255
+ }).then(function (state) {
256
+ if (stopped) return;
257
+ targets = api.notices(state);
258
+ var next = new Set(), fresh = false;
259
+ targets.forEach(function (t) { var key = t.kind + ':' + t.id; next.add(key); if (!seen.has(key)) fresh = true; });
260
+ seen = next;
261
+ // Error > attention/approval > completion, including mixed notifications.
262
+ var kind = targets.some(function (t) { return t.kind === 'error'; }) ? 'error'
263
+ : targets.some(function (t) { return t.kind === 'waiting'; }) ? 'waiting' : targets.length ? 'done' : '';
264
+ root.dataset.noticeKind = kind;
265
+ if (!kind) root.classList.remove('signal');
266
+ if (fresh) { root.classList.remove('signal'); void root.offsetWidth; root.classList.add('signal'); }
267
+ $('notice').hidden = !targets.length;
268
+ $('notice').textContent = (kind === 'error' ? '任务失败' : kind === 'waiting' ? '需要注意或授权' : '任务完成')
269
+ + ' · ' + targets.length + ' 个会话 · Esc 查看';
270
+ var key = targets.map(function (t) { return t.kind + t.id; }).join('|');
271
+ if (key !== noticeKey && game.paused) renderTargets(); noticeKey = key;
272
+ $('connection').textContent = state.stale ? 'DSH 状态暂未更新;游戏仍可继续。' : 'DSH 已连接';
273
+ }).catch(function () { $('connection').textContent = '暂时无法连接 DSH,正在重试。'; })
274
+ .finally(function () { clearTimeout(timeout); if (!stopped) pollTimer = setTimeout(poll, 900); });
275
+ }
276
+ // Rice and porcelain are drawn locally; no image assets or network dependencies.
277
+ var riceTextures = new Map(), bowlImages = new Map();
278
+ function bowlImage(source) {
279
+ if (!source) return null;
280
+ if (!bowlImages.has(source)) {
281
+ var record = {ready:false,image:new Image()}; bowlImages.set(source,record);
282
+ record.image.onload = function () { record.ready = record.image.naturalWidth <= 2048 && record.image.naturalHeight <= 2048; };
283
+ record.image.onerror = function () {}; record.image.src = source;
284
+ }
285
+ var record = bowlImages.get(source); return record.ready ? record.image : null;
286
+ }
287
+ function riceSeed(index) { return (Math.imul(index + 1, 2654435761) ^ Math.imul(game.level, 2246822519)) >>> 0; }
288
+ function randomSource(seed) { return function () { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; }; }
289
+ function riceVariant(index) { return riceSeed(index) % 3; }
290
+ function looseRice(brick, index) {
291
+ var palette = brick.riceStyle;
292
+ var key = game.initialLayoutId + ':' + index, texture = riceTextures.get(key);
293
+ if (!texture) {
294
+ texture = document.createElement('canvas'); texture.width = 96; texture.height = 72;
295
+ var c = texture.getContext('2d'), random = randomSource(riceSeed(index)), variant = riceVariant(index);
296
+ var rx = [29, 32, 27][variant], ry = [21, 18, 24][variant], phase = random() * 6;
297
+ c.translate(48, 36); c.beginPath();
298
+ for (var i = 0; i <= 80; i++) {
299
+ var a = i / 80 * Math.PI * 2, bump = 1 + 0.1 * Math.sin(a * 5 + phase) + 0.06 * Math.cos(a * 9 - phase);
300
+ var x = Math.cos(a) * rx * bump, y = Math.sin(a) * ry * bump;
301
+ if (i) c.lineTo(x, y); else c.moveTo(x, y);
302
+ }
303
+ c.closePath(); var shade = c.createLinearGradient(-20, -20, 24, 25);
304
+ shade.addColorStop(0, palette.heapLight); shade.addColorStop(1, palette.heapDark); c.fillStyle = shade; c.fill();
305
+ var grains = [];
306
+ for (var grain = 0; grain < 125; grain++) {
307
+ var theta = random() * Math.PI * 2, distance = Math.sqrt(random()) * (grain < 8 ? 1.2 : 1);
308
+ grains.push({ x: Math.cos(theta) * distance * rx, y: Math.sin(theta) * distance * ry,
309
+ angle: random() * 2.1 - 0.8, length: 3.1 + random() * 1.1, width: 1.2 + random() * 0.45, light: random() });
310
+ }
311
+ grains.sort(function (a, b) { return a.y - b.y; });
312
+ grains.forEach(function (grain) {
313
+ c.save(); c.translate(grain.x, grain.y); c.rotate(grain.angle);
314
+ c.fillStyle = palette.shadow + '55'; c.beginPath(); c.ellipse(0.35, 0.6, grain.length, grain.width, 0, 0, Math.PI * 2); c.fill();
315
+ c.fillStyle = grain.light > 0.2 ? palette.grain : palette.grainShade;
316
+ c.beginPath(); c.ellipse(0, 0, grain.length, grain.width, 0, 0, Math.PI * 2); c.fill();
317
+ c.restore();
318
+ });
319
+ riceTextures.set(key, texture);
320
+ if (riceTextures.size > 256) riceTextures.delete(riceTextures.keys().next().value);
321
+ }
322
+ var width = brick.w * 0.8, height = brick.h * 1.2;
323
+ ctx.drawImage(texture, brick.x + (brick.w - width) / 2, brick.y + (brick.h - height) / 2, width, height);
324
+ }
325
+ function rice(brick, index) {
326
+ if (brick.hp === 0) { if (brick.rice) looseRice(brick, index); return; }
327
+ var style = brick.style, palette = brick.riceStyle;
328
+ var x = brick.x + brick.w / 2, y = brick.y + brick.h * 0.45;
329
+ var visualWidth = brick.w;
330
+ ctx.save(); ctx.translate(x, y); ctx.scale(visualWidth / 95, brick.h / 40);
331
+ // A rounded heap rises from inside the rim; the porcelain front covers its base.
332
+ if (brick.rice && brick.hp > 0) {
333
+ ctx.fillStyle = palette.fill; ctx.beginPath(); ctx.ellipse(0, -1, 35, 19, 0, 0, Math.PI * 2); ctx.fill();
334
+ for (var grain = 0; grain < 30; grain++) {
335
+ var gxHeap = Math.sin(grain * 2.399 + index) * (8 + grain % 5 * 4);
336
+ var gyHeap = -3 - grain % 4 * 3;
337
+ ctx.fillStyle = grain % 3 ? palette.highlight : palette.shade; ctx.beginPath(); ctx.ellipse(gxHeap, gyHeap, 3, 1.3, grain % 4 * 0.4, 0, Math.PI * 2); ctx.fill();
338
+ }
339
+ }
340
+ if (brick.hp > 0) {
341
+ ctx.fillStyle = style.color;
342
+ var image = bowlImage(style.image);
343
+ if (image) ctx.drawImage(image,-40,-2,80,28);
344
+ else { ctx.beginPath(); ctx.moveTo(-40, 0);
345
+ if (style.shape === 'flat') { ctx.lineTo(-22,25);ctx.lineTo(22,25);ctx.lineTo(40,0); }
346
+ else { ctx.quadraticCurveTo(-31,25,0,25);ctx.quadraticCurveTo(31,25,40,0); }
347
+ ctx.closePath(); ctx.fill(); }
348
+ ctx.fillStyle = style.rimColor; ctx.beginPath(); ctx.ellipse(0, 0, 37, 6, 0, 0, Math.PI * 2); ctx.fill();
349
+ ctx.fillStyle = style.baseColor; ctx.fillRect(-12, 23, 24, 3);
350
+ }
351
+ if (brick.rice) {
352
+ ctx.save(); ctx.beginPath(); ctx.ellipse(0, 0, 35, brick.hp > 0 ? 5 : 12, 0, 0, Math.PI * 2); ctx.clip();
353
+ ctx.fillStyle = palette.fill; ctx.fillRect(-36, -13, 72, 26);
354
+ for (var i = 0; i < 23; i++) {
355
+ var gx = Math.sin(i * 2.399 + index) * (9 + i % 5 * 5), gy = (i % 3 - 1) * (brick.hp > 0 ? 2 : 6);
356
+ ctx.fillStyle = i % 3 ? palette.highlight : palette.shade; ctx.beginPath(); ctx.ellipse(gx, gy, 3.5, 1.4, (i % 4) * 0.5, 0, Math.PI * 2); ctx.fill();
357
+ }
358
+ ctx.restore();
359
+ }
360
+ if (brick.hp > 0) {
361
+ ctx.strokeStyle = style.edgeColor + 'b0'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(0, 0, 39, 6, 0, 0, Math.PI * 2); ctx.stroke();
362
+ var damage = brick.maxHp - brick.hp;
363
+ ctx.strokeStyle = style.crackColor; ctx.lineWidth = 2.5;
364
+ if (damage > 0) { ctx.beginPath(); ctx.moveTo(-11, 4); ctx.lineTo(-6, 10); ctx.lineTo(-12, 15); ctx.lineTo(-5, 23); ctx.stroke(); }
365
+ if (damage > 1) { ctx.beginPath(); ctx.moveTo(16, 3); ctx.lineTo(8, 9); ctx.lineTo(13, 15); ctx.lineTo(4, 20); ctx.moveTo(8, 9); ctx.lineTo(1, 8); ctx.stroke(); }
366
+ }
367
+ ctx.restore();
368
+ }
369
+ function draw(dt) {
370
+ ctx.clearRect(0, 0, api.WIDTH, api.HEIGHT);
371
+ ctx.strokeStyle = '#afcdd512'; ctx.lineWidth = 1;
372
+ for (var x = 30; x < api.WIDTH; x += 40) { ctx.beginPath(); ctx.moveTo(x, 82); ctx.lineTo(x, 680); ctx.stroke(); }
373
+ ctx.strokeStyle = '#b6d5d430'; ctx.strokeRect(1, 1, api.WIDTH - 2, api.HEIGHT - 2);
374
+ game.bricks.forEach(function (b, index) { if (b.alive) rice(b, index); });
375
+ particles.forEach(function (p) {
376
+ if (!game.paused) {
377
+ p.life -= dt; p.x += p.vx * dt; p.y += p.vy * dt;
378
+ p.vy += (p.type === 'shard' ? 520 : 180) * dt;
379
+ if (p.type === 'shard') p.angle += p.spin * dt;
380
+ }
381
+ });
382
+ particles = particles.filter(function (p) { return p.life > 0; });
383
+ particles.forEach(function (p) {
384
+ ctx.globalAlpha = Math.min(1, p.life / (p.type === 'shard' ? 0.45 : 1)); ctx.fillStyle = p.color || '#ffe6af';
385
+ if (p.type === 'shard') {
386
+ ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.angle); ctx.beginPath();
387
+ p.shape.forEach(function (point, i) { if (i) ctx.lineTo(point[0], point[1]); else ctx.moveTo(point[0], point[1]); });
388
+ ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#ffffff88'; ctx.lineWidth = 0.6; ctx.stroke(); ctx.restore();
389
+ } else { ctx.beginPath(); ctx.ellipse(p.x, p.y, 3, 1.8, p.life * 4, 0, Math.PI * 2); ctx.fill(); }
390
+ }); ctx.globalAlpha = 1;
391
+ var p = game.paddle;
392
+ ctx.shadowColor = '#e6c586'; ctx.shadowBlur = 16; ctx.fillStyle = '#efdaaf';
393
+ ctx.beginPath(); ctx.roundRect(p.x - p.w / 2, p.y, p.w, p.h, 8); ctx.fill(); ctx.shadowBlur = 0;
394
+ if (!modelReady) {
395
+ ctx.fillStyle = '#f5d89f'; ctx.beginPath(); ctx.arc(game.ball.x, game.ball.y, game.ball.r, 0, Math.PI * 2); ctx.fill();
396
+ ctx.fillStyle = '#243d48'; ctx.font = '24px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('饭', game.ball.x, game.ball.y + 8);
397
+ }
398
+ if (game.phase === 'serve' || game.phase === 'cleared') {
399
+ ctx.textAlign = 'center'; ctx.fillStyle = '#f6e7cb'; ctx.font = '20px "Microsoft YaHei",sans-serif';
400
+ ctx.fillText(game.phase === 'cleared' ? (game.level === api.levelCount() ? '全部光盘!' : '这一桌,光盘!下一关…') : '左键发射 · 开饭!', 500, 610);
401
+ }
402
+ if (!game.paused && game.phase === 'playing') angle += dt * appearance.spin;
403
+ var size = appearance.size * view.scale;
404
+ pet.style.width = size + 'px'; pet.style.height = size + 'px';
405
+ pet.style.left = game.ball.x * view.scale - size / 2 + 'px';
406
+ pet.style.top = game.ball.y * view.scale - size / 2 + 'px';
407
+ if (scene && modelReady) updateFlight(dt);
408
+ var remaining = game.remainingRice();
409
+ renderLives();
410
+ drawHud('level', 'LEVEL:' + game.level);
411
+ drawHud('score', 'RICE: ' + remaining);
412
+ }
413
+ function handleEvents(events) {
414
+ events.forEach(function (event) {
415
+ if (event.type === 'eat') for (var i = 0; i < 9; i++) particles.push({ x: event.x, y: event.y, vx: Math.cos(i * 2.4) * 100, vy: Math.sin(i * 2.4) * 100, life: 0.8 });
416
+ if (event.type === 'break') {
417
+ var random = randomSource(riceSeed(event.index || 0));
418
+ var color = game.bricks[event.index || 0].color || '#f4f5f3';
419
+ for (var shard = 0; shard < 9; shard++) {
420
+ var size = 3 + random() * 5;
421
+ particles.push({ type: 'shard', x: event.x + (random() - 0.5) * 24, y: event.y,
422
+ vx: (random() - 0.5) * 260, vy: -70 - random() * 100, life: 1.2 + random() * 0.5,
423
+ angle: random() * 6, spin: (random() - 0.5) * 14, color: color,
424
+ shape: [[-size, -size * 0.3], [size * 0.3, -size * 0.65], [size, size * 0.4], [-size * 0.4, size * 0.7]] });
425
+ }
426
+ }
427
+ if (event.type === 'cleared') clearTime = 1.5;
428
+ if (event.type === 'run-reset') { particles = []; clearTime = 0; $('model-status').textContent = '机会已用完,已回到第1关重新开始。'; }
429
+ save();
430
+ });
431
+ }
432
+ function frame(time) {
433
+ if (stopped) return;
434
+ var dt = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0; lastTime = time;
435
+ handleEvents(game.step(dt));
436
+ advanceCleared(dt);
437
+ saveTime += dt; if (saveTime >= 1) { saveTime = 0; save(); }
438
+ draw(dt); frameId = requestAnimationFrame(frame);
439
+ }
440
+ function advanceCleared(dt) {
441
+ if (game.phase === 'cleared' && !game.paused) {
442
+ clearTime -= dt;
443
+ if (clearTime <= 0) { if (!game.advance()) pause(true); save(); }
444
+ }
445
+ }
446
+ function fullscreen() {
447
+ if (!document.fullscreenElement) root.requestFullscreen().catch(function () { $('model-status').textContent = '可再次点击「全屏」进入全屏。'; });
448
+ else document.exitFullscreen().catch(function () {});
449
+ }
450
+ function captureMouse() {
451
+ if (document.pointerLockElement === canvas) return;
452
+ if (!canvas.requestPointerLock) { pause(true); $('connection').textContent = '当前浏览器不支持鼠标捕获,请使用桌面浏览器。'; return; }
453
+ try {
454
+ var request = canvas.requestPointerLock();
455
+ if (request && request.catch) request.catch(function () { pause(true); $('connection').textContent = '鼠标捕获失败,请点击继续重试。'; });
456
+ } catch (_) { pause(true); $('connection').textContent = '鼠标捕获失败,请点击继续重试。'; }
457
+ }
458
+ document.addEventListener('pointerlockchange', function () {
459
+ var locked = document.pointerLockElement === canvas;
460
+ root.classList.toggle('mouse-captured', locked && (!game.paused || !!resumeEnd));
461
+ if (locked && game.paused && !resumeEnd) document.exitPointerLock();
462
+ else if (!locked && (!game.paused || resumeEnd)) pause(true);
463
+ });
464
+ document.addEventListener('pointerlockerror', function () { pause(true); $('connection').textContent = '鼠标捕获失败,请点击继续重试。'; });
465
+ canvas.addEventListener('pointermove', function (event) {
466
+ if (document.pointerLockElement === canvas) game.move(game.paddle.x + event.movementX / view.scale);
467
+ else if (game.phase === 'serve') game.move((event.clientX - view.x) / view.scale);
468
+ });
469
+ canvas.addEventListener('pointerdown', function (event) {
470
+ if (event.button !== 0 || game.paused) return;
471
+ if (document.pointerLockElement !== canvas) game.move((event.clientX - view.x) / view.scale);
472
+ game.launch(); captureMouse();
473
+ });
474
+ $('pause').onclick = function () { pause(!game.paused); };
475
+ $('notice').onclick = function () { pause(true); };
476
+ $('resume').onclick = beginResume;
477
+ $('restart').textContent = '从第1关重新开始';
478
+ $('restart').onclick = function () { game.reset(); particles = []; clearTime = 0; save(); pause(false); captureMouse(); };
479
+ $('exit').onclick = function () {
480
+ pause(true); if (!save()) return;
481
+ gameWindowFocused = false; clearInterval(focusTimer);
482
+ var released = reportGameFocus(false, true);
483
+ if (bridge && typeof bridge.quit === 'function') {
484
+ // Finish the local release first; a failed host must not prevent closing the game.
485
+ Promise.race([Promise.resolve(released), new Promise(function (resolve) { setTimeout(resolve, 250); })])
486
+ .then(function () { bridge.quit(); });
487
+ return;
488
+ }
489
+ window.close();
490
+ // A manually opened browser tab cannot always close itself.
491
+ $('pause-title').textContent = '已保存,可以关闭此页';
492
+ $('pause-copy').textContent = '下次打开会恢复当前进度。也可以点击继续吃饭。';
493
+ if (document.fullscreenElement) document.exitFullscreen().catch(function () {});
494
+ };
495
+ if (testMode) {
496
+ // Deterministic stepping for headless probes, whose compositor may skip animation frames.
497
+ globalThis.__VRMPET3D_GAME_TEST__ = { countdown: function (elapsed) { if (resumeEnd) tickResume(resumeEnd - 3000 + elapsed); }, hitBowl: function (index, side) {
498
+ var brick = game.bricks[index]; if (!brick || !brick.alive) return [];
499
+ game.paused = false; $('overlay').hidden = true; game.launch(); brick.touching = false;
500
+ game.ball.x = brick.x + brick.w / 2;
501
+ game.ball.y = side === 'bottom' ? brick.y + brick.h + 24 : brick.y - 24;
502
+ game.ball.vx = 0; game.ball.vy = game.speed() * (side === 'bottom' ? -1 : 1);
503
+ var events = game.step(0.02); handleEvents(events); draw(0.02); save(); return events;
504
+ }, effects: function (dt) {
505
+ if (Number.isFinite(dt) && dt > 0 && dt <= 0.05) draw(dt);
506
+ }, step: function (dt) {
507
+ if (!Number.isFinite(dt) || dt <= 0 || dt > 0.05) return [];
508
+ var events = game.step(dt); handleEvents(events); advanceCleared(dt); draw(dt); save(); return events;
509
+ } };
510
+ $('test-controls').hidden = false;
511
+ $('test-rice').onclick = function () {
512
+ var index = game.bricks.findIndex(function (b) { return b.rice; });
513
+ if (index >= 0) globalThis.__VRMPET3D_GAME_TEST__.hitBowl(index, 'top');
514
+ };
515
+ $('test-bowl').onclick = function () {
516
+ // The bottommost bowl is reachable from below without passing through another row.
517
+ for (var i = game.bricks.length - 1; i >= 0; i--) if (game.bricks[i].hp > 0) {
518
+ globalThis.__VRMPET3D_GAME_TEST__.hitBowl(i, 'bottom'); break;
519
+ }
520
+ };
521
+ $('connection').textContent = '独立测试模式 · 无需 DSH';
522
+ $('test-loss').onclick = function () { game.paused = false; $('overlay').hidden = true; game.launch(); game.lose(); particles = []; renderLives(); save(); };
523
+ $('test-clear').onclick = function () { game.bricks.forEach(function (b) { if (b.rice) { b.rice = false; b.alive = b.hp > 0; } }); game.finishLevel(); clearTime = 1.5; draw(0); save(); };
524
+ $('test-notice').onclick = function () { targets = [{ id: 'standalone-test-session', kind: 'waiting' }]; pause(true); };
525
+ }
526
+ $('fullscreen').onclick = fullscreen;
527
+ document.addEventListener('keydown', function (event) {
528
+ if (event.key === 'Escape') { event.preventDefault(); pause(true); }
529
+ // Keep keyboard focus inside the pause dialog.
530
+ if (event.key === 'Tab' && game.paused) {
531
+ var buttons = Array.from($('overlay').querySelectorAll('button,input,select,a')).filter(function (b) { return !b.hidden && b.getClientRects().length; });
532
+ var first = buttons[0], last = buttons[buttons.length - 1];
533
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
534
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
535
+ }
536
+ });
537
+ // Browsers reserve Esc to leave fullscreen; that exit must pause the game too.
538
+ document.addEventListener('fullscreenchange', function () { fit(); if (!document.fullscreenElement) pause(true); });
539
+ document.addEventListener('visibilitychange', function () {
540
+ if (document.hidden) pause(true); lastTime = 0;
541
+ reportGameFocus(!document.hidden && gameWindowFocused);
542
+ });
543
+ window.addEventListener('blur', function () { gameWindowFocused = false; pause(true); reportGameFocus(false); });
544
+ window.addEventListener('focus', function () { gameWindowFocused = true; reportGameFocus(!document.hidden); });
545
+ window.addEventListener('resize', fit);
546
+ window.addEventListener('pagehide', function () {
547
+ cancelResume(); clearInterval(focusTimer); reportGameFocus(false, true);
548
+ save();
549
+ stopped = true; clearTimeout(pollTimer); cancelAnimationFrame(frameId);
550
+ if (statusRequest) statusRequest.abort(); if (scene) scene.dispose();
551
+ });
552
+ fit(); renderLives();
553
+ if (!testMode) {
554
+ poll(); reportGameFocus(gameWindowFocused && !document.hidden);
555
+ focusTimer = setInterval(function () { if (gameWindowFocused && !document.hidden) reportGameFocus(true); }, 1000);
556
+ }
557
+ if (restored) pause(true); frameId = requestAnimationFrame(frame);
558
+ // Snapshot the selected model; the game never writes model preferences or moves the pet's scene.
559
+ Promise.all([
560
+ fetch('/vrmpet3d/settings', { credentials: 'omit' }).then(function (r) { if (!r.ok) throw new Error('无法读取模型设置'); return r.json(); }),
561
+ fetch('/vrmpet3d/catalog', { credentials: 'omit' }).then(function (r) { if (!r.ok) throw new Error('无法读取模型列表'); return r.json(); }),
562
+ fetch('/vrmpet3d/motions', { credentials: 'omit' }).then(function (r) { if (!r.ok) throw new Error('无法读取分发的弹簧骨默认参数'); return r.json(); }),
563
+ ]).then(function (values) {
564
+ if (stopped) return;
565
+ var prefs = values[0].preferences || {}, models = values[1].models || [];
566
+ var wanted = new URLSearchParams(location.search).get('model') || prefs.modelId;
567
+ var model = models.find(function (m) { return m.id === wanted; }) || models[0];
568
+ if (!model) throw new Error('还没有可用的桌宠模型');
569
+ var locked = api.avatarPreferences(globalThis.__VRMPET3D_CORE__);
570
+ var springDefaults = JSON.parse(JSON.stringify((values[2].springDefaults || {})[model.id] || {}));
571
+ scene = new globalThis.__VRMPET3D_SCENE__.Scene(pet, { assetBase: '/vrmpet3d/asset', modelId: model.id,
572
+ preferences: locked, onError: function () {} });
573
+ scene.resize(128, 128);
574
+ return scene.loadModel(model.id).then(function (status) {
575
+ if (stopped) { scene.dispose(); return; }
576
+ if (!status.ok) throw new Error(status.error || '模型加载失败');
577
+ // These are per-game immutable snapshots; no settings polling or editing channel is mounted.
578
+ Object.defineProperty(scene, 'preferences', { value: locked, writable: false, configurable: false });
579
+ // Include chains missing from the catalog; every joint must have zero game gravity.
580
+ (scene.springChainInfo || []).forEach(function (chain) {
581
+ var key = chain.key || chain.name;
582
+ springDefaults[key] = Object.assign({}, springDefaults[key], { gravityPower: 0 });
583
+ chain.joints.forEach(function (entry) { entry.joint.settings.gravityPower = 0; });
584
+ });
585
+ Object.keys(springDefaults).forEach(function (key) { springDefaults[key].gravityPower = 0; });
586
+ api.freeze(springDefaults);
587
+ scene.setSpringDefaults(springDefaults);
588
+ Object.defineProperty(scene, 'springDefaults', { value: springDefaults, writable: false, configurable: false });
589
+ prepareFlightRig();
590
+ captureLifePortrait();
591
+ modelReady = true; $('model-status').textContent = ''; pet.hidden = false;
592
+ });
593
+ }).catch(function (error) { pet.hidden = true; $('model-status').textContent = '模型暂不可用,使用饭团球 · ' + error.message; });
594
+ globalThis.__VRMPET3D_GAME_STATE__ = function () { return { phase: game.phase, paused: game.paused, level: game.level,
595
+ lives: game.lives, flightSpeed: game.flightSpeed || 1,
596
+ countdown: resumeEnd ? Number($('countdown-number').textContent) : 0,
597
+ debug: debugMode, unlockedLevel: game.unlockedLevel, availableLevels: api.LEVELS.map(function (level) { return level.id; }),
598
+ fragments: particles.filter(function (p) { return p.type === 'shard'; }).map(function (p) { return { x: p.x, y: p.y, vy: p.vy, angle: p.angle, life: p.life }; }),
599
+ riceVariants: game.bricks.map(function (_, index) { return riceVariant(index); }),
600
+ paddleBoost: game.paddleBoost === undefined ? 1.5 : game.paddleBoost, mouseCaptured: document.pointerLockElement === canvas,
601
+ paddleX: game.paddle.x,
602
+ ball: { x: game.ball.x, y: game.ball.y, vx: game.ball.vx, vy: game.ball.vy }, appearance: { size: appearance.size, spin: appearance.spin },
603
+ remaining: game.remainingRice(), bowls: game.bricks.map(function (b) { return { x: b.x, y: b.y, w: b.w, h: b.h, hp: b.hp, color: b.color, rice: b.rice, alive: b.alive }; }), modelReady: modelReady, targets: targets.length,
604
+ avatar: modelReady ? { locked: Object.isFrozen(scene.preferences) && Object.isFrozen(scene.springDefaults),
605
+ lookAtPointer: scene.preferences.lookAtPointer, expression: scene.expressionApplied.TiePen,
606
+ body: up.clone().applyQuaternion(flightRig.quaternion).toArray(),
607
+ gaze: new scene.THREE.Vector3(0, 0, 1).applyQuaternion(flightRig.quaternion).toArray(),
608
+ direction: direction.toArray(), angle: angle,
609
+ physics: { enabled: scene.motion.physicsEnabled, error: scene.motion.physicsError || null,
610
+ position: flightRig.position.toArray(),
611
+ joints: (scene.springChainInfo || []).flatMap(function (chain) { return chain.joints.map(function (entry) { return entry.joint.bone.quaternion.toArray(); }); }) },
612
+ gravity: (scene.springChainInfo || []).flatMap(function (chain) { return chain.joints.map(function (entry) { return entry.joint.settings.gravityPower; }); }),
613
+ springs: (scene.springChainInfo || []).map(function (chain) { var settings = chain.joints[0].joint.settings;
614
+ return { key: chain.key || chain.name, stiffness: settings.stiffness, dragForce: settings.dragForce, gravityPower: settings.gravityPower }; }) } : null }; };
615
+ })();
@@ -0,0 +1,19 @@
1
+ :root{color-scheme:dark;font-family:"Microsoft YaHei",system-ui,sans-serif;color:#f6f1e6;background:#101b24}*{box-sizing:border-box}body{margin:0;overflow:hidden}button{font:inherit;color:inherit;cursor:pointer;border:1px solid #ffffff25;background:#ffffff09;border-radius:12px;padding:10px 17px}button:hover{background:#ffffff19}button:focus-visible{outline:2px solid #ffe1a0;outline-offset:4px}[hidden]{display:none!important}#game{position:relative;width:100vw;height:100vh;height:100dvh;overflow:hidden;background:radial-gradient(ellipse at 50% 25%,#25414a,#101b24 75%)}#arena{position:absolute}#pet{position:absolute;pointer-events:none;transform-origin:center;filter:drop-shadow(0 0 9px #ffe4a644)}header{position:absolute;inset:22px 32px auto;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:14px}.controls{display:flex;justify-self:end;gap:14px}.brand{font-size:24px;font-weight:750;letter-spacing:3px}.brand small{display:block;font-size:9px;letter-spacing:4px;color:#a6bdc3;margin-top:3px}#score{flex:1;text-align:center;color:#e6cf9e;font-size:14px;letter-spacing:2px}kbd{font:11px inherit;color:#a6bdc3;margin-left:8px}#hint{position:absolute;bottom:8px;width:100%;text-align:center;color:#a6bdc3;font-size:12px;letter-spacing:2px;pointer-events:none}#model-status{position:absolute;left:24px;bottom:12px;font-size:11px;color:#a6bdc3;max-width:30%}#notice{position:absolute;right:32px;top:95px;color:#ffe3aa;background:#3b3528;border-color:#e3bd6d;font-size:13px;max-width:80%}#game[data-notice-kind="error"]{--notice-rgb:255,83,91}#game[data-notice-kind="waiting"]{--notice-rgb:255,205,79}#game[data-notice-kind="done"]{--notice-rgb:68,225,151}.signal::before{content:"";position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse at center,transparent 28%,rgba(var(--notice-rgb),.48) 100%);box-shadow:inset 0 0 160px 24px rgba(var(--notice-rgb),.3);animation:signal 2.4s ease-in-out 2}@keyframes signal{0%,100%{opacity:0}35%{opacity:1}}#game[data-notice-kind="error"] #notice{color:#ffd9da;background:#44252b;border-color:#ff7179}#game[data-notice-kind="done"] #notice{color:#c5ffe2;background:#1d3b31;border-color:#44d99a}#overlay{position:absolute;inset:0;display:grid;place-items:center;background:#081118bc;backdrop-filter:blur(14px);padding:24px}.panel{width:min(540px,100%);max-height:85vh;overflow:auto;padding:40px;border:1px solid #9db6bb30;border-radius:24px;background:#1a2b34;box-shadow:0 24px 100px #0005}.eyebrow{color:#e4bd76;font-size:10px;letter-spacing:4px}h1{font-size:36px;margin:15px 0}p{line-height:1.7;color:#aac0c5}.actions{display:flex;gap:12px;margin-top:28px}.primary{background:#efd3a0;color:#24343a;border:0}.primary:hover{background:#ffe5b6}#sessions{display:grid;gap:10px;margin-top:20px}#sessions button{text-align:left;padding:14px;overflow-wrap:anywhere}#connection{font-size:12px;margin-bottom:0}@media(prefers-reduced-motion:reduce){.signal::before{animation:none;opacity:.65}}@media(max-width:650px){header{inset:12px;gap:7px}.controls{gap:7px}.brand{font-size:17px}.brand small{font-size:7px}header button{padding:8px}#score{font-size:11px;letter-spacing:0}.panel{padding:25px}#model-status{bottom:36px;max-width:85%}}
2
+ .model-controls{display:grid;gap:8px;margin:22px 0 0;border:1px solid #ffffff25;border-radius:12px;padding:14px}
3
+ .model-controls label{font-size:13px;color:#e6cf9e}.model-controls input{width:100%;accent-color:#efd3a0}.model-controls small{color:#aac0c5;font-size:11px}
4
+ #playfield{position:absolute;overflow:hidden;aspect-ratio:4/3;background:#12252b;box-shadow:0 0 0 1px #b6d5d430}
5
+ #lives{position:absolute;display:flex;gap:3px;pointer-events:none}
6
+ #lives img,#lives canvas{width:var(--life-width,100px);height:calc(var(--life-width,100px) * 1.33);object-fit:contain;filter:drop-shadow(0 1px 3px #0008);flex:none}
7
+ .mouse-captured,.mouse-captured *{cursor:none!important}
8
+ #game-hud{position:absolute;display:flex;justify-content:space-between;align-items:center;height:24px;pointer-events:none;color:#e6cf9e;font-size:14px;letter-spacing:1px}
9
+ #game-hud #score{flex:none;text-align:right;font-size:inherit;letter-spacing:inherit}
10
+ @media(max-width:650px){#game-hud{font-size:12px}}
11
+ #game #game-hud #level,#game #game-hud #score{font-size:0;line-height:0;letter-spacing:0}
12
+ #game-hud canvas{display:block}
13
+ #debug-entry{display:inline-block;margin-top:18px;color:#aac0c5;font-size:12px}
14
+ #resume-countdown{position:absolute;inset:0;display:grid;place-content:center;gap:16px;text-align:center;background:#08111866;color:#efd3a0;pointer-events:none;z-index:2}
15
+ #countdown-number{font:80px/1 monospace;font-weight:500}
16
+ #resume-countdown span{font-size:14px;color:#d3e1e4}
17
+ .panel{position:relative;z-index:1}
18
+ .signal #overlay::before{content:"";position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse,transparent 25%,rgba(var(--notice-rgb),.4));animation:signal 2.4s ease-in-out 2}
19
+ @media(prefers-reduced-motion:reduce){.signal #overlay::before{animation:none;opacity:.65}}