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,329 @@
1
+ // Standalone deterministic breakout rules; no DSH, DOM or pet state dependencies.
2
+ (function () {
3
+ 'use strict';
4
+ var W = 1000, H = 750, R = 21, PY = 692;
5
+ var clamp = function (n, a, b) { return Math.max(a, Math.min(b, n)); };
6
+ var LETTERS = {
7
+ D: ['110','101','101','101','110'],
8
+ S: ['111','100','111','001','111'],
9
+ '-': ['000','000','111','000','000'],
10
+ B: ['110','101','110','101','110'],
11
+ A: ['010','101','111','101','101'],
12
+ L: ['100','100','100','100','111']
13
+ };
14
+ var WIDE_LETTERS = {
15
+ D: ['1111110','1100011','1100011','1100011','1111110'],
16
+ S: ['0111111','1100000','0111110','0000011','1111110']
17
+ };
18
+ var remoteLevels = null, builtinIds = null;
19
+ var defaultRice = {heapLight:'#fffdf1',heapDark:'#ddd9ca',grain:'#fffdf4',grainShade:'#e8e4d5',shadow:'#a8a593',fill:'#f6eedb',highlight:'#fff9ed',shade:'#dfd5be'};
20
+ function isColor(value) { return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value); }
21
+ function normalizeLevels(manifest) {
22
+ if (!manifest || manifest.version !== 1 || !Array.isArray(manifest.levels) || !manifest.levels.length || manifest.levels.length > 100) return null;
23
+ var total = 0, levels = [];
24
+ for (var i = 0; i < manifest.levels.length; i++) {
25
+ var level = manifest.levels[i], styles = {}, rice = {};
26
+ if (!level || level.id !== i + 1 || typeof level.name !== 'string' || !level.name.trim() || level.name.length > 80
27
+ || !level.resources || !level.resources.bowls || !level.resources.rice
28
+ || !Array.isArray(level.bricks) || !level.bricks.length || level.bricks.length > 1000 || (total += level.bricks.length) > 10000) return null;
29
+ for (var key of Object.keys(defaultRice)) {
30
+ if (!isColor(level.resources.rice[key])) return null; rice[key] = level.resources.rice[key];
31
+ }
32
+ var names = Object.keys(level.resources.bowls);
33
+ if (names.length > 100) return null;
34
+ for (var name of names) {
35
+ var style = level.resources.bowls[name], clean = {};
36
+ if (!/^[a-zA-Z0-9_-]{1,40}$/.test(name) || !style || !['round','flat'].includes(style.shape)) return null;
37
+ for (var key of ['color','rimColor','baseColor','edgeColor','crackColor']) {
38
+ if (!isColor(style[key])) return null; clean[key] = style[key];
39
+ }
40
+ if (style.image !== null && (typeof style.image !== 'string' || style.image.length > 131072
41
+ || !/^data:image\/(png|webp|jpeg);base64,[a-zA-Z0-9+/]+=*$/.test(style.image))) return null;
42
+ clean.shape = style.shape; clean.image = style.image; styles[name] = clean;
43
+ }
44
+ var bricks = [];
45
+ for (var j = 0; j < level.bricks.length; j++) {
46
+ var b = level.bricks[j];
47
+ if (!b || !['x','y','w','h'].every(function (k) { return Number.isFinite(b[k]); })
48
+ || b.x < 0 || b.y < 0 || b.w < 8 || b.w > 150 || b.h < 4 || b.h > 100 || b.x + b.w > W || b.y + b.h > 600
49
+ || !Number.isInteger(b.hp) || b.hp < 0 || b.hp > 10 || typeof b.rice !== 'boolean' || (!b.hp && !b.rice)
50
+ || (b.bowl !== null && !Object.prototype.hasOwnProperty.call(styles,b.bowl)) || (b.hp > 0 && b.bowl === null)) return null;
51
+ bricks.push({x:b.x,y:b.y,w:b.w,h:b.h,hp:b.hp,rice:b.rice,bowl:b.bowl});
52
+ }
53
+ if (!bricks.some(function (b) { return b.rice; })) return null;
54
+ levels.push({id:level.id,name:level.name,resources:{bowls:styles,rice:rice},bricks:bricks});
55
+ }
56
+ return {version:1,levels:levels};
57
+ }
58
+ function setLevels(manifest) {
59
+ var clean; try { clean = normalizeLevels(manifest); } catch (_) { return false; } if (!clean) return false;
60
+ if (!builtinIds) { builtinIds = []; for (var i=1;i<=2;i++) { var g = new Game(); g.level=i;g.buildLevel();builtinIds.push(g.initialLayoutId); } }
61
+ remoteLevels = freeze(clean.levels);
62
+ api.LEVELS = remoteLevels.map(function (level) { return {id:level.id,name:level.name}; }); return true;
63
+ }
64
+ function levelCount() { return remoteLevels ? remoteLevels.length : 2; }
65
+ function layoutId(bricks) {
66
+ var text = JSON.stringify(bricks.map(function (b) { return [b.x,b.y,b.w,b.h,b.maxHp,b.rice,b.style,b.riceStyle]; })), hash = 2166136261;
67
+ for (var i=0;i<text.length;i++) hash = Math.imul(hash ^ text.charCodeAt(i),16777619);
68
+ return (hash >>> 0).toString(16);
69
+ }
70
+ function finishBuild(game) {
71
+ game.bricks.forEach(function (b) {
72
+ if (!b.style) b.style = {color:b.color || '#f4f5f3',rimColor:'#253d43',baseColor:game.level===1?'#d7dcda':'#739c9e',edgeColor:'#effaf1',crackColor:'#34474e',shape:'round',image:null};
73
+ if (!b.riceStyle) b.riceStyle = defaultRice;
74
+ });
75
+ game.initialLayoutId = layoutId(game.bricks);game.phase='serve';game.readyBall();
76
+ }
77
+ function Game() { this.reset(); }
78
+ Game.prototype.reset = function () {
79
+ this.level = 1; this.lives = 3; this.paused = false;
80
+ this.unlockedLevel = clamp(this.unlockedLevel || 1, 1, levelCount());
81
+ this.paddle = { x: W / 2, y: PY, w: 150, h: 16 };
82
+ this.buildLevel();
83
+ };
84
+ Game.prototype.buildLevel = function () {
85
+ if (!Number.isInteger(this.level) || this.level < 1 || this.level > levelCount()) this.level = 1;
86
+ this.paddle.x = W / 2;
87
+ this.bricks = [];
88
+ if (remoteLevels) {
89
+ this.bricks = remoteLevels[this.level - 1].bricks.map(function (b) {
90
+ var resources = remoteLevels[this.level - 1].resources, style = b.bowl === null ? null : resources.bowls[b.bowl];
91
+ return Object.assign({}, b, {maxHp:b.hp,alive:true,touching:false,style:style,color:style ? style.color : undefined,riceStyle:resources.rice});
92
+ }, this);
93
+ finishBuild(this); return;
94
+ }
95
+ if (this.level === 1) {
96
+ var bricks = this.bricks;
97
+ ['DS', 'BALL'].forEach(function (line, lineIndex) {
98
+ var offset = 0, left = 82;
99
+ line.split('').forEach(function (letter) {
100
+ (lineIndex === 0 ? WIDE_LETTERS[letter] : LETTERS[letter]).forEach(function (row, y) { row.split('').forEach(function (pixel, x) {
101
+ if (pixel === '1') bricks.push({ x: left + (offset + x) * 56, y: 112 + lineIndex * 208 + y * 32,
102
+ w: 52, h: 26, alive: true, rice: true, hp: 1, maxHp: 1, color: '#f4f5f3', touching: false });
103
+ }); });
104
+ offset += lineIndex === 0 ? 8 : 4;
105
+ });
106
+ });
107
+ finishBuild(this); return;
108
+ }
109
+ for (var row = 0; row < 8; row++) {
110
+ var count = [6, 10, 12, 14, 14, 16, 16, 16][row], start = (16 - count) / 2;
111
+ for (var col = start; col < start + count; col++) this.bricks.push({ x: 60 + col * 55.5 + (row % 2 ? 0 : 2),
112
+ y: 8 + row * 24, w: 52, h: 26, alive: true, rice: true, hp: 0, maxHp: 0, touching: false });
113
+ }
114
+ for (var row = 0; row < 12; row++) {
115
+ var count = [16, 16, 16, 15, 14, 13, 12, 10, 8, 6, 4, 4][row], start = (16 - count) / 2;
116
+ var color = Math.floor(row / 2) % 2 === 0 ? '#244c9c' : '#f4f5f3';
117
+ for (var col = start; col < start + count; col++) this.bricks.push({ x: 60 + col * 55.5,
118
+ y: 204 + row * 23, w: 52, h: 26, alive: true, rice: false, hp: 1, maxHp: 1, color: color, touching: false });
119
+ }
120
+ finishBuild(this);
121
+ };
122
+ Game.prototype.readyBall = function () {
123
+ this.bricks.forEach(function (brick) { brick.touching = false; });
124
+ this.hitMultiplier = 1;
125
+ this.ball = { x: this.paddle.x, y: PY - R - 3, vx: 0, vy: 0, r: R };
126
+ };
127
+ Game.prototype.move = function (x) {
128
+ if (this.paused) return;
129
+ this.paddle.x = clamp(x, this.paddle.w / 2 + 12, W - this.paddle.w / 2 - 12);
130
+ if (this.phase === 'serve') this.ball.x = this.paddle.x;
131
+ };
132
+ Game.prototype.launch = function () {
133
+ if (this.paused || this.phase !== 'serve') return;
134
+ this.phase = 'playing';
135
+ var speed = this.speed();
136
+ this.ball.vx = speed * 0.28; this.ball.vy = -speed * Math.sqrt(1 - 0.28 * 0.28);
137
+ };
138
+ Game.prototype.speed = function () { return Math.min(4000, Math.min(850, 390 + this.level * 32) * (this.flightSpeed || 1) * (this.hitMultiplier || 1)); };
139
+ Game.prototype.setPaddleBoost = function (percent) {
140
+ if (Number.isFinite(percent)) this.paddleBoost = clamp(percent, 0, 20);
141
+ };
142
+ Game.prototype.setFlightSpeed = function (value) {
143
+ if (!Number.isFinite(value)) return;
144
+ var old = this.speed(); this.flightSpeed = clamp(value, 0.5, 2);
145
+ var ratio = this.speed() / old;
146
+ this.ball.vx *= ratio; this.ball.vy *= ratio;
147
+ };
148
+ Game.prototype.advance = function () {
149
+ if (this.phase !== 'cleared' || this.level >= levelCount()) return false;
150
+ this.level++; this.unlockedLevel = Math.max(this.unlockedLevel, this.level); this.buildLevel(); return true;
151
+ };
152
+ Game.prototype.finishLevel = function () {
153
+ if (this.phase === 'cleared' || this.remainingRice() !== 0) return false;
154
+ this.phase = 'cleared'; this.lives = Math.min(10, this.lives + 1);
155
+ this.unlockedLevel = Math.max(this.unlockedLevel, Math.min(levelCount(), this.level + 1)); return true;
156
+ };
157
+ Game.prototype.selectLevel = function (level, debug) {
158
+ if (!Number.isInteger(level) || level < 1 || level > levelCount() || (debug !== true && level > this.unlockedLevel)) return false;
159
+ this.level = level; this.buildLevel(); return true;
160
+ };
161
+ Game.prototype.lose = function () {
162
+ if (this.paused || this.phase !== 'playing') return [];
163
+ this.lives--;
164
+ var reset = this.lives === 0;
165
+ if (reset) this.reset();
166
+ else { this.phase = 'serve'; this.readyBall(); }
167
+ return [{ type: reset ? 'run-reset' : 'lost' }];
168
+ };
169
+ Game.prototype.snapshot = function () {
170
+ return JSON.parse(JSON.stringify({ version: 8, layoutId: this.initialLayoutId, level: this.level, unlockedLevel: this.unlockedLevel,
171
+ lives: this.lives, phase: this.phase, flightSpeed: this.flightSpeed || 1,
172
+ paddleBoost: this.paddleBoost === undefined ? 1.5 : this.paddleBoost, hitMultiplier: this.hitMultiplier || 1,
173
+ paddleX: this.paddle.x, ball: this.ball, alive: this.bricks.map(function (b) { return b.alive; }),
174
+ bowls: this.bricks.map(function (b) { return { hp: b.hp, rice: b.rice, touching: b.touching }; }) }));
175
+ };
176
+ Game.prototype.remainingRice = function () { return this.bricks.filter(function (brick) { return brick.rice; }).length; };
177
+ Game.prototype.restore = function (save) {
178
+ if (!save || ![1, 2, 3, 4, 5, 6, 7, 8].includes(save.version) || !Number.isSafeInteger(save.level) || save.level < 1 || save.level > 1000000
179
+ || (save.unlockedLevel !== undefined && (!Number.isInteger(save.unlockedLevel) || save.unlockedLevel < 1 || save.unlockedLevel > 100))
180
+ || !Number.isInteger(save.lives) || save.lives < 1 || save.lives > 10
181
+ || !['serve', 'playing', 'cleared'].includes(save.phase)
182
+ || !Number.isFinite(save.paddleX) || save.paddleX < 87 || save.paddleX > 913) return false;
183
+ var multiplier = save.flightSpeed === undefined ? 1 : save.flightSpeed;
184
+ if (!Number.isFinite(multiplier) || multiplier < 0.5 || multiplier > 2) return false;
185
+ var candidate = new Game(); candidate.flightSpeed = multiplier; candidate.level = save.level; candidate.buildLevel();
186
+ var boost = save.paddleBoost === undefined ? 1.5 : save.paddleBoost;
187
+ var hits = save.hitMultiplier === undefined ? 1 : save.hitMultiplier;
188
+ if (!Number.isFinite(boost) || boost < 0 || boost > 20 || !Number.isFinite(hits) || hits < 1 || hits > 100) return false;
189
+ candidate.paddleBoost = boost; candidate.hitMultiplier = hits;
190
+ candidate.unlockedLevel = Math.min(levelCount(), Math.max(save.unlockedLevel || 1, save.level, save.phase === 'cleared' ? save.level + 1 : 1));
191
+ // Earlier builds had a procedural level two and unfinished later levels.
192
+ if (save.version < 7 && save.level >= 2) {
193
+ candidate.level = save.level === 2 ? 2 : 1; candidate.buildLevel(); candidate.paused = true;
194
+ Object.assign(this, candidate); return true;
195
+ }
196
+ if (save.version >= 7 && save.level > levelCount()) return false;
197
+ if ((save.layoutId && save.layoutId !== candidate.initialLayoutId) || (!save.layoutId && remoteLevels && builtinIds[save.level - 1] !== candidate.initialLayoutId)) {
198
+ candidate.lives = save.lives; candidate.paused = true; Object.assign(this, candidate); return true;
199
+ }
200
+ var allowedCounts = save.version >= 7 ? [candidate.bricks.length]
201
+ : [candidate.bricks.length, Math.min(7, 2 + save.level) * 8, Math.min(14, 4 + save.level * 2) * 16];
202
+ if (!Array.isArray(save.alive) || !allowedCounts.includes(save.alive.length)
203
+ || save.alive.some(function (v) { return typeof v !== 'boolean'; })) return false;
204
+ var bowls = save.version >= 4 ? save.bowls : save.alive.map(function (alive) {
205
+ return { hp: alive ? 1 : 0, rice: alive, touching: false };
206
+ });
207
+ if (!Array.isArray(bowls) || bowls.length !== save.alive.length || bowls.some(function (b, i) {
208
+ return !b || !Number.isInteger(b.hp) || b.hp < 0 || b.hp > (save.version >= 7 ? candidate.bricks[i].maxHp : 1)
209
+ || typeof b.rice !== 'boolean' || typeof b.touching !== 'boolean' || save.alive[i] !== (b.hp > 0 || b.rice);
210
+ }) || (save.phase === 'cleared') !== bowls.every(function (b) { return !b.rice; })) return false;
211
+ // Layout upgrades retain bowl/rice state in reading order. A completed old layout advances.
212
+ if (bowls.length !== candidate.bricks.length) {
213
+ if (save.phase === 'cleared') {
214
+ candidate.phase = 'cleared'; candidate.advance(); candidate.paused = true; Object.assign(this, candidate); return true;
215
+ }
216
+ var oldBowls = bowls;
217
+ bowls = candidate.bricks.map(function (_, i) { return Object.assign({}, oldBowls[Math.floor(i * oldBowls.length / candidate.bricks.length)], { touching: false }); });
218
+ if (!bowls.some(function (b) { return b.rice; })) bowls[bowls.length - 1].rice = true;
219
+ }
220
+ var b = save.ball && Object.assign({}, save.ball);
221
+ if (b && save.version === 1 && save.phase === 'serve' && b.y === 619) b.y = PY - R - 3;
222
+ if (!b || !['x', 'y', 'vx', 'vy'].every(function (k) { return Number.isFinite(b[k]); })
223
+ || b.r !== R || b.x < 0 || b.x > W || b.y < 0 || b.y > H + R + 10
224
+ || Math.hypot(b.vx, b.vy) > candidate.speed() + 1
225
+ || (save.phase === 'serve' && (b.vx !== 0 || b.vy !== 0 || b.x !== save.paddleX || b.y !== PY - R - 3))) return false;
226
+ candidate.lives = save.lives; candidate.phase = save.phase;
227
+ candidate.paddle.x = save.paddleX; candidate.ball = { x: b.x, y: b.y, vx: b.vx, vy: b.vy, r: R };
228
+ candidate.bricks.forEach(function (brick, i) { Object.assign(brick, bowls[i], { alive: bowls[i].hp > 0 || bowls[i].rice }); });
229
+ Object.assign(this, candidate); this.paused = true; return true;
230
+ };
231
+ // Circle against rectangle, with overlap correction so a brick cannot trap a ball.
232
+ function hit(ball, rect) {
233
+ var dx = ball.x - clamp(ball.x, rect.x, rect.x + rect.w);
234
+ var dy = ball.y - clamp(ball.y, rect.y, rect.y + rect.h);
235
+ var d = Math.hypot(dx, dy);
236
+ if (d > ball.r) return false;
237
+ var nx, ny, depth;
238
+ if (d > 0.00001) { nx = dx / d; ny = dy / d; depth = ball.r - d; }
239
+ else {
240
+ var faces = [ball.x - rect.x, rect.x + rect.w - ball.x, ball.y - rect.y, rect.y + rect.h - ball.y];
241
+ var face = faces.indexOf(Math.min.apply(Math, faces));
242
+ nx = face === 0 ? -1 : face === 1 ? 1 : 0;
243
+ ny = face === 2 ? -1 : face === 3 ? 1 : 0;
244
+ depth = ball.r + faces[face];
245
+ }
246
+ ball.x += nx * (depth + 0.01); ball.y += ny * (depth + 0.01);
247
+ var dot = ball.vx * nx + ball.vy * ny;
248
+ if (dot < 0) { ball.vx -= 2 * dot * nx; ball.vy -= 2 * dot * ny; }
249
+ return dot < 0 ? { nx: nx, ny: ny } : false;
250
+ }
251
+ Game.prototype.step = function (dt) {
252
+ if (this.paused || this.phase !== 'playing' || !Number.isFinite(dt) || dt <= 0) return [];
253
+ // At most 4px travel per substep, including at the maximum level speed.
254
+ dt = Math.min(dt, 0.05);
255
+ var b = this.ball, events = [], steps = Math.ceil(this.speed() * 1.2 * dt / 4), slice = dt / steps;
256
+ for (var i = 0; i < steps && this.phase === 'playing'; i++) {
257
+ var previousY = b.y;
258
+ b.x += b.vx * slice; b.y += b.vy * slice;
259
+ if (b.x < R) { b.x = R; b.vx = Math.abs(b.vx); }
260
+ if (b.x > W - R) { b.x = W - R; b.vx = -Math.abs(b.vx); }
261
+ if (b.y < R) { b.y = R; b.vy = Math.abs(b.vy); }
262
+ var p = this.paddle;
263
+ if (b.vy > 0 && previousY + R <= PY && b.y + R >= PY && Math.abs(b.x - p.x) <= p.w / 2 + R * 0.6) {
264
+ this.hitMultiplier = Math.min(100, (this.hitMultiplier || 1) * (1 + (this.paddleBoost === undefined ? 1.5 : this.paddleBoost) / 100));
265
+ var angle = clamp((b.x - p.x) / (p.w / 2), -1, 1) * Math.PI / 3;
266
+ b.vx = this.speed() * Math.sin(angle); b.vy = -this.speed() * Math.cos(angle);
267
+ b.y = PY - R - 0.01; events.push({ type: 'paddle' });
268
+ }
269
+ for (var j = 0; j < this.bricks.length; j++) {
270
+ var brick = this.bricks[j];
271
+ if (!brick.alive) continue;
272
+ var separation = Math.hypot(b.x - clamp(b.x, brick.x, brick.x + brick.w), b.y - clamp(b.y, brick.y, brick.y + brick.h));
273
+ if (separation > b.r + 0.5) brick.touching = false;
274
+ var incomingY = b.vy, contactX = b.x, contact = hit(b, brick);
275
+ if (contact) {
276
+ if (brick.touching) continue;
277
+ brick.touching = true;
278
+ var point = { x: brick.x + brick.w / 2, y: brick.y + brick.h / 2 };
279
+ var mouth = contact.ny < -0.5 && incomingY > 0 && contactX >= brick.x + 4 && contactX <= brick.x + brick.w - 4;
280
+ if (brick.rice && (brick.hp === 0 || mouth)) {
281
+ brick.rice = false;
282
+ events.push({ type: 'eat', x: point.x, y: point.y });
283
+ } else if (brick.hp > 0) {
284
+ brick.hp--;
285
+ events.push({ type: brick.hp === 0 ? 'break' : 'crack', x: point.x, y: point.y, index: j });
286
+ }
287
+ brick.alive = brick.hp > 0 || brick.rice;
288
+ if (this.finishLevel()) events.push({ type: 'cleared' });
289
+ break;
290
+ }
291
+ }
292
+ if (b.y - R > H) {
293
+ events = events.concat(this.lose());
294
+ }
295
+ }
296
+ return events;
297
+ };
298
+ // The game only reads the host's existing status channel. Never acknowledges a pet notice.
299
+ function notices(state) {
300
+ if (!state || state.stale || !state.why) return [];
301
+ var why = state.why, result = [], seen = new Set();
302
+ function add(id, kind) {
303
+ if (typeof id !== 'string' || !id || seen.has(id)) return;
304
+ seen.add(id); result.push({ id: id, kind: kind });
305
+ }
306
+ if (state.state === 'error') add(why.statusSessionId, 'error');
307
+ (why.waitingSessionIds || []).forEach(function (id) { add(id, 'waiting'); });
308
+ if (state.state === 'waiting') add(why.statusSessionId, 'waiting');
309
+ (why.completedSessionIds || []).forEach(function (id) { add(id, 'done'); });
310
+ if (state.state === 'done') add(why.statusSessionId, 'done');
311
+ return result;
312
+ }
313
+ function freeze(value) {
314
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
315
+ Object.keys(value).forEach(function (key) { freeze(value[key]); }); Object.freeze(value);
316
+ }
317
+ return value;
318
+ }
319
+ // Game configuration never inherits user-edited pet springs, camera or expression settings.
320
+ function avatarPreferences(core) {
321
+ return freeze(core.cleanPreferences({ physics: true, sway: 0, lookAtPointer: false,
322
+ yaw: 0, viewYaw: 0, viewPitch: 0, springs: {}, motions: {}, motion: false,
323
+ expressions: { weights: { TiePen: 1 }, blink: { enabled: false }, emotion: { enabled: false } } }));
324
+ }
325
+ var api = { DEBUG: false, LEVELS: [{ id: 1, name: 'DS / BALL' }, { id: 2, name: '蓝白大碗' }],
326
+ normalizeLevels: normalizeLevels, setLevels: setLevels, levelCount: levelCount, Game: Game, WIDTH: W, HEIGHT: H, notices: notices, freeze: freeze, avatarPreferences: avatarPreferences };
327
+ globalThis.__VRMPET3D_MINI_GAME__ = api;
328
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
329
+ })();
@@ -0,0 +1,37 @@
1
+ // Startup-only, bounded, silent refresh. Remote content is data, never executable code.
2
+ import { readFileSync } from 'node:fs';
3
+ import './mini-game-core.js';
4
+ // The public repository stays fixed when the dev twin rewrites the plugin identity.
5
+ const repository = 'dsh-plugin-' + ['vrm', 'pet3d'].join('');
6
+ export const LEVEL_URL = `https://raw.githubusercontent.com/drliuhuan/${repository}/refs/heads/ds-ball/assets/game/levels.json`;
7
+ const api = globalThis.__VRMPET3D_MINI_GAME__;
8
+ export function createLevelStore({ fetchImpl = globalThis.fetch, timeoutMs = 8000 } = {}) {
9
+ const bundled = api.normalizeLevels(JSON.parse(readFileSync(new URL('../assets/game/levels.json', import.meta.url), 'utf8')));
10
+ if (!bundled || bundled.levels.length !== 2) throw new Error('invalid bundled game levels');
11
+ let current = bundled, pending;
12
+ return {
13
+ snapshot() { return current; },
14
+ start() {
15
+ if (pending) return pending;
16
+ pending = (async () => {
17
+ const controller = new AbortController();
18
+ const timer = setTimeout(() => controller.abort(), timeoutMs); timer.unref?.();
19
+ try {
20
+ const response = await fetchImpl(LEVEL_URL, {signal:controller.signal,cache:'no-store',redirect:'error'});
21
+ if (!response.ok || Number(response.headers.get('content-length')) > 2 * 1024 * 1024) return;
22
+ const reader = response.body.getReader(), chunks = []; let size = 0;
23
+ while (true) {
24
+ const {done,value} = await reader.read(); if (done) break;
25
+ size += value.byteLength;
26
+ if (size > 2 * 1024 * 1024) { await reader.cancel(); return; }
27
+ chunks.push(Buffer.from(value));
28
+ }
29
+ const clean = api.normalizeLevels(JSON.parse(Buffer.concat(chunks).toString('utf8')));
30
+ if (clean) current = clean;
31
+ } catch (_) { /* All remote failures intentionally keep the bundled two levels. */ }
32
+ finally { clearTimeout(timer); controller.abort(); }
33
+ })();
34
+ return pending;
35
+ },
36
+ };
37
+ }