@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.
Files changed (71) hide show
  1. package/dist/app.d.ts +10 -2
  2. package/dist/asset.d.ts +18 -3
  3. package/dist/async-element.d.ts +51 -4
  4. package/dist/components/button-component.d.ts +10 -4
  5. package/dist/components/camera-component.d.ts +9 -4
  6. package/dist/components/collision-component.d.ts +9 -4
  7. package/dist/components/component.d.ts +7 -1
  8. package/dist/components/element-component.d.ts +17 -12
  9. package/dist/components/gsplat-component.d.ts +6 -1
  10. package/dist/components/layoutchild-component.d.ts +6 -1
  11. package/dist/components/layoutgroup-component.d.ts +21 -15
  12. package/dist/components/light-component.d.ts +12 -6
  13. package/dist/components/listener-component.d.ts +6 -1
  14. package/dist/components/particlesystem-component.d.ts +6 -1
  15. package/dist/components/render-component.d.ts +13 -4
  16. package/dist/components/rigidbody-component.d.ts +9 -4
  17. package/dist/components/screen-component.d.ts +6 -1
  18. package/dist/components/script-component.d.ts +116 -16
  19. package/dist/components/script.d.ts +72 -8
  20. package/dist/components/scrollbar-component.d.ts +10 -4
  21. package/dist/components/scrollview-component.d.ts +17 -10
  22. package/dist/components/sound-component.d.ts +6 -1
  23. package/dist/components/sound-slot.d.ts +8 -3
  24. package/dist/entity.d.ts +26 -2
  25. package/dist/index.d.ts +3 -2
  26. package/dist/material.d.ts +10 -0
  27. package/dist/model.d.ts +5 -0
  28. package/dist/module.d.ts +5 -0
  29. package/dist/pwc.cjs +1574 -853
  30. package/dist/pwc.cjs.map +1 -1
  31. package/dist/pwc.js +1574 -853
  32. package/dist/pwc.js.map +1 -1
  33. package/dist/pwc.min.js +1 -1
  34. package/dist/pwc.min.js.map +1 -1
  35. package/dist/pwc.mjs +1575 -855
  36. package/dist/pwc.mjs.map +1 -1
  37. package/dist/scene.d.ts +14 -5
  38. package/dist/sky.d.ts +6 -0
  39. package/dist/utils.d.ts +81 -23
  40. package/package.json +7 -7
  41. package/src/app.ts +38 -12
  42. package/src/asset.ts +61 -8
  43. package/src/async-element.ts +86 -5
  44. package/src/components/button-component.ts +26 -19
  45. package/src/components/camera-component.ts +29 -23
  46. package/src/components/collision-component.ts +19 -13
  47. package/src/components/component.ts +32 -13
  48. package/src/components/element-component.ts +58 -52
  49. package/src/components/gsplat-component.ts +14 -7
  50. package/src/components/layoutchild-component.ts +16 -9
  51. package/src/components/layoutgroup-component.ts +38 -31
  52. package/src/components/light-component.ts +33 -31
  53. package/src/components/listener-component.ts +8 -2
  54. package/src/components/particlesystem-component.ts +8 -2
  55. package/src/components/render-component.ts +19 -8
  56. package/src/components/rigidbody-component.ts +21 -15
  57. package/src/components/screen-component.ts +15 -9
  58. package/src/components/script-component.ts +512 -125
  59. package/src/components/script.ts +116 -16
  60. package/src/components/scrollbar-component.ts +19 -12
  61. package/src/components/scrollview-component.ts +37 -29
  62. package/src/components/sound-component.ts +16 -9
  63. package/src/components/sound-slot.ts +23 -14
  64. package/src/entity.ts +61 -71
  65. package/src/index.ts +5 -2
  66. package/src/material.ts +34 -1
  67. package/src/model.ts +6 -0
  68. package/src/module.ts +6 -0
  69. package/src/scene.ts +25 -12
  70. package/src/sky.ts +34 -16
  71. 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 enable changes
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
- const scriptName = scriptElement.getAttribute('name');
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:assetId" → resolves to an Asset instance
65
- * - "entity:entityId" → resolves to an Entity instance
66
- * - "vec2:1,2" new Vec2(1,2)
67
- * - "vec3:1,2,3" → new Vec3(1,2,3)
68
- * - "vec4:1,2,3,4" → new Vec4(1,2,3,4)
69
- * - "color:1,0.5,0.5,1" → new Color(1,0.5,0.5,1)
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
- if (item.startsWith('asset:')) {
76
- const assetId = item.slice(6);
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
- // If it's an array of objects, convert each element individually.
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
- * Preprocess the attributes object by converting its values.
138
- * @param attrs - The attributes object to preprocess.
139
- * @returns The preprocessed attributes object.
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
- if (
154
- source[key] &&
155
- typeof source[key] === 'object' &&
156
- !Array.isArray(source[key])
157
- ) {
158
- if (!target[key] || typeof target[key] !== 'object') {
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], source[key]);
310
+ this.mergeDeep(target[key], value);
162
311
  } else {
163
- target[key] = source[key];
312
+ target[key] = value;
164
313
  }
165
314
  }
166
315
  return target;
167
316
  }
168
317
 
169
318
  /**
170
- * Update script attributes by merging preprocessed values into the script.
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 | null) {
175
- try {
176
- const attributesObject = attributes ? JSON.parse(attributes) : {};
177
- const converted = this.convertAttributes(attributesObject);
178
- this.mergeDeep(script, converted);
179
- } catch (error) {
180
- console.error(`Error parsing attributes JSON string ${attributes}:`, error);
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 scriptName = scriptElement.getAttribute('name');
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
- this.applyAttributes(script, event.detail.attributes);
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
- const script = this.component.get(scriptName);
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
- private createScript(name: string, attributes: string | null): Script | null {
207
- if (!this.component) return null;
208
-
209
- let attributesObject = {};
210
- if (attributes) {
211
- try {
212
- attributesObject = JSON.parse(attributes);
213
- // Preprocess attributes: convert arrays or strings into vectors, colors, asset references, etc.
214
- attributesObject = this.preprocessAttributes(attributesObject);
215
- } catch (error) {
216
- console.error(`Error parsing attributes JSON string ${attributes}:`, error);
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
- return this.component.create(name, {
220
- properties: attributesObject
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 added nodes
232
- mutation.addedNodes.forEach((node) => {
233
- if (node instanceof HTMLElement && node.tagName.toLowerCase() === 'pc-script') {
234
- const scriptName = node.getAttribute('name');
235
- const attributes = node.getAttribute('attributes');
236
- if (scriptName) {
237
- this.createScript(scriptName, attributes);
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 HTMLElement && node.tagName.toLowerCase() === 'pc-script') {
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 | null {
264
- return super.component as ScriptComponent | null;
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 };