@playcanvas/web-components 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +10 -2
- package/dist/asset.d.ts +18 -3
- package/dist/async-element.d.ts +51 -4
- package/dist/components/button-component.d.ts +10 -4
- package/dist/components/camera-component.d.ts +9 -4
- package/dist/components/collision-component.d.ts +9 -4
- package/dist/components/component.d.ts +7 -1
- package/dist/components/element-component.d.ts +17 -12
- package/dist/components/gsplat-component.d.ts +6 -1
- package/dist/components/layoutchild-component.d.ts +6 -1
- package/dist/components/layoutgroup-component.d.ts +21 -15
- package/dist/components/light-component.d.ts +12 -6
- package/dist/components/listener-component.d.ts +6 -1
- package/dist/components/particlesystem-component.d.ts +6 -1
- package/dist/components/render-component.d.ts +13 -4
- package/dist/components/rigidbody-component.d.ts +9 -4
- package/dist/components/screen-component.d.ts +6 -1
- package/dist/components/script-component.d.ts +116 -16
- package/dist/components/script.d.ts +72 -8
- package/dist/components/scrollbar-component.d.ts +10 -4
- package/dist/components/scrollview-component.d.ts +17 -10
- package/dist/components/sound-component.d.ts +6 -1
- package/dist/components/sound-slot.d.ts +8 -3
- package/dist/entity.d.ts +26 -2
- package/dist/index.d.ts +3 -2
- package/dist/material.d.ts +10 -0
- package/dist/model.d.ts +5 -0
- package/dist/module.d.ts +5 -0
- package/dist/pwc.cjs +1574 -853
- package/dist/pwc.cjs.map +1 -1
- package/dist/pwc.js +1574 -853
- package/dist/pwc.js.map +1 -1
- package/dist/pwc.min.js +1 -1
- package/dist/pwc.min.js.map +1 -1
- package/dist/pwc.mjs +1575 -855
- package/dist/pwc.mjs.map +1 -1
- package/dist/scene.d.ts +14 -5
- package/dist/sky.d.ts +6 -0
- package/dist/utils.d.ts +81 -23
- package/package.json +7 -7
- package/src/app.ts +38 -12
- package/src/asset.ts +61 -8
- package/src/async-element.ts +86 -5
- package/src/components/button-component.ts +26 -19
- package/src/components/camera-component.ts +29 -23
- package/src/components/collision-component.ts +19 -13
- package/src/components/component.ts +32 -13
- package/src/components/element-component.ts +58 -52
- package/src/components/gsplat-component.ts +14 -7
- package/src/components/layoutchild-component.ts +16 -9
- package/src/components/layoutgroup-component.ts +38 -31
- package/src/components/light-component.ts +33 -31
- package/src/components/listener-component.ts +8 -2
- package/src/components/particlesystem-component.ts +8 -2
- package/src/components/render-component.ts +19 -8
- package/src/components/rigidbody-component.ts +21 -15
- package/src/components/screen-component.ts +15 -9
- package/src/components/script-component.ts +512 -125
- package/src/components/script.ts +116 -16
- package/src/components/scrollbar-component.ts +19 -12
- package/src/components/scrollview-component.ts +37 -29
- package/src/components/sound-component.ts +16 -9
- package/src/components/sound-slot.ts +23 -14
- package/src/entity.ts +61 -71
- package/src/index.ts +5 -2
- package/src/material.ts +34 -1
- package/src/model.ts +6 -0
- package/src/module.ts +6 -0
- package/src/scene.ts +25 -12
- package/src/sky.ts +34 -16
- package/src/utils.ts +180 -40
|
@@ -1,24 +1,207 @@
|
|
|
1
|
-
import { Color, ScriptComponent, Script, Vec2, Vec3, Vec4 } from 'playcanvas';
|
|
1
|
+
import { Color, Quat, ScriptComponent, Script, Vec2, Vec3, Vec4 } from 'playcanvas';
|
|
2
2
|
|
|
3
3
|
import { AssetElement } from '../asset';
|
|
4
4
|
import { ComponentElement } from './component';
|
|
5
|
-
import { EntityElement } from '../entity';
|
|
6
5
|
import { ScriptElement } from './script';
|
|
6
|
+
import { getEntity, parseBool, parseColor, parseComponents, parseNumber, parseQuat, parseVec2, parseVec3, parseVec4 } from '../utils';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Attributes on `pc-script` that never map to script attributes: the element's own API (derived
|
|
10
|
+
* from its observed attributes) plus reserved and global HTML attribute names.
|
|
11
|
+
*/
|
|
12
|
+
const RESERVED_ATTRIBUTES = new Set([
|
|
13
|
+
...ScriptElement.observedAttributes,
|
|
14
|
+
'accesskey', 'autocapitalize', 'autofocus', 'class', 'contenteditable', 'dir', 'draggable',
|
|
15
|
+
'exportparts', 'hidden', 'id', 'inert', 'is', 'itemid', 'itemprop', 'itemref', 'itemscope',
|
|
16
|
+
'itemtype', 'lang', 'nonce', 'part', 'popover', 'role', 'slot', 'spellcheck', 'style',
|
|
17
|
+
'tabindex', 'title', 'translate'
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Checks whether a `pc-script` attribute name is reserved (and so never maps to a script
|
|
22
|
+
* attribute). Reserved names are the element's own API, global HTML attribute names, `data-*`
|
|
23
|
+
* and `aria-*` attributes, names starting with `_` (framework-stamped attributes), and real
|
|
24
|
+
* inline event handler names (`onclick` etc. — detected via the platform, so script attributes
|
|
25
|
+
* that merely start with 'on', like `once`, still map).
|
|
26
|
+
* @param name - The attribute name.
|
|
27
|
+
* @returns Whether the attribute name is reserved.
|
|
28
|
+
*/
|
|
29
|
+
const isReservedAttribute = (name: string): boolean => {
|
|
30
|
+
return RESERVED_ATTRIBUTES.has(name) ||
|
|
31
|
+
name.startsWith('data-') ||
|
|
32
|
+
name.startsWith('aria-') ||
|
|
33
|
+
name.startsWith('_') ||
|
|
34
|
+
(name.startsWith('on') && name in HTMLElement.prototype);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Script API members that per-property attributes must never overwrite: the engine bindings and
|
|
39
|
+
* the (optional, so possibly undefined) lifecycle methods.
|
|
40
|
+
*/
|
|
41
|
+
const SCRIPT_API_MEMBERS = new Set([
|
|
42
|
+
'app', 'entity', 'destroy', 'initialize', 'postInitialize', 'postUpdate', 'swap', 'update'
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Converts a kebab-case attribute name to the camelCase script attribute name.
|
|
47
|
+
* @param name - The attribute name.
|
|
48
|
+
* @returns The camelCase name.
|
|
49
|
+
*/
|
|
50
|
+
const kebabToCamel = (name: string): string => {
|
|
51
|
+
return name.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Converts a camelCase script attribute name to its kebab-case attribute spelling.
|
|
56
|
+
* @param name - The camelCase name.
|
|
57
|
+
* @returns The kebab-case name.
|
|
58
|
+
*/
|
|
59
|
+
const camelToKebab = (name: string): string => {
|
|
60
|
+
return name.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A conversion applied to a script attribute value carrying an explicit type prefix. Receives the
|
|
65
|
+
* text after the prefix plus the raw value, and returns the raw value (having warned) when it
|
|
66
|
+
* cannot resolve or parse it — callers rely on that identity to tell failure from success.
|
|
67
|
+
*/
|
|
68
|
+
type Conversion = (rest: string, raw: string) => any;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolves an `asset:` prefix to the Asset created by the `pc-asset` element with that id.
|
|
72
|
+
* @param rest - The asset id.
|
|
73
|
+
* @param raw - The raw value, returned unchanged when the id does not resolve.
|
|
74
|
+
* @returns The asset, or `raw`.
|
|
75
|
+
*/
|
|
76
|
+
const assetConversion: Conversion = (rest, raw) => {
|
|
77
|
+
const asset = AssetElement.get(rest);
|
|
78
|
+
if (asset) {
|
|
79
|
+
return asset;
|
|
80
|
+
}
|
|
81
|
+
console.warn(`Unable to resolve '${raw}' in script attributes - no pc-asset found with id '${rest}'.`);
|
|
82
|
+
return raw;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolves an `entity:` prefix to the Entity backing a `pc-entity` element. The reference can be a
|
|
87
|
+
* CSS selector, an element id or an entity name.
|
|
88
|
+
* @param rest - The entity reference.
|
|
89
|
+
* @param raw - The raw value, returned unchanged when the reference does not resolve.
|
|
90
|
+
* @returns The entity, or `raw`.
|
|
91
|
+
*/
|
|
92
|
+
const entityConversion: Conversion = (rest, raw) => {
|
|
93
|
+
const entity = getEntity(rest);
|
|
94
|
+
if (entity) {
|
|
95
|
+
return entity;
|
|
96
|
+
}
|
|
97
|
+
console.warn(`Unable to resolve '${raw}' in script attributes - no pc-entity found matching '${rest}'.`);
|
|
98
|
+
return raw;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Builds the conversion for a `vec2:`/`vec3:`/`vec4:` prefix.
|
|
103
|
+
* @param length - The number of components the prefix carries.
|
|
104
|
+
* @param Ctor - The vector type to construct.
|
|
105
|
+
* @returns The conversion.
|
|
106
|
+
*/
|
|
107
|
+
const vectorConversion = (length: 2 | 3 | 4, Ctor: new (components: number[]) => Vec2 | Vec3 | Vec4): Conversion => {
|
|
108
|
+
return (rest, raw) => {
|
|
109
|
+
const components = parseComponents(rest, length);
|
|
110
|
+
if (components) {
|
|
111
|
+
return new Ctor(components);
|
|
112
|
+
}
|
|
113
|
+
console.warn(`Invalid script attribute value '${raw}'. Expected ${length} space-separated numbers after 'vec${length}:'.`);
|
|
114
|
+
return raw;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Converts a `color:` prefix to a Color, accepting 3 or 4 components.
|
|
120
|
+
* @param rest - The space-separated components.
|
|
121
|
+
* @param raw - The raw value, returned unchanged when the components do not parse.
|
|
122
|
+
* @returns The color, or `raw`.
|
|
123
|
+
*/
|
|
124
|
+
const colorConversion: Conversion = (rest, raw) => {
|
|
125
|
+
const components = parseComponents(rest, 4) ?? parseComponents(rest, 3);
|
|
126
|
+
if (components) {
|
|
127
|
+
return new Color(components);
|
|
128
|
+
}
|
|
129
|
+
console.warn(`Invalid script attribute value '${raw}'. Expected 3 or 4 space-separated numbers after 'color:'.`);
|
|
130
|
+
return raw;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The conversion prefixes recognised in script attribute values, mapped to the conversion each
|
|
135
|
+
* performs. These keys are the single source of truth for the prefix vocabulary: they drive both
|
|
136
|
+
* the conversion in `convertAttributes` and the has-a-prefix test in `setScriptProperty`, so a
|
|
137
|
+
* prefix added here is automatically known to both.
|
|
138
|
+
*/
|
|
139
|
+
const CONVERSIONS = new Map<string, Conversion>([
|
|
140
|
+
['asset', assetConversion],
|
|
141
|
+
['entity', entityConversion],
|
|
142
|
+
['vec2', vectorConversion(2, Vec2)],
|
|
143
|
+
['vec3', vectorConversion(3, Vec3)],
|
|
144
|
+
['vec4', vectorConversion(4, Vec4)],
|
|
145
|
+
['color', colorConversion]
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Matches a value against the conversion prefixes. A prefix is the text before the first colon,
|
|
150
|
+
* so a value whose remainder itself contains colons (`asset:a:b`) still resolves, and a value
|
|
151
|
+
* with an unrecognised prefix (`https://...`) or no colon does not match.
|
|
152
|
+
* @param value - The value to inspect.
|
|
153
|
+
* @returns The matching converter and the text after the prefix, or `null` if the value carries
|
|
154
|
+
* no recognised prefix.
|
|
155
|
+
*/
|
|
156
|
+
const matchConversion = (value: string) => {
|
|
157
|
+
const index = value.indexOf(':');
|
|
158
|
+
if (index <= 0) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const convert = CONVERSIONS.get(value.slice(0, index));
|
|
162
|
+
return convert ? { convert, rest: value.slice(index + 1) } : null;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Finds a script property whose name matches `key` case-insensitively (but not exactly). Used
|
|
167
|
+
* to suggest the kebab-case spelling when a camelCase attribute has been lowercased by the HTML
|
|
168
|
+
* parser (e.g. `focusPoint` arriving as 'focuspoint').
|
|
169
|
+
* @param script - The script instance to search.
|
|
170
|
+
* @param key - The lowercased key that failed to match.
|
|
171
|
+
* @returns The matching property name, or `null`.
|
|
172
|
+
*/
|
|
173
|
+
const findCaseMatch = (script: any, key: string): string | null => {
|
|
174
|
+
const names = new Set(Object.keys(script));
|
|
175
|
+
for (const name of Object.getOwnPropertyNames(Object.getPrototypeOf(script))) {
|
|
176
|
+
names.add(name);
|
|
177
|
+
}
|
|
178
|
+
for (const name of names) {
|
|
179
|
+
if (name !== key && name.toLowerCase() === key.toLowerCase()) {
|
|
180
|
+
return name;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
};
|
|
7
185
|
|
|
8
186
|
// Add these interfaces at the top of the file, after the imports
|
|
9
187
|
interface ScriptAttributesChangeEvent extends CustomEvent {
|
|
10
|
-
detail: { attributes: any };
|
|
188
|
+
detail: { attributes: Record<string, any> };
|
|
11
189
|
}
|
|
12
190
|
|
|
13
191
|
interface ScriptEnableChangeEvent extends CustomEvent {
|
|
14
192
|
detail: { enabled: boolean };
|
|
15
193
|
}
|
|
16
194
|
|
|
195
|
+
interface ScriptNameChangeEvent extends CustomEvent {
|
|
196
|
+
detail: { oldName: string, newName: string };
|
|
197
|
+
}
|
|
198
|
+
|
|
17
199
|
// Add this interface before the ScriptComponentElement class
|
|
18
200
|
declare global {
|
|
19
201
|
interface HTMLElementEventMap {
|
|
20
202
|
'scriptattributeschange': ScriptAttributesChangeEvent;
|
|
21
203
|
'scriptenablechange': ScriptEnableChangeEvent;
|
|
204
|
+
'scriptnamechange': ScriptNameChangeEvent;
|
|
22
205
|
}
|
|
23
206
|
}
|
|
24
207
|
|
|
@@ -39,87 +222,51 @@ class ScriptComponentElement extends ComponentElement {
|
|
|
39
222
|
|
|
40
223
|
// Create mutation observer to watch for child script elements
|
|
41
224
|
this.observer = new MutationObserver(this.handleMutations.bind(this));
|
|
42
|
-
this.observer.observe(this, {
|
|
43
|
-
childList: true
|
|
44
|
-
});
|
|
45
225
|
|
|
46
|
-
// Listen for script attribute and
|
|
226
|
+
// Listen for script attribute, enable and name changes
|
|
47
227
|
this.addEventListener('scriptattributeschange', this.handleScriptAttributesChange.bind(this));
|
|
48
228
|
this.addEventListener('scriptenablechange', this.handleScriptEnableChange.bind(this));
|
|
229
|
+
this.addEventListener('scriptnamechange', this.handleScriptNameChange.bind(this));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
connectedCallback() {
|
|
233
|
+
// (Re-)observe on every connection - disconnectedCallback disconnects the observer.
|
|
234
|
+
// Attribute changes on child pc-script elements are watched here too: per-property
|
|
235
|
+
// script attributes are not statically known, so they cannot use observedAttributes.
|
|
236
|
+
this.observer.observe(this, { childList: true, subtree: true, attributes: true });
|
|
237
|
+
return super.connectedCallback();
|
|
49
238
|
}
|
|
50
239
|
|
|
51
240
|
initComponent() {
|
|
52
241
|
// Handle initial script elements
|
|
53
242
|
this.querySelectorAll<ScriptElement>(':scope > pc-script').forEach((scriptElement) => {
|
|
54
|
-
|
|
55
|
-
const attributes = scriptElement.getAttribute('attributes');
|
|
56
|
-
if (scriptName) {
|
|
57
|
-
this.createScript(scriptName, attributes);
|
|
58
|
-
}
|
|
243
|
+
this.createScript(scriptElement);
|
|
59
244
|
});
|
|
60
245
|
}
|
|
61
246
|
|
|
62
247
|
/**
|
|
63
248
|
* Recursively converts raw attribute data into proper PlayCanvas types. Supported conversions:
|
|
64
|
-
* - "asset:
|
|
65
|
-
* - "entity:
|
|
66
|
-
*
|
|
67
|
-
* - "
|
|
68
|
-
* - "
|
|
69
|
-
* - "
|
|
249
|
+
* - "asset:id" → the Asset created by the `pc-asset` element with that id
|
|
250
|
+
* - "entity:ref" → the Entity backing a `pc-entity` element. The reference can be a CSS
|
|
251
|
+
* selector, an element id or an entity name.
|
|
252
|
+
* - "vec2:1 2" → new Vec2(1, 2)
|
|
253
|
+
* - "vec3:1 2 3" → new Vec3(1, 2, 3)
|
|
254
|
+
* - "vec4:1 2 3 4" → new Vec4(1, 2, 3, 4)
|
|
255
|
+
* - "color:1 0.5 0.5 1" → new Color(1, 0.5, 0.5, 1)
|
|
256
|
+
*
|
|
257
|
+
* A prefixed string that fails to resolve or parse logs a warning and is left as the raw
|
|
258
|
+
* string.
|
|
70
259
|
* @param item - The item to convert.
|
|
71
260
|
* @returns The converted item.
|
|
72
261
|
*/
|
|
73
262
|
private convertAttributes(item: any): any {
|
|
74
263
|
if (typeof item === 'string') {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const assetElement = document.querySelector(`pc-asset#${assetId}`) as AssetElement;
|
|
78
|
-
if (assetElement) {
|
|
79
|
-
return assetElement.asset;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
if (item.startsWith('entity:')) {
|
|
83
|
-
const entityId = item.slice(7);
|
|
84
|
-
const entityElement = document.querySelector(`pc-entity[name="${entityId}"]`) as EntityElement;
|
|
85
|
-
if (entityElement) {
|
|
86
|
-
return entityElement.entity;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
if (item.startsWith('vec2:')) {
|
|
90
|
-
const parts = item.slice(5).split(',').map(Number);
|
|
91
|
-
if (parts.length === 2 && parts.every(v => !isNaN(v))) {
|
|
92
|
-
return new Vec2(parts[0], parts[1]);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
if (item.startsWith('vec3:')) {
|
|
96
|
-
const parts = item.slice(5).split(',').map(Number);
|
|
97
|
-
if (parts.length === 3 && parts.every(v => !isNaN(v))) {
|
|
98
|
-
return new Vec3(parts[0], parts[1], parts[2]);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
if (item.startsWith('vec4:')) {
|
|
102
|
-
const parts = item.slice(5).split(',').map(Number);
|
|
103
|
-
if (parts.length === 4 && parts.every(v => !isNaN(v))) {
|
|
104
|
-
return new Vec4(parts[0], parts[1], parts[2], parts[3]);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
if (item.startsWith('color:')) {
|
|
108
|
-
const parts = item.slice(6).split(',').map(Number);
|
|
109
|
-
if (parts.length === 4 && parts.every(v => !isNaN(v))) {
|
|
110
|
-
return new Color(parts[0], parts[1], parts[2], parts[3]);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return item;
|
|
264
|
+
const match = matchConversion(item);
|
|
265
|
+
return match ? match.convert(match.rest, item) : item;
|
|
114
266
|
}
|
|
115
267
|
|
|
116
268
|
if (Array.isArray(item)) {
|
|
117
|
-
|
|
118
|
-
if (item.length > 0 && typeof item[0] === 'object') {
|
|
119
|
-
return item.map((el: any) => this.convertAttributes(el));
|
|
120
|
-
}
|
|
121
|
-
// Otherwise, leave the numeric array unchanged but process each element.
|
|
122
|
-
return item.map((el: any) => this.convertAttributes(el));
|
|
269
|
+
return item.map((element: any) => this.convertAttributes(element));
|
|
123
270
|
}
|
|
124
271
|
|
|
125
272
|
if (item && typeof item === 'object') {
|
|
@@ -134,91 +281,304 @@ class ScriptComponentElement extends ComponentElement {
|
|
|
134
281
|
}
|
|
135
282
|
|
|
136
283
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
private preprocessAttributes(attrs: any): any {
|
|
142
|
-
return this.convertAttributes(attrs);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Recursively merge properties from source into target.
|
|
284
|
+
* Recursively merge properties from source into target. When the target value is a Vec2,
|
|
285
|
+
* Vec3, Vec4 or Color and the source value is a plain numeric array, the array is converted
|
|
286
|
+
* to the target's type — so script attributes with math-typed defaults can be written as
|
|
287
|
+
* plain JSON arrays (e.g. `"focusPoint": [0, 1.75, 0]`).
|
|
147
288
|
* @param target - The target object to merge into.
|
|
148
289
|
* @param source - The source object to merge from.
|
|
149
290
|
* @returns The merged object.
|
|
150
291
|
*/
|
|
151
292
|
private mergeDeep(target: any, source: any): any {
|
|
152
293
|
for (const key in source) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
294
|
+
const value = source[key];
|
|
295
|
+
const current = target[key];
|
|
296
|
+
if (this.isMathType(current) && Array.isArray(value)) {
|
|
297
|
+
const converted = this.arrayToMathType(current, value, key);
|
|
298
|
+
if (converted) {
|
|
299
|
+
target[key] = converted;
|
|
300
|
+
}
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
// Only recurse into plain objects. Class instances (Vec3, Color, Asset, Entity...)
|
|
304
|
+
// are leaf values assigned whole, so accessor-typed script attributes receive them
|
|
305
|
+
// through their setters instead of having a getter's returned copy mutated.
|
|
306
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype) {
|
|
307
|
+
if (!current || typeof current !== 'object') {
|
|
159
308
|
target[key] = {};
|
|
160
309
|
}
|
|
161
|
-
this.mergeDeep(target[key],
|
|
310
|
+
this.mergeDeep(target[key], value);
|
|
162
311
|
} else {
|
|
163
|
-
target[key] =
|
|
312
|
+
target[key] = value;
|
|
164
313
|
}
|
|
165
314
|
}
|
|
166
315
|
return target;
|
|
167
316
|
}
|
|
168
317
|
|
|
169
318
|
/**
|
|
170
|
-
*
|
|
319
|
+
* Checks whether a value is one of the math types that plain numeric arrays convert to.
|
|
320
|
+
* @param value - The value to check.
|
|
321
|
+
* @returns Whether the value is a math type.
|
|
322
|
+
*/
|
|
323
|
+
private isMathType(value: any): value is Vec2 | Vec3 | Vec4 | Color | Quat {
|
|
324
|
+
return value instanceof Vec2 || value instanceof Vec3 || value instanceof Vec4 || value instanceof Color || value instanceof Quat;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Converts a plain numeric array to the math type of `current`. A 3-element array targeting
|
|
329
|
+
* a Quat is interpreted as Euler angles in degrees, mirroring the `parseQuat` attribute
|
|
330
|
+
* grammar. Returns `null` (and logs a warning) when the array's length or contents don't
|
|
331
|
+
* match the type.
|
|
332
|
+
* @param current - The current (typed) value of the property.
|
|
333
|
+
* @param value - The incoming array.
|
|
334
|
+
* @param key - The property name, used in the warning message.
|
|
335
|
+
* @returns The converted value, or `null`.
|
|
336
|
+
*/
|
|
337
|
+
private arrayToMathType(current: Vec2 | Vec3 | Vec4 | Color | Quat, value: any[], key: string): Vec2 | Vec3 | Vec4 | Color | Quat | null {
|
|
338
|
+
if (value.every(component => typeof component === 'number' && Number.isFinite(component))) {
|
|
339
|
+
if (current instanceof Vec2 && value.length === 2) return new Vec2(value);
|
|
340
|
+
if (current instanceof Vec3 && value.length === 3) return new Vec3(value);
|
|
341
|
+
if (current instanceof Vec4 && value.length === 4) return new Vec4(value);
|
|
342
|
+
if (current instanceof Color && (value.length === 3 || value.length === 4)) return new Color(value);
|
|
343
|
+
if (current instanceof Quat && value.length === 3) return new Quat().setFromEulerAngles(value[0], value[1], value[2]);
|
|
344
|
+
}
|
|
345
|
+
console.warn(`Cannot convert script attribute '${key}' array [${value}] to ${current.constructor.name}. Keeping the current value.`);
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Update script attributes by merging converted values into the script. `enabled` is always
|
|
351
|
+
* excluded (it is configured through the element's `enabled` attribute, not the JSON blob),
|
|
352
|
+
* as are any keys in `exclude` — used to keep per-property attributes authoritative over
|
|
353
|
+
* the blob without writing a property twice.
|
|
171
354
|
* @param script - The script to update.
|
|
172
355
|
* @param attributes - The attributes to merge into the script.
|
|
356
|
+
* @param exclude - Keys to strip from the merge.
|
|
173
357
|
*/
|
|
174
|
-
private applyAttributes(script: any, attributes: string
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
358
|
+
private applyAttributes(script: any, attributes: Record<string, any>, exclude?: Set<string>) {
|
|
359
|
+
const converted = this.convertAttributes(attributes);
|
|
360
|
+
if (converted && typeof converted === 'object') {
|
|
361
|
+
delete converted.enabled;
|
|
362
|
+
if (exclude) {
|
|
363
|
+
for (const key of exclude) {
|
|
364
|
+
delete converted[key];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
this.mergeDeep(script, converted);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Returns the camelCase keys of the per-property attributes present on a `pc-script`
|
|
373
|
+
* element.
|
|
374
|
+
* @param scriptElement - The `pc-script` element.
|
|
375
|
+
* @returns The camelCase keys.
|
|
376
|
+
*/
|
|
377
|
+
private inlineKeys(scriptElement: ScriptElement): Set<string> {
|
|
378
|
+
const keys = new Set<string>();
|
|
379
|
+
for (const attr of Array.from(scriptElement.attributes)) {
|
|
380
|
+
if (!isReservedAttribute(attr.name)) {
|
|
381
|
+
keys.add(kebabToCamel(attr.name));
|
|
382
|
+
}
|
|
181
383
|
}
|
|
384
|
+
return keys;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Resolves the script instance owned by a `pc-script` element. Returns `null` when the
|
|
389
|
+
* element has no created script, or when its name resolves to a script created by a
|
|
390
|
+
* different element (e.g. a duplicate-named sibling).
|
|
391
|
+
* @param scriptElement - The `pc-script` element.
|
|
392
|
+
* @returns The owned script, or `null`.
|
|
393
|
+
*/
|
|
394
|
+
private scriptFor(scriptElement: ScriptElement): Script | null {
|
|
395
|
+
const name = scriptElement.getAttribute('name');
|
|
396
|
+
if (!name || !this.component) return null;
|
|
397
|
+
|
|
398
|
+
const script = this.component.get(name);
|
|
399
|
+
return script && script === scriptElement._script ? script : null;
|
|
182
400
|
}
|
|
183
401
|
|
|
184
402
|
private handleScriptAttributesChange(event: ScriptAttributesChangeEvent) {
|
|
185
403
|
const scriptElement = event.target as ScriptElement;
|
|
186
|
-
const
|
|
187
|
-
if (!scriptName || !this.component) return;
|
|
188
|
-
|
|
189
|
-
const script = this.component.get(scriptName);
|
|
404
|
+
const script = this.scriptFor(scriptElement);
|
|
190
405
|
if (script) {
|
|
191
|
-
|
|
406
|
+
// Per-property attributes stay authoritative: keys they pin are excluded here
|
|
407
|
+
this.applyAttributes(script, event.detail.attributes, this.inlineKeys(scriptElement));
|
|
192
408
|
}
|
|
193
409
|
}
|
|
194
410
|
|
|
195
411
|
private handleScriptEnableChange(event: ScriptEnableChangeEvent) {
|
|
196
412
|
const scriptElement = event.target as ScriptElement;
|
|
197
|
-
const scriptName = scriptElement.getAttribute('name');
|
|
198
|
-
if (!scriptName || !this.component) return;
|
|
199
413
|
|
|
200
|
-
|
|
414
|
+
// Apply any queued per-property changes first, so that initialize() (fired by the
|
|
415
|
+
// engine on first effective enable) sees every attribute value set this tick
|
|
416
|
+
this.handleMutations(this.observer.takeRecords());
|
|
417
|
+
|
|
418
|
+
const script = this.scriptFor(scriptElement);
|
|
201
419
|
if (script) {
|
|
202
420
|
script.enabled = event.detail.enabled;
|
|
203
421
|
}
|
|
204
422
|
}
|
|
205
423
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
424
|
+
/**
|
|
425
|
+
* Handles a runtime `name` change on a child `pc-script`, swapping the engine script instance
|
|
426
|
+
* to match. Without this the element would keep pointing at the old-name instance: the old
|
|
427
|
+
* script would go on running while every subsequent update (attribute changes, enable
|
|
428
|
+
* changes, destruction on removal) resolved the new name and silently no-opped.
|
|
429
|
+
*
|
|
430
|
+
* The new instance is built by the normal creation path, so both attribute channels are
|
|
431
|
+
* re-applied to it and the declared enabled state is restored.
|
|
432
|
+
* @param event - The name change event.
|
|
433
|
+
*/
|
|
434
|
+
private handleScriptNameChange(event: ScriptNameChangeEvent) {
|
|
435
|
+
const scriptElement = event.target as ScriptElement;
|
|
436
|
+
|
|
437
|
+
// Only direct children are managed, matching initComponent's ':scope > pc-script'
|
|
438
|
+
// contract - the event bubbles, so a deeper pc-script must not be created here
|
|
439
|
+
if (scriptElement.parentElement !== this) return;
|
|
440
|
+
|
|
441
|
+
// Before the component exists there is nothing to swap: initComponent creates from
|
|
442
|
+
// whatever the name is by then
|
|
443
|
+
if (!this.component) return;
|
|
444
|
+
|
|
445
|
+
// Only tear down the old-name script if this element actually owns it - a duplicate-named
|
|
446
|
+
// element whose own create() failed must not take down the live script on rename
|
|
447
|
+
const { oldName } = event.detail;
|
|
448
|
+
if (oldName && scriptElement._script && this.component.get(oldName) === scriptElement._script) {
|
|
449
|
+
this.destroyScript(oldName);
|
|
450
|
+
}
|
|
451
|
+
scriptElement._script = null;
|
|
452
|
+
|
|
453
|
+
this.createScript(scriptElement);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Creates the script instance for a `pc-script` element. The instance is created disabled,
|
|
458
|
+
* the element's converted attributes are merged over the instance's defaults (which is what
|
|
459
|
+
* allows plain numeric arrays to be typed against those defaults), and only then is the
|
|
460
|
+
* declared enabled state applied — so `initialize()` runs with every attribute in place.
|
|
461
|
+
* @param scriptElement - The `pc-script` element to create the script instance for.
|
|
462
|
+
* @returns The created script, or `null`.
|
|
463
|
+
*/
|
|
464
|
+
private createScript(scriptElement: ScriptElement): Script | null {
|
|
465
|
+
const name = scriptElement.getAttribute('name');
|
|
466
|
+
if (!name || !this.component) return null;
|
|
467
|
+
|
|
468
|
+
const script = this.component.create(name, { enabled: false });
|
|
469
|
+
if (!script) return null;
|
|
470
|
+
|
|
471
|
+
scriptElement._script = script;
|
|
472
|
+
|
|
473
|
+
// The JSON blob first with per-property-shadowed keys stripped, then the per-property
|
|
474
|
+
// attributes: each property is written exactly once and individual attributes win
|
|
475
|
+
this.applyAttributes(script, scriptElement.scriptAttributes, this.inlineKeys(scriptElement));
|
|
476
|
+
this.applyInlineAttributes(script, scriptElement);
|
|
477
|
+
script.enabled = scriptElement.enabled;
|
|
478
|
+
|
|
479
|
+
scriptElement._onScriptCreated();
|
|
480
|
+
|
|
481
|
+
return script;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Applies the per-property attributes present on a `pc-script` element — any attribute that
|
|
486
|
+
* is not part of the element's own API or a reserved HTML attribute name. These are applied
|
|
487
|
+
* after the `attributes` JSON, so an individual attribute always takes precedence over the
|
|
488
|
+
* blob.
|
|
489
|
+
* @param script - The script to apply the attributes to.
|
|
490
|
+
* @param scriptElement - The `pc-script` element holding the attributes.
|
|
491
|
+
*/
|
|
492
|
+
private applyInlineAttributes(script: any, scriptElement: ScriptElement) {
|
|
493
|
+
const scriptName = scriptElement.getAttribute('name') ?? '';
|
|
494
|
+
for (const attr of Array.from(scriptElement.attributes)) {
|
|
495
|
+
if (!isReservedAttribute(attr.name)) {
|
|
496
|
+
this.setScriptProperty(script, scriptName, attr.name, attr.value);
|
|
217
497
|
}
|
|
218
498
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Applies a single per-property attribute change to the script of a `pc-script` element.
|
|
503
|
+
* When the attribute has been removed, the value from the `attributes` JSON (if any) takes
|
|
504
|
+
* effect again.
|
|
505
|
+
* @param scriptElement - The `pc-script` element whose attribute changed.
|
|
506
|
+
* @param attributeName - The name of the changed attribute.
|
|
507
|
+
*/
|
|
508
|
+
private applyScriptProperty(scriptElement: ScriptElement, attributeName: string) {
|
|
509
|
+
const script = this.scriptFor(scriptElement);
|
|
510
|
+
if (!script) return;
|
|
511
|
+
|
|
512
|
+
const value = scriptElement.getAttribute(attributeName);
|
|
513
|
+
if (value === null) {
|
|
514
|
+
const key = kebabToCamel(attributeName);
|
|
515
|
+
const fallback = scriptElement.scriptAttributes[key];
|
|
516
|
+
if (fallback !== undefined) {
|
|
517
|
+
this.applyAttributes(script, { [key]: fallback });
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
this.setScriptProperty(script, scriptElement.getAttribute('name') ?? '', attributeName, value);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Applies one attribute string to a script property. A string-typed attribute takes the
|
|
526
|
+
* value verbatim (so literals like 'color:red' are never hijacked by prefix conversion).
|
|
527
|
+
* Otherwise, explicit prefixes (`asset:`, `entity:`, `vec2:`, `vec3:`, `vec4:`, `color:`)
|
|
528
|
+
* carry their own type, and unprefixed values are parsed according to the type of the
|
|
529
|
+
* attribute's current value. The Script API itself (methods, `entity`, `app`) is never
|
|
530
|
+
* overwritten, invalid values keep the current value, and exceptions thrown by user
|
|
531
|
+
* getters/setters are contained so one bad attribute cannot abort the rest of a batch.
|
|
532
|
+
* @param script - The script to apply the value to.
|
|
533
|
+
* @param scriptName - The script name, used in warning messages.
|
|
534
|
+
* @param attributeName - The (kebab-case) element attribute name.
|
|
535
|
+
* @param value - The attribute value.
|
|
536
|
+
*/
|
|
537
|
+
private setScriptProperty(script: any, scriptName: string, attributeName: string, value: string) {
|
|
538
|
+
const key = kebabToCamel(attributeName);
|
|
539
|
+
try {
|
|
540
|
+
const current = script[key];
|
|
541
|
+
|
|
542
|
+
if (typeof current === 'function' || SCRIPT_API_MEMBERS.has(key)) {
|
|
543
|
+
console.warn(`Ignoring attribute '${attributeName}' on pc-script '${scriptName}' - '${key}' is part of the Script API.`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (typeof current === 'string') {
|
|
548
|
+
script[key] = value;
|
|
549
|
+
} else if (matchConversion(value)) {
|
|
550
|
+
const converted = this.convertAttributes(value);
|
|
551
|
+
// A prefix that failed to resolve or parse comes back as the raw string
|
|
552
|
+
// (convertAttributes already warned) - never clobber a typed value with it
|
|
553
|
+
if (converted !== value || current === undefined || current === null) {
|
|
554
|
+
script[key] = converted;
|
|
555
|
+
}
|
|
556
|
+
} else if (typeof current === 'number') {
|
|
557
|
+
script[key] = parseNumber(value, current, attributeName);
|
|
558
|
+
} else if (typeof current === 'boolean') {
|
|
559
|
+
script[key] = parseBool(value, current);
|
|
560
|
+
} else if (current instanceof Vec2) {
|
|
561
|
+
script[key] = parseVec2(value, current, attributeName);
|
|
562
|
+
} else if (current instanceof Vec3) {
|
|
563
|
+
script[key] = parseVec3(value, current, attributeName);
|
|
564
|
+
} else if (current instanceof Vec4) {
|
|
565
|
+
script[key] = parseVec4(value, current, attributeName);
|
|
566
|
+
} else if (current instanceof Color) {
|
|
567
|
+
script[key] = parseColor(value, current, attributeName);
|
|
568
|
+
} else if (current instanceof Quat) {
|
|
569
|
+
script[key] = parseQuat(value, current, attributeName);
|
|
570
|
+
} else {
|
|
571
|
+
const match = findCaseMatch(script, key);
|
|
572
|
+
if (match) {
|
|
573
|
+
console.warn(`Script '${scriptName}' has no attribute '${key}' - did you mean '${camelToKebab(match)}'? Attribute names are kebab-case.`);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
console.warn(`Script '${scriptName}' has no typed attribute '${key}' - assigning the raw string from '${attributeName}'.`);
|
|
577
|
+
script[key] = value;
|
|
578
|
+
}
|
|
579
|
+
} catch (error) {
|
|
580
|
+
console.warn(`Error applying attribute '${attributeName}' to script '${scriptName}': ${(error as Error).message}`);
|
|
581
|
+
}
|
|
222
582
|
}
|
|
223
583
|
|
|
224
584
|
private destroyScript(name: string): void {
|
|
@@ -228,24 +588,45 @@ class ScriptComponentElement extends ComponentElement {
|
|
|
228
588
|
|
|
229
589
|
private handleMutations(mutations: MutationRecord[]) {
|
|
230
590
|
for (const mutation of mutations) {
|
|
231
|
-
// Handle
|
|
232
|
-
mutation.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
591
|
+
// Handle per-property attribute changes on child pc-script elements
|
|
592
|
+
if (mutation.type === 'attributes') {
|
|
593
|
+
const target = mutation.target;
|
|
594
|
+
if (
|
|
595
|
+
target instanceof ScriptElement &&
|
|
596
|
+
target.parentElement === this &&
|
|
597
|
+
mutation.attributeName &&
|
|
598
|
+
!isReservedAttribute(mutation.attributeName)
|
|
599
|
+
) {
|
|
600
|
+
this.applyScriptProperty(target, mutation.attributeName);
|
|
239
601
|
}
|
|
240
|
-
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Only direct children are managed - the observer watches the subtree for attribute
|
|
606
|
+
// changes, but deeper childList records must not create or destroy scripts
|
|
607
|
+
// (matching initComponent's ':scope > pc-script' contract)
|
|
608
|
+
if (mutation.target !== this) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
241
611
|
|
|
242
|
-
// Handle removed nodes
|
|
612
|
+
// Handle removed nodes first, so that replacing a pc-script with a same-named one
|
|
613
|
+
// destroys the old script before the replacement is created. Only destroy a script
|
|
614
|
+
// this element actually owns - a duplicate-named element whose own create() failed
|
|
615
|
+
// must not take down the live script on removal.
|
|
243
616
|
mutation.removedNodes.forEach((node) => {
|
|
244
|
-
if (node instanceof
|
|
617
|
+
if (node instanceof ScriptElement) {
|
|
245
618
|
const scriptName = node.getAttribute('name');
|
|
246
|
-
if (scriptName) {
|
|
619
|
+
if (scriptName && node._script && this.component && this.component.get(scriptName) === node._script) {
|
|
247
620
|
this.destroyScript(scriptName);
|
|
248
621
|
}
|
|
622
|
+
node._script = null;
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Handle added nodes
|
|
627
|
+
mutation.addedNodes.forEach((node) => {
|
|
628
|
+
if (node instanceof ScriptElement) {
|
|
629
|
+
this.createScript(node);
|
|
249
630
|
}
|
|
250
631
|
});
|
|
251
632
|
}
|
|
@@ -260,11 +641,17 @@ class ScriptComponentElement extends ComponentElement {
|
|
|
260
641
|
* Gets the underlying PlayCanvas script component.
|
|
261
642
|
* @returns The script component.
|
|
262
643
|
*/
|
|
263
|
-
get component(): ScriptComponent
|
|
264
|
-
return super.component as ScriptComponent
|
|
644
|
+
get component(): ScriptComponent {
|
|
645
|
+
return super.component as ScriptComponent;
|
|
265
646
|
}
|
|
266
647
|
}
|
|
267
648
|
|
|
268
649
|
customElements.define('pc-scripts', ScriptComponentElement);
|
|
269
650
|
|
|
651
|
+
declare global {
|
|
652
|
+
interface HTMLElementTagNameMap {
|
|
653
|
+
'pc-scripts': ScriptComponentElement;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
270
657
|
export { ScriptComponentElement };
|