dsh-plugin-vrmpet3d 0.1.1 → 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.
- package/README.md +1 -0
- package/assets/game/levels.json +2925 -0
- package/desktop/game-visibility.cjs +50 -0
- package/desktop/main.js +60 -3
- package/desktop/preload.js +11 -0
- package/lib/client.core.js +279 -41
- package/lib/client.js +144 -16
- package/lib/client.menu.js +17 -4
- package/lib/client.scene.js +134 -7
- package/lib/config-page.html +11 -0
- package/lib/config-page.js +153 -25
- package/lib/core.js +279 -41
- package/lib/desktop-page.js +167 -40
- package/lib/desktop.js +18 -1
- package/lib/global-page.html +34 -4
- package/lib/index.js +94 -25
- package/lib/menu.js +17 -4
- package/lib/mini-game-core.js +329 -0
- package/lib/mini-game-level-store.js +37 -0
- package/lib/mini-game-page.js +615 -0
- package/lib/mini-game.css +19 -0
- package/lib/mini-game.html +50 -0
- package/lib/scene.js +134 -7
- package/lib/vrm-info.js +16 -1
- package/package.json +3 -3
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// dsh-plugin-vrmpet3d
|
|
1
|
+
// dsh-plugin-vrmpet3d — host half.
|
|
2
2
|
//
|
|
3
3
|
// Responsibilities, and deliberately nothing else:
|
|
4
4
|
//
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// text, tool arguments and attachments are never read, stored or forwarded.
|
|
8
8
|
// 2. Serve the user's own model and authored-motion files to the page over the
|
|
9
9
|
// web carrier's `/vrmpet3d` routes. A plugin's `assets/` directory is NOT served
|
|
10
|
-
// to the browser by DSH
|
|
11
|
-
// `/plugins/<id>/client*.js`
|
|
10
|
+
// to the browser by DSH — the only plugin-owned browser routes are
|
|
11
|
+
// `/plugins/<id>/client*.js` — so a model or a `.vrma` the user drops
|
|
12
12
|
// on disk can only reach the page through a host route. Every route is
|
|
13
13
|
// allowlisted to the configured directories and never exposes an absolute
|
|
14
14
|
// path.
|
|
@@ -25,8 +25,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
25
25
|
import { SettingsStore, settingsFileFor } from './settings.js';
|
|
26
26
|
// `VRMPET3D_*` with the pre-rename `PET3D_*` names as a fallback — see the module's own note.
|
|
27
27
|
import { envVar } from './env.js';
|
|
28
|
+
import { createLevelStore } from './mini-game-level-store.js';
|
|
28
29
|
|
|
29
|
-
// `lib/core.js` is a classic script that publishes itself on the global
|
|
30
|
+
// `lib/core.js` is a classic script that publishes itself on the global — that is
|
|
30
31
|
// how the client half reads it too, so importing it here is what keeps ONE list
|
|
31
32
|
// of file extensions in the project. A host-side copy of the motion extensions is
|
|
32
33
|
// exactly the kind of mirror that drifts: the browser would offer the user a
|
|
@@ -78,7 +79,7 @@ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', '.svn', '__pycache_
|
|
|
78
79
|
*
|
|
79
80
|
* Taken from `lib/core.js` rather than listed again here. The motion catalog
|
|
80
81
|
* beside it is built from `core.MOTION_EXTENSIONS`, and the two must stay
|
|
81
|
-
* disjoint
|
|
82
|
+
* disjoint — a file offered by both catalogs, or by neither, is a class of bug
|
|
82
83
|
* that no single route can see, so membership is decided in one place.
|
|
83
84
|
*/
|
|
84
85
|
const MODEL_EXTENSIONS = new Set(core.MODEL_EXTENSIONS);
|
|
@@ -130,14 +131,14 @@ export const ROUTES = {
|
|
|
130
131
|
/** The desktop window's page, its sources, and its on/off control. */
|
|
131
132
|
window: '/vrmpet3d/window',
|
|
132
133
|
/**
|
|
133
|
-
* The page's own sources, as a prefix
|
|
134
|
+
* The page's own sources, as a prefix — WITHOUT a trailing slash.
|
|
134
135
|
*
|
|
135
136
|
* DSH's matcher decides a prefix hit with `pathname === prefix || pathname.startsWith(prefix + '/')`,
|
|
136
137
|
* so a registered prefix that already ends in `/` can only ever match the literal
|
|
137
138
|
* `/vrmpet3d/window/lib//...`, which no browser sends. The first version of this route had the
|
|
138
139
|
* trailing slash and every source 404'd: the window opened, the page loaded, the glue never
|
|
139
140
|
* ran, and the pet was simply invisible. Every other prefix here works because it has no
|
|
140
|
-
* trailing slash
|
|
141
|
+
* trailing slash — which is the tell that this one was wrong.
|
|
141
142
|
*/
|
|
142
143
|
windowLib: '/vrmpet3d/window/lib',
|
|
143
144
|
desktop: '/vrmpet3d/desktop',
|
|
@@ -208,6 +209,7 @@ export const ROUTES = {
|
|
|
208
209
|
* guide's 偏好模型 step — one page plus one shared component, instead of two hand-kept copies of a grid.
|
|
209
210
|
*/
|
|
210
211
|
picker: '/vrmpet3d/picker',
|
|
212
|
+
game: '/vrmpet3d/game',
|
|
211
213
|
/**
|
|
212
214
|
* The pet's live state, reported by the in-app pet and read by the floating window.
|
|
213
215
|
*
|
|
@@ -267,6 +269,7 @@ export const WINDOW_SOURCES = new Set([
|
|
|
267
269
|
// grid of thumbnails is exactly how the two would drift (and the operator asked for them to be identical).
|
|
268
270
|
'model-picker.js',
|
|
269
271
|
'picker-page.js',
|
|
272
|
+
'mini-game-core.js', 'mini-game-page.js', 'mini-game.css',
|
|
270
273
|
// The stylesheet the TWO settings pages share. One file rather than a copy in each page's `<head>`: the two
|
|
271
274
|
// render the same rows, tables and status lines, and a copy is how the two drift apart.
|
|
272
275
|
'pages.css',
|
|
@@ -1230,7 +1233,7 @@ export function motionLoops(manifest, name) {
|
|
|
1230
1233
|
*
|
|
1231
1234
|
* Compares by ID, never by object identity. `agents.list()` is a live registry
|
|
1232
1235
|
* whose returned objects are not guaranteed to be the same references that
|
|
1233
|
-
* `agents.roots()` holds
|
|
1236
|
+
* `agents.roots()` holds — a copy, a proxy or a re-read all produce
|
|
1234
1237
|
* `roots().includes(agent) === false` and would silently classify EVERY session
|
|
1235
1238
|
* as a subagent, which turns the whole pet off. That failure is invisible: the
|
|
1236
1239
|
* pet simply never reacts.
|
|
@@ -1284,7 +1287,7 @@ export function isInside(root, absolute) {
|
|
|
1284
1287
|
* directory that holds it, but a legitimate request also addresses the model's own
|
|
1285
1288
|
* file, so paths are interpreted relative to that directory. With `..` permitted,
|
|
1286
1289
|
* a path can climb above the declared directory and still resolve to something
|
|
1287
|
-
* inside the root's parent
|
|
1290
|
+
* inside the root's parent — a containment check against the wrong baseline serves
|
|
1288
1291
|
* it quite happily. Rejecting `..` outright removes the class instead of arguing
|
|
1289
1292
|
* about which baselines happen to be safe.
|
|
1290
1293
|
*/
|
|
@@ -1313,7 +1316,7 @@ export function resolveAssetPath(root, relative) {
|
|
|
1313
1316
|
|
|
1314
1317
|
let modelIdCounter = 0;
|
|
1315
1318
|
|
|
1316
|
-
/** Absolute path
|
|
1319
|
+
/** Absolute path → id, so a rescan hands the same file back the same id. */
|
|
1317
1320
|
const modelIdsByPath = new Map();
|
|
1318
1321
|
|
|
1319
1322
|
/**
|
|
@@ -1327,7 +1330,7 @@ const modelIdsByPath = new Map();
|
|
|
1327
1330
|
*
|
|
1328
1331
|
* That breaks the page in a way that looks like a broken model rather than a broken
|
|
1329
1332
|
* id: the client persists the selected `modelId`, reloads it on mount and after a
|
|
1330
|
-
* page refresh, and asks `/vrmpet3d/asset?m=<saved id>`
|
|
1333
|
+
* page refresh, and asks `/vrmpet3d/asset?m=<saved id>` — which then 404s and drops the
|
|
1331
1334
|
* pet back to the placeholder. The id is therefore a property of the path, not of the
|
|
1332
1335
|
* scan.
|
|
1333
1336
|
*
|
|
@@ -1430,7 +1433,7 @@ function scanDirectory(root, options = {}) {
|
|
|
1430
1433
|
}
|
|
1431
1434
|
|
|
1432
1435
|
/**
|
|
1433
|
-
* Build the catalog and the id
|
|
1436
|
+
* Build the catalog and the id → file mapping in one pass.
|
|
1434
1437
|
*
|
|
1435
1438
|
* @param {object} config
|
|
1436
1439
|
* @param {string[]} [config.modelDirs] extra directories supplied by the user
|
|
@@ -1498,7 +1501,7 @@ export function buildCatalog(config = {}, limits = {}) {
|
|
|
1498
1501
|
|
|
1499
1502
|
let motionIdCounter = 0;
|
|
1500
1503
|
|
|
1501
|
-
/** Absolute path
|
|
1504
|
+
/** Absolute path → id, so a rescan hands the same file back the same id. */
|
|
1502
1505
|
const motionIdsByPath = new Map();
|
|
1503
1506
|
|
|
1504
1507
|
/**
|
|
@@ -1508,7 +1511,7 @@ const motionIdsByPath = new Map();
|
|
|
1508
1511
|
*
|
|
1509
1512
|
* * The `'a'` prefix gives motions a namespace of their own. Model ids carry an
|
|
1510
1513
|
* `'m'` and come from a separate counter that also starts at 1, so without
|
|
1511
|
-
* the prefix the first motion and the first model would both be `1`
|
|
1514
|
+
* the prefix the first motion and the first model would both be `1` — the two
|
|
1512
1515
|
* maps hold different files, and a page asking one route for the other's id
|
|
1513
1516
|
* would be served the wrong kind of file by a lookup that reported success.
|
|
1514
1517
|
* * The id does not encode the path, because an id travels to the browser and
|
|
@@ -1539,7 +1542,7 @@ export function bundledMotionDir() {
|
|
|
1539
1542
|
}
|
|
1540
1543
|
|
|
1541
1544
|
/**
|
|
1542
|
-
* Build the motion catalog and the id
|
|
1545
|
+
* Build the motion catalog and the id → file mapping in one pass.
|
|
1543
1546
|
*
|
|
1544
1547
|
* The plugin's own `assets/motions` is scanned FIRST (source `bundled`), exactly as `assets/models` is for
|
|
1545
1548
|
* models, so the shipped pack is offered out of the box instead of waiting for a user to import a directory.
|
|
@@ -1548,7 +1551,7 @@ export function bundledMotionDir() {
|
|
|
1548
1551
|
* apart by the same key the model catalog uses.
|
|
1549
1552
|
*
|
|
1550
1553
|
* Membership is decided by the extension, never by the bytes. That is the only
|
|
1551
|
-
* test that can keep this catalog disjoint from the model catalog
|
|
1554
|
+
* test that can keep this catalog disjoint from the model catalog — a GLB that
|
|
1552
1555
|
* happens to carry `VRMC_vrm_animation` is still a `.glb`, and it must not turn
|
|
1553
1556
|
* up in both lists because the page fetches the two lists by different routes.
|
|
1554
1557
|
* `core.detectMotionFormat` remains the client's check that a file it was handed
|
|
@@ -1805,7 +1808,7 @@ function sendJson(req, res, payload) {
|
|
|
1805
1808
|
* Read a small JSON request body, or answer 413/400 and return without calling back.
|
|
1806
1809
|
*
|
|
1807
1810
|
* One policy for every POST route: a bounded body, an honest status for each refusal. Two
|
|
1808
|
-
* copies of this drifted before
|
|
1811
|
+
* copies of this drifted before — one capped at 64 KB and one not — and the route without the
|
|
1809
1812
|
* cap is the one that reads an arbitrary upload into memory.
|
|
1810
1813
|
*/
|
|
1811
1814
|
function readJsonBody(req, res, limit, onBody) {
|
|
@@ -2015,7 +2018,7 @@ export function createModelInfoHandler(store) {
|
|
|
2015
2018
|
}
|
|
2016
2019
|
|
|
2017
2020
|
/**
|
|
2018
|
-
* `/vrmpet3d/asset?m=<id>&p=<relative>`
|
|
2021
|
+
* `/vrmpet3d/asset?m=<id>&p=<relative>` — stream one model or texture file.
|
|
2019
2022
|
*
|
|
2020
2023
|
* Streamed rather than buffered: VRM files are routinely tens of MB and
|
|
2021
2024
|
* a read-into-memory route would spike the host's heap on every reload. Only
|
|
@@ -2163,11 +2166,11 @@ export function createModelThumbnailHandler(store) {
|
|
|
2163
2166
|
}
|
|
2164
2167
|
|
|
2165
2168
|
/**
|
|
2166
|
-
* `/vrmpet3d/motion?m=<id>`
|
|
2169
|
+
* `/vrmpet3d/motion?m=<id>` — stream one authored-motion file.
|
|
2167
2170
|
*
|
|
2168
2171
|
* Deliberately has no `p` parameter. A model needs one because its textures sit
|
|
2169
2172
|
* in the same directory; a `.vrma` is self-contained, so the only file
|
|
2170
|
-
* this route can ever name is the exact one an id was scanned from
|
|
2173
|
+
* this route can ever name is the exact one an id was scanned from — nothing
|
|
2171
2174
|
* beside it becomes reachable, and there is no relative path to get wrong.
|
|
2172
2175
|
*
|
|
2173
2176
|
* A COMPRESSED clip (`<name>.vrma.br`, the dance pack) is unpacked HERE, so the bytes that reach the renderer
|
|
@@ -2278,6 +2281,8 @@ function sendBuffer(req, res, bytes, name) {
|
|
|
2278
2281
|
* routes, the service and the stream listeners in one step.
|
|
2279
2282
|
*/
|
|
2280
2283
|
export function apply(ctx, config = {}) {
|
|
2284
|
+
const gameLevelStore = createLevelStore();
|
|
2285
|
+
void gameLevelStore.start();
|
|
2281
2286
|
const agents = ctx.get('agents');
|
|
2282
2287
|
const hub = new VrmPet3DBoundaryHub({ agents });
|
|
2283
2288
|
|
|
@@ -2420,7 +2425,7 @@ export function apply(ctx, config = {}) {
|
|
|
2420
2425
|
};
|
|
2421
2426
|
|
|
2422
2427
|
/**
|
|
2423
|
-
* `/vrmpet3d/motions`
|
|
2428
|
+
* `/vrmpet3d/motions` — the motion catalog, shaped like `/vrmpet3d/catalog`.
|
|
2424
2429
|
*
|
|
2425
2430
|
* Its own route rather than a field of the model catalog: the two are rebuilt
|
|
2426
2431
|
* from separate directories, and the settings panel opens one picker at a time.
|
|
@@ -2797,7 +2802,7 @@ export function apply(ctx, config = {}) {
|
|
|
2797
2802
|
* The always-on-top window, as a separate process.
|
|
2798
2803
|
*
|
|
2799
2804
|
* A plugin cannot ask DSH for a window (the host half has no Electron, the client half's IPC
|
|
2800
|
-
* surface has no such channel
|
|
2805
|
+
* surface has no such channel — see HANDOFF sec.12), so the window is started here: the
|
|
2801
2806
|
* launcher in `desktop/` is run against a bare-Electron runtime borrowed from the
|
|
2802
2807
|
* installation by hard links. `lib/desktop.js` owns all of that; this half owns the routes
|
|
2803
2808
|
* that switch it on and off, and the requirement that it dies with the plugin.
|
|
@@ -2861,10 +2866,12 @@ export function apply(ctx, config = {}) {
|
|
|
2861
2866
|
* repeatable, the counter is not, so a preview that is set twice in a row is still noticed once.
|
|
2862
2867
|
*/
|
|
2863
2868
|
let previewValue = null;
|
|
2869
|
+
let navigation = { seq: 0, sessionId: null };
|
|
2870
|
+
const navigationCursor = { waiting: null, completed: null };
|
|
2864
2871
|
let previewSeq = 0;
|
|
2865
2872
|
const previewHandler = (req, res) => {
|
|
2866
2873
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
2867
|
-
sendJson(req, res, { seq: previewSeq, preview: previewValue });
|
|
2874
|
+
sendJson(req, res, { seq: previewSeq, preview: previewValue, navigation });
|
|
2868
2875
|
return;
|
|
2869
2876
|
}
|
|
2870
2877
|
if (req.method !== 'POST') {
|
|
@@ -2936,9 +2943,13 @@ export function apply(ctx, config = {}) {
|
|
|
2936
2943
|
const value = Number(raw[key]);
|
|
2937
2944
|
if (Number.isFinite(value)) out[key] = Math.floor(value);
|
|
2938
2945
|
}
|
|
2939
|
-
for (const key of ['scope', 'session', 'interaction']) {
|
|
2946
|
+
for (const key of ['scope', 'session', 'interaction', 'statusSessionId', 'completedSessionId']) {
|
|
2940
2947
|
if (typeof raw[key] === 'string' && raw[key]) out[key] = raw[key].slice(0, 64);
|
|
2941
2948
|
}
|
|
2949
|
+
for (const key of ['waitingSessionIds', 'completedSessionIds']) {
|
|
2950
|
+
if (Array.isArray(raw[key])) out[key] = [...new Set(raw[key].filter(id => typeof id === 'string' && id))]
|
|
2951
|
+
.slice(0, 256).map(id => id.slice(0, 64));
|
|
2952
|
+
}
|
|
2942
2953
|
if (Array.isArray(raw.runningIds)) {
|
|
2943
2954
|
out.runningIds = raw.runningIds
|
|
2944
2955
|
.filter((id) => typeof id === 'string' && id)
|
|
@@ -3871,6 +3882,11 @@ export function apply(ctx, config = {}) {
|
|
|
3871
3882
|
let html = null;
|
|
3872
3883
|
try {
|
|
3873
3884
|
html = readFileSync(join(PLUGIN_ROOT, 'lib', file));
|
|
3885
|
+
if (file === 'mini-game.html') {
|
|
3886
|
+
const data = JSON.stringify(gameLevelStore.snapshot()).replaceAll('<', '\\u003c');
|
|
3887
|
+
html = Buffer.from(html.toString('utf8').replace('<script id="game-level-data" type="application/json">null</script>',
|
|
3888
|
+
'<script id="game-level-data" type="application/json">' + data + '</script>'));
|
|
3889
|
+
}
|
|
3874
3890
|
} catch (error) {
|
|
3875
3891
|
sendStatus(res, 500, `cannot read the ${label}: ${error.message}`);
|
|
3876
3892
|
return;
|
|
@@ -3904,6 +3920,7 @@ export function apply(ctx, config = {}) {
|
|
|
3904
3920
|
* mounts is the only version of that which cannot drift.
|
|
3905
3921
|
*/
|
|
3906
3922
|
const pickerPageHandler = settingsPageHandler('picker-page.html', 'model-picker page');
|
|
3923
|
+
const gamePageHandler = settingsPageHandler('mini-game.html', 'mini-game page');
|
|
3907
3924
|
|
|
3908
3925
|
/**
|
|
3909
3926
|
* The page's own sources, by NAME — the module-level `WINDOW_SOURCES`, so the verification suite reads the
|
|
@@ -4003,6 +4020,12 @@ export function apply(ctx, config = {}) {
|
|
|
4003
4020
|
* way: numbers and short CSS lengths only, sanitised here the way every other report is.
|
|
4004
4021
|
*/
|
|
4005
4022
|
let desktopPageMetrics = null;
|
|
4023
|
+
const gameFocusLeases = new Map();
|
|
4024
|
+
function gameHasFocus() {
|
|
4025
|
+
const now = Date.now();
|
|
4026
|
+
for (const [client, deadline] of gameFocusLeases) if (deadline <= now) gameFocusLeases.delete(client);
|
|
4027
|
+
return gameFocusLeases.size > 0;
|
|
4028
|
+
}
|
|
4006
4029
|
function cleanPageMetrics(raw) {
|
|
4007
4030
|
if (!raw || typeof raw !== 'object') return null;
|
|
4008
4031
|
const out = {};
|
|
@@ -4037,6 +4060,7 @@ export function apply(ctx, config = {}) {
|
|
|
4037
4060
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
4038
4061
|
sendJson(req, res, {
|
|
4039
4062
|
...desktopWindow.describe(),
|
|
4063
|
+
gameFocused: gameHasFocus(),
|
|
4040
4064
|
/**
|
|
4041
4065
|
* The WINDOW's OWN bubble measurements, posted by its page (action metrics below).
|
|
4042
4066
|
*
|
|
@@ -4056,6 +4080,50 @@ export function apply(ctx, config = {}) {
|
|
|
4056
4080
|
}
|
|
4057
4081
|
readJsonBody(req, res, 16 * 1024, (body) => {
|
|
4058
4082
|
const action = typeof body?.action === 'string' ? body.action : '';
|
|
4083
|
+
if (action === 'game-focus') {
|
|
4084
|
+
const clientId = body?.clientId;
|
|
4085
|
+
if (typeof clientId !== 'string' || !/^[a-zA-Z0-9-]{8,96}$/.test(clientId) || typeof body.active !== 'boolean') {
|
|
4086
|
+
sendStatus(res, 400, 'invalid game focus report'); return;
|
|
4087
|
+
}
|
|
4088
|
+
gameHasFocus();
|
|
4089
|
+
if (body.active && (gameFocusLeases.has(clientId) || gameFocusLeases.size < 64)) gameFocusLeases.set(clientId, Date.now() + 3500);
|
|
4090
|
+
else if (!body.active) gameFocusLeases.delete(clientId);
|
|
4091
|
+
sendJson(req, res, { ok: true, gameFocused: gameHasFocus() }); return;
|
|
4092
|
+
}
|
|
4093
|
+
if (action === 'game-session') {
|
|
4094
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : '';
|
|
4095
|
+
const allowed = petStateStale ? [] : [petStateWhy?.statusSessionId,
|
|
4096
|
+
...(petStateWhy?.waitingSessionIds || []), ...(petStateWhy?.completedSessionIds || [])];
|
|
4097
|
+
if (!sessionId || !allowed.includes(sessionId)) { sendJson(req, res, { ok: false }); return; }
|
|
4098
|
+
navigation = { seq: navigation.seq + 1, sessionId };
|
|
4099
|
+
sendJson(req, res, { ok: true });
|
|
4100
|
+
return;
|
|
4101
|
+
}
|
|
4102
|
+
if (action === 'game') {
|
|
4103
|
+
const gameUrl = pageUrlFor(req, ROUTES.game);
|
|
4104
|
+
if (!gameUrl) { sendStatus(res, 400, 'no host header'); return; }
|
|
4105
|
+
const url = new URL(gameUrl);
|
|
4106
|
+
if (typeof body.modelId === 'string') url.searchParams.set('model', body.modelId.slice(0, 256));
|
|
4107
|
+
const opened = desktopWindow.openConfig({ url: url.href });
|
|
4108
|
+
if (!opened.ok) { sendStatus(res, 500, opened.error || 'game could not be opened'); return; }
|
|
4109
|
+
sendJson(req, res, { state: 'game-open', pid: opened.pid || null, url: url.href });
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
if (action === 'open-status-session' || action === 'open-completed-session') {
|
|
4113
|
+
let sessionId = petStateStale ? null : action === 'open-completed-session'
|
|
4114
|
+
? (['working', 'waiting'].includes(petStateValue) && petStateUnread > 0 ? petStateWhy?.completedSessionId : null)
|
|
4115
|
+
: (['done', 'waiting', 'error'].includes(petStateValue) ? petStateWhy?.statusSessionId : null);
|
|
4116
|
+
const kind = action === 'open-completed-session' || petStateValue === 'done' ? 'completed' : 'waiting';
|
|
4117
|
+
const ids = petStateWhy?.[kind === 'completed' ? 'completedSessionIds' : 'waitingSessionIds'];
|
|
4118
|
+
if (sessionId && petStateValue !== 'error' && Array.isArray(ids) && ids.length) {
|
|
4119
|
+
sessionId = ids[(ids.indexOf(navigationCursor[kind]) + 1) % ids.length];
|
|
4120
|
+
navigationCursor[kind] = sessionId;
|
|
4121
|
+
}
|
|
4122
|
+
if (!sessionId) { sendJson(req, res, { ok: false }); return; }
|
|
4123
|
+
navigation = { seq: navigation.seq + 1, sessionId };
|
|
4124
|
+
sendJson(req, res, { ok: true });
|
|
4125
|
+
return;
|
|
4126
|
+
}
|
|
4059
4127
|
/**
|
|
4060
4128
|
* The window reporting its own bubble numbers — see pageMetrics in the GET above. Answered by the HOST rather than
|
|
4061
4129
|
* forwarded to the launcher, so an older launcher cannot reject it.
|
|
@@ -4209,7 +4277,7 @@ export function apply(ctx, config = {}) {
|
|
|
4209
4277
|
*
|
|
4210
4278
|
* `ctx.inject` is the declarative fix: Cordis calls the callback with a scope in
|
|
4211
4279
|
* which `webServer` exists, whenever that becomes true, and disposes the scope if
|
|
4212
|
-
* it goes away. The service is an OPTIONAL dependency on purpose
|
|
4280
|
+
* it goes away. The service is an OPTIONAL dependency on purpose — a hard
|
|
4213
4281
|
* `inject: ['webServer']` would leave the plugin waiting for activation forever
|
|
4214
4282
|
* in a composition with no web server, which is a far worse failure than a pet
|
|
4215
4283
|
* that cannot load model files.
|
|
@@ -4235,6 +4303,7 @@ export function apply(ctx, config = {}) {
|
|
|
4235
4303
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.config, handler: configPageHandler }));
|
|
4236
4304
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.global, handler: globalPageHandler }));
|
|
4237
4305
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.picker, handler: pickerPageHandler }));
|
|
4306
|
+
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.game, handler: gamePageHandler }));
|
|
4238
4307
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.settings, handler: settingsHandler }));
|
|
4239
4308
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.preview, handler: previewHandler }));
|
|
4240
4309
|
disposers.push(webServer.register({ kind: 'exact', path: ROUTES.setup, handler: setupPageHandler }));
|
package/lib/menu.js
CHANGED
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
* whose effect ends by itself (the clip finishes, or the user double-clicks the pet, or work starts).
|
|
61
61
|
*/
|
|
62
62
|
'dance',
|
|
63
|
+
'miniGame',
|
|
63
64
|
/**
|
|
64
65
|
* 重载模型 — refresh the model on screen without touching anything else (operator, 2026-10-14: 「给内置和外置宠物的右键菜单
|
|
65
66
|
* 新增“重载模型”的选项,用来刷新模型」).
|
|
@@ -102,10 +103,11 @@
|
|
|
102
103
|
resetSize: 'Reset size',
|
|
103
104
|
globalSettings: 'General settings',
|
|
104
105
|
switchModel: 'Switch model',
|
|
106
|
+
miniGame: 'Pet breakout',
|
|
105
107
|
reloadModel: 'Reload model',
|
|
106
108
|
dance: 'Dance for me',
|
|
107
109
|
modelSettings: "This model's parameters",
|
|
108
|
-
surfaceWindow: '
|
|
110
|
+
surfaceWindow: 'Switch to an independent pet',
|
|
109
111
|
surfaceApp: 'Move back inside the app',
|
|
110
112
|
surfaceOff: 'disabled while debug mode is on',
|
|
111
113
|
gravity: 'Gravity',
|
|
@@ -130,10 +132,11 @@
|
|
|
130
132
|
resetSize: '重置大小',
|
|
131
133
|
globalSettings: '通用设置',
|
|
132
134
|
switchModel: '切换模型',
|
|
135
|
+
miniGame: '小游戏 · 吃白饭',
|
|
133
136
|
reloadModel: '重载模型',
|
|
134
137
|
dance: '跳支舞',
|
|
135
138
|
modelSettings: '当前模型参数设置',
|
|
136
|
-
surfaceWindow: '
|
|
139
|
+
surfaceWindow: '切换为独立宠物',
|
|
137
140
|
surfaceApp: '切换回窗口内',
|
|
138
141
|
surfaceOff: '调试模式下不生效',
|
|
139
142
|
gravity: '重力',
|
|
@@ -228,6 +231,7 @@
|
|
|
228
231
|
var onChoose = typeof options.onChoose === 'function' ? options.onChoose : function () {};
|
|
229
232
|
var toSurface = options.toSurface === 'app' ? 'app' : 'window';
|
|
230
233
|
var surfaceAllowed = options.surfaceAllowed !== false;
|
|
234
|
+
var danceState = 'resting';
|
|
231
235
|
/** Whether this surface offers the 切换 row at all. See the `showSurface` option. */
|
|
232
236
|
var showSurface = options.showSurface !== false;
|
|
233
237
|
|
|
@@ -254,6 +258,8 @@
|
|
|
254
258
|
|
|
255
259
|
/** The label for one id, from the state this menu was built with. */
|
|
256
260
|
function labelFor(id) {
|
|
261
|
+
if (id === 'dance') return danceState === 'dancing' ? (options.language === 'en' ? 'Another dance' : '换一支舞')
|
|
262
|
+
: (['resting', 'offline'].indexOf(danceState) === -1 ? (options.language === 'en' ? 'The pet is busy…' : '宠物目前很忙…') : text.dance);
|
|
257
263
|
if (id === 'surface') return toSurface === 'app' ? text.surfaceApp : text.surfaceWindow;
|
|
258
264
|
if (id === 'closePet') return toSurface === 'app' ? text.closePetWindow : text.closePetApp;
|
|
259
265
|
return text[id] || id;
|
|
@@ -267,7 +273,8 @@
|
|
|
267
273
|
ITEMS.forEach(function (id) {
|
|
268
274
|
// The 切换 row is the one entry a surface may omit — the window's 关闭独立宠物 already does its job.
|
|
269
275
|
if (id === 'surface' && !showSurface) return;
|
|
270
|
-
var disabled = id === 'surface' && surfaceAllowed !== true
|
|
276
|
+
var disabled = (id === 'surface' && surfaceAllowed !== true)
|
|
277
|
+
|| (id === 'dance' && ['resting', 'offline', 'dancing'].indexOf(danceState) === -1);
|
|
271
278
|
var toggle = TOGGLES.indexOf(id) !== -1;
|
|
272
279
|
var checked = toggle && checkedState[id] === true;
|
|
273
280
|
var item = doc.createElement('button');
|
|
@@ -292,7 +299,7 @@
|
|
|
292
299
|
* waiting to happen ("the switch does nothing"). The reason is the operator's own qualifier —
|
|
293
300
|
* 非debug模式生效 — spelled out for the mode that turns it off.
|
|
294
301
|
*/
|
|
295
|
-
item.title = text.surfaceOff;
|
|
302
|
+
item.title = id === 'dance' ? label : text.surfaceOff;
|
|
296
303
|
} else {
|
|
297
304
|
if (toggle && !checked) item.title = text.gravityOff;
|
|
298
305
|
item.addEventListener('click', function (event) {
|
|
@@ -448,6 +455,12 @@
|
|
|
448
455
|
setSurface: function (next) { toSurface = next === 'app' ? 'app' : 'window'; },
|
|
449
456
|
/** Whether that row may be used at all (false in debug mode). */
|
|
450
457
|
setSurfaceAllowed: function (allowed) { surfaceAllowed = allowed !== false; },
|
|
458
|
+
setSurfaceVisible: function (visible) { showSurface = visible === true; },
|
|
459
|
+
setDanceState: function (state) {
|
|
460
|
+
if (danceState === state) return;
|
|
461
|
+
danceState = state;
|
|
462
|
+
if (!element.hidden) render();
|
|
463
|
+
},
|
|
451
464
|
/**
|
|
452
465
|
* Which way a SWITCH row is thrown, read at OPEN time by the surface that owns the preference.
|
|
453
466
|
*
|