@volter/editor-blender 0.1.151 → 0.1.152
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/contributions/blender-model-opening.tsx +25 -51
- package/contributions/blender-node-editor.document.tsx +2 -1
- package/contributions/blender-play-area.tsx +3 -4
- package/contributions/blender-properties-model.ts +44 -1
- package/contributions/blender-properties-view.tsx +24 -34
- package/contributions/blender-runtime.document.tsx +59 -385
- package/contributions/blender-timeline.document.tsx +6 -4
- package/contributions/blender-version.status.tsx +2 -8
- package/contributions/blender.page.ts +12 -11
- package/contributions/blender.status.tsx +1 -7
- package/contributions/models.finder.ts +4 -11
- package/dist-node/serving.mjs +0 -2
- package/host/blender-runtime-host.ts +290 -76
- package/node/blender-mcp.ts +3 -2
- package/package.json +56 -44
- package/serving/blender-routes.ts +0 -5
- package/src/api.ts +2 -2
- package/src/play-mode.ts +10 -65
- package/src/play-tool.ts +44 -0
- package/src/model-play-extension.ts +0 -173
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publisher": "volter",
|
|
4
4
|
"author": "Volter AI, Inc.",
|
|
5
5
|
"license": "AGPL-3.0-only AND GPL-3.0-or-later",
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.152",
|
|
7
7
|
"engines": {
|
|
8
8
|
"vscode": "*"
|
|
9
9
|
},
|
|
@@ -95,8 +95,20 @@
|
|
|
95
95
|
],
|
|
96
96
|
"views": {
|
|
97
97
|
"statusbar": [
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
{
|
|
99
|
+
"id": "version",
|
|
100
|
+
"name": "Version",
|
|
101
|
+
"alignment": "right",
|
|
102
|
+
"priority": -5998,
|
|
103
|
+
"module": "./contributions/blender-version.status.tsx"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"id": "mouseHints",
|
|
107
|
+
"name": "Mouse hints",
|
|
108
|
+
"alignment": "left",
|
|
109
|
+
"priority": 5000,
|
|
110
|
+
"module": "./contributions/blender.status.tsx"
|
|
111
|
+
}
|
|
100
112
|
]
|
|
101
113
|
},
|
|
102
114
|
"finders": [
|
|
@@ -535,179 +547,179 @@
|
|
|
535
547
|
{
|
|
536
548
|
"command": "volter.edit.duplicate",
|
|
537
549
|
"key": "shift+D",
|
|
538
|
-
"when": "volter.focused && !inputFocus && volter.
|
|
550
|
+
"when": "volter.focused && !inputFocus && volter.keymap == 'blender'"
|
|
539
551
|
},
|
|
540
552
|
{
|
|
541
553
|
"command": "volter.edit.selectAll",
|
|
542
554
|
"key": "A",
|
|
543
|
-
"when": "volter.focused && !inputFocus && volter.
|
|
555
|
+
"when": "volter.focused && !inputFocus && volter.keymap == 'blender'"
|
|
544
556
|
},
|
|
545
557
|
{
|
|
546
558
|
"command": "volter.edit.deselectAll",
|
|
547
559
|
"key": "alt+A",
|
|
548
|
-
"when": "volter.focused && !inputFocus && volter.
|
|
560
|
+
"when": "volter.focused && !inputFocus && volter.keymap == 'blender'"
|
|
549
561
|
},
|
|
550
562
|
{
|
|
551
563
|
"command": "volter.edit.delete",
|
|
552
564
|
"key": "X",
|
|
553
|
-
"when": "volter.focused && !inputFocus && volter.
|
|
565
|
+
"when": "volter.focused && !inputFocus && volter.keymap == 'blender'"
|
|
554
566
|
},
|
|
555
567
|
{
|
|
556
568
|
"command": "volter.transform.select",
|
|
557
569
|
"key": "W",
|
|
558
|
-
"when": "volter.stage.focused && volter.
|
|
570
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
559
571
|
},
|
|
560
572
|
{
|
|
561
573
|
"command": "volter.transform.translate",
|
|
562
574
|
"key": "G",
|
|
563
|
-
"when": "volter.stage.focused && volter.
|
|
575
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
564
576
|
},
|
|
565
577
|
{
|
|
566
578
|
"command": "volter.transform.rotate",
|
|
567
579
|
"key": "R",
|
|
568
|
-
"when": "volter.stage.focused && volter.
|
|
580
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
569
581
|
},
|
|
570
582
|
{
|
|
571
583
|
"command": "volter.transform.scale",
|
|
572
584
|
"key": "S",
|
|
573
|
-
"when": "volter.stage.focused && volter.
|
|
585
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
574
586
|
},
|
|
575
587
|
{
|
|
576
588
|
"command": "volter.viewport.frameSelection",
|
|
577
589
|
"key": "NumPad_Decimal",
|
|
578
|
-
"when": "volter.stage.focused && volter.
|
|
590
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
579
591
|
},
|
|
580
592
|
{
|
|
581
593
|
"command": "volter.viewport.frameSelection",
|
|
582
594
|
"key": ".",
|
|
583
|
-
"when": "volter.stage.focused && volter.
|
|
595
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
584
596
|
},
|
|
585
597
|
{
|
|
586
598
|
"command": "volter.viewport.cyclePivot",
|
|
587
599
|
"key": ",",
|
|
588
|
-
"when": "volter.stage.focused && volter.
|
|
600
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
589
601
|
},
|
|
590
602
|
{
|
|
591
603
|
"command": "volter.view.bottom",
|
|
592
604
|
"key": "ctrl+NumPad7",
|
|
593
|
-
"when": "volter.stage.focused && volter.
|
|
605
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
594
606
|
},
|
|
595
607
|
{
|
|
596
608
|
"command": "volter.view.back",
|
|
597
609
|
"key": "ctrl+NumPad1",
|
|
598
|
-
"when": "volter.stage.focused && volter.
|
|
610
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
599
611
|
},
|
|
600
612
|
{
|
|
601
613
|
"command": "volter.view.left",
|
|
602
614
|
"key": "ctrl+NumPad3",
|
|
603
|
-
"when": "volter.stage.focused && volter.
|
|
615
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
604
616
|
},
|
|
605
617
|
{
|
|
606
618
|
"command": "volter.view.orbitLeft",
|
|
607
619
|
"key": "NumPad4",
|
|
608
|
-
"when": "volter.stage.focused && volter.
|
|
620
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
609
621
|
},
|
|
610
622
|
{
|
|
611
623
|
"command": "volter.view.orbitRight",
|
|
612
624
|
"key": "NumPad6",
|
|
613
|
-
"when": "volter.stage.focused && volter.
|
|
625
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
614
626
|
},
|
|
615
627
|
{
|
|
616
628
|
"command": "volter.view.orbitUp",
|
|
617
629
|
"key": "NumPad8",
|
|
618
|
-
"when": "volter.stage.focused && volter.
|
|
630
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
619
631
|
},
|
|
620
632
|
{
|
|
621
633
|
"command": "volter.view.orbitDown",
|
|
622
634
|
"key": "NumPad2",
|
|
623
|
-
"when": "volter.stage.focused && volter.
|
|
635
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
624
636
|
},
|
|
625
637
|
{
|
|
626
638
|
"command": "volter.view.opposite",
|
|
627
639
|
"key": "NumPad9",
|
|
628
|
-
"when": "volter.stage.focused && volter.
|
|
640
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
629
641
|
},
|
|
630
642
|
{
|
|
631
643
|
"command": "volter.view.rollLeft",
|
|
632
644
|
"key": "shift+NumPad4",
|
|
633
|
-
"when": "volter.stage.focused && volter.
|
|
645
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
634
646
|
},
|
|
635
647
|
{
|
|
636
648
|
"command": "volter.view.rollRight",
|
|
637
649
|
"key": "shift+NumPad6",
|
|
638
|
-
"when": "volter.stage.focused && volter.
|
|
650
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
639
651
|
},
|
|
640
652
|
{
|
|
641
653
|
"command": "volter.view.all",
|
|
642
654
|
"key": "Home",
|
|
643
|
-
"when": "volter.stage.focused && volter.
|
|
655
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
644
656
|
},
|
|
645
657
|
{
|
|
646
658
|
"command": "volter.view.zoomIn",
|
|
647
659
|
"key": "ctrl+=",
|
|
648
|
-
"when": "volter.stage.focused && volter.
|
|
660
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
649
661
|
},
|
|
650
662
|
{
|
|
651
663
|
"command": "volter.view.zoomIn",
|
|
652
664
|
"key": "NumPad_Add",
|
|
653
|
-
"when": "volter.stage.focused && volter.
|
|
665
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
654
666
|
},
|
|
655
667
|
{
|
|
656
668
|
"command": "volter.view.zoomOut",
|
|
657
669
|
"key": "ctrl+-",
|
|
658
|
-
"when": "volter.stage.focused && volter.
|
|
670
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
659
671
|
},
|
|
660
672
|
{
|
|
661
673
|
"command": "volter.view.zoomOut",
|
|
662
674
|
"key": "NumPad_Subtract",
|
|
663
|
-
"when": "volter.stage.focused && volter.
|
|
675
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
664
676
|
},
|
|
665
677
|
{
|
|
666
678
|
"command": "volter.view.projection",
|
|
667
679
|
"key": "NumPad5",
|
|
668
|
-
"when": "volter.stage.focused && volter.
|
|
680
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
669
681
|
},
|
|
670
682
|
{
|
|
671
683
|
"command": "volter.unboundKey",
|
|
672
684
|
"key": "ctrl+D",
|
|
673
685
|
"mac": "cmd+D",
|
|
674
|
-
"when": "volter.focused && !inputFocus && volter.
|
|
686
|
+
"when": "volter.focused && !inputFocus && volter.keymap == 'blender'"
|
|
675
687
|
},
|
|
676
688
|
{
|
|
677
689
|
"command": "volter.edit.delete",
|
|
678
690
|
"key": "Delete",
|
|
679
|
-
"when": "volter.focused && !inputFocus && volter.
|
|
691
|
+
"when": "volter.focused && !inputFocus && volter.keymap == 'blender'"
|
|
680
692
|
},
|
|
681
693
|
{
|
|
682
694
|
"command": "volter.unboundKey",
|
|
683
695
|
"key": "Backspace",
|
|
684
|
-
"when": "volter.focused && !inputFocus && volter.
|
|
696
|
+
"when": "volter.focused && !inputFocus && volter.keymap == 'blender'"
|
|
685
697
|
},
|
|
686
698
|
{
|
|
687
699
|
"command": "volter.unboundKey",
|
|
688
700
|
"key": "Q",
|
|
689
|
-
"when": "volter.stage.focused && volter.
|
|
701
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
690
702
|
},
|
|
691
703
|
{
|
|
692
704
|
"command": "volter.unboundKey",
|
|
693
705
|
"key": "E",
|
|
694
|
-
"when": "volter.stage.focused && volter.
|
|
706
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
695
707
|
},
|
|
696
708
|
{
|
|
697
709
|
"command": "volter.unboundKey",
|
|
698
710
|
"key": "F",
|
|
699
|
-
"when": "volter.stage.focused && volter.
|
|
711
|
+
"when": "volter.stage.focused && volter.keymap == 'blender'"
|
|
700
712
|
},
|
|
701
713
|
{
|
|
702
714
|
"command": "volter.unboundKey",
|
|
703
715
|
"key": "M",
|
|
704
|
-
"when": "volter.stage.focused && volter.stage.
|
|
716
|
+
"when": "volter.stage.focused && volter.stage.surface == 'canvas' && volter.keymap == 'blender'"
|
|
705
717
|
},
|
|
706
718
|
{
|
|
707
719
|
"command": "volter.unboundKey",
|
|
708
720
|
"key": "ctrl+'",
|
|
709
721
|
"mac": "cmd+'",
|
|
710
|
-
"when": "volter.stage.focused && volter.stage.
|
|
722
|
+
"when": "volter.stage.focused && volter.stage.surface == 'canvas' && volter.keymap == 'blender'"
|
|
711
723
|
}
|
|
712
724
|
],
|
|
713
725
|
"languageModelTools": [
|
|
@@ -1233,17 +1245,17 @@
|
|
|
1233
1245
|
}
|
|
1234
1246
|
},
|
|
1235
1247
|
"dependencies": {
|
|
1236
|
-
"@volter/editor-movie": "0.5.
|
|
1248
|
+
"@volter/editor-movie": "0.5.213",
|
|
1237
1249
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
1238
|
-
"@volter/blender-engine": "0.1.
|
|
1239
|
-
"@volter/editor-threejs": "0.5.
|
|
1250
|
+
"@volter/blender-engine": "0.1.150",
|
|
1251
|
+
"@volter/editor-threejs": "0.5.213",
|
|
1240
1252
|
"zod": "^4.3.6",
|
|
1241
1253
|
"@fortawesome/free-solid-svg-icons": "^7.2.0"
|
|
1242
1254
|
},
|
|
1243
1255
|
"peerDependencies": {
|
|
1244
|
-
"@volter/live": "0.5.
|
|
1245
|
-
"@volter/project": "0.5.
|
|
1246
|
-
"@volter/sdk": "0.5.
|
|
1256
|
+
"@volter/live": "0.5.213",
|
|
1257
|
+
"@volter/project": "0.5.213",
|
|
1258
|
+
"@volter/sdk": "0.5.213",
|
|
1247
1259
|
"react": "^19.0.0",
|
|
1248
1260
|
"react-dom": "^19.0.0",
|
|
1249
1261
|
"three": "^0.186.1",
|
|
@@ -90,7 +90,6 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
|
|
|
90
90
|
method: 'GET',
|
|
91
91
|
match: /^\/__editor\/blender-wasm\/status$/,
|
|
92
92
|
handle: async (_req, res) => {
|
|
93
|
-
services.allowCrossOriginFrameEmbedding(res);
|
|
94
93
|
json(res, await blenderWasmStatus());
|
|
95
94
|
},
|
|
96
95
|
},
|
|
@@ -169,10 +168,6 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
|
|
|
169
168
|
match: /^\/__editor\/blender-wasm\/([^/]+)$/,
|
|
170
169
|
handle: async (req, res, url) => {
|
|
171
170
|
const file = decodeURIComponent(url.pathname.slice('/__editor/blender-wasm/'.length));
|
|
172
|
-
// The frame's page is cross-origin-isolated and this session is not its origin, so every
|
|
173
|
-
// byte of the engine has to say it may be embedded — the glue above all, which Blender's
|
|
174
|
-
// pthreads load with `importScripts`.
|
|
175
|
-
services.allowCrossOriginFrameEmbedding(res);
|
|
176
171
|
const bundled = (BLENDER_WASM_FILES as readonly string[]).includes(file);
|
|
177
172
|
if (!bundled && file !== BLENDER_WALI_ARTIFACT) {
|
|
178
173
|
json(res, { error: `The Blender build has no ${file}.` }, 404);
|
package/src/api.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EDITOR-BLENDER'S API, AS TYPES: what its page half's `activate` returns
|
|
3
|
-
* (`../contributions/blender.page.ts`), the
|
|
3
|
+
* (`../contributions/blender.page.ts`), the Play tool seam's type and a landed save's. A package that reads the
|
|
4
4
|
* API imports these type-only; the values come from `getExtension(...).exports`.
|
|
5
5
|
*/
|
|
6
6
|
export type BlenderPageApi = ReturnType<typeof import('../contributions/blender.page').activate>;
|
|
7
|
-
export type
|
|
7
|
+
export type { PlayTool } from './play-tool';
|
|
8
8
|
export type { ModelPlayMode } from './play-mode';
|
|
9
9
|
export type { ModelDocumentSaved } from '../host/blender-runtime-host';
|
package/src/play-mode.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** A model's bottom area shows Blender's animation editors or the Play tool's Game panel; switching leaves a running game running. Remember the person's mode per model for this browser session; otherwise a model opens in Game where the project has a game (`PlayTool.hasGame`). The model on screen announces itself, so focusing the bottom area does not change which model it serves. */
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from './
|
|
3
|
+
playTool,
|
|
4
|
+
subscribePlayTool,
|
|
5
|
+
} from './play-tool';
|
|
6
6
|
|
|
7
7
|
export type ModelPlayMode = 'game' | 'animation';
|
|
8
8
|
|
|
@@ -13,8 +13,6 @@ export function playModeOf(value: unknown): ModelPlayMode | null {
|
|
|
13
13
|
|
|
14
14
|
const STORAGE_PREFIX = 'volter.blender.play-mode:';
|
|
15
15
|
const listeners = new Set<() => void>();
|
|
16
|
-
/** Verbs waiting for a document to be on screen (`documentOnScreen`), told at every change. */
|
|
17
|
-
const waiters = new Set<() => void>();
|
|
18
16
|
/** The person's pick per document; `null` once storage was read and held none. */
|
|
19
17
|
const chosen = new Map<string, ModelPlayMode | null>();
|
|
20
18
|
/** Each model document's `.blend`, as the document reported it. */
|
|
@@ -25,7 +23,6 @@ let version = 0;
|
|
|
25
23
|
function publish(): void {
|
|
26
24
|
version += 1;
|
|
27
25
|
for (const listener of [...listeners]) listener();
|
|
28
|
-
for (const waiter of [...waiters]) waiter();
|
|
29
26
|
}
|
|
30
27
|
|
|
31
28
|
function stored(documentId: string): ModelPlayMode | null {
|
|
@@ -38,7 +35,7 @@ function stored(documentId: string): ModelPlayMode | null {
|
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
/** The person's own pick for a document, or `null` when the default stands. */
|
|
41
|
-
|
|
38
|
+
function chosenPlayMode(documentId: string): ModelPlayMode | null {
|
|
42
39
|
let value = chosen.get(documentId);
|
|
43
40
|
if (value === undefined) {
|
|
44
41
|
value = stored(documentId);
|
|
@@ -81,57 +78,6 @@ export function servedModelDocument(): string | null {
|
|
|
81
78
|
return served;
|
|
82
79
|
}
|
|
83
80
|
|
|
84
|
-
/** Model documents on screen whose file is still opening (read and first presented), the newest last. */
|
|
85
|
-
const opening: string[] = [];
|
|
86
|
-
|
|
87
|
-
/** An opening Model document does not serve the bottom area until presentation completes. Return its withdrawal function; documentOnScreen waits for it rather than reporting no model. */
|
|
88
|
-
export function noteModelOpening(documentId: string): () => void {
|
|
89
|
-
opening.push(documentId);
|
|
90
|
-
publish();
|
|
91
|
-
return () => {
|
|
92
|
-
const at = opening.lastIndexOf(documentId);
|
|
93
|
-
if (at < 0) return;
|
|
94
|
-
opening.splice(at, 1);
|
|
95
|
-
publish();
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** The model document still opening on screen, or `null` with none. */
|
|
100
|
-
export function openingModelDocument(): string | null {
|
|
101
|
-
return opening.at(-1) ?? null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* THE MODEL DOCUMENT ON SCREEN, once it is: the one served now, else the one opening, waited for at most `waitMs`.
|
|
106
|
-
* Answers `{ served }` once one serves the bottom area, `{ opening }` when one is still opening at the end of the wait
|
|
107
|
-
* (the caller asks again: an open can take minutes), and `null` when none is on screen or opening.
|
|
108
|
-
*/
|
|
109
|
-
export function documentOnScreen(waitMs: number): Promise<{ readonly served: string } | { readonly opening: string } | null> {
|
|
110
|
-
const now = (): { readonly served: string } | { readonly opening: string } | null =>
|
|
111
|
-
served !== null ? { served } : opening.length > 0 ? { opening: opening.at(-1)! } : null;
|
|
112
|
-
const first = now();
|
|
113
|
-
if (first === null || 'served' in first || waitMs <= 0) return Promise.resolve(first);
|
|
114
|
-
return new Promise((resolve) => {
|
|
115
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
-
const settle = (answer: ReturnType<typeof now>): void => {
|
|
117
|
-
waiters.delete(check);
|
|
118
|
-
clearTimeout(timer);
|
|
119
|
-
clearTimeout(recheck);
|
|
120
|
-
resolve(answer);
|
|
121
|
-
};
|
|
122
|
-
let recheck: ReturnType<typeof setTimeout> | undefined;
|
|
123
|
-
function check(): void {
|
|
124
|
-
const answer = now();
|
|
125
|
-
if (answer !== null && 'served' in answer) { clearTimeout(recheck); settle(answer); return; }
|
|
126
|
-
// (an opening withdrawn is read again a task later: the document's own commit withdraws it before its
|
|
127
|
-
// viewport announces itself, both in one commit, and only an open that ended with nothing on screen is none)
|
|
128
|
-
if (answer === null) { clearTimeout(recheck); recheck = setTimeout(() => { const later = now(); if (later === null || 'served' in later) settle(later); }, 0); }
|
|
129
|
-
}
|
|
130
|
-
waiters.add(check);
|
|
131
|
-
timer = setTimeout(() => settle(now()), waitMs);
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
81
|
/** The `.blend` a model document reported, if it has a file of its own. */
|
|
136
82
|
export function modelDocumentSource(documentId: string): string | undefined {
|
|
137
83
|
return sources.get(documentId);
|
|
@@ -139,17 +85,16 @@ export function modelDocumentSource(documentId: string): string | undefined {
|
|
|
139
85
|
|
|
140
86
|
/**
|
|
141
87
|
* THE MODE A DOCUMENT IS IN: Animation whenever no Play tool is installed (there is no game to
|
|
142
|
-
* switch to), else the person's pick, else Game exactly when the
|
|
88
|
+
* switch to), else the person's pick, else Game exactly when the project has a game. A
|
|
143
89
|
* game not yet looked for counts as none, so a document opens on the Timeline and turns to
|
|
144
90
|
* the Game panel when the answer arrives.
|
|
145
91
|
*/
|
|
146
92
|
export function modelPlayMode(documentId: string): ModelPlayMode {
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
93
|
+
const tool = playTool();
|
|
94
|
+
if (tool === null) return 'animation';
|
|
149
95
|
const pick = chosenPlayMode(documentId);
|
|
150
96
|
if (pick !== null) return pick;
|
|
151
|
-
|
|
152
|
-
return blend !== undefined && extension.hasScript?.(blend) === true ? 'game' : 'animation';
|
|
97
|
+
return sources.has(documentId) && tool.hasGame() === true ? 'game' : 'animation';
|
|
153
98
|
}
|
|
154
99
|
|
|
155
100
|
export function playModeVersion(): number {
|
|
@@ -169,7 +114,7 @@ export function playModeVersion(): number {
|
|
|
169
114
|
let stopToolWatch: (() => void) | null = null;
|
|
170
115
|
export function subscribePlayMode(listener: () => void): () => void {
|
|
171
116
|
listeners.add(listener);
|
|
172
|
-
stopToolWatch ??=
|
|
117
|
+
stopToolWatch ??= subscribePlayTool(publish);
|
|
173
118
|
return () => {
|
|
174
119
|
listeners.delete(listener);
|
|
175
120
|
if (listeners.size === 0 && stopToolWatch) {
|
package/src/play-tool.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLAY, THE PROJECT'S, as editor-blender's bottom area shows it (`registerPlay` in its API, `docs/adr/plugin-apis.md`):
|
|
3
|
+
* a Play tool (play's) owns its run, its controls and its Game view; the bottom area shows the tool's panel under every
|
|
4
|
+
* document but a model in its Animation mode, and asks the tool whether the project has a game, by which a model opens
|
|
5
|
+
* in Game or Animation (`play-mode.ts`).
|
|
6
|
+
*/
|
|
7
|
+
import type { ComponentType } from 'react';
|
|
8
|
+
|
|
9
|
+
export interface PlayTool {
|
|
10
|
+
/** The run's panel: Play, its transport and its log. */
|
|
11
|
+
readonly Panel: ComponentType;
|
|
12
|
+
/** Whether the project has a game to play: `null` while the tool has not looked yet. */
|
|
13
|
+
hasGame(): boolean | null;
|
|
14
|
+
/** Every change of what the tool answers. */
|
|
15
|
+
subscribe(listener: () => void): () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let registered: PlayTool | null = null;
|
|
19
|
+
const listeners = new Set<() => void>();
|
|
20
|
+
function publish(): void { for (const listener of listeners) listener(); }
|
|
21
|
+
|
|
22
|
+
/** The registered Play tool, or `null` while none is. */
|
|
23
|
+
export function playTool(): PlayTool | null {
|
|
24
|
+
return registered;
|
|
25
|
+
}
|
|
26
|
+
/** A registration, and every change the registered tool announces. */
|
|
27
|
+
export function subscribePlayTool(listener: () => void): () => void {
|
|
28
|
+
listeners.add(listener);
|
|
29
|
+
return () => { listeners.delete(listener); };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The API's `registerPlay`: one Play tool at a time. */
|
|
33
|
+
export function registerPlay(tool: PlayTool): () => void {
|
|
34
|
+
if (registered) throw new Error('A Play tool is already registered.');
|
|
35
|
+
registered = tool;
|
|
36
|
+
const stop = tool.subscribe(publish);
|
|
37
|
+
publish();
|
|
38
|
+
return () => {
|
|
39
|
+
stop();
|
|
40
|
+
if (registered !== tool) return;
|
|
41
|
+
registered = null;
|
|
42
|
+
publish();
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE MODEL DOCUMENT'S PLAY, which editor-blender owns and offers in its API (`registerModelPlay`,
|
|
3
|
-
* `docs/adr/plugin-apis.md`). A Play tool owns its controls, state and runner; the model document
|
|
4
|
-
* lends that runner its detached stage. play registers its tool from its own `activate`, through
|
|
5
|
-
* the API; the host hears only whether a document plays (`@volter/sdk/document-play-extension`).
|
|
6
|
-
*/
|
|
7
|
-
import { setDocumentPlaySignal } from '@volter/sdk/document-play-extension';
|
|
8
|
-
import type { ComponentType } from 'react';
|
|
9
|
-
|
|
10
|
-
export interface DocumentPlayControlProps {
|
|
11
|
-
readonly documentId: string | undefined;
|
|
12
|
-
readonly onClose: () => void;
|
|
13
|
-
}
|
|
14
|
-
/** Where the document's own area stands while its game runs (`DocumentPlayStage.edit`): a box is in fractions of the
|
|
15
|
-
* stage. */
|
|
16
|
-
export type DocumentPlayEditArea = 'live' | 'hidden' | { readonly left: number; readonly top: number; readonly width: number; readonly height: number };
|
|
17
|
-
export interface DocumentPlayStage {
|
|
18
|
-
readonly documentId: string;
|
|
19
|
-
readonly sourcePath: string;
|
|
20
|
-
readonly report: (title: string, detail: string) => void;
|
|
21
|
-
/** Where the tool draws its game, over the document's own area. */
|
|
22
|
-
readonly container: HTMLElement;
|
|
23
|
-
/** The game's first frame stands. */
|
|
24
|
-
readonly ready: () => void;
|
|
25
|
-
/** THE DOCUMENT'S OWN AREA WHILE THE GAME RUNS ON: `live` brings the editing area back in front, live for editing
|
|
26
|
-
* (its keys the editor's); a box shows it drawing in that part of the stage, taking no press (a tile among the
|
|
27
|
-
* game's views); `hidden` puts the game in front again. */
|
|
28
|
-
readonly edit?: (area: DocumentPlayEditArea) => void;
|
|
29
|
-
/** A layer over the editing area and the play area alike, for the tool's view controls, which stand whichever area
|
|
30
|
-
* is in front; it takes no press itself. */
|
|
31
|
-
readonly overlay?: HTMLElement;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* A RUNNING GAME'S CLOCK, as the Play tool keeps it: the game's own seconds and frames, which stand still while paused
|
|
36
|
-
* and run at `speed` times the page's own clock otherwise.
|
|
37
|
-
*/
|
|
38
|
-
export interface DocumentPlayClock {
|
|
39
|
-
/** The game's seconds since this run began. */
|
|
40
|
-
readonly time: number;
|
|
41
|
-
/** The frames the game has run since this run began. */
|
|
42
|
-
readonly tick: number;
|
|
43
|
-
readonly paused: boolean;
|
|
44
|
-
/** Simulation seconds per real second; one of the transport's `speeds`. */
|
|
45
|
-
readonly speed: number;
|
|
46
|
-
/** While playing with no game running because the game failed to start or threw: why. The
|
|
47
|
-
* run stays playing (a save of the game retries it); a running game clears it. */
|
|
48
|
-
readonly failure?: string | null;
|
|
49
|
-
/** A game has started in this run and is running (its first update ran). False while the run
|
|
50
|
-
* is still preparing, and while it plays with a `failure`. A reader that must know whether Play
|
|
51
|
-
* WORKED (the CLI's `play`) waits for this or for `failure`, never for "playing" alone. */
|
|
52
|
-
readonly running: boolean;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* THE GAME'S TRANSPORT — what a Play tool offers beyond Play and Stop, for a layout that draws a
|
|
56
|
-
* game panel (Cyclotron's Game mode, `@volter/editor-blender`). Optional: a tool without
|
|
57
|
-
* one still plays and stops, and a layout draws only the controls a tool answers.
|
|
58
|
-
*/
|
|
59
|
-
export interface DocumentPlayTransport {
|
|
60
|
-
/** The speeds `setSpeed` accepts, slowest first. */
|
|
61
|
-
readonly speeds: readonly number[];
|
|
62
|
-
clock(documentId: string): DocumentPlayClock;
|
|
63
|
-
/** The clock moves every played frame, so it has a subscription of its own: `subscribe` would
|
|
64
|
-
* re-render everything that only asks whether a document plays. */
|
|
65
|
-
subscribeClock(listener: () => void): () => void;
|
|
66
|
-
/** Hold or release the game; the answer settles once the game has. */
|
|
67
|
-
setPaused(documentId: string, paused: boolean): void | Promise<void>;
|
|
68
|
-
/** `frames` frames (one when absent), pausing the game first. */
|
|
69
|
-
step(documentId: string, frames?: number): void | Promise<void>;
|
|
70
|
-
setSpeed(documentId: string, speed: number): void | Promise<void>;
|
|
71
|
-
/** Begin the run again on a FRESH detached copy, the clock at zero. */
|
|
72
|
-
restart(documentId: string): void;
|
|
73
|
-
/** Changes on every restart. The document that lends the stage keys its detached copy on it,
|
|
74
|
-
* so a new generation is a new copy; the change is announced through `subscribe`. */
|
|
75
|
-
generation(documentId: string): number;
|
|
76
|
-
/** Whether the running game's own bot drives the player in view, and the goals it offers; a change is announced
|
|
77
|
-
* through `subscribeClock`. Absent, the tool has no autoplay. */
|
|
78
|
-
autoplay?(documentId: string): DocumentPlayAutoplay;
|
|
79
|
-
/** Switch the bot on (it must be offered, and the game playing) or off. On plays `goal` (the game's own when
|
|
80
|
-
* absent); the answer settles once the goal has started. */
|
|
81
|
-
setAutoplay?(documentId: string, on: boolean, by: 'panel' | 'cli', goal?: string): void | Promise<void>;
|
|
82
|
-
/** While stopped, ask the next start to switch the bot on with `goal` as soon as its player joins. Absent, autoplay
|
|
83
|
-
* can only be switched while running. */
|
|
84
|
-
armAutoplay?(documentId: string, armed: boolean, goal?: string): void;
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* AUTOPLAY, AS THE EDITOR OWNS IT: a game offers a bot and its goals, the editor decides whether it drives the player
|
|
88
|
-
* in view. A person's own press in the game takes it back (`takeover`).
|
|
89
|
-
*/
|
|
90
|
-
export interface DocumentPlayAutoplay {
|
|
91
|
-
readonly on: boolean;
|
|
92
|
-
/** The bot can be switched on for the player in view. */
|
|
93
|
-
readonly available: boolean;
|
|
94
|
-
/** Why the bot cannot be switched on now, in the tool's words (no goals, a view with no player, a player not yet
|
|
95
|
-
* joined): the Game panel and `play state` say it. */
|
|
96
|
-
readonly unavailable?: string | null;
|
|
97
|
-
/** Who made the last change. */
|
|
98
|
-
readonly by: 'panel' | 'cli' | 'takeover' | null;
|
|
99
|
-
/** Armed while stopped (`armAutoplay`): the next start turns it on once its player joins. */
|
|
100
|
-
readonly armed?: boolean;
|
|
101
|
-
/** The goals the game's bot offers, and the one driving, armed, or the game's own. */
|
|
102
|
-
readonly behaviors?: readonly string[];
|
|
103
|
-
readonly behavior?: string | null;
|
|
104
|
-
/** A goal played where the person is not looking (a std game's headless play), and how it stands: `<goal>:
|
|
105
|
-
* playing`, `<goal>: achieved` or `<goal>: failed — why`; null before any. */
|
|
106
|
-
readonly state?: string | null;
|
|
107
|
-
}
|
|
108
|
-
export interface DocumentPlayExtension {
|
|
109
|
-
readonly Control: ComponentType<DocumentPlayControlProps>;
|
|
110
|
-
readonly Menu: ComponentType<DocumentPlayControlProps>;
|
|
111
|
-
/** The run's panel, which a document's layout shows in its bottom area while it plays, when the tool has one. */
|
|
112
|
-
readonly Panel?: ComponentType;
|
|
113
|
-
playing(documentId: string): boolean;
|
|
114
|
-
/** Intended live frame shape; absent/null lets the document fill its area. */
|
|
115
|
-
aspectRatio?(documentId: string): number | null;
|
|
116
|
-
setPlaying(documentId: string, value: boolean): void;
|
|
117
|
-
escape?(documentId: string): void;
|
|
118
|
-
subscribe(listener: () => void): () => void;
|
|
119
|
-
run(stage: DocumentPlayStage): () => void;
|
|
120
|
-
/** Pause, step, speed, restart and the clock, when the tool keeps them. */
|
|
121
|
-
readonly transport?: DocumentPlayTransport;
|
|
122
|
-
/** What this tool runs to play a document's source file (a game's dev server command). */
|
|
123
|
-
scriptPath?(sourcePath: string): string;
|
|
124
|
-
/** Whether the document's source has a game to play: `null` while the tool has not looked yet. A change is announced
|
|
125
|
-
* through `subscribe`. A layout uses it to open a document as a game or as a model. */
|
|
126
|
-
hasScript?(sourcePath: string): boolean | null;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
let registered: DocumentPlayExtension | null = null;
|
|
130
|
-
const listeners = new Set<() => void>();
|
|
131
|
-
function publish(): void { for (const listener of listeners) listener(); }
|
|
132
|
-
|
|
133
|
-
/** The registered Play tool, or `null` while none is. */
|
|
134
|
-
export function modelPlayExtension(): DocumentPlayExtension | null {
|
|
135
|
-
return registered;
|
|
136
|
-
}
|
|
137
|
-
/** A registration, and every change the registered tool announces. */
|
|
138
|
-
export function subscribeModelPlayExtension(listener: () => void): () => void {
|
|
139
|
-
listeners.add(listener);
|
|
140
|
-
return () => { listeners.delete(listener); };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Whether a document's Play runs and is paused, for the host. A pause is a clock change, and the
|
|
144
|
-
* clock moves every played frame, so its subscribers are readers whose snapshot changes rarely. */
|
|
145
|
-
function signal(extension: DocumentPlayExtension) {
|
|
146
|
-
return {
|
|
147
|
-
state(documentId: string): 'playing' | 'paused' | 'stopped' {
|
|
148
|
-
if (!extension.playing(documentId)) return 'stopped';
|
|
149
|
-
return extension.transport?.clock(documentId).paused ? 'paused' : 'playing';
|
|
150
|
-
},
|
|
151
|
-
subscribe(listener: () => void): () => void {
|
|
152
|
-
const stopPlay = extension.subscribe(listener);
|
|
153
|
-
const stopClock = extension.transport?.subscribeClock(listener);
|
|
154
|
-
return () => { stopPlay(); stopClock?.(); };
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** The API's `registerModelPlay`: one Play tool for model documents at a time. */
|
|
160
|
-
export function registerModelPlay(extension: DocumentPlayExtension): () => void {
|
|
161
|
-
if (registered) throw new Error('A Play tool already plays model documents.');
|
|
162
|
-
registered = extension;
|
|
163
|
-
const stop = extension.subscribe(publish);
|
|
164
|
-
setDocumentPlaySignal('model', signal(extension));
|
|
165
|
-
publish();
|
|
166
|
-
return () => {
|
|
167
|
-
stop();
|
|
168
|
-
if (registered !== extension) return;
|
|
169
|
-
registered = null;
|
|
170
|
-
setDocumentPlaySignal('model', null);
|
|
171
|
-
publish();
|
|
172
|
-
};
|
|
173
|
-
}
|